from http import HTTPStatus from uuid import UUID from django.core.exceptions import ValidationError from django.http import Http404, HttpRequest from django.shortcuts import get_object_or_404 from ninja import Router from api.v1.reviews.schemas import ( ApproverGroupAddApproverIn, ApproverGroupCreateIn, ApproverGroupListOut, ApproverGroupOut, ApproverGroupRemoveApproverIn, ApproverGroupUpdateIn, ApproverOut, EffectiveReviewPolicyOut, ReviewSettingsOut, ReviewSettingsUpdateIn, ) from apps.reviews.models import ApproverGroup from apps.reviews.selectors import ( approver_group_get_by_experimenter_id, approver_group_list, get_effective_approvers_for_experimenter, review_settings_load, ) from apps.reviews.services import ( approver_group_add_approver, approver_group_create, approver_group_delete, approver_group_remove_approver, approver_group_update, review_settings_update, ) from apps.users.auth.bearer import jwt_bearer, require_admin from apps.users.models import User, UserRole router = Router(tags=["reviews"], auth=jwt_bearer) @router.get( "/approver-groups", response={HTTPStatus.OK: ApproverGroupListOut}, summary="List approver groups", description=( "Return a paginated list of all approver groups. Admin only." ), ) @require_admin def list_approver_groups( request: HttpRequest, limit: int = 50, offset: int = 0, ) -> tuple[HTTPStatus, ApproverGroupListOut]: qs = approver_group_list() total = qs.count() items = [ ApproverGroupOut.model_validate(item) for item in qs[offset : offset + limit] ] return HTTPStatus.OK, ApproverGroupListOut(count=total, items=items) @router.post( "/approver-groups", response={HTTPStatus.CREATED: ApproverGroupOut}, summary="Create approver group", description=( "Create a new approver group for an experimenter. " "Each experimenter can have at most one group. Admin only." ), ) @require_admin def create_approver_group( request: HttpRequest, payload: ApproverGroupCreateIn, ) -> tuple[HTTPStatus, ApproverGroupOut]: experimenter = get_object_or_404(User, pk=payload.experimenter_id) group = approver_group_create( experimenter=experimenter, approver_ids=payload.approver_ids or None, min_approvals=payload.min_approvals, ) response_group = get_object_or_404( ApproverGroup.objects.select_related("experimenter").prefetch_related( "approvers" ), pk=group.pk, ) return HTTPStatus.CREATED, ApproverGroupOut.model_validate(response_group) @router.get( "/approver-groups/{group_id}", response={HTTPStatus.OK: ApproverGroupOut}, summary="Get approver group", description="Retrieve a single approver group by its UUID. Admin only.", ) @require_admin def get_approver_group( request: HttpRequest, group_id: UUID, ) -> tuple[HTTPStatus, ApproverGroupOut]: group = get_object_or_404( ApproverGroup.objects.select_related("experimenter").prefetch_related( "approvers" ), pk=group_id, ) return HTTPStatus.OK, ApproverGroupOut.model_validate(group) @router.patch( "/approver-groups/{group_id}", response={HTTPStatus.OK: ApproverGroupOut}, summary="Update approver group", description=( "Partially update an existing approver group. " "Only non-null fields in the payload are applied. Admin only." ), ) @require_admin def update_approver_group( request: HttpRequest, group_id: UUID, payload: ApproverGroupUpdateIn, ) -> tuple[HTTPStatus, ApproverGroupOut]: group = get_object_or_404( ApproverGroup.objects.select_related("experimenter").prefetch_related( "approvers" ), pk=group_id, ) updated = approver_group_update( group=group, approver_ids=payload.approver_ids, min_approvals=payload.min_approvals, ) response_group = get_object_or_404( ApproverGroup.objects.select_related("experimenter").prefetch_related( "approvers" ), pk=updated.pk, ) return HTTPStatus.OK, ApproverGroupOut.model_validate(response_group) @router.delete( "/approver-groups/{group_id}", response={HTTPStatus.NO_CONTENT: None}, summary="Delete approver group", description=( "Delete an approver group. After deletion the experimenter " "falls back to the global review-settings policy. Admin only." ), ) @require_admin def delete_approver_group( request: HttpRequest, group_id: UUID, ) -> tuple[HTTPStatus, None]: group = get_object_or_404( ApproverGroup.objects.select_related("experimenter").prefetch_related( "approvers" ), pk=group_id, ) approver_group_delete(group=group) return HTTPStatus.NO_CONTENT, None @router.post( "/approver-groups/{group_id}/approvers/add", response={HTTPStatus.OK: ApproverGroupOut}, summary="Add approver to group", description=( "Add a single approver user to an existing approver group. " "The user must have the 'approver' role. Admin only." ), ) @require_admin def add_approver_to_group( request: HttpRequest, group_id: UUID, payload: ApproverGroupAddApproverIn, ) -> tuple[HTTPStatus, ApproverGroupOut]: group = get_object_or_404( ApproverGroup.objects.select_related("experimenter").prefetch_related( "approvers" ), pk=group_id, ) approver = get_object_or_404(User, pk=payload.approver_id) approver_group_add_approver(group=group, approver=approver) response_group = get_object_or_404( ApproverGroup.objects.select_related("experimenter").prefetch_related( "approvers" ), pk=group.pk, ) return HTTPStatus.OK, ApproverGroupOut.model_validate(response_group) @router.post( "/approver-groups/{group_id}/approvers/remove", response={HTTPStatus.OK: ApproverGroupOut}, summary="Remove approver from group", description=( "Remove a single approver user from an existing approver group. " "Fails if removal would leave fewer approvers than the " "minimum approval threshold. Admin only." ), ) @require_admin def remove_approver_from_group( request: HttpRequest, group_id: UUID, payload: ApproverGroupRemoveApproverIn, ) -> tuple[HTTPStatus, ApproverGroupOut]: group = get_object_or_404( ApproverGroup.objects.select_related("experimenter").prefetch_related( "approvers" ), pk=group_id, ) approver = get_object_or_404(User, pk=payload.approver_id) approver_group_remove_approver(group=group, approver=approver) response_group = get_object_or_404( ApproverGroup.objects.select_related("experimenter").prefetch_related( "approvers" ), pk=group.pk, ) return HTTPStatus.OK, ApproverGroupOut.model_validate(response_group) @router.get( "/approver-groups/by-experimenter/{experimenter_id}", response={HTTPStatus.OK: ApproverGroupOut}, summary="Get approver group by experimenter", description=( "Retrieve the approver group assigned to a specific experimenter. " "Returns 404 if no explicit group exists. " "Available to any authenticated user." ), ) @require_admin def get_approver_group_by_experimenter( request: HttpRequest, experimenter_id: UUID, ) -> tuple[HTTPStatus, ApproverGroupOut]: group = approver_group_get_by_experimenter_id(str(experimenter_id)) if group is None: raise Http404 return HTTPStatus.OK, ApproverGroupOut.model_validate(group) @router.get( "/settings", response={HTTPStatus.OK: ReviewSettingsOut}, summary="Get review settings", description=( "Retrieve the global fallback review settings. " "Available to any authenticated user." ), ) def get_review_settings( request: HttpRequest, ) -> tuple[HTTPStatus, ReviewSettingsOut]: settings = review_settings_load() return HTTPStatus.OK, ReviewSettingsOut.model_validate(settings) @router.put( "/settings", response={HTTPStatus.OK: ReviewSettingsOut}, summary="Update review settings", description=( "Update the global fallback review settings. " "Only non-null fields in the payload are applied. Admin only." ), ) @require_admin def update_review_settings( request: HttpRequest, payload: ReviewSettingsUpdateIn, ) -> tuple[HTTPStatus, ReviewSettingsOut]: settings = review_settings_update( default_min_approvals=payload.default_min_approvals, allow_any_approver=payload.allow_any_approver, ) return HTTPStatus.OK, ReviewSettingsOut.model_validate(settings) @router.get( "/effective-policy/{experimenter_id}", response={HTTPStatus.OK: EffectiveReviewPolicyOut}, summary="Get effective review policy for an experimenter", description=( "Resolve and return the effective review policy for a given " "experimenter, taking into account both an explicit approver " "group (if any) and the global fallback settings. " "Available to any authenticated user." ), ) def get_effective_policy( request: HttpRequest, experimenter_id: UUID, ) -> tuple[HTTPStatus, EffectiveReviewPolicyOut]: experimenter = get_object_or_404(User, pk=experimenter_id) if experimenter.role != UserRole.EXPERIMENTER: raise ValidationError( { "experimenter_id": ( f"User '{experimenter.username}' does not have the " f"'{UserRole.EXPERIMENTER}' role." ) } ) approvers_qs, min_approvals = get_effective_approvers_for_experimenter( experimenter, ) has_group = ( approver_group_get_by_experimenter_id(str(experimenter_id)) is not None ) return HTTPStatus.OK, EffectiveReviewPolicyOut( experimenter_id=str(experimenter.pk), min_approvals=min_approvals, approvers=[ ApproverOut.model_validate(approver) for approver in approvers_qs ], source="approver_group" if has_group else "global_fallback", has_explicit_group=has_group, )