from __future__ import annotations from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from govoplan_core.api.v1.schemas import ( ReferenceOptionListResponse, ReferenceOptionResponse, ) from govoplan_core.auth import ApiPrincipal, require_scope from govoplan_core.audit.logging import audit_from_principal from govoplan_core.core.references import ( access_scope_reference_page, access_scope_reference_provider_available, ) from govoplan_core.db.session import get_session from govoplan_files.backend.runtime import get_registry from govoplan_files.backend.schemas import ( BulkFileShareRequest, BulkFileShareResponse, FileShareRequest, FileShareResponse, FileSharesResponse, ) from govoplan_files.backend.storage.common import FileStorageError from govoplan_files.backend.storage.files import ( current_file_share_for_target, get_asset_for_share_management, list_file_shares, revoke_file_share, share_file, share_files, ) from govoplan_files.backend.route_support import ( _file_share_response, _http_error, _is_admin, ) router = APIRouter(prefix="/files", tags=["files"]) @router.get( "/{file_id}/share-target-options", response_model=ReferenceOptionListResponse, ) def search_share_targets( file_id: str, target_type: str, q: str = "", selected: list[str] = Query(default=[]), limit: int = Query(default=50, ge=1, le=200), cursor: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("files:file:share")), ) -> ReferenceOptionListResponse: if target_type not in {"user", "group"}: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Share target type must be user or group", ) try: get_asset_for_share_management( session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, is_admin=_is_admin(principal), ) registry = get_registry() page = access_scope_reference_page( registry, principal, scope_type=target_type, reference_kind="membership" if target_type == "user" else "group", query=q, selected_values=selected, limit=limit, cursor=cursor, administrative=True, session=session, ) except ValueError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc except FileStorageError as exc: raise _http_error(exc) from exc return ReferenceOptionListResponse( options=[ ReferenceOptionResponse(**option.to_dict()) for option in page.options ], provider_available=access_scope_reference_provider_available(registry), next_cursor=page.next_cursor, has_more=page.has_more, ) @router.get("/{file_id}/shares", response_model=FileSharesResponse) def list_shares( file_id: str, include_inactive: bool = False, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("files:file:share")), ): try: asset = get_asset_for_share_management( session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, is_admin=_is_admin(principal), ) return FileSharesResponse( shares=[ _file_share_response(share) for share in list_file_shares( session, tenant_id=principal.tenant_id, asset_id=asset.id, include_inactive=include_inactive, ) ] ) except FileStorageError as exc: raise _http_error(exc) from exc @router.post("/{file_id}/shares", response_model=FileShareResponse) def create_share( file_id: str, payload: FileShareRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("files:file:share")), ): try: asset = get_asset_for_share_management( session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, is_admin=_is_admin(principal), ) previous = current_file_share_for_target( session, tenant_id=principal.tenant_id, asset_id=asset.id, target_type=payload.target_type, target_id=payload.target_id, ) previous_permission = previous.permission if previous else None previous_expiry = previous.expires_at if previous else None share = share_file( session, tenant_id=principal.tenant_id, asset=asset, target_type=payload.target_type, target_id=payload.target_id, permission=payload.permission, user_id=principal.user.id, expires_at=payload.expires_at, ) session.flush() action = _share_audit_action( existed=previous is not None, previous_permission=previous_permission, permission=share.permission, previous_expiry=previous_expiry, expiry=share.expires_at, ) if action: _audit_share_change(session, principal, share, action=action) session.commit() return _file_share_response(share) except FileStorageError as exc: session.rollback() raise _http_error(exc) from exc @router.post("/bulk-shares", response_model=BulkFileShareResponse) def create_bulk_shares( payload: BulkFileShareRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("files:file:share")), ): try: file_ids = list(dict.fromkeys(payload.file_ids)) assets = [ get_asset_for_share_management( session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, is_admin=_is_admin(principal), ) for file_id in file_ids ] previous_by_asset = { asset.id: current_file_share_for_target( session, tenant_id=principal.tenant_id, asset_id=asset.id, target_type=payload.target_type, target_id=payload.target_id, ) for asset in assets } previous_values = { asset_id: ( share.permission if share else None, share.expires_at if share else None, ) for asset_id, share in previous_by_asset.items() } shares = share_files( session, tenant_id=principal.tenant_id, assets=assets, target_type=payload.target_type, target_id=payload.target_id, permission=payload.permission, user_id=principal.user.id, expires_at=payload.expires_at, ) session.flush() for share in shares: previous_permission, previous_expiry = previous_values[share.file_asset_id] action = _share_audit_action( existed=previous_by_asset[share.file_asset_id] is not None, previous_permission=previous_permission, permission=share.permission, previous_expiry=previous_expiry, expiry=share.expires_at, ) if action: _audit_share_change(session, principal, share, action=action) session.commit() return BulkFileShareResponse( shared_count=len(shares), shares=[_file_share_response(share) for share in shares], ) except FileStorageError as exc: session.rollback() raise _http_error(exc) from exc @router.delete("/{file_id}/shares/{share_id}", response_model=FileShareResponse) def revoke_share( file_id: str, share_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("files:file:share")), ): try: asset = get_asset_for_share_management( session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, is_admin=_is_admin(principal), ) share, changed = revoke_file_share( session, tenant_id=principal.tenant_id, asset_id=asset.id, share_id=share_id, user_id=principal.user.id, ) if changed: _audit_share_change( session, principal, share, action="files.share.revoked" ) session.commit() return _file_share_response(share) except FileStorageError as exc: session.rollback() raise _http_error(exc) from exc def _share_audit_action( *, existed: bool, previous_permission: str | None, permission: str, previous_expiry: datetime | None, expiry: datetime | None, ) -> str | None: if not existed: return "files.share.granted" permission_changed = previous_permission != permission expiry_changed = _normalized_expiry(previous_expiry) != _normalized_expiry(expiry) if permission_changed: return "files.share.changed" if expiry_changed: return "files.share.expiry_changed" return None def _audit_share_change( session: Session, principal: ApiPrincipal, share, *, action: str, ) -> None: audit_from_principal( session, principal, action=action, object_type="file_share", object_id=share.id, details={ "file_asset_id": share.file_asset_id, "target_type": share.target_type, "target_id": share.target_id, "permission": share.permission, "expires_at": _normalized_expiry(share.expires_at), }, ) def _normalized_expiry(value: datetime | None) -> str | None: if value is None: return None if value.tzinfo is None: value = value.replace(tzinfo=timezone.utc) return value.astimezone(timezone.utc).isoformat()