346 lines
10 KiB
Python
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,
|
|
)
|