feat(backend): added auth, reviews, users modules
also provided tests
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
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[int, 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[int, 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: str,
|
||||
) -> tuple[int, 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: str,
|
||||
payload: ApproverGroupUpdateIn,
|
||||
) -> tuple[int, 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: str,
|
||||
) -> tuple[int, 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: str,
|
||||
payload: ApproverGroupAddApproverIn,
|
||||
) -> tuple[int, 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: str,
|
||||
payload: ApproverGroupRemoveApproverIn,
|
||||
) -> tuple[int, 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: str,
|
||||
) -> tuple[int, ApproverGroupOut]:
|
||||
group = approver_group_get_by_experimenter_id(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[int, 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[int, 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: str,
|
||||
) -> tuple[int, 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(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,
|
||||
)
|
||||
Reference in New Issue
Block a user