commit 07865480c70724ca755d8bf052138d62be610cd8 Author: Edwin Eames Date: Sun Feb 1 19:02:13 2026 -0500 Initial commit: Add EAMCO Service API - 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5702744 --- /dev/null +++ b/.gitignore @@ -0,0 +1,123 @@ +# Created by .ignore support plugin (hsz.mobi) +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +*.idea +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +*.iml +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +db.sqlite3 + +# Flask stuff: + +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +venvwindows/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.vscode/ + +instance/config.py +.idea/ +/passwords.py + +getnewitems.py +helperfunctions/ +test.py +tools/ +nginx.txt + +# Service-specific settings +settings_dev.py +settings_local.py +settings_prod.py diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..1183d16 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,19 @@ +FROM python:3.12-bullseye + +ENV PYTHONFAULTHANDLER=1 + +ENV PYTHONUNBUFFERED=1 + +ENV MODE="DEVELOPMENT" + +RUN mkdir -p /app + +COPY requirements.txt /app + +WORKDIR /app + +RUN pip3 install -r requirements.txt + +EXPOSE 8000 + +COPY . /app diff --git a/Dockerfile.local b/Dockerfile.local new file mode 100644 index 0000000..96d34b1 --- /dev/null +++ b/Dockerfile.local @@ -0,0 +1,19 @@ +FROM python:3.12-bullseye + +ENV PYTHONFAULTHANDLER=1 + +ENV PYTHONUNBUFFERED=1 + +ENV MODE="LOCAL" + +RUN mkdir -p /app + +COPY requirements.txt /app + +WORKDIR /app + +RUN pip3 install -r requirements.txt + +EXPOSE 8000 + +COPY . /app diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 0000000..087531d --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,21 @@ +FROM python:3.12-bullseye + +ENV PYTHONFAULTHANDLER=1 + +ENV PYTHONUNBUFFERED=1 + +ENV MODE="PRODUCTION" + +RUN mkdir -p /app + +COPY requirements.txt /app + +WORKDIR /app + +RUN pip3 install -r requirements.txt + +EXPOSE 8000 + +COPY . /app + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5e2757f --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# EAMCO Service API + +Service management API for EAMCO oil delivery system. + +## Features + +- Service scheduling and management +- Customer service history tracking +- Integration with EAMCO office API + +## Setup + +### Local Development + +```bash +docker-compose -f docker-compose.local.yml up --build +``` + +### Development Environment + +```bash +docker-compose -f docker-compose.dev.yml up --build +``` + +### Production Environment + +```bash +docker-compose -f docker-compose.prod.yml up --build +``` + +## API Documentation + +Once running, visit `/docs` for interactive API documentation. + +## Environment Variables + +See `settings_*.py` files for environment-specific configuration. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..94dd2c7 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# eamco_service app module diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..d09a4f8 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,41 @@ +import logging +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session +from database import get_db +from app.models.auth import Auth_User + +logger = logging.getLogger(__name__) + +security = HTTPBearer() + + +def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db) +) -> Auth_User: + """ + Validates the Bearer token and returns the authenticated user. + Raises HTTPException 401 if token is invalid or user not found. + """ + token = credentials.credentials + + user = db.query(Auth_User).filter(Auth_User.api_key == token).first() + + if not user: + logger.warning("Authentication failed: invalid API key") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if user.active != 1: + logger.warning(f"Authentication failed: user {user.username} is inactive") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User account is inactive", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return user diff --git a/app/constants.py b/app/constants.py new file mode 100644 index 0000000..28b7f3f --- /dev/null +++ b/app/constants.py @@ -0,0 +1 @@ +DEFAULT_PAGE_SIZE = 50 diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..7acf69a --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,5 @@ +# Models module +from .service import Service_Service, Service_Parts, Service_Plans +from .customer import Customer_Customer +from .auto import Auto_Delivery +from .auth import Auth_User diff --git a/app/models/auth.py b/app/models/auth.py new file mode 100644 index 0000000..3cbd840 --- /dev/null +++ b/app/models/auth.py @@ -0,0 +1,20 @@ +from sqlalchemy import Column, Integer, String, Text, TIMESTAMP +from database import Base + + +class Auth_User(Base): + __tablename__ = 'auth_users' + __table_args__ = {"schema": "public"} + + id = Column(Integer, primary_key=True, autoincrement=True) + uuid = Column(String(32)) + api_key = Column(Text) + username = Column(String(40)) + password_hash = Column(Text) + member_since = Column(TIMESTAMP) + email = Column(String(350)) + last_seen = Column(TIMESTAMP) + admin = Column(Integer) + admin_role = Column(Integer) + confirmed = Column(Integer) + active = Column(Integer, default=1) diff --git a/app/models/auto.py b/app/models/auto.py new file mode 100644 index 0000000..8410a64 --- /dev/null +++ b/app/models/auto.py @@ -0,0 +1,28 @@ +from sqlalchemy import Column, Integer, String, Numeric, Date +from database import Base + + +class Auto_Delivery(Base): + """Read-only auto delivery model for syncing hot_water_summer.""" + __tablename__ = 'auto_delivery' + __table_args__ = {"schema": "public"} + + id = Column(Integer, primary_key=True, autoincrement=True) + customer_id = Column(Integer) + account_number = Column(String(25)) + customer_town = Column(String(140)) + customer_state = Column(Integer) + customer_address = Column(String(1000)) + customer_zip = Column(String(25)) + customer_full_name = Column(String(250)) + last_fill = Column(Date) + days_since_last_fill = Column(Integer) + last_updated = Column(Date) + estimated_gallons_left = Column(Numeric(6, 2)) + estimated_gallons_left_prev_day = Column(Numeric(6, 2)) + tank_height = Column(String(25)) + tank_size = Column(String(25)) + house_factor = Column(Numeric(5, 2)) + hot_water_summer = Column(Integer) + auto_status = Column(Integer) + open_ticket_id = Column(Integer) diff --git a/app/models/customer.py b/app/models/customer.py new file mode 100644 index 0000000..2028ec4 --- /dev/null +++ b/app/models/customer.py @@ -0,0 +1,28 @@ +from sqlalchemy import Column, Integer, String, TIMESTAMP, Boolean +from database import Base + + +class Customer_Customer(Base): + """Read-only customer model for reference.""" + __tablename__ = 'customer_customer' + __table_args__ = {"schema": "public"} + + id = Column(Integer, primary_key=True, autoincrement=True) + auth_net_profile_id = Column(String, unique=True, index=True, nullable=True) + account_number = Column(String(25)) + customer_last_name = Column(String(250)) + customer_first_name = Column(String(250)) + customer_town = Column(String(140)) + customer_state = Column(Integer) + customer_zip = Column(String(25)) + customer_first_call = Column(TIMESTAMP) + customer_email = Column(String(500)) + customer_automatic = Column(Integer) + customer_phone_number = Column(String(25)) + customer_home_type = Column(Integer) + customer_apt = Column(String(140)) + customer_address = Column(String(1000)) + company_id = Column(Integer) + customer_latitude = Column(String(250)) + customer_longitude = Column(String(250)) + correct_address = Column(Boolean) diff --git a/app/models/service.py b/app/models/service.py new file mode 100644 index 0000000..cc4208b --- /dev/null +++ b/app/models/service.py @@ -0,0 +1,48 @@ +from sqlalchemy import Column, Integer, String, Numeric, DateTime, Text, Date +from database import Base + + +class Service_Service(Base): + __tablename__ = 'service_service' + __table_args__ = {"schema": "public"} + + id = Column(Integer, primary_key=True, autoincrement=True) + customer_id = Column(Integer) + customer_name = Column(String(1000)) + customer_address = Column(String(1000)) + customer_town = Column(String(140)) + customer_state = Column(String(140)) + customer_zip = Column(String(10)) + # tune-up = 0, no heat = 1, fix = 2, tank = 3, other = 4 + type_service_call = Column(Integer) + when_ordered = Column(DateTime) + scheduled_date = Column(DateTime) + description = Column(Text) + service_cost = Column(Numeric(10, 2), nullable=True) + payment_type = Column(Integer, nullable=True) + payment_card_id = Column(Integer, nullable=True) + payment_status = Column(Integer, nullable=True) + + +class Service_Parts(Base): + __tablename__ = 'service_parts' + __table_args__ = {"schema": "public"} + + id = Column(Integer, primary_key=True, autoincrement=True) + customer_id = Column(Integer) + oil_filter = Column(String(100)) + oil_filter_2 = Column(String(100)) + oil_nozzle = Column(String(10)) + oil_nozzle_2 = Column(String(10)) + hot_water_tank = Column(Integer) + + +class Service_Plans(Base): + __tablename__ = 'service_plans' + __table_args__ = {"schema": "public"} + + id = Column(Integer, primary_key=True, autoincrement=True) + customer_id = Column(Integer) + contract_plan = Column(Integer, default=0) # 0=no contract, 1=standard, 2=premium + contract_years = Column(Integer, default=1) + contract_start_date = Column(Date) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..22da696 --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1 @@ +# Routers module diff --git a/app/routers/service.py b/app/routers/service.py new file mode 100644 index 0000000..a8a9d84 --- /dev/null +++ b/app/routers/service.py @@ -0,0 +1,579 @@ +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) diff --git a/app/schema/__init__.py b/app/schema/__init__.py new file mode 100644 index 0000000..d80bc96 --- /dev/null +++ b/app/schema/__init__.py @@ -0,0 +1 @@ +# Schema module diff --git a/app/schema/service.py b/app/schema/service.py new file mode 100644 index 0000000..92d181b --- /dev/null +++ b/app/schema/service.py @@ -0,0 +1,111 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime, date +from decimal import Decimal + + +class ServiceBase(BaseModel): + customer_id: Optional[int] = None + customer_name: Optional[str] = None + customer_address: Optional[str] = None + customer_town: Optional[str] = None + customer_state: Optional[str] = None + customer_zip: Optional[str] = None + type_service_call: Optional[int] = None + scheduled_date: Optional[datetime] = None + description: Optional[str] = None + service_cost: Optional[Decimal] = None + payment_type: Optional[int] = None + payment_card_id: Optional[int] = None + payment_status: Optional[int] = None + + +class ServiceResponse(ServiceBase): + id: int + when_ordered: Optional[datetime] = None + + class Config: + from_attributes = True + + +class ServiceCreateRequest(BaseModel): + customer_id: int + expected_delivery_date: str + type_service_call: Optional[int] = None + description: Optional[str] = None + + +class ServiceUpdateRequest(BaseModel): + scheduled_date: Optional[str] = None + type_service_call: Optional[int] = None + description: Optional[str] = None + service_cost: Optional[Decimal] = None + + +class ServiceCostUpdateRequest(BaseModel): + service_cost: Decimal + + +class ServicePartsBase(BaseModel): + customer_id: Optional[int] = None + oil_filter: Optional[str] = None + oil_filter_2: Optional[str] = None + oil_nozzle: Optional[str] = None + oil_nozzle_2: Optional[str] = None + hot_water_tank: Optional[int] = None + + +class ServicePartsResponse(ServicePartsBase): + id: Optional[int] = None + + class Config: + from_attributes = True + + +class ServicePartsUpdateRequest(BaseModel): + oil_filter: Optional[str] = None + oil_filter_2: Optional[str] = None + oil_nozzle: Optional[str] = None + oil_nozzle_2: Optional[str] = None + hot_water_tank: Optional[int] = None + + +class ServicePlanBase(BaseModel): + customer_id: Optional[int] = None + contract_plan: Optional[int] = None + contract_years: Optional[int] = None + contract_start_date: Optional[date] = None + + +class ServicePlanResponse(ServicePlanBase): + id: Optional[int] = None + customer_name: Optional[str] = None + customer_address: Optional[str] = None + customer_town: Optional[str] = None + + class Config: + from_attributes = True + + +class ServicePlanCreateRequest(BaseModel): + customer_id: int + contract_plan: int + contract_years: int + contract_start_date: str + + +class ServicePlanUpdateRequest(BaseModel): + contract_plan: Optional[int] = None + contract_years: Optional[int] = None + contract_start_date: Optional[str] = None + + +class CalendarEvent(BaseModel): + id: int + title: str + start: Optional[str] = None + end: Optional[str] = None + backgroundColor: Optional[str] = None + textColor: Optional[str] = None + borderColor: Optional[str] = None + extendedProps: Optional[dict] = None diff --git a/config.py b/config.py new file mode 100644 index 0000000..8e9a841 --- /dev/null +++ b/config.py @@ -0,0 +1,22 @@ +import os + + +def load_config(mode=os.environ.get('MODE')): + try: + if mode == 'PRODUCTION': + from settings_prod import ApplicationConfig + return ApplicationConfig + + elif mode == 'LOCAL': + from settings_local import ApplicationConfig + return ApplicationConfig + + elif mode == 'DEVELOPMENT': + from settings_dev import ApplicationConfig + return ApplicationConfig + else: + pass + + except ImportError: + from settings_local import ApplicationConfig + return ApplicationConfig diff --git a/database.py b/database.py new file mode 100644 index 0000000..85ca956 --- /dev/null +++ b/database.py @@ -0,0 +1,42 @@ +from sqlalchemy import create_engine +from sqlalchemy.engine import URL +from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import declarative_base + +from config import load_config + + +ApplicationConfig = load_config() + +url = URL.create( + drivername="postgresql", + username=ApplicationConfig.POSTGRES_USERNAME, + password=ApplicationConfig.POSTGRES_PW, + host=ApplicationConfig.POSTGRES_SERVER, + database=ApplicationConfig.POSTGRES_DBNAME, + port=ApplicationConfig.POSTGRES_PORT +) + +engine = create_engine( + url, + pool_pre_ping=True, + pool_size=5, + max_overflow=10, + pool_recycle=3600, +) + +Session = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Keep the global session for backwards compatibility +session = Session() + +Base = declarative_base() +Base.metadata.create_all(engine) + + +def get_db(): + db = Session() + try: + yield db + finally: + db.close() diff --git a/main.py b/main.py new file mode 100644 index 0000000..70b4ae5 --- /dev/null +++ b/main.py @@ -0,0 +1,116 @@ +import logging +import sys +import uuid +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.base import BaseHTTPMiddleware +from config import load_config +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +from app.routers import service + + +ApplicationConfig = load_config() + + +# Configure logging +def setup_logging(): + """Configure structured logging for the application.""" + log_level = logging.DEBUG if ApplicationConfig.CURRENT_SETTINGS != 'PRODUCTION' else logging.INFO + + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + + root_logger = logging.getLogger() + root_logger.setLevel(log_level) + root_logger.handlers.clear() + + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(log_level) + console_handler.setFormatter(formatter) + root_logger.addHandler(console_handler) + + logging.getLogger('uvicorn.access').setLevel(logging.WARNING) + + return logging.getLogger('eamco_service') + + +logger = setup_logging() + +# Database setup with connection pooling +engine = create_engine( + ApplicationConfig.SQLALCHEMY_DATABASE_URI, + pool_pre_ping=True, + pool_size=5, + max_overflow=10, + pool_recycle=3600, +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def check_db_connection(): + """Test database connectivity.""" + try: + db = SessionLocal() + db.execute(text("SELECT 1")) + db.close() + return True + except Exception: + return False + + +app = FastAPI() + + +# Request ID middleware for request tracking/correlation +class RequestIDMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())[:8] + request.state.request_id = request_id + response = await call_next(request) + response.headers["X-Request-ID"] = request_id + return response + + +app.add_middleware(RequestIDMiddleware) + +app.include_router(service.router) + +app.add_middleware( + CORSMiddleware, + allow_origins=ApplicationConfig.CORS_ALLOWED_ORIGINS, + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With"], +) + + +@app.get("/") +def health_check(): + return {"ok": True, "service": "eamco_service"} + + +@app.on_event("startup") +async def startup_event(): + """Application startup - log configuration and test DB connection.""" + logger.info("eamco_service STARTING") + mode = ApplicationConfig.CURRENT_SETTINGS.upper() + if mode in ['DEVELOPMENT', 'DEV']: + logger.info("Mode: Development") + elif mode in ['PRODUCTION', 'PROD']: + logger.info("WARNING PRODUCTION") + # Sanitize DB URI to avoid logging credentials + db_uri = ApplicationConfig.SQLALCHEMY_DATABASE_URI + if '@' in db_uri: + db_uri = db_uri.split('@')[-1] + logger.info(f"DB: ...@{db_uri[:50]}") + logger.info(f"CORS: {len(ApplicationConfig.CORS_ALLOWED_ORIGINS)} origins configured") + + # Test database connection + if check_db_connection(): + logger.info("DB Connection: OK") + else: + logger.info("DB Connection: FAILED") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..eb05e5a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +# eamco_service dependencies +# FastAPI web framework and server +fastapi==0.115.6 +uvicorn[standard]==0.34.0 + +# Database +SQLAlchemy==2.0.40 +psycopg2-binary==2.9.10 + +# Validation +pydantic==2.10.4