Files
Lotty/src/backend/api/v1/reviews/endpoints.py
T
2026-02-13 10:56:03 +03:00

346 lines
10 KiB
Python

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,
)