import asyncio import json from collections import deque from box import Box from fastapi import APIRouter, Depends, Query from fastapi.responses import JSONResponse from pydantic import conint from ... import logger, models from ...constants import Auth0, YtCid from ...exception_handlers import handle_exceptions from ...logic import audit from ...responses import json_200, json_200_data, json_201, json_201_data from ...typings import AuditGroupID, UserID from ...users import manage as user_management from ...users.manage import verify_auth0 from ...utils.datetimes import make_datetimes_serializable logger = logger.new_logger(__name__) ROUTE: str = "/audits/data" def create_router(state: Box, *args, **kwargs): """Create FastAPI router with a state container.""" app = APIRouter() @app.get("/audit_groups", response_class=JSONResponse) async def audit_groups_list( created_by: int = Query( None, description="Filter by user ID who created the audit group.", ), status: str = Query( None, description="Filter by status. One of 'PENDING', 'IN_PROGRESS', 'COMPLETED'.", ), text: str = Query( None, description="Filter by text. Searches in audit group label and user nickname.", ), audit_types=Query( None, description="Filter by audit types. If provided, they must be a list of " "comma-separated values.", example="SR,AT,MV", ), scheduled_date_start: str = Query( None, description="Filter by scheduled date start. Format: 'YYYY-MM-DD'.", ), scheduled_date_end: str = Query( None, description="Filter by scheduled date end. Format: 'YYYY-MM-DD'.", ), score_min: conint(ge=0, le=100) = Query( None, description="Filter by minimum score.", ), score_max: conint(ge=0, le=100) = Query( None, description="Filter by maximum score.", ), order_by: str = Query( "date_created_utc", description="Field to order the results by." ), order_desc: bool = Query( True, description="Direction of sorting, True for descending, False for ascending.", ), offset: int = Query(0, description="Offset of the first record to return."), limit: conint(ge=1, le=100) = Query( 100, description="Number of records to return." ), archived: bool = Query( False, description="Filter by archived status. If True, include archived audit " "groups, otherwise exclude them.", ), ): """Retrieve audit groups. Returns: Response: List of audit groups and related metadata. """ if audit_types: # Parse comma-separated values into a list of uppercase strings, # e.g. "SR,AT,MV" -> ["SR", "AT", "MV"] audit_types = [ audit_type.strip().upper() for audit_type in audit_types.split(",") ] resp = await state.db.AuditGroups.list( created_by=created_by, status=status, text=text, audit_types=audit_types, scheduled_date_start=scheduled_date_start, scheduled_date_end=scheduled_date_end, score_min=score_min, score_max=score_max, order_by=order_by, order_desc=order_desc, offset=offset, limit=limit, archived=archived, ) rows, total_row_count = resp for row in rows: del row["created_by"] row["created_by"] = json.loads(row.pop("_created_by")) return json_200_data( { "rows": make_datetimes_serializable(rows), "total_row_count": total_row_count, } ) @app.get("/audit_groups/get_single", response_class=JSONResponse) async def audit_groups_get_single(group: AuditGroupID): """Retrieve a single audit group data in a quick and efficient way, without fetching joined data from other tables, such as audits, flags, logs, etc. Returns: Response: Data of a single audit group. """ data = await state.db.AuditGroups.get(group) return json_200_data(data) @app.get("/audits", response_class=JSONResponse) async def audits_list(): """Retrieve audits.""" resp = await state.db.Audits.list() return json_200_data(make_datetimes_serializable(resp)) @app.post("/audits", response_class=JSONResponse) async def audit_new(body: models.NewAudit, auth0=Depends(verify_auth0)): """Create new audit.""" audit_group_id_callback = asyncio.get_event_loop().create_future() requesting_user_id: UserID = await user_management.get_subject_user_id( state.db, auth0[Auth0.SUB] ) _ = asyncio.create_task( audit.run( user=requesting_user_id, label_id=body.label_id, audit_audio=body.audit_audio, audit_video=body.audit_video, audit_art_track=body.audit_art_track, audit_group_id_callback=audit_group_id_callback, ) ) audit_group_id = await audit_group_id_callback return json_201(audit_group_id=audit_group_id) @app.get("/audits/{audit_group_id}", response_class=JSONResponse) async def audit_group_get_audits(audit_group_id: AuditGroupID): """Get audits belonging to audit group. Args: audit_group_id (int): Audit group ID. """ resp = await state.db.AuditGroups.list_children(audit_group_id) return json_200_data(resp) @app.get("/audits/{audit_group_id}/{resource}", response_class=JSONResponse) async def audit_group_get_resource(audit_group_id: AuditGroupID, resource: str): """Get audit resource. Args: audit_group_id (int): Audit group ID. resource (str): Resource to get, one of "flags" or "logs". """ func = { "flags": state.db.AuditGroups.get_flags, "logs": state.db.AuditGroups.get_logs, "rows": state.db.AuditRows.get_by_audit_group_id, }[resource] resp = await func(audit_group_id) return json_200_data(make_datetimes_serializable(resp)) @app.post("/audits/archive", response_class=JSONResponse) async def audit_archive(body: models.AuditIds): """Archive multiple audit groups. This won't delete any data, but will mark the audit groups as archived, and hence it is reversible. """ await state.db.AuditGroups.archive(body.audit_ids) return json_200() @app.post("/audits/delete", response_class=JSONResponse) async def audit_delete(body: models.AuditIds): """Delete multiple audit groups and all their related assets ( audits, flags, logs). """ await state.db.AuditGroups.delete(body.audit_ids) return json_200() @app.post("/flags/count", response_class=JSONResponse) async def flags_count(body: models.AuditGroupIds): """Get unique flagged row counts for one or more audit groups.""" resp = await state.db.Flags.unique_rows(body.audit_group_ids) return json_200_data(resp) @app.post("/flags/resolve", response_class=JSONResponse) async def flags_resolve(body: models.FlagResolve, auth0=Depends(verify_auth0)): """Resolve flag and mark as resolver the user who sent the request, based on the Auth0 token subject. Returns updated flag data.""" subject = auth0[Auth0.SUB] user_id = await user_management.get_subject_user_id(state.db, subject) resp = await state.db.Flags.resolve( body.flag_ids, user_id, body.resolution, body.resolution_subtype ) return json_201_data(make_datetimes_serializable(resp)) @app.post("/reports/assets", response_class=JSONResponse) @handle_exceptions async def assets_lookup(assets_lookup_request: models.YTListRequest): """Bulk lookup YouTube Content ID Assets by ID. Returns: Response: List of assets in JSON format. """ assets = deque( await state.youtube_api_cid_client.assets_list( assets_lookup_request.asset_ids, fetchOwnership=YtCid.EFFECTIVE, fetchMetadata=YtCid.EFFECTIVE, fetchMatchPolicy=YtCid.EFFECTIVE, ) ) asset_jsons = [] while assets: asset = assets.popleft() # Memory efficient asset_jsons.append(asset.json()) return json_200_data(asset_jsons) @app.post("/reports/references", response_class=JSONResponse) @handle_exceptions async def references_lookup(references_lookup_request: models.YTListRequest): """Bulk lookup YouTube Content ID References by Asset ID. Returns: Response: List of lists of references. Each list of references corresponds to an asset ID in the request. """ logger.info( "Looking up YouTube References via API for {} YouTube Assets: {}", len(references_lookup_request.asset_ids), ", ".join(references_lookup_request.asset_ids), ) assets = deque( await asyncio.gather( *[ state.youtube_api_cid_client.references_list(asset_id) for asset_id in references_lookup_request.asset_ids ] ) ) # TODO: implement pagination OR NotImplementedError asset_references = [] while assets: asset = assets.popleft() # Memory efficient asset_references.append([reference.json() for reference in asset]) logger.info( "YouTube references lookup completed. Found {} " "YouTube References for {} YouTube Assets.", sum(map(len, asset_references)), len(asset_references), ) return json_200_data(asset_references) @app.get("/audit_meta", response_class=JSONResponse) async def audit_meta_get(audit_id: int, key: str): """Get audit metadata.""" resp = await state.db.Meta.get(audit_id, [key]) value = resp[key] if resp else None return json_200(value=value) @app.post("/audit_meta", response_class=JSONResponse) async def audit_meta_set(body: models.AuditMetaSet): """Set audit metadata. If the key already exists for the given audit ID, its value will be updated with the new value. """ await state.db.Meta.set(body.audit_id, body.key, body.value) return json_201() return app