diff --git a/README.md b/README.md index 93a36f7..02f8b26 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,15 @@ A compact FastAPI backend for managing college food stalls, staff, menus and orders. -Quick summary -- Auth: Firebase ID tokens (verify with firebase_admin). -- Data: Firestore collections (colleges, stalls, staffs, users, menu_items, orders). -- AI menu extraction: optional (Google Gemini) — returns JSON list of {name, price, description}. -- Payments: Razorpay integration for creating orders and a webhook to record payments. -- Docker ready (Dockerfile + compose.yaml). - -Quick start (local) +### Quick summary +- **Auth:** Firebase ID tokens (verify with firebase_admin). +- **Data:** Firestore collections (colleges, stalls, staffs, users, menu_items, orders). +- **Analytics:** Staff performance tracking (monthly/daily logs) and manager dashboard. +- **AI menu extraction:** optional (Google Gemini) — returns JSON list of items. +- **Payments:** Razorpay integration for orders + webhook validation. +- **Docker ready:** `Dockerfile` + `compose.yaml` included. + +### Quick start (local) 1. Create & activate a virtualenv: ```bash @@ -32,7 +33,7 @@ python main.py uvicorn app.app:app --reload --host 0.0.0.0 --port 8000 ``` -Docker +### Quick start (Docker) - Build and run container (simple): ```bash @@ -49,7 +50,7 @@ docker compose -f compose.yaml --env-file .env up --build -d docker compose -f compose.yaml down ``` -Authentication +### Authentication - Use Firebase ID token (NOT the UID). Send in Authorization header as a Bearer token: ``` @@ -58,14 +59,14 @@ Authorization: Bearer - Obtain a test idToken via the helper `get_token.py` (dev-only). -Key environment variables +### Key environment variables - FIREBASE_SERVICE_ACCOUNT: required — service account JSON content (or path) used by firebase_admin. For CI you can set `CI=true` to skip strict local validation. - FIREBASE_API_KEY, FIREBASE_PROJECT_ID, etc. — used by helper scripts. - GEMINI_API_KEY — optional, required for image-based menu scanning (Gemini model: gemini-2.5-flash). - RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET — required for creating Razorpay orders. - RAZORPAY_WEBHOOK_SECRET — required for validating Razorpay webhook signatures (header `X-Razorpay-Signature`). -Important files +### Important files - `app/firebase_init.py` — initializes firebase_admin and exposes `db` (Firestore client). - `app/auth.py` — verifies tokens and initializes manager records when a manager signs in using the stall email. - `app/schema.py` — Pydantic models (MenuSchema, MenuItemSchema, MenuScanResponse, CreateOrderSchema, etc.). @@ -75,72 +76,76 @@ Important files - `app/webhook.py` — Razorpay webhook: validates HMAC signature and updates `orders` documents with `razorpay_payment_id`, `razorpay_payment_data`, `status: 'PAID'`, and a generated `pickup_code`. - `get_token.py` — helper to exchange email/password for idToken (dev/test only). -Core rules / behavior (short) +### Core rules / behavior (short) - Staff authorization: tokens are verified and mapped to a `staffs` document. Only staff with `status == 'active'` are allowed to use staff routes. - Stall ownership: a staff can only manage the stall they belong to (stall_id is enforced on menu writes/updates). - Manager init: when a user signs in and their email matches a stall's registered email, a manager `staffs` document is auto-created. - `POST /staff/add-member` returns a `reset_link` (Firebase password reset) to onboard newly created staff users. -Menu upload & scan +### Menu upload & scan - Menu upload expects JSON matching `MenuSchema` (see `app/schema.py`): `stall_id` must match authenticated staff's stall; `items` cannot be empty; `price` must be > 0. - Image scan (`POST /staff/menu/scan-image`) accepts JPEG/PNG only and max file size 5MB; uses Gemini (`gemini-2.5-flash`) to extract items and returns a `MenuScanResponse` that must be reviewed before saving. -API (selected endpoints) -- GET /health — health check - -Auth -- POST /auth/verify-staff — Verify staff token; initializes manager if needed. -- POST /auth/verify-student — Verify student token and auto-register student (by college domain). - -User (student) -- GET /user/menu — List menus for the student's college (only active & verified stalls returned). -- POST /user/order/create — Create a Razorpay order (payload: CreateOrderSchema) -- POST /user/order/verify — Client-side payment verification endpoint (accepts razorpay_order_id, razorpay_payment_id, razorpay_signature and internal_order_id); verifies signature and marks the internal order PAID with a pickup code. -- PATCH /user/profile — Update student profile (name, roll_number, phone). -- GET /user/orders — List student's orders (shows pickup code for PAID/READY orders). - -Staff / Manager -- POST /staff/add-member — Manager adds a staff (payload: {email}) and receives a `reset_link`. -- GET /staff/list — Manager: list staff for manager's stall -- DELETE /staff/{staff_uid} — Manager: remove a staff member (must be same stall) -- PUT /staff/{staff_uid}/email — Manager: change a staff's email (creates user if needed) - -Staff menu management -- POST /staff/menu — Upload menu JSON for the authenticated staff's stall (MenuSchema) -- GET /staff/menu — Get menu for authenticated staff's stall -- POST /staff/menu/scan-image — Upload image (JPEG/PNG, <5MB) → returns MenuScanResponse (requires GEMINI_API_KEY) -- PATCH /staff/menu/{item_id} — Update a menu item -- DELETE /staff/menu/{item_id} — Delete a menu item - -Staff order management -- GET /staff/orders?status=PAID — List stall orders by status (default PAID) -- PATCH /staff/orders/{order_id}/status — Update an order status (only for orders belonging to the staff's stall) -- POST /staff/orders/verify-pickup — Verify 4-digit pickup code and mark order CLAIMED - -Webhook -- POST /webhook/razorpay — Razorpay will POST payment events here; the endpoint verifies `X-Razorpay-Signature` using `RAZORPAY_WEBHOOK_SECRET` and updates the related `orders/{internal_order_id}` with `razorpay_payment_id`, `razorpay_payment_data`, `status: 'PAID'`, and a generated `pickup_code`. Configure Razorpay webhook to include `notes.internal_order_id` when creating payments. - -Testing & troubleshooting +### API (selected endpoints) +- `GET /health` — health check + +### Auth +- `POST /auth/verify-staff` — Verify staff token; initializes manager if needed. +- `POST /auth/verify-student` — Verify student token and auto-register student (by college domain). + +### User (student) +- `GET /user/menu` — List menus for the student's college (only active & verified stalls returned). +- `POST /user/order/create` — Create a Razorpay order (payload: CreateOrderSchema) +- `POST /user/order/verify` — Client-side payment verification endpoint (accepts razorpay_order_id, razorpay_payment_id, razorpay_signature and internal_order_id); verifies signature and marks the internal order PAID with a pickup code. +- `PATCH /user/profile` — Update student profile (name, roll_number, phone). +- `GET /user/orders` — List student's orders (shows pickup code for PAID/READY orders). + +### Staff / Manager +- `PATCH /staff/profile` — Update authenticated staff's profile (name, phone). +- `POST /staff/add-member` — Manager adds a staff (payload: {email}) and receives a `reset_link`. +- `GET /staff/list` — Manager: list staff for manager's stall +- `DELETE /staff/{staff_uid}` — Manager: remove a staff member (must be same stall) +- `PUT /staff/{staff_uid}/email` — Manager: change a staff's email (creates user if needed) + +### Staff menu management +- `POST /staff/menu` — Upload menu JSON for the authenticated staff's stall (MenuSchema) +- `GET /staff/menu` — Get menu for authenticated staff's stall +- `POST /staff/menu/scan-image` — Upload image (JPEG/PNG, <5MB) → returns MenuScanResponse (requires GEMINI_API_KEY) +- `PATCH /staff/menu/{item_id}` — Update a menu item +- `DELETE /staff/menu/{item_id}` — Delete a menu item + +### Staff order management +- `GET /staff/orders?status=PAID` — List stall orders by status (default PAID) +- `PATCH /staff/orders/{order_id}/status` — Update an order status (only for orders belonging to the staff's stall) +- `POST /staff/orders/verify-pickup` — Verify 4-digit pickup code and mark order CLAIMED + +### Analytics & Performance +- `GET /staff/performance/overview?month=X&year=Y` — Manager: Get monthly leaderboard/stats for all staff. + +### Webhook +- `POST /webhook/razorpay` — Razorpay will POST payment events here; the endpoint verifies `X-Razorpay-Signature` using `RAZORPAY_WEBHOOK_SECRET` and updates the related `orders/{internal_order_id}` with `razorpay_payment_id`, `razorpay_payment_data`, `status: 'PAID'`, and a generated `pickup_code`. Configure Razorpay webhook to include `notes.internal_order_id` when creating payments. + +### Testing & troubleshooting - Swagger UI: http://localhost:8000/docs — use the Authorize button and paste the idToken (Bearer token). - If you see {"message":"Authorization header required"} or 401: ensure header name is exactly `Authorization` and value starts with `Bearer ` followed by the idToken. - If token expired or invalid: re-login to get a fresh idToken. -Security notes +### Security notes - Do NOT commit secrets. The repo includes a `secrets/` folder in .gitignore — keep service account JSON and .env out of VCS. - The server uses Firestore security via server-side checks: stall_id and college_id are validated in code before writes. -Where to look next (dev pointers) +### Where to look next (dev pointers) - To change menu schema, edit `app/schema.py` (Pydantic models used for validation). - To modify staff authorization behavior, check `app/auth.py` and `app/staff.py:get_staff_details`. - GEMINI integration is in `app/staff.py` (_extract_menu_from_image) and requires `GEMINI_API_KEY`. -Short checklist for running locally +### Short checklist for running locally 1. Populate `.env` (or export env vars). Ensure FIREBASE_SERVICE_ACCOUNT is set. 2. pip install -r requirements.txt 3. uvicorn app.app:app --reload --host 0.0.0.0 --port 8000 4. Use Swagger or a REST client. Authorize with `Authorization: Bearer `. -License +### License - See LICENSE in repo root. -Last updated: 2026-01-17 +Last updated: 2026-01-20 diff --git a/app/app.py b/app/app.py index 051f601..4bb3e37 100644 --- a/app/app.py +++ b/app/app.py @@ -14,7 +14,8 @@ UpdateOrderStatusSchema, UpdateUserProfileSchema, VerifyPickupSchema, - VerifyPaymentSchema + VerifyPaymentSchema, + UpdateStaffProfileSchema ) from .auth import ( authenticate_student, @@ -29,15 +30,16 @@ add_staff_member, get_stall_orders, update_order_status_staff, - get_my_staff_profile, get_staff_me, verify_order_pickup, - activate_staff + activate_staff, + update_staff_profile ) from .manager import ( get_my_staff, remove_staff_member, update_staff_email, + get_stall_performance_overview ) from .user import ( get_user_menu, @@ -67,7 +69,6 @@ security = HTTPBearer() -app.include_router(webhook_router) @app.get("/health", tags=["health"]) def health_check(): @@ -77,6 +78,8 @@ def health_check(): "environment": os.getenv("ENV", "development") } +app.include_router(webhook_router) + @app.post('/auth/verify-staff', tags=["auth"]) async def verify_staff_endpoint(credentials: HTTPAuthorizationCredentials = Security(security)): return await verify_staff_access(credentials.credentials) @@ -118,6 +121,14 @@ async def verify_order_endpoint( ): return await verify_payment_and_update_order( payment_data, credentials.credentials ) +@app.get("/staff/performance/overview", tags=["manager"]) +async def get_stall_performance_overview_endpoint( + month: int, + year: int, + credentials: HTTPAuthorizationCredentials = Security(security) +): + return await get_stall_performance_overview(month, year, credentials.credentials) + @app.post('/staff/add-member', tags=["manager"]) async def add_staff_endpoint( staff_data: AddStaffSchema, @@ -125,12 +136,6 @@ async def add_staff_endpoint( ): return await add_staff_member(staff_data, credentials.credentials) -@app.post("/staff/activate",tags=["staff"]) -async def activate_staff_endpoint( - credentials:HTTPAuthorizationCredentials = Security(security) -): - return await activate_staff(credentials.credentials) - @app.get('/staff/list', tags=["manager"]) async def get_staff_list_endpoint( credentials: HTTPAuthorizationCredentials = Security(security) @@ -152,12 +157,25 @@ async def update_staff_email_endpoint( ): return await update_staff_email(staff_uid, update_data.new_email, credentials.credentials) +@app.post("/staff/activate",tags=["staff", "manager"]) +async def activate_staff_endpoint( + credentials:HTTPAuthorizationCredentials = Security(security) +): + return await activate_staff(credentials.credentials) + @app.get("/staff/me", tags=["staff", "manager"]) async def get_staff_me_endpoint( credentials: HTTPAuthorizationCredentials = Security(security) ): return await get_staff_me(credentials.credentials) +@app.patch("/staff/profile", tags=["staff", "manager"]) +async def update_staff_profile_endpoint( + profile_data: UpdateStaffProfileSchema, + credentials: HTTPAuthorizationCredentials = Security(security) +): + return await update_staff_profile(profile_data, credentials.credentials) + @app.post("/staff/menu", tags=["staff", "manager"]) async def upload_menu_endpoint( menu_data: MenuSchema, diff --git a/app/manager.py b/app/manager.py index feebd25..d754441 100644 --- a/app/manager.py +++ b/app/manager.py @@ -1,4 +1,4 @@ -#app/manager.py +# app/manager.py from fastapi.responses import JSONResponse from starlette import status @@ -8,6 +8,8 @@ ) from firebase_admin import firestore, auth from .firebase_init import db +from datetime import datetime, time +import calendar async def get_my_staff(id_token: str): try: @@ -39,7 +41,6 @@ async def get_my_staff(id_token: str): except Exception as e: return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": str(e)}) - async def remove_staff_member(target_uid: str, id_token: str): try: requester_data, _ = await get_staff_details(id_token) @@ -55,7 +56,8 @@ async def remove_staff_member(target_uid: str, id_token: str): target_data = target_doc.to_dict() if target_data.get("stall_id") != requester_data.get("stall_id"): - return JSONResponse(status_code=status.HTTP_403_FORBIDDEN, content={"message": "You cannot remove staff from other stalls."}) + return JSONResponse(status_code=status.HTTP_403_FORBIDDEN, + content={"message": "You cannot remove staff from other stalls."}) if target_data.get("role") == "manager": return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={"message": "You cannot remove a Manager."}) @@ -67,7 +69,6 @@ async def remove_staff_member(target_uid: str, id_token: str): except Exception as e: return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": str(e)}) - async def update_staff_email(target_uid: str, new_email: str, id_token: str): try: requester_data, _ = await get_staff_details(id_token) @@ -85,7 +86,8 @@ async def update_staff_email(target_uid: str, new_email: str, id_token: str): if old_data.get("stall_id") != requester_data.get("stall_id"): return JSONResponse(status_code=status.HTTP_403_FORBIDDEN, content={"message": "Unauthorized."}) if old_data.get("role") == "manager": - return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={"message": "Cannot change Manager email here."}) + return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, + content={"message": "Cannot change Manager email here."}) try: user = auth.get_user_by_email(new_email) @@ -95,7 +97,8 @@ async def update_staff_email(target_uid: str, new_email: str, id_token: str): new_uid = user.uid if db.collection("staffs").document(new_uid).get().exists: - return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={"message": "New email is already a staff member."}) + return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, + content={"message": "New email is already a staff member."}) batch = db.batch() @@ -115,3 +118,82 @@ async def update_staff_email(target_uid: str, new_email: str, id_token: str): except Exception as e: return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": str(e)}) + +async def get_stall_performance_overview(month: int, year: int, id_token: str): + try: + requester_data, _ = await get_staff_details(id_token) + if not requester_data or requester_data.get("role") != "manager": + return JSONResponse(status_code=status.HTTP_403_FORBIDDEN, content={"message": "Access denied."}) + + stall_id = requester_data.get("stall_id") + + month_start = datetime(year, month, 1) + last_day = calendar.monthrange(year, month)[1] + month_end = datetime(year, month, last_day, 23, 59, 59) + + now = datetime.now() + today_start = datetime.combine(now.date(), time.min) + today_end = datetime.combine(now.date(), time.max) + + staff_docs = db.collection("staffs").where("stall_id", "==", stall_id).stream() + + staff_map = {} + for doc in staff_docs: + data = doc.to_dict() + email = data.get("email") + if email: + staff_map[email] = { + "uid": doc.id, + "name": data.get("name", "Unknown"), + "email": email, + "role": data.get("role"), + "month_total": 0, + "today_total": 0, + "last_active": None + } + + orders_query = ( + db.collection("orders") + .where("stall_id", "==", stall_id) + .where("status", "==", "CLAIMED") + .where("picked_up_at", ">=", month_start) + .where("picked_up_at", "<=", month_end) + .select(["handled_by", "picked_up_at"]) + ) + + order_docs = orders_query.stream() + + for doc in order_docs: + data = doc.to_dict() + handler_email = data.get("handled_by") + pickup_time = data.get("picked_up_at") + + if handler_email in staff_map and pickup_time: + staff_map[handler_email]["month_total"] += 1 + + if today_start.timestamp() <= pickup_time.timestamp() <= today_end.timestamp(): + staff_map[handler_email]["today_total"] += 1 + + current_last = staff_map[handler_email]["last_active"] + if current_last is None or pickup_time.timestamp() > current_last.timestamp(): + staff_map[handler_email]["last_active"] = pickup_time + + results = list(staff_map.values()) + + results.sort(key=lambda x: x["month_total"], reverse=True) + + for staff in results: + if staff["last_active"] is not None: + staff["last_active"] = staff["last_active"].isoformat() + + return JSONResponse( + status_code=status.HTTP_200_OK, + content={ + "stall_id": stall_id, + "period": f"{month}-{year}", + "staff_stats": results + } + ) + + except Exception as e: + return JSONResponse(status_code=500, content={"message": str(e)}) diff --git a/app/schema.py b/app/schema.py index 03e16b9..bd26fb3 100644 --- a/app/schema.py +++ b/app/schema.py @@ -20,10 +20,18 @@ class UpdateUserProfileSchema(BaseModel): roll_number: str = Field(..., min_length=1) phone: Optional[str] = None +class UpdateStaffProfileSchema(BaseModel): + name: str = Field(..., min_length=2) + phone: Optional[str] = None + class MenuItemSchema(BaseModel): name: str price: float = Field(..., gt=0) description: Optional[str] = None + image_ref: Optional[str] = Field( + None, + description="Reference to menu image (URL, CDN key, or placeholder id)" + ) is_available: bool = True class MenuSchema(BaseModel): @@ -34,6 +42,7 @@ class UpdateMenuItemSchema(BaseModel): name: Optional[str] = None price: Optional[float] = None description: Optional[str] = None + image_ref: Optional[str] = None is_available: Optional[bool] = None class ExtractedMenuItem(BaseModel): @@ -79,3 +88,17 @@ class VerifyPaymentSchema(BaseModel): class VerifyPickupSchema(BaseModel): order_id: str pickup_code: str = Field(..., min_length=4, max_length=4, description="4-digit pickup code") + +class StaffStats(BaseModel): + uid: str + name: str + email: str + role: str + month_total: int + today_total: int + last_active: Optional[str] = None + +class StallPerformanceResponse(BaseModel): + stall_id: str + period: str + staff_stats: List[StaffStats] diff --git a/app/staff.py b/app/staff.py index 5506c65..4ef6398 100644 --- a/app/staff.py +++ b/app/staff.py @@ -5,7 +5,7 @@ import json import google.generativeai as genai from fastapi import UploadFile -from .schema import MenuSchema, UpdateMenuItemSchema, AddStaffSchema, UpdateOrderStatusSchema, VerifyPickupSchema +from .schema import MenuSchema, UpdateMenuItemSchema, AddStaffSchema, UpdateOrderStatusSchema, VerifyPickupSchema, UpdateStaffProfileSchema from fastapi.responses import JSONResponse from starlette import status from .firebase_init import db @@ -158,7 +158,6 @@ async def get_my_staff_profile(id_token:str): } ) - async def get_staff_me(id_token: str): staff_data, staff_uid = await get_staff_details(id_token) @@ -213,6 +212,28 @@ async def activate_staff(id_token: str): return JSONResponse(status_code=200,content={"message": "Staff activated"}) +async def update_staff_profile(profile_data: UpdateStaffProfileSchema, id_token: str): + try: + staff_data, uid = await get_staff_details(id_token) + + if not staff_data: + return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content={"message": "Unauthorized"}) + + ref = db.collection("staffs").document(uid) + + ref.update({ + "name": profile_data.name, + "phone": profile_data.phone, + "updated_at": firestore.SERVER_TIMESTAMP + }) + + return JSONResponse( + status_code=status.HTTP_200_OK, + content={"message": "Profile updated successfully"} + ) + except Exception as e: + return JSONResponse(status_code=500, content={"message": str(e)}) + async def upload_menu(menu_data: MenuSchema, id_token: str): try: staff_data, staff_uid = await get_staff_details(id_token) diff --git a/app/webhook.py b/app/webhook.py index 5aae0a7..1bd359a 100644 --- a/app/webhook.py +++ b/app/webhook.py @@ -75,4 +75,4 @@ def update_in_transaction(transaction, order_ref): else: print(f"⚠️ Payment received without internal_order_id: {payment.get('id')}") - return {"status": "ok"} \ No newline at end of file + return {"status": "ok"} diff --git a/images_for_demo/Cheese-Maggi.jpg b/images_for_demo/Cheese-Maggi.jpg new file mode 100644 index 0000000..a3a123e Binary files /dev/null and b/images_for_demo/Cheese-Maggi.jpg differ diff --git a/images_for_demo/chicken-curry.jpg b/images_for_demo/chicken-curry.jpg new file mode 100644 index 0000000..d166cbd Binary files /dev/null and b/images_for_demo/chicken-curry.jpg differ diff --git a/images_for_demo/cold-coffee.jpg b/images_for_demo/cold-coffee.jpg new file mode 100644 index 0000000..77854c9 Binary files /dev/null and b/images_for_demo/cold-coffee.jpg differ diff --git a/images_for_demo/french-fries.jpg b/images_for_demo/french-fries.jpg new file mode 100644 index 0000000..fde9da5 Binary files /dev/null and b/images_for_demo/french-fries.jpg differ diff --git a/images_for_demo/fried-rice.jpg b/images_for_demo/fried-rice.jpg new file mode 100644 index 0000000..92ddbcb Binary files /dev/null and b/images_for_demo/fried-rice.jpg differ diff --git a/images_for_demo/paneer-butter-masala.jpg b/images_for_demo/paneer-butter-masala.jpg new file mode 100644 index 0000000..58b50ee Binary files /dev/null and b/images_for_demo/paneer-butter-masala.jpg differ diff --git a/images_for_demo/roti.jpg b/images_for_demo/roti.jpg new file mode 100644 index 0000000..80633ba Binary files /dev/null and b/images_for_demo/roti.jpg differ diff --git a/images_for_demo/samosa.jpg b/images_for_demo/samosa.jpg new file mode 100644 index 0000000..a27c2a5 Binary files /dev/null and b/images_for_demo/samosa.jpg differ diff --git a/images_for_demo/veg-sandwich.jpg b/images_for_demo/veg-sandwich.jpg new file mode 100644 index 0000000..8cb0797 Binary files /dev/null and b/images_for_demo/veg-sandwich.jpg differ diff --git a/images_for_demo/veg-thali.jpg b/images_for_demo/veg-thali.jpg new file mode 100644 index 0000000..d1a1a9e Binary files /dev/null and b/images_for_demo/veg-thali.jpg differ