""" FastAPI router for app management endpoints. """ import pymysql import regex from box import Box from fastapi import APIRouter, Depends, Query from fastapi.responses import JSONResponse from .. import logger, models from ..constants import Auth0, User from ..responses import json_200, json_200_data, json_201, json_400 from ..users.manage import verify_auth0 logger = logger.new_logger(__name__) ROUTE: str = "/app" def create_router(state: Box, *args, **kwargs): """Create FastAPI router with a state container.""" app = APIRouter() @app.get("/user", response_class=JSONResponse) async def get_user_data(credentials=Depends(verify_auth0)): """Get user data.""" subject = credentials.get(Auth0.SUB) if not subject: # A valid JWT with "sub" claim is required to proceed return json_400() logger.debug("Getting user data for subject '{}'...", subject) user_data = await state.db.Users.get(subject) logger.debug("User data retrieved successfully.") return json_200_data(user_data) @app.patch("/user", response_class=JSONResponse) async def patch_user_data_no_subject( fields: models.User, credentials=Depends(verify_auth0), ): """Update user data without a subject in the URL. This is useful when the authenticated user is updating their own data. """ subject = credentials.get(Auth0.SUB) return await _patch_user_data_logic(fields, subject=subject) @app.patch("/user/{user_id}", response_class=JSONResponse) async def patch_user_data_with_subject(user_id: str, fields: models.User): """Update user data with a subject in the URL. This is useful when an admin is updating another user's data. """ return await _patch_user_data_logic(fields, user_id=user_id) async def _patch_user_data_logic( fields: models.User, *, subject: str | None = None, user_id: str | None = None ): """Shared logic for handling user data updates. User must be identified by either subject or user_id, but not both. """ if not (subject or user_id) or not fields: return json_400() fields = fields.model_dump(exclude_unset=True) nickname = fields.get(User.NICKNAME) if nickname is None or not nickname.strip(): return json_400(error="Nickname can't be empty.") name = fields.get(User.NAME, "") if not name.strip(): return json_400(error="Full Name can't be empty.") if regex.fullmatch(r"^[\p{L}\s\-']+$", name) is None: return json_400(error="Full Name contains invalid characters.") if len(name) < 3: return json_400(error="Full Name must be at least 3 characters long.") try: if subject: await state.db.Users.update(subject, fields) else: await state.db.Users.update_by_id(user_id, fields) except pymysql.err.IntegrityError as ex: if ex.args[0] == 1062 and User.NICKNAME in ex.args[1]: return json_400(error="Nickname is already in use.") return json_200() @app.get("/users", response_class=JSONResponse) async def list_users_data( limit: int = Query(100, description="Number of users to return."), offset: int = Query(0, description="Offset of the first record to return."), ): """List all users.""" if limit is None: limit = 0 total_count, users = await state.db.Users.list(limit=limit + 1, offset=offset) return json_200_data({"users": users, "total_count": total_count}) @app.post("/permissions/{user_id}", response_class=JSONResponse) async def upsert_user_permissions(user_id: str, permissions: models.Permissions): """Upsert and/or delete user permissions. Provide a dictionary of permission scopes and levels to upsert permissions. Any scope with None (null) level will be deleted. The permissions object is a dictionary of permission scopes and levels. Example: { "MODULES.STATISTICS": "READ", "MODULES.ADMIN": "ALL", "MODULES.AUDITS": null, } """ permissions_obj = permissions.model_dump() await state.db.Permissions.upsert(user_id, permissions_obj) return json_201() @app.get("/permissions/meta", response_class=JSONResponse) async def get_permissions_meta(): """Get permissions metadata. Returns: Response: Permissions metadata, including all available scopes and levels as stored in the database. """ resp = await state.db.Permissions.get_meta() return json_200_data(resp) return app