Spaces:
Runtime error
Runtime error
| # Fast imports first (these can be slow on some systems) | |
| import os | |
| import sys | |
| print("Loading FastAPI...", flush=True) | |
| from fastapi import FastAPI, Depends, HTTPException, status, UploadFile, File, Request | |
| print("Loading middleware...", flush=True) | |
| from fastapi.staticfiles import StaticFiles | |
| from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi import BackgroundTasks | |
| print("Loading database...", flush=True) | |
| from sqlalchemy.orm import Session | |
| from sqlalchemy import text, func | |
| from sqlalchemy.exc import IntegrityError | |
| from pydantic import BaseModel | |
| from typing import Optional, List | |
| from datetime import datetime, timedelta | |
| from zoneinfo import ZoneInfo | |
| import shutil | |
| import uuid | |
| from dotenv import load_dotenv | |
| import httpx | |
| import scripts.generate_provider_embeddings | |
| import skin_api | |
| # Google OAuth (ID token verification) | |
| try: | |
| from google.oauth2 import id_token as google_id_token | |
| from google.auth.transport import requests as google_requests | |
| except Exception: | |
| google_id_token = None # type: ignore | |
| google_requests = None # type: ignore | |
| # Load environment variables from .env file | |
| load_dotenv() | |
| print("Loading modules...", flush=True) | |
| import models, auth, database | |
| import booking_utils | |
| import notifications | |
| import google_calendar | |
| from jose import jwt, JWTError | |
| # Stripe Payment Integration (optional - gracefully handle if not installed) | |
| stripe = None | |
| try: | |
| import stripe | |
| stripe.api_key = os.getenv("STRIPE_SECRET_KEY", "") | |
| if stripe.api_key: | |
| print("Stripe payment integration enabled") | |
| else: | |
| print(" Stripe secret key not set. Payment functionality disabled.") | |
| stripe = None | |
| except ImportError: | |
| print("Stripe not installed. Payment functionality disabled.") | |
| stripe = None | |
| # Optional: APScheduler for reminders (gracefully handle if not installed) | |
| try: | |
| from apscheduler.schedulers.asyncio import AsyncIOScheduler | |
| scheduler = AsyncIOScheduler() | |
| print(" APScheduler available for booking reminders") | |
| except ImportError: | |
| scheduler = None | |
| print(" APScheduler not installed. Reminder functionality will be limited.") | |
| try: | |
| print("🔄 Connecting to database...") | |
| # Test connection first (with timeout) | |
| from sqlalchemy import text | |
| with database.engine.connect() as conn: | |
| conn.execute(text("SELECT 1")) | |
| print("✅ Database connection successful") | |
| # Create tables (non-blocking) | |
| models.Base.metadata.create_all(bind=database.engine) | |
| print(" Database tables ready") | |
| except Exception as e: | |
| print(f" Database connection issue: {e}") | |
| print(" Server will start but database operations may fail") | |
| print(" Please ensure PostgreSQL is running and connection string is correct") | |
| # Setup Upload Directory | |
| UPLOAD_DIR = "uploads" | |
| os.makedirs(UPLOAD_DIR, exist_ok=True) | |
| os.makedirs(f"{UPLOAD_DIR}/portfolio", exist_ok=True) | |
| os.makedirs(f"{UPLOAD_DIR}/profile", exist_ok=True) | |
| os.makedirs(f"{UPLOAD_DIR}/documents", exist_ok=True) | |
| app = FastAPI() | |
| # --- Include modular routers --- | |
| from ratings.routes import router as ratings_router | |
| from admin.routes import router as admin_router | |
| from standby.routes import router as standby_router | |
| from ai_assistant.routes import router as ai_assistant_router | |
| from skin_analysis.routes import router as skin_analysis_router | |
| app.include_router(ratings_router) | |
| app.include_router(admin_router) | |
| app.include_router(standby_router) | |
| app.include_router(ai_assistant_router) | |
| app.include_router(skin_analysis_router) | |
| # Startup event to initialize scheduler | |
| async def startup_event(): | |
| """Initialize scheduler when app starts""" | |
| print("🚀 Loading Skin Analysis AI Model...") | |
| skin_api._load_model() | |
| if scheduler: | |
| try: | |
| scheduler.start() | |
| print(" APScheduler started for booking reminders") | |
| except Exception as e: | |
| print(f" Failed to start scheduler: {e}") | |
| async def shutdown_event(): | |
| """Shutdown scheduler when app stops""" | |
| if scheduler: | |
| try: | |
| scheduler.shutdown() | |
| print(" APScheduler stopped") | |
| except Exception as e: | |
| print(f" Error stopping scheduler: {e}") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["http://localhost:3000"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # Serve uploaded files | |
| app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads") | |
| # Security | |
| security = HTTPBearer() | |
| # Google OAuth config | |
| GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID") | |
| # Dependency to get current provider from token | |
| def get_current_provider(credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(database.get_db)): | |
| token = credentials.credentials | |
| try: | |
| payload = jwt.decode(token, auth.SECRET_KEY, algorithms=[auth.ALGORITHM]) | |
| email: str = payload.get("sub") | |
| role: str = payload.get("role") | |
| if email is None or role != "provider": | |
| raise HTTPException(status_code=401, detail="Invalid authentication credentials") | |
| except JWTError: | |
| raise HTTPException(status_code=401, detail="Invalid authentication credentials") | |
| provider = db.query(models.ServiceProvider).filter(models.ServiceProvider.email == email).first() | |
| if provider is None: | |
| raise HTTPException(status_code=404, detail="Provider not found") | |
| return provider | |
| # --- Pydantic Schemas --- | |
| class CustomerCreate(BaseModel): | |
| email: str | |
| password: str | |
| full_name: str | |
| phone: str | |
| location: Optional[str] = None # Customer location/city | |
| id_token: Optional[str] = None | |
| class ProviderCreate(BaseModel): | |
| email: str | |
| password: str # Required for signup | |
| full_name: str | |
| phone: str | |
| business_name: str | |
| city: str | |
| bio: Optional[str] = None | |
| cnic_id: Optional[str] = None | |
| certificates: Optional[str] = None | |
| business_license: Optional[str] = None | |
| id_token: Optional[str] = None | |
| class LoginRequest(BaseModel): | |
| email: str | |
| password: str | |
| class Token(BaseModel): | |
| access_token: str | |
| token_type: str | |
| role: str # 'customer' or 'provider' | |
| class ExtendedProviderCreate(ProviderCreate): | |
| profile_photo: Optional[str] = None | |
| portfolio_items: Optional[List[dict]] = None | |
| services: Optional[List[dict]] = None | |
| class ServiceCreate(BaseModel): | |
| name: str | |
| category: str | |
| description: Optional[str] = None | |
| price: str | |
| duration: str | |
| availability_schedule: Optional[dict] = None | |
| class PortfolioItemCreate(BaseModel): | |
| title: str | |
| description: Optional[str] = None | |
| experience_details: Optional[str] = None | |
| image_url: Optional[str] = None | |
| video_url: Optional[str] = None | |
| class TimeSlotCreate(BaseModel): | |
| day: str # e.g., "monday", "tuesday", etc. (for legacy weekly schedule) | |
| start_time: str # e.g., "09:00" (for legacy weekly schedule) | |
| end_time: str # e.g., "17:00" (for legacy weekly schedule) | |
| is_available: bool = True | |
| class TimeSlotAdd(BaseModel): | |
| """Model for adding individual time slots (date + time)""" | |
| service_id: int | |
| slot_datetime: str # ISO format datetime string | |
| is_available: bool = True | |
| class TimeSlotBulkAdd(BaseModel): | |
| """Model for bulk adding time slots""" | |
| service_id: int | |
| start_date: str # ISO format date string | |
| end_date: str # ISO format date string (max 3 months from start) | |
| days_of_week: List[int] # 0=Monday, 6=Sunday | |
| start_time: str # HH:MM format | |
| end_time: str # HH:MM format | |
| interval_minutes: int = 30 # Interval between slots | |
| class BookingCreate(BaseModel): | |
| service_id: int | |
| time_slot_id: int # Reference to time slot | |
| notes: Optional[str] = None | |
| class BookingStatusUpdate(BaseModel): | |
| status: str # "confirmed", "rejected", "cancelled", "completed" | |
| class PaymentIntentCreate(BaseModel): | |
| booking_id: int | |
| class PaymentConfirm(BaseModel): | |
| payment_intent_id: str | |
| booking_id: int | |
| class GoogleAuthRequest(BaseModel): | |
| """Payload for Google OAuth sign-in from frontend (ID token based).""" | |
| id_token: str | |
| role: str # "customer" or "provider" | |
| intent: str # "login" or "signup" | |
| # --- CUSTOMER Routes --- | |
| async def signup_customer(user: CustomerCreate, db: Session = Depends(database.get_db)): | |
| try: | |
| import re | |
| # Check if email exists in Customer table | |
| if db.query(models.Customer).filter(models.Customer.email == user.email).first(): | |
| raise HTTPException(status_code=400, detail="Email already registered as Customer") | |
| # Validate name (only letters and spaces) | |
| if not user.full_name or not user.full_name.strip(): | |
| raise HTTPException(status_code=400, detail="Full name is required.") | |
| name_clean = user.full_name.strip() | |
| if not re.match(r'^[a-zA-Z\s]+$', name_clean): | |
| raise HTTPException(status_code=400, detail="Name should only contain letters and spaces (no numbers or special characters).") | |
| # Validate phone number (11 digits, numeric only) | |
| if not user.phone or not user.phone.strip(): | |
| raise HTTPException(status_code=400, detail="Phone number is required.") | |
| phone_clean = re.sub(r'[\s\-()]', '', user.phone.strip()) # Remove spaces, dashes, parentheses | |
| if not re.match(r'^\d+$', phone_clean): | |
| raise HTTPException(status_code=400, detail="Phone number should contain only numeric values.") | |
| if len(phone_clean) != 11: | |
| raise HTTPException(status_code=400, detail="Phone number must be exactly 11 digits.") | |
| # Validate password | |
| if not user.password: | |
| raise HTTPException(status_code=400, detail="Password is required for signup.") | |
| password_clean = user.password.strip() | |
| if not password_clean: | |
| raise HTTPException(status_code=400, detail="Password cannot be empty or only whitespace.") | |
| if len(password_clean) < 6: | |
| raise HTTPException(status_code=400, detail="Password must be at least 6 characters long.") | |
| # Check for at least one special character | |
| if not re.search(r'[!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?]', password_clean): | |
| raise HTTPException(status_code=400, detail="Password must contain at least one special character.") | |
| hashed_pwd = auth.get_password_hash(password_clean) | |
| google_id = None | |
| picture = None | |
| if user.id_token: | |
| try: | |
| idinfo = _verify_google_id_token(user.id_token) | |
| google_id = idinfo.get("sub") | |
| picture = idinfo.get("picture") | |
| except Exception as e: | |
| print(f"Warning: Failed to verify id_token during customer signup: {e}") | |
| new_user = models.Customer( | |
| email=user.email, | |
| full_name=name_clean, | |
| phone=phone_clean, | |
| location=user.location, | |
| hashed_password=hashed_pwd, | |
| google_id=google_id, | |
| profile_picture=picture | |
| ) | |
| db.add(new_user) | |
| db.commit() | |
| db.refresh(new_user) | |
| access_token = auth.create_access_token(data={"sub": new_user.email, "role": "customer"}) | |
| return {"access_token": access_token, "token_type": "bearer", "role": "customer"} | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| print(f"Error in customer signup: {e}") | |
| db.rollback() | |
| raise HTTPException(status_code=500, detail=f"Signup failed: {str(e)}") | |
| async def login_customer(user: LoginRequest, db: Session = Depends(database.get_db)): | |
| # STRICT CHECK: Only look in Customer table | |
| # Using .first() with indexed email column for fast lookup | |
| db_user = db.query(models.Customer).filter(models.Customer.email == user.email).first() | |
| if not db_user: | |
| # Use same error message to prevent email enumeration | |
| raise HTTPException(status_code=400, detail="Invalid customer credentials") | |
| # Check if account has no password (invalid account) | |
| if db_user.hashed_password is None: | |
| raise HTTPException(status_code=400, detail="Account setup incomplete. Please contact support.") | |
| # Verify password for regular users (Argon2 verification is optimized) | |
| if not auth.verify_password(user.password, db_user.hashed_password): | |
| raise HTTPException(status_code=400, detail="Invalid customer credentials") | |
| access_token = auth.create_access_token(data={"sub": db_user.email, "role": "customer", "customer_id": db_user.id}) | |
| return {"access_token": access_token, "token_type": "bearer", "role": "customer"} | |
| def _verify_google_id_token(id_token_str: str) -> dict: | |
| """Verify Google ID token and return decoded payload.""" | |
| if not google_id_token or not google_requests: | |
| raise HTTPException(status_code=503, detail="Google OAuth libraries are not available on the server.") | |
| if not GOOGLE_CLIENT_ID: | |
| raise HTTPException(status_code=503, detail="GOOGLE_CLIENT_ID is not configured on the server.") | |
| try: | |
| idinfo = google_id_token.verify_oauth2_token( | |
| id_token_str, | |
| google_requests.Request(), | |
| GOOGLE_CLIENT_ID, | |
| ) | |
| except Exception as e: | |
| raise HTTPException(status_code=400, detail=f"Invalid Google ID token: {e}") | |
| if not idinfo.get("email"): | |
| raise HTTPException(status_code=400, detail="Google token does not contain an email address.") | |
| if not idinfo.get("email_verified", False): | |
| raise HTTPException(status_code=400, detail="Google email address is not verified.") | |
| return idinfo | |
| async def google_auth( | |
| payload: GoogleAuthRequest, | |
| db: Session = Depends(database.get_db), | |
| ): | |
| """ | |
| Unified Google OAuth entry point for both login and signup flows. | |
| Frontend sends an ID token obtained from Google Identity Services. | |
| - intent = "login": if user exists and is active → issue JWT and log them in. | |
| if not exists → tell frontend to redirect to signup with pre-filled email. | |
| - intent = "signup": just verify token and return profile info so signup forms can be pre-filled. | |
| """ | |
| intent = payload.intent.lower().strip() | |
| role = payload.role.lower().strip() | |
| if role not in {"customer", "provider"}: | |
| raise HTTPException(status_code=400, detail="Invalid role. Must be 'customer' or 'provider'.") | |
| if intent not in {"login", "signup"}: | |
| raise HTTPException(status_code=400, detail="Invalid intent. Must be 'login' or 'signup'.") | |
| idinfo = _verify_google_id_token(payload.id_token) | |
| email = idinfo.get("email") | |
| google_sub = idinfo.get("sub") | |
| full_name = idinfo.get("name") | |
| picture = idinfo.get("picture") | |
| Model = models.Customer if role == "customer" else models.ServiceProvider | |
| user = db.query(Model).filter(Model.email == email).first() | |
| # For signup intent: we never create the user here. We only return verified info | |
| # so the frontend can prefill the form with a locked email. | |
| if intent == "signup": | |
| return { | |
| "email": email, | |
| "name": full_name, | |
| "google_id": google_sub, | |
| "picture": picture, | |
| "role": role, | |
| "already_registered": bool(user), | |
| } | |
| # intent == "login" | |
| if not user: | |
| # No account exists – frontend should redirect the user to appropriate signup page | |
| # with pre-filled, non-editable email. | |
| return { | |
| "needs_signup": True, | |
| "email": email, | |
| "name": full_name, | |
| "google_id": google_sub, | |
| "picture": picture, | |
| "role": role, | |
| } | |
| # Ensure user is active / approved | |
| if hasattr(user, "is_active") and not user.is_active: | |
| raise HTTPException(status_code=403, detail="Account is not approved or is inactive.") | |
| # Link Google account to existing user record (id + picture) if not already linked | |
| updated = False | |
| if hasattr(user, "google_id") and not user.google_id and google_sub: | |
| user.google_id = google_sub | |
| updated = True | |
| # Customers and providers both have profile_picture field (provider also has profile_photo) | |
| if picture and hasattr(user, "profile_picture") and not user.profile_picture: | |
| user.profile_picture = picture # type: ignore | |
| updated = True | |
| if updated: | |
| db.add(user) | |
| db.commit() | |
| db.refresh(user) | |
| token_data = {"sub": user.email, "role": role} | |
| if role == "customer": | |
| token_data["customer_id"] = user.id | |
| else: | |
| token_data["provider_id"] = user.id | |
| access_token = auth.create_access_token(data=token_data) | |
| return {"access_token": access_token, "token_type": "bearer", "role": role} | |
| # --- SERVICE PROVIDER Routes --- | |
| async def signup_provider(user: ProviderCreate, db: Session = Depends(database.get_db)): | |
| try: | |
| import re | |
| # Check if email exists in Provider table | |
| if db.query(models.ServiceProvider).filter(models.ServiceProvider.email == user.email).first(): | |
| raise HTTPException(status_code=400, detail="Email already registered as Provider") | |
| # Validate name (only letters and spaces) | |
| if not user.full_name or not user.full_name.strip(): | |
| raise HTTPException(status_code=400, detail="Full name is required.") | |
| name_clean = user.full_name.strip() | |
| if not re.match(r'^[a-zA-Z\s]+$', name_clean): | |
| raise HTTPException(status_code=400, detail="Name should only contain letters and spaces (no numbers or special characters).") | |
| # Validate phone number (11 digits, numeric only) | |
| if not user.phone or not user.phone.strip(): | |
| raise HTTPException(status_code=400, detail="Phone number is required.") | |
| phone_clean = re.sub(r'[\s\-()]', '', user.phone.strip()) # Remove spaces, dashes, parentheses | |
| if not re.match(r'^\d+$', phone_clean): | |
| raise HTTPException(status_code=400, detail="Phone number should contain only numeric values.") | |
| if len(phone_clean) != 11: | |
| raise HTTPException(status_code=400, detail="Phone number must be exactly 11 digits.") | |
| # Validate CNIC (13 digits, numeric only) - if provided | |
| if user.cnic_id and user.cnic_id.strip(): | |
| cnic_clean = re.sub(r'[\s\-]', '', user.cnic_id.strip()) # Remove spaces and dashes | |
| if not re.match(r'^\d+$', cnic_clean): | |
| raise HTTPException(status_code=400, detail="CNIC should contain only numeric values.") | |
| if len(cnic_clean) != 13: | |
| raise HTTPException(status_code=400, detail="CNIC must be exactly 13 digits.") | |
| # Validate password | |
| if not user.password: | |
| raise HTTPException(status_code=400, detail="Password is required for signup.") | |
| password_clean = user.password.strip() | |
| if not password_clean: | |
| raise HTTPException(status_code=400, detail="Password cannot be empty or only whitespace.") | |
| if len(password_clean) < 6: | |
| raise HTTPException(status_code=400, detail="Password must be at least 6 characters long.") | |
| # Check for at least one special character | |
| if not re.search(r'[!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?]', password_clean): | |
| raise HTTPException(status_code=400, detail="Password must contain at least one special character.") | |
| # Hash password | |
| hashed_pwd = auth.get_password_hash(password_clean) | |
| # Clean CNIC if provided | |
| cnic_clean = None | |
| if user.cnic_id and user.cnic_id.strip(): | |
| cnic_clean = re.sub(r'[\s\-]', '', user.cnic_id.strip()) | |
| google_id = None | |
| picture = None | |
| if user.id_token: | |
| try: | |
| idinfo = _verify_google_id_token(user.id_token) | |
| google_id = idinfo.get("sub") | |
| picture = idinfo.get("picture") | |
| except Exception as e: | |
| print(f"Warning: Failed to verify id_token during provider signup: {e}") | |
| new_provider = models.ServiceProvider( | |
| email=user.email, | |
| full_name=name_clean, | |
| phone=phone_clean, | |
| business_name=user.business_name, | |
| city=user.city, | |
| bio=user.bio, | |
| cnic_id=cnic_clean, | |
| certificates=user.certificates, | |
| business_license=user.business_license, | |
| hashed_password=hashed_pwd, | |
| level="beginner", | |
| google_id=google_id, | |
| profile_picture=picture | |
| ) | |
| db.add(new_provider) | |
| db.commit() | |
| db.refresh(new_provider) | |
| # Create Stripe Express connected account for payouts (optional but recommended) | |
| if stripe: | |
| try: | |
| connect_country = os.getenv("STRIPE_CONNECT_COUNTRY", "US") | |
| account = stripe.Account.create( | |
| type="express", | |
| country=connect_country, | |
| email=new_provider.email, | |
| capabilities={ | |
| "card_payments": {"requested": True}, | |
| "transfers": {"requested": True}, | |
| }, | |
| business_type="individual", | |
| ) | |
| new_provider.stripe_account_id = account.id | |
| new_provider.stripe_onboarding_complete = False | |
| db.commit() | |
| db.refresh(new_provider) | |
| except Exception as e: | |
| print(f"Warning: Failed to create Stripe connected account for provider {new_provider.id}: {e}") | |
| access_token = auth.create_access_token(data={"sub": new_provider.email, "role": "provider", "provider_id": new_provider.id}) | |
| return {"access_token": access_token, "token_type": "bearer", "role": "provider"} | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| print(f"Error in provider signup: {e}") | |
| db.rollback() | |
| raise HTTPException(status_code=500, detail=f"Signup failed: {str(e)}") | |
| async def login_provider(user: LoginRequest, db: Session = Depends(database.get_db)): | |
| # STRICT CHECK: Only look in Provider table | |
| db_user = db.query(models.ServiceProvider).filter(models.ServiceProvider.email == user.email).first() | |
| if not db_user: | |
| raise HTTPException(status_code=400, detail="Invalid provider credentials") | |
| # Check if account has no password (invalid account) | |
| if db_user.hashed_password is None: | |
| raise HTTPException(status_code=400, detail="Account setup incomplete. Please contact support.") | |
| # Verify password for regular users | |
| if not auth.verify_password(user.password, db_user.hashed_password): | |
| raise HTTPException(status_code=400, detail="Invalid provider credentials") | |
| access_token = auth.create_access_token(data={"sub": db_user.email, "role": "provider", "provider_id": db_user.id}) | |
| return {"access_token": access_token, "token_type": "bearer", "role": "provider"} | |
| # --- File Upload Routes --- | |
| async def upload_portfolio(file: UploadFile = File(...)): | |
| file_ext = file.filename.split(".")[-1] | |
| allowed_exts = ["jpg", "jpeg", "png", "gif", "mp4", "mov", "webm"] | |
| if file_ext.lower() not in allowed_exts: | |
| raise HTTPException(status_code=400, detail="File type not allowed") | |
| file_path = f"{UPLOAD_DIR}/portfolio/{file.filename}" | |
| with open(file_path, "wb") as buffer: | |
| shutil.copyfileobj(file.file, buffer) | |
| return {"url": f"/{file_path}", "filename": file.filename} | |
| async def upload_profile( | |
| file: UploadFile = File(...), | |
| credentials: HTTPAuthorizationCredentials = Depends(security), | |
| db: Session = Depends(database.get_db) | |
| ): | |
| """Upload profile photo - works for both providers and customers""" | |
| token = credentials.credentials | |
| try: | |
| payload = jwt.decode(token, auth.SECRET_KEY, algorithms=[auth.ALGORITHM]) | |
| email = payload.get("sub") | |
| role = payload.get("role") | |
| if not email or role not in ("provider", "customer"): | |
| raise HTTPException(status_code=401, detail="Invalid authentication credentials") | |
| except JWTError: | |
| raise HTTPException(status_code=401, detail="Invalid authentication credentials") | |
| file_ext = file.filename.split(".")[-1].lower() | |
| if file_ext not in ["jpg", "jpeg", "png", "gif", "webp"]: | |
| raise HTTPException(status_code=400, detail="Only image files allowed") | |
| os.makedirs(f"{UPLOAD_DIR}/profile", exist_ok=True) | |
| unique_filename = f"{uuid.uuid4()}.{file_ext}" | |
| file_path = f"{UPLOAD_DIR}/profile/{unique_filename}" | |
| with open(file_path, "wb") as buffer: | |
| shutil.copyfileobj(file.file, buffer) | |
| # Auto-update the profile photo in DB based on role | |
| if role == "provider": | |
| provider = db.query(models.ServiceProvider).filter(models.ServiceProvider.email == email).first() | |
| if provider: | |
| provider.profile_photo = f"/{file_path}" | |
| provider.profile_picture = f"/{file_path}" | |
| db.commit() | |
| elif role == "customer": | |
| customer = db.query(models.Customer).filter(models.Customer.email == email).first() | |
| if customer: | |
| customer.profile_picture = f"/{file_path}" | |
| db.commit() | |
| return {"url": f"/{file_path}", "filename": unique_filename} | |
| async def upload_documents(file: UploadFile = File(...)): | |
| """Upload certificates, licenses, or other documents (PDF, images)""" | |
| file_ext = file.filename.split(".")[-1] | |
| allowed_exts = ["pdf", "jpg", "jpeg", "png", "doc", "docx"] | |
| if file_ext.lower() not in allowed_exts: | |
| raise HTTPException(status_code=400, detail=f"File type .{file_ext} not allowed. Allowed: PDF, images, DOC") | |
| # Create documents directory | |
| os.makedirs(f"{UPLOAD_DIR}/documents", exist_ok=True) | |
| file_path = f"{UPLOAD_DIR}/documents/{file.filename}" | |
| with open(file_path, "wb") as buffer: | |
| shutil.copyfileobj(file.file, buffer) | |
| return {"url": f"/{file_path}", "filename": file.filename} | |
| # --- Portfolio Management Routes (For Providers) --- | |
| async def add_portfolio_item(item: PortfolioItemCreate, current_provider: models.ServiceProvider = Depends(get_current_provider), db: Session = Depends(database.get_db)): | |
| portfolio_item = models.PortfolioItem( | |
| provider_id=current_provider.id, | |
| title=item.title, | |
| description=item.description, | |
| experience_details=item.experience_details, | |
| image_url=item.image_url, | |
| video_url=item.video_url | |
| ) | |
| db.add(portfolio_item) | |
| db.commit() | |
| db.refresh(portfolio_item) | |
| return {"id": portfolio_item.id, "message": "Portfolio item added"} | |
| async def get_portfolio(current_provider: models.ServiceProvider = Depends(get_current_provider), db: Session = Depends(database.get_db)): | |
| items = db.query(models.PortfolioItem).filter(models.PortfolioItem.provider_id == current_provider.id).all() | |
| return [ | |
| { | |
| "id": i.id, | |
| "title": i.title, | |
| "description": i.description, | |
| "experience_details": i.experience_details, | |
| "image_url": i.image_url, | |
| "video_url": i.video_url | |
| } for i in items | |
| ] | |
| async def update_portfolio_item(item_id: int, item: PortfolioItemCreate, current_provider: models.ServiceProvider = Depends(get_current_provider), db: Session = Depends(database.get_db)): | |
| db_item = db.query(models.PortfolioItem).filter(models.PortfolioItem.id == item_id, models.PortfolioItem.provider_id == current_provider.id).first() | |
| if not db_item: | |
| raise HTTPException(status_code=404, detail="Item not found") | |
| db_item.title = item.title | |
| db_item.description = item.description | |
| db_item.experience_details = item.experience_details | |
| if item.image_url: | |
| db_item.image_url = item.image_url | |
| if item.video_url: | |
| db_item.video_url = item.video_url | |
| db.commit() | |
| db.refresh(db_item) | |
| return { | |
| "message": "Updated", | |
| "item": { | |
| "id": db_item.id, | |
| "title": db_item.title, | |
| "description": db_item.description, | |
| "experience_details": db_item.experience_details, | |
| "image_url": db_item.image_url, | |
| "video_url": db_item.video_url | |
| } | |
| } | |
| async def delete_portfolio_item(item_id: int, current_provider: models.ServiceProvider = Depends(get_current_provider), db: Session = Depends(database.get_db)): | |
| item = db.query(models.PortfolioItem).filter(models.PortfolioItem.id == item_id, models.PortfolioItem.provider_id == current_provider.id).first() | |
| if not item: | |
| raise HTTPException(status_code=404, detail="Item not found") | |
| db.delete(item) | |
| db.commit() | |
| return {"message": "Deleted"} | |
| # --- Service Management Routes --- | |
| async def add_service(service: ServiceCreate, current_provider: models.ServiceProvider = Depends(get_current_provider), db: Session = Depends(database.get_db)): | |
| new_service = models.Service( | |
| provider_id=current_provider.id, | |
| name=service.name, | |
| category=service.category, | |
| description=service.description, | |
| price=service.price, | |
| duration=service.duration, | |
| availability_schedule=json.dumps(service.availability_schedule) if service.availability_schedule else None | |
| ) | |
| db.add(new_service) | |
| db.commit() | |
| db.refresh(new_service) | |
| return {"id": new_service.id, "message": "Service added"} | |
| async def get_services(current_provider: models.ServiceProvider = Depends(get_current_provider), db: Session = Depends(database.get_db)): | |
| services = db.query(models.Service).filter(models.Service.provider_id == current_provider.id).all() | |
| return [ | |
| { | |
| "id": s.id, | |
| "name": s.name, | |
| "category": s.category, | |
| "description": s.description, | |
| "price": s.price, | |
| "duration": s.duration, | |
| "availability_schedule": json.loads(s.availability_schedule) if s.availability_schedule else None | |
| } for s in services | |
| ] | |
| async def update_service(service_id: int, service: ServiceCreate, current_provider: models.ServiceProvider = Depends(get_current_provider), db: Session = Depends(database.get_db)): | |
| db_service = db.query(models.Service).filter(models.Service.id == service_id, models.Service.provider_id == current_provider.id).first() | |
| if not db_service: | |
| raise HTTPException(status_code=404, detail="Service not found") | |
| db_service.name = service.name | |
| db_service.category = service.category | |
| db_service.description = service.description | |
| db_service.price = service.price | |
| db_service.duration = service.duration | |
| db_service.availability_schedule = json.dumps(service.availability_schedule) if service.availability_schedule else None | |
| db.commit() | |
| return {"message": "Service updated"} | |
| async def delete_service(service_id: int, current_provider: models.ServiceProvider = Depends(get_current_provider), db: Session = Depends(database.get_db)): | |
| service = db.query(models.Service).filter(models.Service.id == service_id, models.Service.provider_id == current_provider.id).first() | |
| if not service: | |
| raise HTTPException(status_code=404, detail="Service not found") | |
| db.delete(service) | |
| db.commit() | |
| return {"message": "Service deleted"} | |
| async def get_provider_bookings(current_provider: models.ServiceProvider = Depends(get_current_provider), db: Session = Depends(database.get_db)): | |
| """Get all bookings for the provider (including ones they originally had before standby reassignment)""" | |
| from sqlalchemy import or_ | |
| bookings = db.query(models.Booking).filter( | |
| or_( | |
| models.Booking.provider_id == current_provider.id, | |
| models.Booking.original_provider_id == current_provider.id, | |
| ) | |
| ).order_by(models.Booking.booking_date.desc()).all() | |
| # Include customer and service details | |
| result = [] | |
| for booking in bookings: | |
| customer = db.query(models.Customer).filter(models.Customer.id == booking.customer_id).first() | |
| service = db.query(models.Service).filter(models.Service.id == booking.service_id).first() | |
| result.append({ | |
| "id": booking.id, | |
| "customer": { | |
| "id": customer.id if customer else None, | |
| "full_name": customer.full_name if customer else None, | |
| "email": customer.email if customer else None, | |
| "phone": customer.phone if customer else None, | |
| }, | |
| "service": { | |
| "id": service.id if service else None, | |
| "name": service.name if service else None, | |
| "price": service.price if service else None, | |
| }, | |
| "booking_date": booking.booking_date, | |
| "status": booking.status, | |
| "notes": booking.notes, | |
| "created_at": booking.created_at, | |
| "payment_status": booking.payment_status if hasattr(booking, 'payment_status') else "UNPAID", | |
| "booking_status": getattr(booking, "booking_status", None), | |
| "original_provider_id": getattr(booking, "original_provider_id", None), | |
| "assigned_provider_id": getattr(booking, "assigned_provider_id", None), | |
| }) | |
| return result | |
| async def update_booking_status( | |
| booking_id: int, | |
| status_update: BookingStatusUpdate, | |
| background_tasks: BackgroundTasks, | |
| current_provider: models.ServiceProvider = Depends(get_current_provider), | |
| db: Session = Depends(database.get_db) | |
| ): | |
| """Update booking status (accept/reject/cancel) with Google Calendar integration""" | |
| booking = db.query(models.Booking).filter( | |
| models.Booking.id == booking_id, | |
| models.Booking.provider_id == current_provider.id | |
| ).first() | |
| if not booking: | |
| raise HTTPException(status_code=404, detail="Booking not found") | |
| if status_update.status not in ["accepted", "confirmed", "rejected", "cancelled", "completed", "cancelled_by_provider", "standby_selected", "standby_pending", "awaiting_extra_payment"]: | |
| raise HTTPException(status_code=400, detail="Invalid status.") | |
| old_status = booking.status | |
| # When provider accepts, set status to "accepted" (not "confirmed") | |
| # Booking becomes "confirmed" only after payment has succeeded and funds are in escrow. | |
| if status_update.status == "confirmed": | |
| # Only allow setting to "confirmed" if payment is already captured/held in escrow | |
| if booking.payment_status not in ["HELD_IN_ESCROW", "RELEASED_TO_PROVIDER"]: | |
| raise HTTPException( | |
| status_code=400, | |
| detail="Cannot confirm booking. Payment must be completed and held in escrow first. Use 'accepted' status to approve the booking." | |
| ) | |
| booking.status = status_update.status | |
| booking.booking_status = status_update.status | |
| # Get customer and service for notifications | |
| customer = db.query(models.Customer).filter(models.Customer.id == booking.customer_id).first() | |
| service = db.query(models.Service).filter(models.Service.id == booking.service_id).first() | |
| provider_tz = current_provider.timezone or "UTC" | |
| booking_date_local = booking_utils.convert_to_timezone(booking.booking_date, provider_tz) | |
| # Google Calendar integration - only create event when booking is confirmed (after payment) | |
| if status_update.status == "confirmed" and booking.payment_status in ["HELD_IN_ESCROW", "RELEASED_TO_PROVIDER"] and current_provider.google_calendar_access_token: | |
| # Create calendar event | |
| if not booking.google_calendar_event_id: | |
| event_id = google_calendar.create_booking_calendar_event( | |
| access_token=current_provider.google_calendar_access_token, | |
| refresh_token=current_provider.google_calendar_refresh_token, | |
| customer_name=customer.full_name or "Customer", | |
| customer_email=customer.email, | |
| provider_name=current_provider.full_name or current_provider.business_name, | |
| service_name=service.name if service else "Service", | |
| booking_date=booking.booking_date, | |
| end_date=booking.end_date or booking_utils.calculate_end_time(booking.booking_date, service.duration if service else "60 minutes"), | |
| notes=booking.notes, | |
| location=current_provider.city | |
| ) | |
| if event_id: | |
| booking.google_calendar_event_id = event_id | |
| # Schedule reminder (if scheduler is available) | |
| if scheduler and booking.booking_date > datetime.now(ZoneInfo("UTC")): | |
| reminder_time = booking.booking_date - timedelta(hours=24) | |
| if reminder_time > datetime.now(ZoneInfo("UTC")): | |
| scheduler.add_job( | |
| notifications.notify_booking_reminder, | |
| 'date', | |
| run_date=reminder_time, | |
| args=[ | |
| customer.email, | |
| customer.phone, | |
| customer.full_name or "Customer", | |
| current_provider.full_name or current_provider.business_name, | |
| service.name if service else "Service", | |
| booking_date_local, | |
| 24 | |
| ] | |
| ) | |
| elif status_update.status in ["cancelled", "rejected"] and booking.google_calendar_event_id: | |
| # Cancel/delete calendar event | |
| if current_provider.google_calendar_access_token: | |
| service_obj = google_calendar.get_google_calendar_service( | |
| current_provider.google_calendar_access_token, | |
| current_provider.google_calendar_refresh_token | |
| ) | |
| if service_obj: | |
| google_calendar.update_calendar_event( | |
| service_obj, | |
| booking.google_calendar_event_id, | |
| status="cancelled" | |
| ) | |
| # Free up time slot when booking is cancelled or rejected | |
| if status_update.status in ["cancelled", "rejected", "cancelled_by_provider"] and booking.time_slot_id: | |
| time_slot = db.query(models.TimeSlot).filter(models.TimeSlot.id == booking.time_slot_id).first() | |
| if time_slot: | |
| time_slot.is_available = True | |
| booking.time_slot_id = None | |
| # Handle payout to provider when marking booking as completed | |
| if status_update.status == "completed": | |
| # Only attempt payout once, when funds are held in escrow | |
| if booking.payment_status == "HELD_IN_ESCROW": | |
| if not stripe: | |
| raise HTTPException(status_code=503, detail="Stripe payment integration is not available") | |
| provider = current_provider | |
| if not provider.stripe_account_id: | |
| raise HTTPException( | |
| status_code=400, | |
| detail="Provider payouts are not set up. Please complete Stripe onboarding from the dashboard." | |
| ) | |
| # Refresh onboarding status from Stripe to avoid stale DB flags | |
| try: | |
| account = stripe.Account.retrieve(provider.stripe_account_id) | |
| provider.stripe_onboarding_complete = bool( | |
| getattr(account, "charges_enabled", False) and getattr(account, "payouts_enabled", False) | |
| ) | |
| db.commit() | |
| db.refresh(provider) | |
| except Exception as e: | |
| print(f"Warning: Failed to refresh Stripe account status for provider {provider.id}: {e}") | |
| if not provider.stripe_onboarding_complete: | |
| raise HTTPException( | |
| status_code=400, | |
| detail="Provider payouts are not yet enabled in Stripe. Please complete onboarding first." | |
| ) | |
| # Determine payout amount: booking amount minus 10% platform fee | |
| payment = booking.payment | |
| if not payment: | |
| payment = db.query(models.Payment).filter(models.Payment.booking_id == booking.id).first() | |
| if not payment: | |
| raise HTTPException(status_code=400, detail="No payment record found for this booking.") | |
| try: | |
| gross_amount = float(payment.amount.replace("$", "").replace(",", "").strip()) | |
| except Exception: | |
| raise HTTPException(status_code=400, detail=f"Invalid payment amount format: {payment.amount}") | |
| gross_cents = int(round(gross_amount * 100)) | |
| provider_cents = int(round(gross_cents * 0.95)) # 5% platform fee | |
| currency = (payment.currency or "usd").lower() | |
| if not booking.stripe_charge_id: | |
| # Fallback to payment's charge id if present | |
| if payment.stripe_charge_id: | |
| booking.stripe_charge_id = payment.stripe_charge_id | |
| else: | |
| raise HTTPException( | |
| status_code=400, | |
| detail="Missing Stripe charge ID for payout. Cannot transfer funds to provider." | |
| ) | |
| try: | |
| transfer = stripe.Transfer.create( | |
| amount=provider_cents, | |
| currency=currency, | |
| destination=provider.stripe_account_id, | |
| source_transaction=booking.stripe_charge_id, | |
| metadata={ | |
| "booking_id": booking.id, | |
| "provider_id": provider.id, | |
| "customer_id": booking.customer_id, | |
| }, | |
| ) | |
| booking.payment_status = "RELEASED_TO_PROVIDER" | |
| booking.stripe_transfer_id = transfer.id | |
| except stripe.error.StripeError as e: | |
| raise HTTPException(status_code=400, detail=f"Stripe transfer error: {str(e)}") | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"Failed to create payout transfer: {str(e)}") | |
| db.commit() | |
| db.refresh(booking) | |
| # Send notifications | |
| if status_update.status == "accepted" and old_status != "accepted": | |
| background_tasks.add_task( | |
| notifications.notify_booking_accepted, | |
| customer.email, | |
| customer.full_name or "Customer", | |
| current_provider.full_name or current_provider.business_name, | |
| service.name if service else "Service", | |
| booking_date_local | |
| ) | |
| elif status_update.status == "confirmed" and old_status != "confirmed": | |
| # This will be sent when payment is made (in payment confirmation endpoint) | |
| pass | |
| elif status_update.status == "rejected": | |
| background_tasks.add_task( | |
| notifications.notify_booking_rejected, | |
| customer.email, | |
| customer.full_name or "Customer", | |
| current_provider.full_name or current_provider.business_name, | |
| service.name if service else "Service", | |
| booking_date_local | |
| ) | |
| elif status_update.status == "cancelled": | |
| background_tasks.add_task( | |
| notifications.notify_booking_cancelled, | |
| customer.email, | |
| customer.full_name or "Customer", | |
| current_provider.full_name or current_provider.business_name, | |
| service.name if service else "Service", | |
| booking_date_local, | |
| "provider" | |
| ) | |
| return {"message": f"Booking {status_update.status}", "booking_id": booking.id} | |
| async def update_service_time_slots( | |
| service_id: int, | |
| time_slots: List[TimeSlotCreate], | |
| current_provider: models.ServiceProvider = Depends(get_current_provider), | |
| db: Session = Depends(database.get_db) | |
| ): | |
| """Update time slots for a service with overlap validation""" | |
| service = db.query(models.Service).filter( | |
| models.Service.id == service_id, | |
| models.Service.provider_id == current_provider.id | |
| ).first() | |
| if not service: | |
| raise HTTPException(status_code=404, detail="Service not found") | |
| # Validate time slots (check for overlaps within same day) | |
| schedule = {} | |
| for slot in time_slots: | |
| if not slot.is_available: | |
| continue | |
| day = slot.day.lower() | |
| if day not in schedule: | |
| schedule[day] = [] | |
| # Validate time format | |
| try: | |
| start = datetime.strptime(slot.start_time, "%H:%M").time() | |
| end = datetime.strptime(slot.end_time, "%H:%M").time() | |
| if start >= end: | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Invalid time slot for {day}: start time must be before end time" | |
| ) | |
| # Check for overlaps with existing slots for the same day | |
| for existing_slot in schedule[day]: | |
| existing_start = datetime.strptime(existing_slot["start_time"], "%H:%M").time() | |
| existing_end = datetime.strptime(existing_slot["end_time"], "%H:%M").time() | |
| # Check if times overlap | |
| if not (end <= existing_start or start >= existing_end): | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Time slot overlap detected for {day}: {slot.start_time}-{slot.end_time} overlaps with {existing_slot['start_time']}-{existing_slot['end_time']}" | |
| ) | |
| schedule[day].append({ | |
| "start_time": slot.start_time, | |
| "end_time": slot.end_time, | |
| "is_available": slot.is_available | |
| }) | |
| except ValueError: | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Invalid time format for {day}. Use HH:MM format (e.g., 09:00)" | |
| ) | |
| service.availability_schedule = json.dumps(schedule) | |
| db.commit() | |
| db.refresh(service) | |
| return {"message": "Time slots updated", "availability_schedule": service.availability_schedule} | |
| async def get_service_time_slots( | |
| service_id: int, | |
| start_date: Optional[str] = None, | |
| end_date: Optional[str] = None, | |
| current_provider: models.ServiceProvider = Depends(get_current_provider), | |
| db: Session = Depends(database.get_db) | |
| ): | |
| """Get time slots for a service (new system - individual slots)""" | |
| service = db.query(models.Service).filter( | |
| models.Service.id == service_id, | |
| models.Service.provider_id == current_provider.id | |
| ).first() | |
| if not service: | |
| raise HTTPException(status_code=404, detail="Service not found") | |
| # Query time slots | |
| query = db.query(models.TimeSlot).filter(models.TimeSlot.service_id == service_id) | |
| # Filter by date range if provided | |
| if start_date: | |
| try: | |
| start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00')) | |
| query = query.filter(models.TimeSlot.slot_date >= start_dt) | |
| except: | |
| pass | |
| if end_date: | |
| try: | |
| end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00')) | |
| query = query.filter(models.TimeSlot.slot_date <= end_dt) | |
| except: | |
| pass | |
| time_slots = query.order_by(models.TimeSlot.slot_date.asc()).all() | |
| return { | |
| "service_id": service_id, | |
| "time_slots": [ | |
| { | |
| "id": slot.id, | |
| "slot_date": slot.slot_date.isoformat(), | |
| "is_available": slot.is_available | |
| } | |
| for slot in time_slots | |
| ] | |
| } | |
| async def create_bulk_time_slots( | |
| service_id: int, | |
| request: dict, | |
| current_provider: models.ServiceProvider = Depends(get_current_provider), | |
| db: Session = Depends(database.get_db) | |
| ): | |
| """Create time slots in bulk for a service (up to 3 months)""" | |
| service = db.query(models.Service).filter( | |
| models.Service.id == service_id, | |
| models.Service.provider_id == current_provider.id | |
| ).first() | |
| if not service: | |
| raise HTTPException(status_code=404, detail="Service not found") | |
| # Parse request | |
| start_date_str = request.get("start_date") | |
| end_date_str = request.get("end_date") | |
| days_of_week = request.get("days_of_week", []) # 0=Monday, 6=Sunday | |
| start_time_str = request.get("start_time") # HH:MM | |
| end_time_str = request.get("end_time") # HH:MM | |
| interval_minutes = request.get("interval_minutes", 30) | |
| if not all([start_date_str, end_date_str, start_time_str, end_time_str]): | |
| raise HTTPException(status_code=400, detail="Missing required fields") | |
| # Parse dates | |
| try: | |
| start_date = datetime.fromisoformat(start_date_str.replace('Z', '+00:00')) | |
| end_date = datetime.fromisoformat(end_date_str.replace('Z', '+00:00')) | |
| except: | |
| raise HTTPException(status_code=400, detail="Invalid date format") | |
| # Validate 3 months limit | |
| max_date = start_date + timedelta(days=90) | |
| if end_date > max_date: | |
| raise HTTPException(status_code=400, detail="Time slots can only be created up to 3 months in advance") | |
| # Parse times | |
| try: | |
| start_time = datetime.strptime(start_time_str, "%H:%M").time() | |
| end_time = datetime.strptime(end_time_str, "%H:%M").time() | |
| except: | |
| raise HTTPException(status_code=400, detail="Invalid time format. Use HH:MM") | |
| if start_time >= end_time: | |
| raise HTTPException(status_code=400, detail="Start time must be before end time") | |
| # Get provider timezone | |
| provider_tz = current_provider.timezone or "UTC" | |
| # Generate time slots | |
| created_slots = [] | |
| current_date = start_date.date() | |
| end_date_only = end_date.date() | |
| while current_date <= end_date_only: | |
| # Check if this day of week is selected (0=Monday, 6=Sunday) | |
| day_of_week = current_date.weekday() | |
| if day_of_week in days_of_week: | |
| # Create slots for this day | |
| current_time = start_time | |
| while current_time < end_time: | |
| # Create datetime for this slot | |
| # Build an aware datetime in the provider's timezone. | |
| # NOTE: `zoneinfo.ZoneInfo` does not support `.localize()` (that's a pytz API). | |
| # Using replace(tzinfo=...) is sufficient here (Pakistan has no DST edge cases). | |
| slot_datetime = datetime.combine(current_date, current_time).replace(tzinfo=ZoneInfo(provider_tz)) | |
| # Check if slot already exists | |
| existing = db.query(models.TimeSlot).filter( | |
| models.TimeSlot.service_id == service_id, | |
| models.TimeSlot.slot_date == slot_datetime | |
| ).first() | |
| if not existing: | |
| new_slot = models.TimeSlot( | |
| service_id=service_id, | |
| slot_date=slot_datetime, | |
| is_available=True | |
| ) | |
| db.add(new_slot) | |
| created_slots.append(new_slot) | |
| # Move to next slot | |
| current_time = (datetime.combine(datetime.min, current_time) + timedelta(minutes=interval_minutes)).time() | |
| current_date += timedelta(days=1) | |
| # Fix sequence before committing to prevent duplicate key errors | |
| try: | |
| from sqlalchemy import text | |
| # Get current max ID and sequence value | |
| max_id_result = db.execute(text("SELECT COALESCE(MAX(id), 0) FROM time_slots")) | |
| max_id = max_id_result.scalar() or 0 | |
| seq_result = db.execute(text("SELECT last_value FROM time_slots_id_seq")) | |
| seq_val = seq_result.scalar() or 0 | |
| # If sequence is behind or equal to max_id, fix it | |
| if seq_val <= max_id: | |
| new_seq_val = max_id + 100 # Set it well ahead to avoid conflicts | |
| db.execute(text(f"SELECT setval('time_slots_id_seq', {new_seq_val}, false)")) | |
| db.commit() # Commit the sequence fix | |
| except Exception as seq_error: | |
| # If sequence fix fails, log but continue | |
| print(f"Warning: Could not fix sequence: {seq_error}") | |
| db.rollback() | |
| # Now commit the time slots | |
| try: | |
| db.commit() | |
| except IntegrityError as e: | |
| db.rollback() | |
| error_str = str(e).lower() | |
| # Check if it's a duplicate key/sequence issue | |
| is_sequence_error = ( | |
| "duplicate key" in error_str or | |
| "unique constraint" in error_str or | |
| "time_slots_pkey" in error_str or | |
| "uniqueviolation" in error_str | |
| ) | |
| if is_sequence_error: | |
| try: | |
| from sqlalchemy import text | |
| # Fix sequence to be much higher than max to avoid any gaps | |
| result = db.execute(text("SELECT COALESCE(MAX(id), 0) FROM time_slots")) | |
| max_id = result.scalar() or 0 | |
| new_seq_val = max_id + 1000 # Set it well ahead to avoid future conflicts | |
| db.execute(text(f"SELECT setval('time_slots_id_seq', {new_seq_val}, false)")) | |
| db.commit() | |
| # Re-add all slots and try again | |
| for slot in created_slots: | |
| db.add(slot) | |
| db.commit() | |
| except Exception as retry_error: | |
| db.rollback() | |
| raise HTTPException( | |
| status_code=500, | |
| detail=f"Failed to create time slots after sequence fix. Please try again. Error: {str(retry_error)}" | |
| ) | |
| else: | |
| raise HTTPException( | |
| status_code=500, | |
| detail=f"Failed to create time slots: {str(e)}" | |
| ) | |
| except Exception as e: | |
| db.rollback() | |
| raise HTTPException( | |
| status_code=500, | |
| detail=f"Failed to create time slots: {str(e)}" | |
| ) | |
| return { | |
| "message": f"Created {len(created_slots)} time slots", | |
| "created_count": len(created_slots), | |
| "service_id": service_id | |
| } | |
| async def update_time_slot( | |
| time_slot_id: int, | |
| request: dict, | |
| current_provider: models.ServiceProvider = Depends(get_current_provider), | |
| db: Session = Depends(database.get_db) | |
| ): | |
| """Update a time slot (mark as available/unavailable)""" | |
| time_slot = db.query(models.TimeSlot).join(models.Service).filter( | |
| models.TimeSlot.id == time_slot_id, | |
| models.Service.provider_id == current_provider.id | |
| ).first() | |
| if not time_slot: | |
| raise HTTPException(status_code=404, detail="Time slot not found") | |
| if "is_available" in request: | |
| time_slot.is_available = request.get("is_available") | |
| db.commit() | |
| db.refresh(time_slot) | |
| return { | |
| "message": "Time slot updated", | |
| "time_slot": { | |
| "id": time_slot.id, | |
| "slot_date": time_slot.slot_date.isoformat(), | |
| "is_available": time_slot.is_available | |
| } | |
| } | |
| async def delete_time_slot( | |
| time_slot_id: int, | |
| current_provider: models.ServiceProvider = Depends(get_current_provider), | |
| db: Session = Depends(database.get_db) | |
| ): | |
| """Delete a time slot""" | |
| time_slot = db.query(models.TimeSlot).join(models.Service).filter( | |
| models.TimeSlot.id == time_slot_id, | |
| models.Service.provider_id == current_provider.id | |
| ).first() | |
| if not time_slot: | |
| raise HTTPException(status_code=404, detail="Time slot not found") | |
| # Check if slot has a booking | |
| booking = db.query(models.Booking).filter(models.Booking.time_slot_id == time_slot_id).first() | |
| if booking: | |
| raise HTTPException(status_code=400, detail="Cannot delete time slot with existing booking") | |
| db.delete(time_slot) | |
| db.commit() | |
| return {"message": "Time slot deleted"} | |
| async def get_provider_dashboard(current_provider: models.ServiceProvider = Depends(get_current_provider), db: Session = Depends(database.get_db)): | |
| """Get dashboard data: services, bookings, and portfolio items""" | |
| from services.provider_level_service import ProviderLevelService | |
| # Check and update stripe onboarding status if pending | |
| if stripe and current_provider.stripe_account_id and not current_provider.stripe_onboarding_complete: | |
| try: | |
| account = stripe.Account.retrieve(current_provider.stripe_account_id) | |
| is_complete = bool(getattr(account, "charges_enabled", False) and getattr(account, "payouts_enabled", False)) | |
| if is_complete: | |
| current_provider.stripe_onboarding_complete = True | |
| db.commit() | |
| db.refresh(current_provider) | |
| except Exception as e: | |
| print(f"Warning: Failed to refresh Stripe onboarding status: {e}") | |
| # Query data | |
| db_services = db.query(models.Service).filter(models.Service.provider_id == current_provider.id, models.Service.is_active == True).all() | |
| db_bookings = db.query(models.Booking).filter(models.Booking.provider_id == current_provider.id).order_by(models.Booking.booking_date.desc()).limit(10).all() | |
| db_portfolio = db.query(models.PortfolioItem).filter(models.PortfolioItem.provider_id == current_provider.id).order_by(models.PortfolioItem.created_at.desc()).limit(6).all() | |
| # Manually serialize services | |
| services = [] | |
| for s in db_services: | |
| services.append({ | |
| "id": s.id, | |
| "name": s.name, | |
| "category": s.category, | |
| "description": s.description, | |
| "price": s.price, | |
| "duration": s.duration, | |
| "is_active": s.is_active | |
| }) | |
| # Manually serialize bookings | |
| bookings = [] | |
| for b in db_bookings: | |
| customer = db.query(models.Customer).filter(models.Customer.id == b.customer_id).first() | |
| service = db.query(models.Service).filter(models.Service.id == b.service_id).first() | |
| bookings.append({ | |
| "id": b.id, | |
| "service_id": b.service_id, | |
| "booking_date": b.booking_date.isoformat() if b.booking_date else None, | |
| "status": b.status, | |
| "payment_status": getattr(b, "payment_status", "UNPAID"), | |
| "customer": { | |
| "full_name": customer.full_name if customer else "Customer", | |
| "email": customer.email if customer else "" | |
| }, | |
| "service": { | |
| "name": service.name if service else "Service", | |
| "price": service.price if service else "0" | |
| } | |
| }) | |
| # Manually serialize portfolio items | |
| portfolio_items = [] | |
| for p in db_portfolio: | |
| portfolio_items.append({ | |
| "id": p.id, | |
| "title": p.title, | |
| "description": p.description, | |
| "image_url": p.image_url, | |
| "video_url": p.video_url, | |
| "created_at": p.created_at.isoformat() if p.created_at else None | |
| }) | |
| # Get level info | |
| level_info = ProviderLevelService.get_level_info(current_provider.level or "beginner") | |
| return { | |
| "provider": { | |
| "id": current_provider.id, | |
| "name": current_provider.full_name, | |
| "business_name": current_provider.business_name, | |
| "email": current_provider.email, | |
| "bio": current_provider.bio, | |
| "city": current_provider.city, | |
| "phone": current_provider.phone, | |
| "profile_picture": current_provider.profile_picture, | |
| "profile_photo": current_provider.profile_photo, | |
| "level": current_provider.level or "beginner", | |
| "level_info": level_info, | |
| "stripe_account_id": getattr(current_provider, "stripe_account_id", None), | |
| "stripe_onboarding_complete": getattr(current_provider, "stripe_onboarding_complete", False), | |
| }, | |
| "services": services, | |
| "bookings": bookings, | |
| "portfolio_items": portfolio_items, | |
| "stats": { | |
| "total_services": len(services), | |
| "total_bookings": db.query(models.Booking).filter(models.Booking.provider_id == current_provider.id).count(), | |
| "pending_bookings": db.query(models.Booking).filter(models.Booking.provider_id == current_provider.id, models.Booking.status == "pending").count(), | |
| "portfolio_items_count": db.query(models.PortfolioItem).filter(models.PortfolioItem.provider_id == current_provider.id).count() | |
| } | |
| } | |
| async def update_provider_bio(request: dict, current_provider: models.ServiceProvider = Depends(get_current_provider), db: Session = Depends(database.get_db)): | |
| """Update provider bio""" | |
| current_provider.bio = request.get("bio", "") | |
| db.commit() | |
| db.refresh(current_provider) | |
| return {"message": "Bio updated", "bio": current_provider.bio} | |
| async def get_provider_profile(current_provider: models.ServiceProvider = Depends(get_current_provider), db: Session = Depends(database.get_db)): | |
| """Get current provider profile""" | |
| from services.provider_level_service import ProviderLevelService | |
| level_info = ProviderLevelService.get_level_info(current_provider.level or "beginner") | |
| return { | |
| "id": current_provider.id, | |
| "full_name": current_provider.full_name, | |
| "email": current_provider.email, | |
| "phone": current_provider.phone, | |
| "city": current_provider.city, | |
| "business_name": current_provider.business_name, | |
| "bio": current_provider.bio, | |
| "timezone": current_provider.timezone, | |
| "profile_picture": current_provider.profile_picture, | |
| "profile_photo": current_provider.profile_photo, | |
| "level": current_provider.level or "beginner", | |
| "level_info": level_info | |
| } | |
| async def update_provider_profile(request: dict, current_provider: models.ServiceProvider = Depends(get_current_provider), db: Session = Depends(database.get_db)): | |
| """Update provider profile information including timezone""" | |
| if "full_name" in request: | |
| current_provider.full_name = request.get("full_name") | |
| if "phone" in request: | |
| current_provider.phone = request.get("phone") | |
| if "city" in request: | |
| current_provider.city = request.get("city") | |
| if "business_name" in request: | |
| current_provider.business_name = request.get("business_name") | |
| if "timezone" in request: | |
| # Validate timezone | |
| try: | |
| ZoneInfo(request.get("timezone")) | |
| current_provider.timezone = request.get("timezone") | |
| except: | |
| raise HTTPException(status_code=400, detail="Invalid timezone. Use IANA timezone format (e.g., 'America/New_York', 'Asia/Karachi')") | |
| db.commit() | |
| db.refresh(current_provider) | |
| return { | |
| "message": "Profile updated", | |
| "provider": { | |
| "id": current_provider.id, | |
| "full_name": current_provider.full_name, | |
| "email": current_provider.email, | |
| "phone": current_provider.phone, | |
| "city": current_provider.city, | |
| "business_name": current_provider.business_name, | |
| "timezone": current_provider.timezone, | |
| } | |
| } | |
| async def update_provider_profile_photo(request: dict, current_provider: models.ServiceProvider = Depends(get_current_provider), db: Session = Depends(database.get_db)): | |
| """Update provider profile photo""" | |
| photo_url = request.get("profile_photo", "") | |
| current_provider.profile_photo = photo_url | |
| current_provider.profile_picture = photo_url | |
| db.commit() | |
| db.refresh(current_provider) | |
| return {"message": "Profile photo updated", "profile_photo": current_provider.profile_photo} | |
| async def create_stripe_account_link( | |
| current_provider: models.ServiceProvider = Depends(get_current_provider), | |
| db: Session = Depends(database.get_db), | |
| ): | |
| """ | |
| Create a Stripe AccountLink for provider payouts onboarding. | |
| Redirect the provider to this URL on the frontend. | |
| """ | |
| if not stripe: | |
| raise HTTPException(status_code=503, detail="Stripe payment integration is not available") | |
| # Ensure provider has a connected account | |
| if not current_provider.stripe_account_id: | |
| try: | |
| connect_country = os.getenv("STRIPE_CONNECT_COUNTRY", "US") | |
| account = stripe.Account.create( | |
| type="express", | |
| country=connect_country, | |
| email=current_provider.email, | |
| capabilities={ | |
| "card_payments": {"requested": True}, | |
| "transfers": {"requested": True}, | |
| }, | |
| business_type="individual", | |
| ) | |
| current_provider.stripe_account_id = account.id | |
| current_provider.stripe_onboarding_complete = False | |
| db.commit() | |
| db.refresh(current_provider) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"Failed to create Stripe connected account: {str(e)}") | |
| try: | |
| refresh_url = os.getenv("STRIPE_ONBOARDING_REFRESH_URL", "http://localhost:3000/dashboard") | |
| return_url = os.getenv("STRIPE_ONBOARDING_RETURN_URL", "http://localhost:3000/dashboard") | |
| link = stripe.AccountLink.create( | |
| account=current_provider.stripe_account_id, | |
| refresh_url=refresh_url, | |
| return_url=return_url, | |
| type="account_onboarding", | |
| ) | |
| return {"url": link.url} | |
| except stripe.error.StripeError as e: | |
| raise HTTPException(status_code=400, detail=f"Stripe error while creating account link: {str(e)}") | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"Failed to create Stripe account link: {str(e)}") | |
| async def change_provider_password(request: dict, current_provider: models.ServiceProvider = Depends(get_current_provider), db: Session = Depends(database.get_db)): | |
| """Change provider password (no 2FA required)""" | |
| import re | |
| current_password = request.get("current_password", "") | |
| new_password = request.get("new_password", "") | |
| # Verify current password | |
| if not current_provider.hashed_password: | |
| raise HTTPException(status_code=400, detail="Account has no password set. Please contact support.") | |
| if not auth.verify_password(current_password, current_provider.hashed_password): | |
| raise HTTPException(status_code=400, detail="Current password is incorrect.") | |
| # Validate new password | |
| if not new_password: | |
| raise HTTPException(status_code=400, detail="New password is required.") | |
| password_clean = new_password.strip() | |
| if not password_clean: | |
| raise HTTPException(status_code=400, detail="Password cannot be empty or only whitespace.") | |
| if len(password_clean) < 6: | |
| raise HTTPException(status_code=400, detail="Password must be at least 6 characters long.") | |
| # Check for at least one special character | |
| if not re.search(r'[!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?]', password_clean): | |
| raise HTTPException(status_code=400, detail="Password must contain at least one special character.") | |
| # Hash and update password | |
| current_provider.hashed_password = auth.get_password_hash(password_clean) | |
| db.commit() | |
| return {"message": "Password changed successfully"} | |
| # --- Customer Routes (View Providers) --- | |
| async def get_all_providers(db: Session = Depends(database.get_db)): | |
| """Get all active service providers (for customers to browse)""" | |
| from services.provider_level_service import ProviderLevelService | |
| providers = db.query(models.ServiceProvider).filter(models.ServiceProvider.is_active == True).all() | |
| # Add level info to each provider | |
| result = [] | |
| for provider in providers: | |
| level_info = ProviderLevelService.get_level_info(provider.level or "beginner") | |
| provider_dict = { | |
| "id": provider.id, | |
| "full_name": provider.full_name, | |
| "business_name": provider.business_name, | |
| "email": provider.email, | |
| "phone": provider.phone, | |
| "city": provider.city, | |
| "bio": provider.bio, | |
| "profile_picture": provider.profile_picture, | |
| "profile_photo": provider.profile_photo, | |
| "level": provider.level or "beginner", | |
| "level_info": level_info | |
| } | |
| result.append(provider_dict) | |
| return result | |
| async def get_all_customers(db: Session = Depends(database.get_db)): | |
| """Get all customers (for providers to search when creating bookings)""" | |
| customers = db.query(models.Customer).filter(models.Customer.is_active == True).all() | |
| return [{"id": c.id, "email": c.email, "full_name": c.full_name, "phone": c.phone} for c in customers] | |
| async def get_provider_details(provider_id: int, db: Session = Depends(database.get_db)): | |
| """Get provider details with portfolio and services (for customers)""" | |
| provider = db.query(models.ServiceProvider).filter(models.ServiceProvider.id == provider_id).first() | |
| if not provider: | |
| raise HTTPException(status_code=404, detail="Provider not found") | |
| portfolio_items = db.query(models.PortfolioItem).filter(models.PortfolioItem.provider_id == provider_id).all() | |
| services = db.query(models.Service).filter(models.Service.provider_id == provider_id, models.Service.is_active == True).all() | |
| # Get level info | |
| from services.provider_level_service import ProviderLevelService | |
| level_info = ProviderLevelService.get_level_info(provider.level or "beginner") | |
| return { | |
| "provider": { | |
| "id": provider.id, | |
| "name": provider.full_name, | |
| "full_name": provider.full_name, | |
| "business_name": provider.business_name, | |
| "email": provider.email, | |
| "bio": provider.bio, | |
| "city": provider.city, | |
| "phone": provider.phone, | |
| "profile_picture": provider.profile_picture or provider.profile_photo, | |
| "level": provider.level or "beginner", | |
| "level_info": level_info | |
| }, | |
| "portfolio_items": portfolio_items, | |
| "services": services | |
| } | |
| # --- Customer Booking Routes --- | |
| def get_current_customer(credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(database.get_db)): | |
| """Get current authenticated customer""" | |
| token = credentials.credentials | |
| try: | |
| payload = jwt.decode(token, auth.SECRET_KEY, algorithms=[auth.ALGORITHM]) | |
| email: str = payload.get("sub") | |
| role: str = payload.get("role") | |
| if email is None or role != "customer": | |
| raise HTTPException(status_code=401, detail="Invalid authentication credentials") | |
| except JWTError: | |
| raise HTTPException(status_code=401, detail="Invalid authentication credentials") | |
| customer = db.query(models.Customer).filter(models.Customer.email == email).first() | |
| if customer is None: | |
| raise HTTPException(status_code=404, detail="Customer not found") | |
| return customer | |
| async def get_customer_dashboard(current_customer: models.Customer = Depends(get_current_customer), db: Session = Depends(database.get_db)): | |
| """Get dashboard data for customer: bookings, preferences, and stats""" | |
| import json | |
| # Get customer bookings (recent 10) | |
| bookings = db.query(models.Booking).filter( | |
| models.Booking.customer_id == current_customer.id | |
| ).order_by(models.Booking.created_at.desc()).limit(10).all() | |
| # Format bookings with provider and service details | |
| formatted_bookings = [] | |
| for booking in bookings: | |
| provider = db.query(models.ServiceProvider).filter(models.ServiceProvider.id == booking.provider_id).first() | |
| service = db.query(models.Service).filter(models.Service.id == booking.service_id).first() | |
| formatted_bookings.append({ | |
| "id": booking.id, | |
| "service_id": booking.service_id, | |
| "provider_id": booking.provider_id, | |
| "booking_date": booking.booking_date.isoformat() if booking.booking_date else None, | |
| "status": booking.status, | |
| "notes": booking.notes, | |
| "created_at": booking.created_at.isoformat() if booking.created_at else None, | |
| "provider": { | |
| "id": provider.id if provider else None, | |
| "full_name": provider.full_name if provider else None, | |
| "business_name": provider.business_name if provider else None, | |
| }, | |
| "service": { | |
| "id": service.id if service else None, | |
| "name": service.name if service else None, | |
| "price": service.price if service else None, | |
| } | |
| }) | |
| # Parse preferences | |
| categories = [] | |
| if current_customer.categories: | |
| try: | |
| categories = json.loads(current_customer.categories) | |
| except: | |
| categories = [] | |
| # Calculate stats | |
| total_bookings = db.query(models.Booking).filter(models.Booking.customer_id == current_customer.id).count() | |
| return { | |
| "customer": { | |
| "id": current_customer.id, | |
| "name": current_customer.full_name, | |
| "email": current_customer.email, | |
| "phone": current_customer.phone, | |
| "location": current_customer.location, | |
| "profile_picture": current_customer.profile_picture, | |
| "skin_type": current_customer.skin_type, | |
| "categories": categories, | |
| "budget_range": current_customer.budget_range | |
| }, | |
| "bookings": formatted_bookings, | |
| "stats": { | |
| "total_bookings": total_bookings | |
| } | |
| } | |
| async def get_customer_profile(current_customer: models.Customer = Depends(get_current_customer), db: Session = Depends(database.get_db)): | |
| """Get current customer profile""" | |
| import json | |
| categories = [] | |
| if current_customer.categories: | |
| try: | |
| categories = json.loads(current_customer.categories) | |
| except: | |
| categories = [] | |
| return { | |
| "id": current_customer.id, | |
| "email": current_customer.email, | |
| "full_name": current_customer.full_name, | |
| "phone": current_customer.phone, | |
| "location": current_customer.location, | |
| "profile_picture": current_customer.profile_picture, | |
| "is_active": current_customer.is_active, | |
| "skin_type": current_customer.skin_type, | |
| "categories": categories, | |
| "budget_range": current_customer.budget_range | |
| } | |
| async def update_customer_profile(request: dict, current_customer: models.Customer = Depends(get_current_customer), db: Session = Depends(database.get_db)): | |
| """Update customer profile information including preferences""" | |
| import json | |
| if "full_name" in request: | |
| current_customer.full_name = request.get("full_name") | |
| if "phone" in request: | |
| current_customer.phone = request.get("phone") | |
| if "location" in request: | |
| current_customer.location = request.get("location") | |
| if "skin_type" in request: | |
| current_customer.skin_type = request.get("skin_type") | |
| if "categories" in request: | |
| categories = request.get("categories", []) | |
| if isinstance(categories, list): | |
| current_customer.categories = json.dumps(categories) | |
| else: | |
| current_customer.categories = None | |
| if "budget_range" in request: | |
| current_customer.budget_range = request.get("budget_range") | |
| db.commit() | |
| db.refresh(current_customer) | |
| # Parse categories for response | |
| categories = [] | |
| if current_customer.categories: | |
| try: | |
| categories = json.loads(current_customer.categories) | |
| except: | |
| categories = [] | |
| return { | |
| "message": "Profile updated", | |
| "customer": { | |
| "id": current_customer.id, | |
| "full_name": current_customer.full_name, | |
| "email": current_customer.email, | |
| "phone": current_customer.phone, | |
| "location": current_customer.location, | |
| "skin_type": current_customer.skin_type, | |
| "categories": categories, | |
| "budget_range": current_customer.budget_range | |
| } | |
| } | |
| async def update_customer_profile_photo(request: dict, current_customer: models.Customer = Depends(get_current_customer), db: Session = Depends(database.get_db)): | |
| """Update customer profile photo""" | |
| current_customer.profile_picture = request.get("profile_photo", "") | |
| db.commit() | |
| db.refresh(current_customer) | |
| return {"message": "Profile photo updated", "profile_picture": current_customer.profile_picture} | |
| async def get_available_time_slots(service_id: int, date: str, db: Session = Depends(database.get_db)): | |
| """Get available time slots for a service on a specific date (new system)""" | |
| service = db.query(models.Service).filter(models.Service.id == service_id).first() | |
| if not service: | |
| raise HTTPException(status_code=404, detail="Service not found") | |
| # Get provider for timezone | |
| provider = db.query(models.ServiceProvider).filter(models.ServiceProvider.id == service.provider_id).first() | |
| provider_tz = provider.timezone if provider else "UTC" | |
| # Parse the date | |
| try: | |
| target_date = datetime.fromisoformat(date.replace('Z', '+00:00')) | |
| # Get start and end of day in provider timezone | |
| target_local = booking_utils.convert_to_timezone(target_date, provider_tz) | |
| start_of_day = target_local.replace(hour=0, minute=0, second=0, microsecond=0) | |
| end_of_day = start_of_day + timedelta(days=1) | |
| # Convert back to UTC for query | |
| start_of_day_utc = start_of_day.astimezone(ZoneInfo("UTC")) | |
| end_of_day_utc = end_of_day.astimezone(ZoneInfo("UTC")) | |
| except: | |
| raise HTTPException(status_code=400, detail="Invalid date format. Use ISO format.") | |
| # Get available time slots for this date (must be in future and marked available) | |
| now_utc = datetime.now(ZoneInfo("UTC")) | |
| available_slots = db.query(models.TimeSlot).filter( | |
| models.TimeSlot.service_id == service_id, | |
| models.TimeSlot.slot_date >= start_of_day_utc, | |
| models.TimeSlot.slot_date < end_of_day_utc, | |
| models.TimeSlot.slot_date > now_utc, | |
| models.TimeSlot.is_available == True | |
| ).all() | |
| # Check which slots are already booked | |
| booked_slot_ids = [] | |
| if available_slots: | |
| booked_slot_ids = [ | |
| booking.time_slot_id for booking in db.query(models.Booking).filter( | |
| models.Booking.time_slot_id.in_([slot.id for slot in available_slots]), | |
| models.Booking.status.in_(["pending", "accepted", "confirmed", "standby_pending", "awaiting_extra_payment", "completed"]) | |
| ).all() | |
| if booking.time_slot_id | |
| ] | |
| # Filter out booked slots | |
| free_slots = [slot for slot in available_slots if slot.id not in booked_slot_ids] | |
| return { | |
| "service_id": service_id, | |
| "date": date, | |
| "timezone": provider_tz, | |
| "available_slots": [ | |
| { | |
| "id": slot.id, | |
| "slot_date": booking_utils.convert_to_timezone(slot.slot_date, provider_tz).isoformat(), | |
| "time": booking_utils.convert_to_timezone(slot.slot_date, provider_tz).strftime("%H:%M") | |
| } | |
| for slot in free_slots | |
| ], | |
| "duration": service.duration | |
| } | |
| async def create_booking( | |
| booking: BookingCreate, | |
| background_tasks: BackgroundTasks, | |
| current_customer: models.Customer = Depends(get_current_customer), | |
| db: Session = Depends(database.get_db) | |
| ): | |
| """Create a new booking from an available time slot (new system)""" | |
| # Verify service exists | |
| service = db.query(models.Service).filter(models.Service.id == booking.service_id).first() | |
| if not service: | |
| raise HTTPException(status_code=404, detail="Service not found") | |
| # Get provider | |
| provider = db.query(models.ServiceProvider).filter(models.ServiceProvider.id == service.provider_id).first() | |
| if not provider: | |
| raise HTTPException(status_code=404, detail="Provider not found") | |
| # Verify time slot exists and is available | |
| time_slot = db.query(models.TimeSlot).filter( | |
| models.TimeSlot.id == booking.time_slot_id, | |
| models.TimeSlot.service_id == booking.service_id, | |
| models.TimeSlot.is_available == True | |
| ).first() | |
| if not time_slot: | |
| raise HTTPException(status_code=404, detail="Time slot not found or not available") | |
| # Check if slot is already booked | |
| existing_booking = db.query(models.Booking).filter( | |
| models.Booking.time_slot_id == booking.time_slot_id, | |
| models.Booking.status.in_(["pending", "accepted", "confirmed", "standby_pending", "awaiting_extra_payment", "completed"]) | |
| ).first() | |
| if existing_booking: | |
| raise HTTPException(status_code=400, detail="This time slot is already booked") | |
| # Calculate end time from service duration | |
| booking_date = time_slot.slot_date | |
| end_date = booking_utils.calculate_end_time(booking_date, service.duration) | |
| # Create booking (status: pending - waiting for provider approval) | |
| new_booking = models.Booking( | |
| customer_id=current_customer.id, | |
| provider_id=service.provider_id, | |
| service_id=booking.service_id, | |
| time_slot_id=booking.time_slot_id, | |
| booking_date=booking_date, | |
| end_date=end_date, | |
| status="pending", # Always starts as pending | |
| notes=booking.notes, | |
| # Track original / assigned provider for standby workflows | |
| original_provider_id=service.provider_id, | |
| assigned_provider_id=service.provider_id, | |
| booking_status="pending", | |
| ) | |
| db.add(new_booking) | |
| db.commit() | |
| db.refresh(new_booking) | |
| # Convert to provider timezone for notification | |
| provider_tz = provider.timezone or "UTC" | |
| booking_date_provider_tz = booking_utils.convert_to_timezone(booking_date, provider_tz) | |
| # Send notification in background | |
| background_tasks.add_task( | |
| notifications.notify_booking_created, | |
| current_customer.email, | |
| current_customer.full_name or "Customer", | |
| provider.full_name or provider.business_name, | |
| service.name, | |
| booking_date_provider_tz, | |
| new_booking.id | |
| ) | |
| return { | |
| "id": new_booking.id, | |
| "message": "Booking created successfully. Waiting for provider approval.", | |
| "status": new_booking.status, | |
| "booking_date": booking_date_provider_tz.isoformat() | |
| } | |
| async def get_customer_bookings(current_customer: models.Customer = Depends(get_current_customer), db: Session = Depends(database.get_db)): | |
| """Get all bookings for the customer""" | |
| try: | |
| # Order by created_at descending (most recent first) | |
| # This is safer than booking_date which might be None | |
| bookings = db.query(models.Booking).filter( | |
| models.Booking.customer_id == current_customer.id | |
| ).order_by(models.Booking.created_at.desc()).all() | |
| result = [] | |
| for booking in bookings: | |
| try: | |
| provider = db.query(models.ServiceProvider).filter(models.ServiceProvider.id == booking.provider_id).first() | |
| service = db.query(models.Service).filter(models.Service.id == booking.service_id).first() | |
| # Convert to provider timezone for display | |
| provider_tz = provider.timezone if provider else "UTC" | |
| try: | |
| if booking.booking_date: | |
| booking_date_local = booking_utils.convert_to_timezone(booking.booking_date, provider_tz) | |
| booking_date_local_str = booking_date_local.isoformat() | |
| else: | |
| booking_date_local_str = None | |
| except Exception as tz_error: | |
| # Fallback if timezone conversion fails | |
| print(f"Timezone conversion error for booking {booking.id}: {tz_error}") | |
| booking_date_local_str = booking.booking_date.isoformat() if booking.booking_date else None | |
| # ── Payment details (amount paid + refund info) ────── | |
| payment_record = db.query(models.Payment).filter( | |
| models.Payment.booking_id == booking.id | |
| ).first() | |
| paid_amount = None | |
| refunded_amount = None | |
| if payment_record: | |
| paid_amount = payment_record.amount # string like "40.00" | |
| # If a partial refund happened, compute from the original payment | |
| if booking.stripe_refund_id and stripe: | |
| try: | |
| refund_obj = stripe.Refund.retrieve(booking.stripe_refund_id) | |
| refunded_amount = str(refund_obj.amount / 100) # cents → dollars | |
| except Exception: | |
| pass # won't block the listing | |
| result.append({ | |
| "id": booking.id, | |
| "provider": { | |
| "id": provider.id if provider else None, | |
| "full_name": provider.full_name if provider else None, | |
| "business_name": provider.business_name if provider else None, | |
| }, | |
| "service": { | |
| "id": service.id if service else None, | |
| "name": service.name if service else None, | |
| "price": service.price if service else None, | |
| }, | |
| "booking_date": booking.booking_date.isoformat() if booking.booking_date else None, | |
| "booking_date_local": booking_date_local_str, | |
| "timezone": provider_tz, | |
| "status": booking.status, | |
| "booking_status": getattr(booking, "booking_status", None), | |
| "notes": booking.notes, | |
| "created_at": booking.created_at.isoformat() if booking.created_at else None, | |
| "payment_status": booking.payment_status if hasattr(booking, 'payment_status') else "UNPAID", | |
| "original_provider_id": getattr(booking, "original_provider_id", None), | |
| "assigned_provider_id": getattr(booking, "assigned_provider_id", None), | |
| "paid_amount": paid_amount, | |
| "refunded_amount": refunded_amount, | |
| }) | |
| except Exception as e: | |
| # Log error but continue processing other bookings | |
| import traceback | |
| print(f"Error processing booking {booking.id}: {e}") | |
| print(traceback.format_exc()) | |
| continue | |
| return result | |
| except Exception as e: | |
| import traceback | |
| print(f"Error in get_customer_bookings: {e}") | |
| print(traceback.format_exc()) | |
| raise HTTPException(status_code=500, detail=f"Failed to fetch bookings: {str(e)}") | |
| async def cancel_customer_booking( | |
| booking_id: int, | |
| background_tasks: BackgroundTasks, | |
| current_customer: models.Customer = Depends(get_current_customer), | |
| db: Session = Depends(database.get_db) | |
| ): | |
| """Cancel a booking (customer can cancel their own bookings)""" | |
| booking = db.query(models.Booking).filter( | |
| models.Booking.id == booking_id, | |
| models.Booking.customer_id == current_customer.id | |
| ).first() | |
| if not booking: | |
| raise HTTPException(status_code=404, detail="Booking not found") | |
| if booking.status in ["cancelled", "completed", "rejected"]: | |
| raise HTTPException(status_code=400, detail=f"Cannot cancel booking with status: {booking.status}") | |
| booking.status = "cancelled" | |
| # Free up the time slot so it becomes available again | |
| if booking.time_slot_id: | |
| time_slot = db.query(models.TimeSlot).filter(models.TimeSlot.id == booking.time_slot_id).first() | |
| if time_slot: | |
| time_slot.is_available = True | |
| booking.time_slot_id = None | |
| # Cancel Google Calendar event if exists | |
| provider = db.query(models.ServiceProvider).filter(models.ServiceProvider.id == booking.provider_id).first() | |
| if booking.google_calendar_event_id and provider and provider.google_calendar_access_token: | |
| service_obj = google_calendar.get_google_calendar_service( | |
| provider.google_calendar_access_token, | |
| provider.google_calendar_refresh_token | |
| ) | |
| if service_obj: | |
| google_calendar.update_calendar_event( | |
| service_obj, | |
| booking.google_calendar_event_id, | |
| status="cancelled" | |
| ) | |
| # Handle Stripe Refund if already paid | |
| if booking.payment_status == "HELD_IN_ESCROW" and booking.stripe_payment_intent_id and stripe: | |
| try: | |
| refund = stripe.Refund.create(payment_intent=booking.stripe_payment_intent_id) | |
| booking.payment_status = "REFUNDED" | |
| booking.stripe_refund_id = refund.id | |
| payment = db.query(models.Payment).filter(models.Payment.booking_id == booking.id).first() | |
| if payment: | |
| payment.status = "refunded" | |
| payment.refunded_at = datetime.now(ZoneInfo("UTC")) | |
| except Exception as e: | |
| print(f"Warning: Failed to process refund during customer cancellation: {e}") | |
| db.commit() | |
| db.refresh(booking) | |
| # Note: Standby request will be created only when customer opts-in | |
| # Don't create automatically - let customer decide | |
| # Send notification | |
| service = db.query(models.Service).filter(models.Service.id == booking.service_id).first() | |
| provider_tz = provider.timezone if provider else "UTC" | |
| booking_date_local = booking_utils.convert_to_timezone(booking.booking_date, provider_tz) | |
| background_tasks.add_task( | |
| notifications.notify_booking_cancelled, | |
| current_customer.email, | |
| current_customer.full_name or "Customer", | |
| provider.full_name or provider.business_name if provider else "Provider", | |
| service.name if service else "Service", | |
| booking_date_local, | |
| "customer" | |
| ) | |
| return {"message": "Booking cancelled successfully", "booking": booking} | |
| # ==================== PAYMENT ENDPOINTS ==================== | |
| async def create_payment_intent( | |
| request: PaymentIntentCreate, | |
| current_customer: models.Customer = Depends(get_current_customer), | |
| db: Session = Depends(database.get_db) | |
| ): | |
| """Create a Stripe Payment Intent for a booking""" | |
| if not stripe: | |
| raise HTTPException(status_code=503, detail="Stripe payment integration is not available") | |
| # Get booking | |
| booking = db.query(models.Booking).filter( | |
| models.Booking.id == request.booking_id, | |
| models.Booking.customer_id == current_customer.id | |
| ).first() | |
| if not booking: | |
| raise HTTPException(status_code=404, detail="Booking not found") | |
| # Only allow payment for accepted bookings (provider has approved, waiting for payment) | |
| if booking.status != "accepted": | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Cannot make payment. Booking status must be 'accepted' (current: {booking.status}). Please wait for provider approval." | |
| ) | |
| # Get service to get price | |
| service = db.query(models.Service).filter(models.Service.id == booking.service_id).first() | |
| if not service: | |
| raise HTTPException(status_code=404, detail="Service not found") | |
| # Parse price (handle ranges like "50-100" or single values like "50") | |
| price_str = service.price.replace("$", "").replace(",", "").strip() | |
| if "-" in price_str: | |
| # Take the first value if it's a range | |
| price_str = price_str.split("-")[0].strip() | |
| try: | |
| amount = float(price_str) | |
| except ValueError: | |
| raise HTTPException(status_code=400, detail=f"Invalid price format: {service.price}") | |
| # Convert to cents for Stripe | |
| amount_cents = int(amount * 100) | |
| # Check if payment already exists | |
| existing_payment = db.query(models.Payment).filter( | |
| models.Payment.booking_id == booking.id | |
| ).first() | |
| if existing_payment and existing_payment.status == "succeeded": | |
| raise HTTPException(status_code=400, detail="Payment already completed for this booking") | |
| try: | |
| # Create Stripe Payment Intent | |
| # NOTE: No transfer_data here – funds are captured to the platform | |
| # account and later paid out via a separate transfer (escrow-style). | |
| intent = stripe.PaymentIntent.create( | |
| amount=amount_cents, | |
| currency="usd", | |
| metadata={ | |
| "booking_id": booking.id, | |
| "customer_id": current_customer.id, | |
| "provider_id": booking.provider_id, | |
| "service_id": booking.service_id | |
| } | |
| ) | |
| # Create or update payment record | |
| if existing_payment: | |
| existing_payment.stripe_payment_intent_id = intent.id | |
| existing_payment.amount = str(amount) | |
| existing_payment.status = "pending" | |
| payment = existing_payment | |
| else: | |
| payment = models.Payment( | |
| booking_id=booking.id, | |
| customer_id=current_customer.id, | |
| provider_id=booking.provider_id, | |
| amount=str(amount), | |
| currency="USD", | |
| stripe_payment_intent_id=intent.id, | |
| status="pending" | |
| ) | |
| db.add(payment) | |
| # Link payment + intent to booking (escrow) | |
| booking.payment_id = payment.id | |
| booking.stripe_payment_intent_id = intent.id | |
| db.commit() | |
| db.refresh(payment) | |
| return { | |
| "client_secret": intent.client_secret, | |
| "payment_intent_id": intent.id, | |
| "amount": amount, | |
| "currency": "usd", | |
| "payment_id": payment.id | |
| } | |
| except Exception as e: | |
| db.rollback() | |
| raise HTTPException(status_code=500, detail=f"Failed to create payment intent: {str(e)}") | |
| async def confirm_payment( | |
| request: PaymentConfirm, | |
| current_customer: models.Customer = Depends(get_current_customer), | |
| db: Session = Depends(database.get_db) | |
| ): | |
| """Confirm payment after Stripe payment succeeds""" | |
| if not stripe: | |
| raise HTTPException(status_code=503, detail="Stripe payment integration is not available") | |
| # Get booking | |
| booking = db.query(models.Booking).filter( | |
| models.Booking.id == request.booking_id, | |
| models.Booking.customer_id == current_customer.id | |
| ).first() | |
| if not booking: | |
| raise HTTPException(status_code=404, detail="Booking not found") | |
| # Get payment | |
| payment = db.query(models.Payment).filter( | |
| models.Payment.booking_id == booking.id, | |
| models.Payment.stripe_payment_intent_id == request.payment_intent_id | |
| ).first() | |
| if not payment: | |
| raise HTTPException(status_code=404, detail="Payment not found") | |
| try: | |
| # Retrieve payment intent from Stripe | |
| intent = stripe.PaymentIntent.retrieve(request.payment_intent_id) | |
| if intent.status == "succeeded": | |
| # Update payment record | |
| payment.status = "succeeded" | |
| payment.stripe_charge_id = getattr(intent, "latest_charge", None) | |
| payment.paid_at = datetime.now(ZoneInfo("UTC")) | |
| # Persist core Stripe IDs on booking for escrow / payouts / refunds | |
| booking.stripe_payment_intent_id = intent.id | |
| booking.stripe_charge_id = getattr(intent, "latest_charge", None) | |
| # Update booking - set status to "confirmed" and payment to held-in-escrow | |
| booking.status = "confirmed" # Booking becomes confirmed after payment | |
| booking.booking_status = "confirmed" | |
| booking.payment_status = "HELD_IN_ESCROW" | |
| booking.payment_id = payment.id | |
| # Create Google Calendar event if provider has calendar integration | |
| provider = db.query(models.ServiceProvider).filter(models.ServiceProvider.id == booking.provider_id).first() | |
| if provider and provider.google_calendar_access_token and not booking.google_calendar_event_id: | |
| service = db.query(models.Service).filter(models.Service.id == booking.service_id).first() | |
| customer = db.query(models.Customer).filter(models.Customer.id == booking.customer_id).first() | |
| if service and customer: | |
| event_id = google_calendar.create_booking_calendar_event( | |
| access_token=provider.google_calendar_access_token, | |
| refresh_token=provider.google_calendar_refresh_token, | |
| customer_name=customer.full_name or "Customer", | |
| customer_email=customer.email, | |
| provider_name=provider.full_name or provider.business_name, | |
| service_name=service.name, | |
| booking_date=booking.booking_date, | |
| end_date=booking.end_date or booking_utils.calculate_end_time(booking.booking_date, service.duration), | |
| notes=booking.notes, | |
| location=provider.city | |
| ) | |
| if event_id: | |
| booking.google_calendar_event_id = event_id | |
| db.commit() | |
| db.refresh(payment) | |
| db.refresh(booking) | |
| return { | |
| "message": "Payment confirmed successfully", | |
| "payment_status": booking.payment_status, | |
| "payment_id": payment.id | |
| } | |
| else: | |
| payment.status = "failed" | |
| payment.failure_reason = f"Payment intent status: {intent.status}" | |
| booking.payment_status = "failed" | |
| db.commit() | |
| raise HTTPException(status_code=400, detail=f"Payment not succeeded. Status: {intent.status}") | |
| except stripe.error.StripeError as e: | |
| db.rollback() | |
| raise HTTPException(status_code=400, detail=f"Stripe error: {str(e)}") | |
| except Exception as e: | |
| db.rollback() | |
| raise HTTPException(status_code=500, detail=f"Failed to confirm payment: {str(e)}") | |
| async def get_payment_details( | |
| payment_id: int, | |
| current_customer: models.Customer = Depends(get_current_customer), | |
| db: Session = Depends(database.get_db) | |
| ): | |
| """Get payment details""" | |
| payment = db.query(models.Payment).filter( | |
| models.Payment.id == payment_id, | |
| models.Payment.customer_id == current_customer.id | |
| ).first() | |
| if not payment: | |
| raise HTTPException(status_code=404, detail="Payment not found") | |
| return { | |
| "id": payment.id, | |
| "booking_id": payment.booking_id, | |
| "amount": payment.amount, | |
| "currency": payment.currency, | |
| "status": payment.status, | |
| "stripe_payment_intent_id": payment.stripe_payment_intent_id, | |
| "created_at": payment.created_at, | |
| "paid_at": payment.paid_at | |
| } | |
| async def get_provider_payments( | |
| current_provider: models.ServiceProvider = Depends(get_current_provider), | |
| db: Session = Depends(database.get_db) | |
| ): | |
| """Get all payment transactions for the provider (transaction history)""" | |
| payments = db.query(models.Payment).filter( | |
| models.Payment.provider_id == current_provider.id | |
| ).order_by(models.Payment.created_at.desc()).all() | |
| result = [] | |
| for payment in payments: | |
| # Get booking and service details | |
| booking = db.query(models.Booking).filter(models.Booking.id == payment.booking_id).first() | |
| service = None | |
| customer = None | |
| if booking: | |
| service = db.query(models.Service).filter(models.Service.id == booking.service_id).first() | |
| customer = db.query(models.Customer).filter(models.Customer.id == payment.customer_id).first() | |
| result.append({ | |
| "id": payment.id, | |
| "booking_id": payment.booking_id, | |
| "amount": payment.amount, | |
| "currency": payment.currency, | |
| "status": payment.status, | |
| "payment_method": payment.payment_method, | |
| "stripe_payment_intent_id": payment.stripe_payment_intent_id, | |
| "created_at": payment.created_at.isoformat() if payment.created_at else None, | |
| "paid_at": payment.paid_at.isoformat() if payment.paid_at else None, | |
| "refunded_at": payment.refunded_at.isoformat() if payment.refunded_at else None, | |
| "failure_reason": payment.failure_reason, | |
| "service": { | |
| "id": service.id if service else None, | |
| "name": service.name if service else None, | |
| } if service else None, | |
| "customer": { | |
| "id": customer.id if customer else None, | |
| "full_name": customer.full_name if customer else None, | |
| "email": customer.email if customer else None, | |
| } if customer else None, | |
| }) | |
| return result | |
| async def get_customer_payments( | |
| current_customer: models.Customer = Depends(get_current_customer), | |
| db: Session = Depends(database.get_db) | |
| ): | |
| """Get all payment transactions for the customer (transaction history)""" | |
| payments = db.query(models.Payment).filter( | |
| models.Payment.customer_id == current_customer.id | |
| ).order_by(models.Payment.created_at.desc()).all() | |
| result = [] | |
| for payment in payments: | |
| # Get booking and service details | |
| booking = db.query(models.Booking).filter(models.Booking.id == payment.booking_id).first() | |
| service = None | |
| provider = None | |
| if booking: | |
| service = db.query(models.Service).filter(models.Service.id == booking.service_id).first() | |
| provider = db.query(models.ServiceProvider).filter(models.ServiceProvider.id == booking.provider_id).first() | |
| result.append({ | |
| "id": payment.id, | |
| "booking_id": payment.booking_id, | |
| "amount": payment.amount, | |
| "currency": payment.currency, | |
| "status": payment.status, | |
| "payment_method": payment.payment_method, | |
| "stripe_payment_intent_id": payment.stripe_payment_intent_id, | |
| "created_at": payment.created_at.isoformat() if payment.created_at else None, | |
| "paid_at": payment.paid_at.isoformat() if payment.paid_at else None, | |
| "refunded_at": payment.refunded_at.isoformat() if payment.refunded_at else None, | |
| "failure_reason": payment.failure_reason, | |
| "service": { | |
| "id": service.id if service else None, | |
| "name": service.name if service else None, | |
| } if service else None, | |
| "provider": { | |
| "id": provider.id if provider else None, | |
| "business_name": provider.business_name if provider else None, | |
| } if provider else None, | |
| }) | |
| return result | |
| async def stripe_webhook(request: Request): | |
| """Handle Stripe webhook events""" | |
| if not stripe: | |
| raise HTTPException(status_code=503, detail="Stripe payment integration is not available") | |
| payload = await request.body() | |
| sig_header = request.headers.get("stripe-signature") | |
| webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET") | |
| if not webhook_secret: | |
| # Webhook secret not configured, skip signature verification (not recommended for production) | |
| print("⚠️ STRIPE_WEBHOOK_SECRET not set, skipping webhook signature verification") | |
| else: | |
| try: | |
| event = stripe.Webhook.construct_event(payload, sig_header, webhook_secret) | |
| except ValueError: | |
| raise HTTPException(status_code=400, detail="Invalid payload") | |
| except stripe.error.SignatureVerificationError: | |
| raise HTTPException(status_code=400, detail="Invalid signature") | |
| # For now, parse JSON directly if no webhook secret | |
| try: | |
| import json | |
| event = json.loads(payload) | |
| except: | |
| raise HTTPException(status_code=400, detail="Invalid JSON") | |
| # Handle the event | |
| if event.get("type") == "payment_intent.succeeded": | |
| payment_intent = event.get("data", {}).get("object", {}) | |
| payment_intent_id = payment_intent.get("id") | |
| if payment_intent_id: | |
| db = next(database.get_db()) | |
| try: | |
| payment = db.query(models.Payment).filter( | |
| models.Payment.stripe_payment_intent_id == payment_intent_id | |
| ).first() | |
| if payment: | |
| payment.status = "succeeded" | |
| payment.paid_at = datetime.now(ZoneInfo("UTC")) | |
| booking = db.query(models.Booking).filter(models.Booking.id == payment.booking_id).first() | |
| if booking: | |
| booking.status = "confirmed" # Set to confirmed when payment succeeds | |
| booking.booking_status = "confirmed" | |
| booking.payment_status = "HELD_IN_ESCROW" | |
| booking.stripe_payment_intent_id = payment_intent_id | |
| booking.stripe_charge_id = payment.stripe_charge_id or booking.stripe_charge_id | |
| db.commit() | |
| except Exception as e: | |
| db.rollback() | |
| print(f"Error processing webhook: {e}") | |
| finally: | |
| db.close() | |
| return {"status": "success"} | |
| # Import RAG routes (optional - gracefully handle if not available) | |
| try: | |
| from rag.routes import router as rag_router | |
| app.include_router(rag_router) | |
| print("✅ RAG recommendation module loaded") | |
| except ImportError as e: | |
| print(f"⚠️ RAG module not available: {e}") | |
| # Import Rating routes | |
| try: | |
| from ratings.routes import router as ratings_router | |
| app.include_router(ratings_router) | |
| print("✅ Rating system module loaded") | |
| except ImportError as e: | |
| print(f"⚠️ Rating module not available: {e}") | |
| # Fallback list of major Pakistan cities (in case API fails) | |
| PAKISTAN_CITIES_FALLBACK = [ | |
| "Karachi", "Lahore", "Faisalabad", "Rawalpindi", "Multan", "Hyderabad", "Gujranwala", | |
| "Peshawar", "Quetta", "Islamabad", "Bahawalpur", "Sargodha", "Sialkot", "Sukkur", | |
| "Larkana", "Sheikhupura", "Rahim Yar Khan", "Jhang", "Dera Ghazi Khan", "Gujrat", | |
| "Sahiwal", "Wah Cantonment", "Mardan", "Kasur", "Okara", "Mingora", "Nawabshah", | |
| "Chiniot", "Kotri", "Kāmoke", "Hafizabad", "Kohat", "Jacobabad", "Shikarpur", | |
| "Muzaffargarh", "Khanpur", "Gojra", "Bahawalnagar", "Abbottabad", "Muridke", | |
| "Pakpattan", "Khuzdar", "Jhelum", "Chaman", "Kot Abdul Malik", "Dadu", "Mandi Bahauddin", | |
| "Ahmadpur East", "Kamalia", "Tando Adam", "Khairpur", "Dera Ismail Khan", "Vehari", | |
| "Nowshera", "Daska", "Burewala", "Shahkot", "Mianwali", "Kabirwala", "Chishtian", | |
| "Hasilpur", "Attock", "Muzaffarabad", "Mian Channun", "Bhalwal", "Jaranwala", | |
| "Chakwal", "Gujar Khan", "Khanewal" | |
| ] | |
| async def get_pakistan_cities(): | |
| """ | |
| Fetch list of cities in Pakistan from external API | |
| Returns list of city names | |
| Falls back to hardcoded list if API fails | |
| """ | |
| try: | |
| async with httpx.AsyncClient(timeout=30.0) as client: | |
| response = await client.post( | |
| "https://countriesnow.space/api/v0.1/countries/cities", | |
| json={"country": "Pakistan"}, | |
| follow_redirects=True | |
| ) | |
| response.raise_for_status() | |
| data = response.json() | |
| # Check if API returned success with data | |
| if data.get("error") == False and "data" in data and isinstance(data["data"], list): | |
| cities = data["data"] | |
| # Remove duplicates and sort alphabetically | |
| cities = sorted(list(set(cities))) | |
| return {"cities": cities, "count": len(cities)} | |
| elif data.get("error") == True: | |
| # API returned an error message, use fallback | |
| error_msg = data.get("msg", "Unknown error") | |
| print(f"Cities API error: {error_msg}, using fallback list") | |
| return {"cities": sorted(PAKISTAN_CITIES_FALLBACK), "count": len(PAKISTAN_CITIES_FALLBACK), "fallback": True} | |
| else: | |
| # Unexpected format, use fallback | |
| print(f"Unexpected API response format, using fallback list") | |
| return {"cities": sorted(PAKISTAN_CITIES_FALLBACK), "count": len(PAKISTAN_CITIES_FALLBACK), "fallback": True} | |
| except httpx.TimeoutException as e: | |
| print(f"Timeout error: {e}, using fallback list") | |
| return {"cities": sorted(PAKISTAN_CITIES_FALLBACK), "count": len(PAKISTAN_CITIES_FALLBACK), "fallback": True} | |
| except httpx.HTTPStatusError as e: | |
| error_detail = f"Cities API returned error: {e.response.status_code}" | |
| try: | |
| error_body = e.response.json() | |
| error_detail = error_body.get("msg", error_body.get("error", error_detail)) | |
| except: | |
| pass | |
| print(f"HTTP error: {error_detail}, using fallback list") | |
| return {"cities": sorted(PAKISTAN_CITIES_FALLBACK), "count": len(PAKISTAN_CITIES_FALLBACK), "fallback": True} | |
| except httpx.RequestError as e: | |
| print(f"Request error: {e}, using fallback list") | |
| return {"cities": sorted(PAKISTAN_CITIES_FALLBACK), "count": len(PAKISTAN_CITIES_FALLBACK), "fallback": True} | |
| except Exception as e: | |
| import traceback | |
| print(f"Unexpected error: {e}, using fallback list") | |
| print(traceback.format_exc()) | |
| return {"cities": sorted(PAKISTAN_CITIES_FALLBACK), "count": len(PAKISTAN_CITIES_FALLBACK), "fallback": True} | |
| async def read_root(): | |
| return {"message": "GlowSense AI Backend (Multi-Role) is Running!"} | |
| import subprocess | |
| async def launch_ar_studio(): | |
| """Launch the local AR Virtual Makeup Studio Tkinter app.""" | |
| try: | |
| subprocess.Popen(["python", "ar_makeup.py"], cwd=".") | |
| return {"status": "success", "message": "AR Studio launched"} | |
| except Exception as e: | |
| import traceback | |
| return {"status": "error", "message": str(e), "traceback": traceback.format_exc()} | |