- Add FastAPI service for managing oil delivery services - Implement service scheduling and management endpoints - Add customer service history tracking - Include database models for services, customers, and auto-delivery - Add authentication and authorization middleware - Configure Docker support for local, dev, and prod environments - Add comprehensive .gitignore for Python projects
580 lines
21 KiB
Python
580 lines
21 KiB
Python
import logging
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.responses import JSONResponse
|
|
from fastapi.encoders import jsonable_encoder
|
|
from sqlalchemy.orm import Session
|
|
from datetime import datetime, date, timedelta
|
|
|
|
from database import get_db
|
|
from app.auth import get_current_user
|
|
from app.models.service import Service_Service, Service_Parts, Service_Plans
|
|
from app.models.customer import Customer_Customer
|
|
from app.models.auto import Auto_Delivery
|
|
from app.models.auth import Auth_User
|
|
from app.constants import DEFAULT_PAGE_SIZE
|
|
from app.schema.service import (
|
|
ServiceResponse, ServiceCreateRequest, ServiceUpdateRequest,
|
|
ServiceCostUpdateRequest, ServicePartsResponse, ServicePartsUpdateRequest,
|
|
ServicePlanResponse, ServicePlanCreateRequest, ServicePlanUpdateRequest,
|
|
CalendarEvent
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(
|
|
prefix="/service",
|
|
tags=["service"],
|
|
)
|
|
|
|
|
|
def success_response(data: dict = None, status_code: int = 200):
|
|
"""Standard success response."""
|
|
response = {"ok": True}
|
|
if data:
|
|
response.update(data)
|
|
return JSONResponse(content=jsonable_encoder(response), status_code=status_code)
|
|
|
|
|
|
def error_response(message: str, status_code: int = 400):
|
|
"""Standard error response."""
|
|
return JSONResponse(
|
|
content={"ok": False, "error": message},
|
|
status_code=status_code
|
|
)
|
|
|
|
|
|
def serialize_service(service: Service_Service) -> dict:
|
|
"""Serialize a service record to dict with proper date formatting."""
|
|
return {
|
|
"id": service.id,
|
|
"customer_id": service.customer_id,
|
|
"customer_name": service.customer_name,
|
|
"customer_address": service.customer_address,
|
|
"customer_town": service.customer_town,
|
|
"customer_state": service.customer_state,
|
|
"customer_zip": service.customer_zip,
|
|
"type_service_call": service.type_service_call,
|
|
"when_ordered": service.when_ordered.isoformat() if service.when_ordered else None,
|
|
"scheduled_date": service.scheduled_date.isoformat() if service.scheduled_date else None,
|
|
"description": service.description,
|
|
"service_cost": str(service.service_cost) if service.service_cost is not None else None,
|
|
"payment_type": service.payment_type,
|
|
"payment_card_id": service.payment_card_id,
|
|
"payment_status": service.payment_status,
|
|
}
|
|
|
|
|
|
def serialize_parts(parts: Service_Parts) -> dict:
|
|
"""Serialize a service parts record to dict."""
|
|
return {
|
|
"id": parts.id,
|
|
"customer_id": parts.customer_id,
|
|
"oil_filter": parts.oil_filter,
|
|
"oil_filter_2": parts.oil_filter_2,
|
|
"oil_nozzle": parts.oil_nozzle,
|
|
"oil_nozzle_2": parts.oil_nozzle_2,
|
|
"hot_water_tank": parts.hot_water_tank,
|
|
}
|
|
|
|
|
|
def serialize_plan(plan: Service_Plans) -> dict:
|
|
"""Serialize a service plan record to dict."""
|
|
return {
|
|
"id": plan.id,
|
|
"customer_id": plan.customer_id,
|
|
"contract_plan": plan.contract_plan,
|
|
"contract_years": plan.contract_years,
|
|
"contract_start_date": plan.contract_start_date.isoformat() if plan.contract_start_date else None,
|
|
}
|
|
|
|
|
|
@router.get("/all", status_code=200)
|
|
def get_all_service_calls(
|
|
db: Session = Depends(get_db),
|
|
current_user: Auth_User = Depends(get_current_user)
|
|
):
|
|
"""Get all services for calendar view."""
|
|
logger.info("GET /service/all - Fetching all service calls for calendar")
|
|
try:
|
|
all_services = db.query(Service_Service).all()
|
|
color_map = {
|
|
0: {"backgroundColor": "blue", "textColor": "white"},
|
|
1: {"backgroundColor": "red", "textColor": "white"},
|
|
2: {"backgroundColor": "green", "textColor": "white"},
|
|
3: {"backgroundColor": "yellow", "textColor": "black"},
|
|
4: {"backgroundColor": "black", "textColor": "white"}
|
|
}
|
|
service_type_map = {0: 'Tune-up', 1: 'No Heat', 2: 'Fix', 3: 'Tank Install', 4: 'Other'}
|
|
|
|
calendar_events = []
|
|
for service_record in all_services:
|
|
service_type_text = service_type_map.get(service_record.type_service_call, 'Service')
|
|
event_title = f"{service_type_text}: {service_record.customer_name}"
|
|
event_colors = color_map.get(service_record.type_service_call, {"backgroundColor": "gray", "textColor": "white"})
|
|
|
|
start_date = service_record.scheduled_date.isoformat() if service_record.scheduled_date else None
|
|
|
|
event_data = {
|
|
"id": service_record.id,
|
|
"title": event_title,
|
|
"start": start_date,
|
|
"end": None,
|
|
"extendedProps": {
|
|
"customer_id": service_record.customer_id,
|
|
"description": service_record.description,
|
|
"type_service_call": service_record.type_service_call,
|
|
"service_cost": str(service_record.service_cost) if service_record.service_cost is not None else None
|
|
},
|
|
"backgroundColor": event_colors.get("backgroundColor"),
|
|
"textColor": event_colors.get("textColor"),
|
|
"borderColor": event_colors.get("backgroundColor")
|
|
}
|
|
calendar_events.append(event_data)
|
|
|
|
return success_response({"events": calendar_events})
|
|
except Exception as e:
|
|
logger.error(f"Error in /service/all: {e}")
|
|
return error_response(str(e), 500)
|
|
|
|
|
|
@router.get("/upcoming", status_code=200)
|
|
def get_upcoming_service_calls(
|
|
db: Session = Depends(get_db),
|
|
current_user: Auth_User = Depends(get_current_user)
|
|
):
|
|
"""Fetches a list of all future service calls from today onwards."""
|
|
logger.info("GET /service/upcoming - Fetching upcoming service calls")
|
|
now = datetime.now()
|
|
upcoming_services = (
|
|
db.query(Service_Service)
|
|
.filter(Service_Service.scheduled_date >= now)
|
|
.order_by(Service_Service.scheduled_date.asc())
|
|
.limit(DEFAULT_PAGE_SIZE)
|
|
.all()
|
|
)
|
|
|
|
result = [serialize_service(s) for s in upcoming_services]
|
|
return success_response({"services": result})
|
|
|
|
|
|
@router.get("/past", status_code=200)
|
|
def get_past_service_calls(
|
|
db: Session = Depends(get_db),
|
|
current_user: Auth_User = Depends(get_current_user)
|
|
):
|
|
"""Fetches a list of all past service calls before today."""
|
|
past_services = (
|
|
db.query(Service_Service)
|
|
.filter(Service_Service.scheduled_date < datetime.combine(date.today(), datetime.min.time()))
|
|
.order_by(Service_Service.scheduled_date.asc())
|
|
.limit(DEFAULT_PAGE_SIZE)
|
|
.all()
|
|
)
|
|
|
|
result = [serialize_service(s) for s in past_services]
|
|
return success_response({"services": result})
|
|
|
|
|
|
@router.get("/today", status_code=200)
|
|
def get_today_service_calls(
|
|
db: Session = Depends(get_db),
|
|
current_user: Auth_User = Depends(get_current_user)
|
|
):
|
|
"""Fetches a list of all service calls for today."""
|
|
start_of_today = datetime.combine(date.today(), datetime.min.time())
|
|
start_of_tomorrow = datetime.combine(date.today() + timedelta(days=1), datetime.min.time())
|
|
today_services = (
|
|
db.query(Service_Service)
|
|
.filter(Service_Service.scheduled_date >= start_of_today)
|
|
.filter(Service_Service.scheduled_date < start_of_tomorrow)
|
|
.order_by(Service_Service.scheduled_date.asc())
|
|
.limit(DEFAULT_PAGE_SIZE)
|
|
.all()
|
|
)
|
|
|
|
result = [serialize_service(s) for s in today_services]
|
|
return success_response({"services": result})
|
|
|
|
|
|
@router.get("/upcoming/count", status_code=200)
|
|
def get_upcoming_service_calls_count(
|
|
db: Session = Depends(get_db),
|
|
current_user: Auth_User = Depends(get_current_user)
|
|
):
|
|
"""Get count of upcoming service calls."""
|
|
now = datetime.now()
|
|
try:
|
|
count = db.query(Service_Service).filter(Service_Service.scheduled_date >= now).count()
|
|
return success_response({"count": count})
|
|
except Exception as e:
|
|
return error_response(str(e), 500)
|
|
|
|
|
|
@router.get("/for-customer/{customer_id}", status_code=200)
|
|
def get_service_calls_for_customer(
|
|
customer_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: Auth_User = Depends(get_current_user)
|
|
):
|
|
"""Get all service calls for a specific customer."""
|
|
service_records = (
|
|
db.query(Service_Service)
|
|
.filter(Service_Service.customer_id == customer_id)
|
|
.order_by(Service_Service.scheduled_date.desc())
|
|
.all()
|
|
)
|
|
result = [serialize_service(s) for s in service_records]
|
|
return success_response({"services": result})
|
|
|
|
|
|
@router.get("/{id}", status_code=200)
|
|
def get_service_by_id(
|
|
id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: Auth_User = Depends(get_current_user)
|
|
):
|
|
"""Get a specific service call by ID."""
|
|
service_record = db.query(Service_Service).filter(Service_Service.id == id).first()
|
|
if not service_record:
|
|
raise HTTPException(status_code=404, detail="Service not found")
|
|
return success_response({"service": serialize_service(service_record)})
|
|
|
|
|
|
@router.post("/create", status_code=201)
|
|
async def create_service_call(
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: Auth_User = Depends(get_current_user)
|
|
):
|
|
"""Create a new service call."""
|
|
data = await request.json()
|
|
if not data:
|
|
return error_response("No data provided", 400)
|
|
|
|
cus_id = data.get('customer_id')
|
|
get_customer = db.query(Customer_Customer).filter(Customer_Customer.id == cus_id).first()
|
|
if not get_customer:
|
|
return error_response(f"Customer with id {cus_id} not found.", 404)
|
|
|
|
scheduled_datetime_str = data.get('expected_delivery_date')
|
|
scheduled_datetime_obj = datetime.fromisoformat(scheduled_datetime_str)
|
|
|
|
new_service_call = Service_Service(
|
|
customer_id=get_customer.id,
|
|
customer_name=get_customer.customer_first_name + ' ' + get_customer.customer_last_name,
|
|
customer_address=get_customer.customer_address,
|
|
customer_town=get_customer.customer_town,
|
|
customer_state=get_customer.customer_state,
|
|
customer_zip=get_customer.customer_zip,
|
|
type_service_call=data.get('type_service_call'),
|
|
when_ordered=datetime.utcnow(),
|
|
scheduled_date=scheduled_datetime_obj,
|
|
description=data.get('description'),
|
|
service_cost=None,
|
|
)
|
|
db.add(new_service_call)
|
|
db.commit()
|
|
return success_response({"id": new_service_call.id}, 201)
|
|
|
|
|
|
@router.put("/update/{id}", status_code=200)
|
|
async def update_service_call(
|
|
id: int,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: Auth_User = Depends(get_current_user)
|
|
):
|
|
"""Update a service call."""
|
|
service_record = db.query(Service_Service).filter(Service_Service.id == id).first()
|
|
if not service_record:
|
|
raise HTTPException(status_code=404, detail="Service not found")
|
|
|
|
data = await request.json()
|
|
if not data:
|
|
return error_response("No data provided", 400)
|
|
|
|
scheduled_datetime_str = data.get('scheduled_date')
|
|
if scheduled_datetime_str:
|
|
service_record.scheduled_date = datetime.fromisoformat(scheduled_datetime_str)
|
|
|
|
service_record.type_service_call = data.get('type_service_call', service_record.type_service_call)
|
|
service_record.description = data.get('description', service_record.description)
|
|
service_record.service_cost = data.get('service_cost', service_record.service_cost)
|
|
|
|
try:
|
|
db.commit()
|
|
return success_response({"service": serialize_service(service_record)})
|
|
except Exception as e:
|
|
db.rollback()
|
|
return error_response(str(e), 500)
|
|
|
|
|
|
@router.put("/update-cost/{id}", status_code=200)
|
|
async def update_service_cost(
|
|
id: int,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: Auth_User = Depends(get_current_user)
|
|
):
|
|
"""Dedicated endpoint to update only the service cost for a service call."""
|
|
try:
|
|
service_record = db.query(Service_Service).filter(Service_Service.id == id).first()
|
|
if not service_record:
|
|
raise HTTPException(status_code=404, detail="Service not found")
|
|
|
|
data = await request.json()
|
|
if not data:
|
|
return error_response("No data provided", 400)
|
|
|
|
new_cost = data.get('service_cost')
|
|
if new_cost is None:
|
|
return error_response("service_cost is required", 400)
|
|
|
|
try:
|
|
new_cost_float = float(new_cost)
|
|
except (ValueError, TypeError):
|
|
return error_response("service_cost must be a valid number", 400)
|
|
|
|
service_record.service_cost = new_cost_float
|
|
db.commit()
|
|
|
|
return success_response({
|
|
"service_id": id,
|
|
"service_cost_updated": new_cost_float,
|
|
"message": f"Service {id} cost updated to ${new_cost_float}"
|
|
})
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Error updating service cost for service {id}: {e}")
|
|
return error_response(str(e), 500)
|
|
|
|
|
|
@router.delete("/delete/{id}", status_code=200)
|
|
def delete_service_call(
|
|
id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: Auth_User = Depends(get_current_user)
|
|
):
|
|
"""Delete a service call."""
|
|
service_record = db.query(Service_Service).filter(Service_Service.id == id).first()
|
|
if not service_record:
|
|
raise HTTPException(status_code=404, detail="Service not found")
|
|
|
|
try:
|
|
db.delete(service_record)
|
|
db.commit()
|
|
return success_response({"message": "Service deleted successfully"})
|
|
except Exception as e:
|
|
db.rollback()
|
|
return error_response(str(e), 500)
|
|
|
|
|
|
# Service Parts endpoints
|
|
|
|
@router.get("/parts/customer/{customer_id}", status_code=200)
|
|
def get_service_parts(
|
|
customer_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: Auth_User = Depends(get_current_user)
|
|
):
|
|
"""Get service parts for a customer."""
|
|
parts = db.query(Service_Parts).filter(Service_Parts.customer_id == customer_id).first()
|
|
if parts:
|
|
return success_response({"parts": serialize_parts(parts)})
|
|
else:
|
|
return success_response({"parts": {
|
|
"customer_id": customer_id,
|
|
"oil_filter": "",
|
|
"oil_filter_2": "",
|
|
"oil_nozzle": "",
|
|
"oil_nozzle_2": "",
|
|
"hot_water_tank": 0
|
|
}})
|
|
|
|
|
|
@router.post("/parts/update/{customer_id}", status_code=200)
|
|
async def update_service_parts(
|
|
customer_id: int,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: Auth_User = Depends(get_current_user)
|
|
):
|
|
"""Update service parts for a customer."""
|
|
try:
|
|
data = await request.json()
|
|
if not data:
|
|
return error_response("No data provided", 400)
|
|
|
|
get_customer = db.query(Customer_Customer).filter(Customer_Customer.id == customer_id).first()
|
|
parts = db.query(Service_Parts).filter(Service_Parts.customer_id == customer_id).first()
|
|
|
|
if not parts:
|
|
parts = Service_Parts(customer_id=customer_id)
|
|
db.add(parts)
|
|
|
|
parts.oil_filter = data.get('oil_filter', parts.oil_filter)
|
|
parts.oil_filter_2 = data.get('oil_filter_2', parts.oil_filter_2)
|
|
parts.oil_nozzle = data.get('oil_nozzle', parts.oil_nozzle)
|
|
parts.oil_nozzle_2 = data.get('oil_nozzle_2', parts.oil_nozzle_2)
|
|
parts.hot_water_tank = data.get('hot_water_tank', parts.hot_water_tank if parts.hot_water_tank is not None else 0)
|
|
|
|
# Sync to Auto_Delivery if customer is automatic
|
|
if get_customer and get_customer.customer_automatic == 1:
|
|
get_auto = db.query(Auto_Delivery).filter(Auto_Delivery.customer_id == customer_id).first()
|
|
if get_auto:
|
|
get_auto.hot_water_summer = parts.hot_water_tank
|
|
db.add(get_auto)
|
|
|
|
db.commit()
|
|
return success_response({"message": "Service parts updated successfully"})
|
|
except Exception as e:
|
|
db.rollback()
|
|
return error_response(str(e), 500)
|
|
|
|
|
|
# Service Plans endpoints
|
|
|
|
@router.get("/plans/active", status_code=200)
|
|
def get_active_service_plans(
|
|
db: Session = Depends(get_db),
|
|
current_user: Auth_User = Depends(get_current_user)
|
|
):
|
|
"""Get all active service plans (contract_plan > 0)."""
|
|
try:
|
|
plans = db.query(Service_Plans).filter(Service_Plans.contract_plan > 0).all()
|
|
result = []
|
|
|
|
for plan in plans:
|
|
plan_dict = serialize_plan(plan)
|
|
customer = db.query(Customer_Customer).filter(Customer_Customer.id == plan.customer_id).first()
|
|
if customer:
|
|
plan_dict['customer_name'] = f"{customer.customer_first_name} {customer.customer_last_name}"
|
|
plan_dict['customer_address'] = customer.customer_address
|
|
plan_dict['customer_town'] = customer.customer_town
|
|
result.append(plan_dict)
|
|
|
|
return success_response({"plans": result})
|
|
except Exception as e:
|
|
return error_response(str(e), 500)
|
|
|
|
|
|
@router.get("/plans/customer/{customer_id}", status_code=200)
|
|
def get_customer_service_plan(
|
|
customer_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: Auth_User = Depends(get_current_user)
|
|
):
|
|
"""Get service plan for a specific customer."""
|
|
try:
|
|
plan = db.query(Service_Plans).filter(Service_Plans.customer_id == customer_id).first()
|
|
if plan:
|
|
return success_response({"plan": serialize_plan(plan)})
|
|
else:
|
|
return success_response({"plan": None})
|
|
except Exception as e:
|
|
return error_response(str(e), 500)
|
|
|
|
|
|
@router.post("/plans/create", status_code=201)
|
|
async def create_service_plan(
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: Auth_User = Depends(get_current_user)
|
|
):
|
|
"""Create a new service plan for a customer."""
|
|
data = await request.json()
|
|
if not data:
|
|
return error_response("No data provided", 400)
|
|
|
|
try:
|
|
new_plan = Service_Plans(
|
|
customer_id=data['customer_id'],
|
|
contract_plan=data['contract_plan'],
|
|
contract_years=data['contract_years'],
|
|
contract_start_date=datetime.fromisoformat(data['contract_start_date']).date()
|
|
)
|
|
db.add(new_plan)
|
|
db.commit()
|
|
|
|
return success_response({"plan": serialize_plan(new_plan)}, 201)
|
|
except Exception as e:
|
|
db.rollback()
|
|
return error_response(str(e), 500)
|
|
|
|
|
|
@router.put("/plans/update/{customer_id}", status_code=200)
|
|
async def update_service_plan(
|
|
customer_id: int,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: Auth_User = Depends(get_current_user)
|
|
):
|
|
"""Update existing service plan for a customer."""
|
|
data = await request.json()
|
|
if not data:
|
|
return error_response("No data provided", 400)
|
|
|
|
try:
|
|
plan = db.query(Service_Plans).filter(Service_Plans.customer_id == customer_id).first()
|
|
if not plan:
|
|
# Create new plan if it doesn't exist
|
|
plan = Service_Plans(customer_id=customer_id)
|
|
db.add(plan)
|
|
|
|
plan.contract_plan = data.get('contract_plan', plan.contract_plan)
|
|
plan.contract_years = data.get('contract_years', plan.contract_years)
|
|
if data.get('contract_start_date'):
|
|
plan.contract_start_date = datetime.fromisoformat(data['contract_start_date']).date()
|
|
|
|
db.commit()
|
|
return success_response({"plan": serialize_plan(plan)})
|
|
except Exception as e:
|
|
db.rollback()
|
|
return error_response(str(e), 500)
|
|
|
|
|
|
@router.delete("/plans/delete/{customer_id}", status_code=200)
|
|
def delete_service_plan(
|
|
customer_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: Auth_User = Depends(get_current_user)
|
|
):
|
|
"""Delete service plan for a customer."""
|
|
try:
|
|
plan = db.query(Service_Plans).filter(Service_Plans.customer_id == customer_id).first()
|
|
if not plan:
|
|
return error_response("Service plan not found", 404)
|
|
|
|
db.delete(plan)
|
|
db.commit()
|
|
return success_response({"message": "Service plan deleted successfully"})
|
|
except Exception as e:
|
|
db.rollback()
|
|
return error_response(str(e), 500)
|
|
|
|
|
|
@router.put("/payment/{service_id}/{payment_type}", status_code=200)
|
|
def process_service_payment(
|
|
service_id: int,
|
|
payment_type: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: Auth_User = Depends(get_current_user)
|
|
):
|
|
"""Process payment for a service call."""
|
|
service = db.query(Service_Service).filter(Service_Service.id == service_id).first()
|
|
if not service:
|
|
return error_response("Service not found", 404)
|
|
|
|
service.payment_type = payment_type
|
|
service.payment_status = 2
|
|
|
|
try:
|
|
db.commit()
|
|
return success_response({})
|
|
except Exception as e:
|
|
db.rollback()
|
|
return error_response(str(e), 500)
|