feat(backend): added auth, reviews, users modules

also provided tests
This commit is contained in:
ITQ
2026-02-12 20:48:29 +03:00
parent cb9692089f
commit 613c99dce2
60 changed files with 5101 additions and 127 deletions
+108
View File
@@ -0,0 +1,108 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from apps.reviews.models import ApproverGroup, ReviewSettings
@admin.register(ReviewSettings)
class ReviewSettingsAdmin(admin.ModelAdmin):
list_display = (
ReviewSettings.default_min_approvals.field.name,
ReviewSettings.allow_any_approver.field.name,
ReviewSettings.updated_at.field.name,
)
readonly_fields = (ReviewSettings.updated_at.field.name,)
fieldsets = (
(
_("Fallback review policy"),
{
"fields": (
ReviewSettings.default_min_approvals.field.name,
ReviewSettings.allow_any_approver.field.name,
),
"description": _(
"Global fallback settings applied when an experimenter "
"has no explicit approver group assigned."
),
},
),
(
_("Metadata"),
{
"fields": (ReviewSettings.updated_at.field.name,),
},
),
)
def has_add_permission(self, request) -> bool:
if ReviewSettings.objects.exists():
return False
return super().has_add_permission(request)
def has_delete_permission(self, request, obj=None) -> bool:
return False
@admin.register(ApproverGroup)
class ApproverGroupAdmin(admin.ModelAdmin):
list_display = (
ApproverGroup.experimenter.field.name,
ApproverGroup.min_approvals.field.name,
"approver_count",
ApproverGroup.created_at.field.name,
ApproverGroup.updated_at.field.name,
)
list_filter = (ApproverGroup.min_approvals.field.name,)
search_fields = (
f"{ApproverGroup.experimenter.field.name}__username",
f"{ApproverGroup.experimenter.field.name}__email",
f"{ApproverGroup.approvers.field.name}__username",
f"{ApproverGroup.approvers.field.name}__email",
)
raw_id_fields = (ApproverGroup.experimenter.field.name,)
filter_horizontal = (ApproverGroup.approvers.field.name,)
readonly_fields = (
ApproverGroup.created_at.field.name,
ApproverGroup.updated_at.field.name,
)
ordering = (f"-{ApproverGroup.created_at.field.name}",)
fieldsets = (
(
_("Experimenter"),
{
"fields": (ApproverGroup.experimenter.field.name,),
"description": _(
"The experimenter whose experiments this group governs. "
"Each experimenter can have at most one approver group."
),
},
),
(
_("Approvers"),
{
"fields": (
ApproverGroup.approvers.field.name,
ApproverGroup.min_approvals.field.name,
),
"description": _(
"Select which approver-role users may approve this "
"experimenter's experiments and how many approvals "
"are required before an experiment can be launched."
),
},
),
(
_("Metadata"),
{
"fields": (
ApproverGroup.created_at.field.name,
ApproverGroup.updated_at.field.name,
),
},
),
)
@admin.display(description=_("Approvers"))
def approver_count(self, obj):
return obj.approvers.count()
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ReviewsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.reviews"
@@ -0,0 +1,47 @@
# Generated by Django 5.2.11 on 2026-02-12 10:14
import django.core.validators
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ReviewSettings',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('default_min_approvals', models.PositiveIntegerField(default=1, help_text='Fallback number of approvals required when no approver group is explicitly assigned to an experimenter.', validators=[django.core.validators.MinValueValidator(1)], verbose_name='default minimum approvals')),
('allow_any_approver', models.BooleanField(default=True, help_text='When True, any user with the Approver role can approve experiments that have no explicit approver group. When False, experiments without an approver group cannot proceed to review.', verbose_name='allow any approver')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
],
options={
'verbose_name': 'review settings',
'verbose_name_plural': 'review settings',
},
),
migrations.CreateModel(
name='ApproverGroup',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('min_approvals', models.PositiveIntegerField(default=1, help_text="Number of distinct approvals required before an experiment can transition to 'approved'.", validators=[django.core.validators.MinValueValidator(1)], verbose_name='minimum approvals')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
('approvers', models.ManyToManyField(blank=True, help_text="Approver-role users who may approve this experimenter's experiments.", limit_choices_to={'role': 'approver'}, related_name='approvable_groups', to=settings.AUTH_USER_MODEL, verbose_name='approvers')),
('experimenter', models.OneToOneField(help_text='The experimenter whose experiments this group governs.', limit_choices_to={'role': 'experimenter'}, on_delete=django.db.models.deletion.CASCADE, related_name='approver_group', to=settings.AUTH_USER_MODEL, verbose_name='experimenter')),
],
options={
'verbose_name': 'approver group',
'verbose_name_plural': 'approver groups',
},
),
]
+114
View File
@@ -0,0 +1,114 @@
from typing import Any
from django.conf import settings
from django.core.validators import MinValueValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
from apps.core.models import BaseModel
from apps.users.models import User
class ReviewSettings(BaseModel):
default_min_approvals: models.PositiveIntegerField[Any, Any] = (
models.PositiveIntegerField(
default=1,
validators=[MinValueValidator(1)],
verbose_name=_("default minimum approvals"),
help_text=_(
"Fallback number of approvals required when no "
"approver group is explicitly assigned to an experimenter."
),
)
)
allow_any_approver: models.BooleanField[Any, Any] = models.BooleanField(
default=False,
verbose_name=_("allow any approver"),
help_text=_(
"When True, any user with the Approver role can approve "
"experiments that have no explicit approver group. "
"When False, experiments without an approver group "
"cannot proceed to review."
),
)
updated_at: models.DateTimeField = models.DateTimeField(
auto_now=True,
verbose_name=_("updated at"),
)
class Meta:
verbose_name = _("review settings")
verbose_name_plural = _("review settings")
def __str__(self) -> str:
return (
f"ReviewSettings(min_approvals={self.default_min_approvals}, "
f"allow_any_approver={self.allow_any_approver})"
)
def save(self, *args, **kwargs) -> None:
existing: ReviewSettings | None = ReviewSettings.objects.first()
if existing and existing.pk != self.pk:
self.pk = existing.pk
super().save(*args, **kwargs)
ReviewSettings.objects.exclude(pk=self.pk).delete()
@classmethod
def load(cls) -> "ReviewSettings":
obj, _ = cls.objects.get_or_create(
defaults={"default_min_approvals": 1, "allow_any_approver": False},
)
return obj
class ApproverGroup(BaseModel):
experimenter: models.OneToOneField[Any, Any] = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="approver_group",
limit_choices_to={"role": "experimenter"},
verbose_name=_("experimenter"),
help_text=_("The experimenter whose experiments this group governs."),
)
approvers: models.ManyToManyField = models.ManyToManyField(
settings.AUTH_USER_MODEL,
related_name="approvable_groups",
blank=True,
limit_choices_to={"role": "approver"},
verbose_name=_("approvers"),
help_text=_(
"Approver-role users who may approve this experimenter's experiments."
),
)
min_approvals: models.PositiveIntegerField[Any, Any] = (
models.PositiveIntegerField(
default=1,
validators=[MinValueValidator(1)],
verbose_name=_("minimum approvals"),
help_text=_(
"Number of distinct approvals required before "
"an experiment can transition to 'approved'."
),
)
)
created_at: models.DateTimeField = models.DateTimeField(
auto_now_add=True,
verbose_name=_("created at"),
)
updated_at: models.DateTimeField = models.DateTimeField(
auto_now=True,
verbose_name=_("updated at"),
)
class Meta:
verbose_name = _("approver group")
verbose_name_plural = _("approver groups")
def __str__(self) -> str:
return (
f"ApproverGroup(experimenter={self.experimenter.pk}, "
f"min_approvals={self.min_approvals})"
)
def can_approve(self, user: User) -> bool:
return self.approvers.filter(pk=user.pk).exists()
+118
View File
@@ -0,0 +1,118 @@
import uuid
from django.db.models import QuerySet
from apps.reviews.models import ApproverGroup, ReviewSettings
from apps.users.models import User, UserRole
def review_settings_load() -> ReviewSettings:
return ReviewSettings.load()
def approver_group_get_by_id(group_id: str) -> ApproverGroup | None:
try:
uuid.UUID(group_id)
except ValueError:
return None
return (
ApproverGroup.objects.select_related("experimenter")
.prefetch_related("approvers")
.filter(id=group_id)
.first()
)
def approver_group_get_by_experimenter(
experimenter: User,
) -> ApproverGroup | None:
return (
ApproverGroup.objects.select_related("experimenter")
.prefetch_related("approvers")
.filter(experimenter=experimenter)
.first()
)
def approver_group_get_by_experimenter_id(
experimenter_id: str,
) -> ApproverGroup | None:
try:
uuid.UUID(experimenter_id)
except ValueError:
return None
return (
ApproverGroup.objects.select_related("experimenter")
.prefetch_related("approvers")
.filter(experimenter_id=experimenter_id)
.first()
)
def approver_group_list() -> QuerySet[ApproverGroup]:
return (
ApproverGroup.objects.select_related("experimenter")
.prefetch_related("approvers")
.order_by("-created_at")
)
def approver_group_list_for_approver(
approver: User,
) -> QuerySet[ApproverGroup]:
return (
ApproverGroup.objects.filter(approvers=approver)
.select_related("experimenter")
.prefetch_related("approvers")
.order_by("-created_at")
)
def get_effective_approvers_for_experimenter(
experimenter: User,
) -> tuple[QuerySet[User], int]:
group: ApproverGroup | None = approver_group_get_by_experimenter(
experimenter
)
if group is not None:
return group.approvers.filter(is_active=True), group.min_approvals
settings: ReviewSettings = review_settings_load()
if settings.allow_any_approver:
all_approvers: QuerySet[User] = User.objects.filter(
role=UserRole.APPROVER,
is_active=True,
)
return all_approvers, settings.default_min_approvals
return User.objects.none(), settings.default_min_approvals
def can_user_approve_experimenter(
approver: User,
experimenter: User,
) -> bool:
if approver.role != UserRole.APPROVER:
return False
if not approver.is_active:
return False
group: ApproverGroup | None = approver_group_get_by_experimenter(
experimenter
)
if group is not None:
return group.approvers.filter(pk=approver.pk).exists()
settings: ReviewSettings = review_settings_load()
return settings.allow_any_approver
def get_min_approvals_for_experimenter(experimenter: User) -> int:
group: ApproverGroup | None = approver_group_get_by_experimenter(
experimenter
)
if group is not None:
return group.min_approvals
return review_settings_load().default_min_approvals
+236
View File
@@ -0,0 +1,236 @@
from typing import Any
from django.core.exceptions import ValidationError
from django.db import transaction
from apps.reviews.models import ApproverGroup, ReviewSettings
from apps.users.models import User, UserRole
def review_settings_update(
*,
default_min_approvals: int | None = None,
allow_any_approver: bool | None = None,
) -> ReviewSettings:
settings = ReviewSettings.load()
if default_min_approvals is not None:
if default_min_approvals < 1:
raise ValidationError(
{
"default_min_approvals": (
"Minimum approvals must be at least 1."
)
}
)
settings.default_min_approvals = default_min_approvals
if allow_any_approver is not None:
settings.allow_any_approver = allow_any_approver
settings.save()
return settings
def _validate_experimenter(user: User) -> None:
if user.role != UserRole.EXPERIMENTER:
raise ValidationError(
{
"experimenter": (
f"User '{user.username}' has role '{user.role}'. "
f"Only users with the '{UserRole.EXPERIMENTER}' role "
f"can be assigned an approver group."
)
}
)
if not user.is_active:
raise ValidationError(
{"experimenter": "The experimenter must be an active user."}
)
def _validate_approvers(approvers: list[User]) -> None:
invalid = [u for u in approvers if u.role != UserRole.APPROVER]
if invalid:
names = ", ".join(u.username for u in invalid)
raise ValidationError(
{
"approvers": (
f"The following users do not have the "
f"'{UserRole.APPROVER}' role: {names}"
)
}
)
def _validate_min_approvals(
min_approvals: int,
approver_count: int | None = None,
) -> None:
if min_approvals < 1:
raise ValidationError(
{"min_approvals": "Minimum approvals must be at least 1."}
)
if approver_count is not None and min_approvals > approver_count:
raise ValidationError(
{
"min_approvals": (
f"min_approvals ({min_approvals}) cannot exceed the "
f"number of assigned approvers ({approver_count})."
)
}
)
@transaction.atomic
def approver_group_create(
*,
experimenter: User,
approver_ids: list[Any] | None = None,
min_approvals: int = 1,
) -> ApproverGroup:
_validate_experimenter(experimenter)
if ApproverGroup.objects.filter(experimenter=experimenter).exists():
raise ValidationError(
{
"experimenter": (
f"An approver group already exists for "
f"experimenter '{experimenter.username}'."
)
}
)
approvers: list[User] = []
if approver_ids:
approvers = list(
User.objects.filter(pk__in=approver_ids, is_active=True)
)
found_ids = {str(u.pk) for u in approvers}
missing = [
str(aid) for aid in approver_ids if str(aid) not in found_ids
]
if missing:
raise ValidationError(
{
"approvers": (
f"Users not found or inactive: {', '.join(missing)}"
)
}
)
_validate_approvers(approvers)
_validate_min_approvals(
min_approvals, len(approvers) if approvers else None
)
group = ApproverGroup(
experimenter=experimenter,
min_approvals=min_approvals,
)
group.save()
if approvers:
group.approvers.set(approvers)
return group
@transaction.atomic
def approver_group_update(
*,
group: ApproverGroup,
approver_ids: list[Any] | None = None,
min_approvals: int | None = None,
) -> ApproverGroup:
approvers: list[User] | None = None
if approver_ids is not None:
approvers = list(
User.objects.filter(pk__in=approver_ids, is_active=True)
)
found_ids = {str(u.pk) for u in approvers}
missing = [
str(aid) for aid in approver_ids if str(aid) not in found_ids
]
if missing:
raise ValidationError(
{
"approvers": (
f"Users not found or inactive: {', '.join(missing)}"
)
}
)
_validate_approvers(approvers)
if min_approvals is not None:
approver_count = (
len(approvers)
if approvers is not None
else group.approvers.count()
)
_validate_min_approvals(min_approvals, approver_count)
group.min_approvals = min_approvals
group.save()
if approvers is not None:
group.approvers.set(approvers)
return group
@transaction.atomic
def approver_group_delete(*, group: ApproverGroup) -> None:
group.delete()
@transaction.atomic
def approver_group_add_approver(
*,
group: ApproverGroup,
approver: User,
) -> ApproverGroup:
_validate_approvers([approver])
if group.approvers.filter(pk=approver.pk).exists():
raise ValidationError(
{
"approver": (
f"User '{approver.username}' is already in this "
f"approver group."
)
}
)
group.approvers.add(approver)
return group
@transaction.atomic
def approver_group_remove_approver(
*,
group: ApproverGroup,
approver: User,
) -> ApproverGroup:
if not group.approvers.filter(pk=approver.pk).exists():
raise ValidationError(
{
"approver": (
f"User '{approver.username}' is not in this "
f"approver group."
)
}
)
remaining = group.approvers.count() - 1
if remaining < group.min_approvals:
raise ValidationError(
{
"approver": (
f"Cannot remove approver: would leave {remaining} "
f"approver(s), but {group.min_approvals} required."
)
}
)
group.approvers.remove(approver)
return group
@@ -0,0 +1,40 @@
from apps.users.models import User, UserRole
from apps.users.tests._helpers import _make_user
def _make_experimenter(suffix="") -> User:
return _make_user(
username=f"exp{suffix}",
email=f"exp{suffix}@lotty.local",
role=UserRole.EXPERIMENTER,
)
def _make_approver(suffix="") -> User:
return _make_user(
username=f"appr{suffix}",
email=f"appr{suffix}@lotty.local",
role=UserRole.APPROVER,
)
def _make_admin(suffix="") -> User:
return _make_user(
username=f"admin{suffix}",
email=f"admin{suffix}@lotty.local",
role=UserRole.ADMIN,
)
def _make_viewer(suffix="") -> User:
return _make_user(
username=f"viewer{suffix}",
email=f"viewer{suffix}@lotty.local",
role=UserRole.VIEWER,
)
def _get(data, camel_key, snake_key):
if camel_key in data:
return data[camel_key]
return data[snake_key]
@@ -0,0 +1,377 @@
import uuid
from typing import Any
from django.core.exceptions import ValidationError
from django.db.models import QuerySet
from django.test import TestCase
from apps.reviews.models import ApproverGroup
from apps.reviews.selectors import (
approver_group_get_by_experimenter,
approver_group_get_by_experimenter_id,
approver_group_get_by_id,
approver_group_list,
approver_group_list_for_approver,
)
from apps.reviews.services import (
approver_group_add_approver,
approver_group_create,
approver_group_delete,
approver_group_remove_approver,
approver_group_update,
)
from apps.users.models import User
from ._helpers import (
_make_admin,
_make_approver,
_make_experimenter,
_make_viewer,
)
class ApproverGroupModelTest(TestCase):
def setUp(self) -> None:
self.experimenter: User = _make_experimenter("_model")
self.approver: User = _make_approver("_model")
def test_create_group(self) -> None:
group: ApproverGroup = approver_group_create(
experimenter=self.experimenter,
approver_ids=[str(self.approver.pk)],
min_approvals=1,
)
self.assertIsNotNone(group.pk)
self.assertEqual(group.experimenter, self.experimenter)
self.assertEqual(group.min_approvals, 1)
self.assertEqual(group.approvers.count(), 1)
def test_str_representation(self) -> None:
group: ApproverGroup = approver_group_create(
experimenter=self.experimenter,
min_approvals=2,
)
result = str(group)
self.assertIn("ApproverGroup", result)
self.assertIn("min_approvals=2", result)
def test_can_approve_true(self) -> None:
group: ApproverGroup = approver_group_create(
experimenter=self.experimenter,
approver_ids=[str(self.approver.pk)],
)
self.assertTrue(group.can_approve(self.approver))
def test_can_approve_false_not_in_group(self) -> None:
other_approver: User = _make_approver("_other")
group: ApproverGroup = approver_group_create(
experimenter=self.experimenter,
approver_ids=[str(self.approver.pk)],
)
self.assertFalse(group.can_approve(other_approver))
def test_one_to_one_constraint(self) -> None:
approver_group_create(experimenter=self.experimenter)
with self.assertRaises(ValidationError):
approver_group_create(
experimenter=self.experimenter,
min_approvals=1,
)
class ApproverGroupCreateServiceTest(TestCase):
def setUp(self) -> None:
self.experimenter: User = _make_experimenter("_create")
self.approver1: User = _make_approver("_create1")
self.approver2: User = _make_approver("_create2")
def test_create_with_approvers(self) -> None:
group: ApproverGroup = approver_group_create(
experimenter=self.experimenter,
approver_ids=[str(self.approver1.pk), str(self.approver2.pk)],
min_approvals=2,
)
self.assertEqual(group.approvers.count(), 2)
self.assertEqual(group.min_approvals, 2)
def test_create_without_approvers(self) -> None:
group: ApproverGroup = approver_group_create(
experimenter=self.experimenter,
min_approvals=1,
)
self.assertEqual(group.approvers.count(), 0)
self.assertEqual(group.min_approvals, 1)
def test_create_default_min_approvals(self) -> None:
group: ApproverGroup = approver_group_create(
experimenter=self.experimenter
)
self.assertEqual(group.min_approvals, 1)
def test_create_rejects_non_experimenter_user(self) -> None:
admin: User = _make_admin("_cre_admin")
with self.assertRaises(ValidationError):
approver_group_create(experimenter=admin)
def test_create_rejects_viewer_as_experimenter(self) -> None:
viewer: User = _make_viewer("_cre_viewer")
with self.assertRaises(ValidationError):
approver_group_create(experimenter=viewer)
def test_create_rejects_approver_as_experimenter(self) -> None:
approver: User = _make_approver("_cre_as_exp")
with self.assertRaises(ValidationError):
approver_group_create(experimenter=approver)
def test_create_rejects_inactive_experimenter(self) -> None:
self.experimenter.is_active = False
self.experimenter.save()
with self.assertRaises(ValidationError):
approver_group_create(experimenter=self.experimenter)
def test_create_rejects_non_approver_in_approver_list(self) -> None:
viewer: User = _make_viewer("_cre_bad_appr")
with self.assertRaises(ValidationError):
approver_group_create(
experimenter=self.experimenter,
approver_ids=[str(viewer.pk)],
)
def test_create_rejects_missing_approver_ids(self) -> None:
with self.assertRaises(ValidationError):
approver_group_create(
experimenter=self.experimenter,
approver_ids=[str(uuid.uuid4())],
)
def test_create_rejects_duplicate_group(self) -> None:
approver_group_create(experimenter=self.experimenter)
with self.assertRaises(ValidationError):
approver_group_create(experimenter=self.experimenter)
def test_create_min_approvals_zero_raises(self) -> None:
with self.assertRaises(ValidationError):
approver_group_create(
experimenter=self.experimenter,
min_approvals=0,
)
def test_create_min_approvals_exceeds_approvers_raises(self) -> None:
with self.assertRaises(ValidationError):
approver_group_create(
experimenter=self.experimenter,
approver_ids=[str(self.approver1.pk)],
min_approvals=3,
)
class ApproverGroupUpdateServiceTest(TestCase):
def setUp(self) -> None:
self.experimenter: User = _make_experimenter("_upd")
self.approver1: User = _make_approver("_upd1")
self.approver2: User = _make_approver("_upd2")
self.approver3: User = _make_approver("_upd3")
self.group: ApproverGroup = approver_group_create(
experimenter=self.experimenter,
approver_ids=[str(self.approver1.pk), str(self.approver2.pk)],
min_approvals=1,
)
def test_update_min_approvals(self) -> None:
updated: ApproverGroup = approver_group_update(
group=self.group, min_approvals=2
)
self.assertEqual(updated.min_approvals, 2)
def test_update_approver_ids_replaces_set(self) -> None:
updated: ApproverGroup = approver_group_update(
group=self.group,
approver_ids=[str(self.approver3.pk)],
)
approver_pks: set[Any] = set(
updated.approvers.values_list("pk", flat=True)
)
self.assertEqual(approver_pks, {self.approver3.pk})
def test_update_both_fields(self) -> None:
updated: ApproverGroup = approver_group_update(
group=self.group,
approver_ids=[
str(self.approver1.pk),
str(self.approver2.pk),
str(self.approver3.pk),
],
min_approvals=3,
)
self.assertEqual(updated.approvers.count(), 3)
self.assertEqual(updated.min_approvals, 3)
def test_update_no_op(self) -> None:
updated: ApproverGroup = approver_group_update(group=self.group)
self.assertEqual(updated.min_approvals, self.group.min_approvals)
def test_update_min_approvals_exceeds_new_approver_count_raises(
self,
) -> None:
with self.assertRaises(ValidationError):
approver_group_update(
group=self.group,
approver_ids=[str(self.approver1.pk)],
min_approvals=5,
)
def test_update_min_approvals_exceeds_existing_count_raises(self) -> None:
with self.assertRaises(ValidationError):
approver_group_update(group=self.group, min_approvals=10)
def test_update_rejects_non_approver_role(self) -> None:
viewer: User = _make_viewer("_upd_bad")
with self.assertRaises(ValidationError):
approver_group_update(
group=self.group,
approver_ids=[str(viewer.pk)],
)
def test_update_rejects_missing_user(self) -> None:
with self.assertRaises(ValidationError):
approver_group_update(
group=self.group,
approver_ids=[str(uuid.uuid4())],
)
class ApproverGroupDeleteServiceTest(TestCase):
def test_delete_removes_group(self) -> None:
exp: User = _make_experimenter("_del")
group: ApproverGroup = approver_group_create(experimenter=exp)
pk = group.pk
approver_group_delete(group=group)
self.assertFalse(ApproverGroup.objects.filter(pk=pk).exists())
def test_delete_allows_recreating_group(self) -> None:
exp: User = _make_experimenter("_del2")
group: ApproverGroup = approver_group_create(experimenter=exp)
approver_group_delete(group=group)
new_group: ApproverGroup = approver_group_create(experimenter=exp)
self.assertIsNotNone(new_group.pk)
class ApproverGroupAddRemoveServiceTest(TestCase):
def setUp(self) -> None:
self.experimenter: User = _make_experimenter("_ar")
self.approver1: User = _make_approver("_ar1")
self.approver2: User = _make_approver("_ar2")
self.group: ApproverGroup = approver_group_create(
experimenter=self.experimenter,
approver_ids=[str(self.approver1.pk)],
min_approvals=1,
)
def test_add_approver(self) -> None:
approver_group_add_approver(group=self.group, approver=self.approver2)
self.assertEqual(self.group.approvers.count(), 2)
self.assertTrue(
self.group.approvers.filter(pk=self.approver2.pk).exists()
)
def test_add_approver_rejects_non_approver_role(self) -> None:
viewer: User = _make_viewer("_ar_bad")
with self.assertRaises(ValidationError):
approver_group_add_approver(group=self.group, approver=viewer)
def test_add_approver_rejects_duplicate(self) -> None:
with self.assertRaises(ValidationError):
approver_group_add_approver(
group=self.group, approver=self.approver1
)
def test_remove_approver(self) -> None:
approver_group_add_approver(group=self.group, approver=self.approver2)
approver_group_remove_approver(
group=self.group, approver=self.approver1
)
self.assertEqual(self.group.approvers.count(), 1)
self.assertFalse(
self.group.approvers.filter(pk=self.approver1.pk).exists()
)
def test_remove_approver_not_in_group_raises(self) -> None:
with self.assertRaises(ValidationError):
approver_group_remove_approver(
group=self.group, approver=self.approver2
)
def test_remove_approver_below_min_raises(self) -> None:
with self.assertRaises(ValidationError):
approver_group_remove_approver(
group=self.group, approver=self.approver1
)
class ApproverGroupSelectorsTest(TestCase):
def setUp(self) -> None:
self.exp1: User = _make_experimenter("_sel1")
self.exp2: User = _make_experimenter("_sel2")
self.appr1: User = _make_approver("_sel1")
self.appr2: User = _make_approver("_sel2")
self.group1: ApproverGroup = approver_group_create(
experimenter=self.exp1,
approver_ids=[str(self.appr1.pk)],
min_approvals=1,
)
self.group2: ApproverGroup = approver_group_create(
experimenter=self.exp2,
approver_ids=[str(self.appr1.pk), str(self.appr2.pk)],
min_approvals=2,
)
def test_get_by_id(self) -> None:
found: ApproverGroup | None = approver_group_get_by_id(
str(self.group1.pk)
)
self.assertEqual(found, self.group1)
def test_get_by_id_invalid_uuid(self) -> None:
self.assertIsNone(approver_group_get_by_id("not-a-uuid"))
def test_get_by_id_nonexistent(self) -> None:
self.assertIsNone(approver_group_get_by_id(str(uuid.uuid4())))
def test_get_by_experimenter(self) -> None:
found: ApproverGroup | None = approver_group_get_by_experimenter(
self.exp1
)
self.assertEqual(found, self.group1)
def test_get_by_experimenter_no_group(self) -> None:
exp3: User = _make_experimenter("_sel3")
self.assertIsNone(approver_group_get_by_experimenter(exp3))
def test_get_by_experimenter_id(self) -> None:
found: ApproverGroup | None = approver_group_get_by_experimenter_id(
str(self.exp2.pk)
)
self.assertEqual(found, self.group2)
def test_get_by_experimenter_id_invalid(self) -> None:
self.assertIsNone(approver_group_get_by_experimenter_id("bad"))
def test_get_by_experimenter_id_nonexistent(self) -> None:
self.assertIsNone(
approver_group_get_by_experimenter_id(str(uuid.uuid4()))
)
def test_list_all(self) -> None:
qs: QuerySet[ApproverGroup] = approver_group_list()
self.assertEqual(qs.count(), 2)
def test_list_for_approver(self) -> None:
qs: QuerySet[ApproverGroup] = approver_group_list_for_approver(
self.appr1
)
self.assertEqual(qs.count(), 2)
qs2: QuerySet[ApproverGroup] = approver_group_list_for_approver(
self.appr2
)
self.assertEqual(qs2.count(), 1)
@@ -0,0 +1,100 @@
from django.test import TestCase
from apps.reviews.models import ApproverGroup
from apps.reviews.selectors import (
can_user_approve_experimenter,
get_effective_approvers_for_experimenter,
get_min_approvals_for_experimenter,
)
from apps.reviews.services import approver_group_create, review_settings_update
from apps.users.models import User
from ._helpers import _make_admin, _make_approver, _make_experimenter
class EffectiveReviewPolicyTest(TestCase):
def setUp(self) -> None:
self.exp_with_group: User = _make_experimenter("_eff1")
self.exp_without_group: User = _make_experimenter("_eff2")
self.appr1: User = _make_approver("_eff1")
self.appr2: User = _make_approver("_eff2")
self.group: ApproverGroup = approver_group_create(
experimenter=self.exp_with_group,
approver_ids=[str(self.appr1.pk)],
min_approvals=1,
)
def test_with_explicit_group(self) -> None:
approvers, min_approvals = get_effective_approvers_for_experimenter(
self.exp_with_group
)
self.assertEqual(min_approvals, 1)
self.assertEqual(approvers.count(), 1)
self.assertEqual(approvers.first(), self.appr1)
def test_fallback_allow_any_approver(self) -> None:
review_settings_update(
default_min_approvals=2, allow_any_approver=True
)
approvers, min_approvals = get_effective_approvers_for_experimenter(
self.exp_without_group
)
self.assertEqual(min_approvals, 2)
self.assertIn(self.appr1, approvers)
self.assertIn(self.appr2, approvers)
def test_fallback_deny_any_approver(self) -> None:
review_settings_update(allow_any_approver=False)
approvers, _ = get_effective_approvers_for_experimenter(
self.exp_without_group
)
self.assertEqual(approvers.count(), 0)
def test_get_min_approvals_with_group(self) -> None:
result: int = get_min_approvals_for_experimenter(self.exp_with_group)
self.assertEqual(result, 1)
def test_get_min_approvals_fallback(self) -> None:
review_settings_update(default_min_approvals=5)
result: int = get_min_approvals_for_experimenter(
self.exp_without_group
)
self.assertEqual(result, 5)
class CanUserApproveTest(TestCase):
def setUp(self) -> None:
self.exp: User = _make_experimenter("_can")
self.appr_in: User = _make_approver("_can_in")
self.appr_out: User = _make_approver("_can_out")
self.group: ApproverGroup = approver_group_create(
experimenter=self.exp,
approver_ids=[str(self.appr_in.pk)],
)
def test_approver_in_group_can_approve(self) -> None:
self.assertTrue(can_user_approve_experimenter(self.appr_in, self.exp))
def test_approver_not_in_group_cannot_approve(self) -> None:
self.assertFalse(
can_user_approve_experimenter(self.appr_out, self.exp)
)
def test_non_approver_role_cannot_approve(self) -> None:
admin: User = _make_admin("_can_a")
self.assertFalse(can_user_approve_experimenter(admin, self.exp))
def test_inactive_approver_cannot_approve(self) -> None:
self.appr_in.is_active = False
self.appr_in.save()
self.assertFalse(can_user_approve_experimenter(self.appr_in, self.exp))
def test_fallback_any_approver_can_approve(self) -> None:
exp2: User = _make_experimenter("_can2")
review_settings_update(allow_any_approver=True)
self.assertTrue(can_user_approve_experimenter(self.appr_out, exp2))
def test_fallback_deny_blocks_approval(self) -> None:
exp2: User = _make_experimenter("_can3")
review_settings_update(allow_any_approver=False)
self.assertFalse(can_user_approve_experimenter(self.appr_out, exp2))
@@ -0,0 +1,100 @@
from django.core.exceptions import ValidationError
from django.test import TestCase
from apps.reviews.models import ReviewSettings
from apps.reviews.selectors import review_settings_load
from apps.reviews.services import review_settings_update
class ReviewSettingsModelTest(TestCase):
def test_load_creates_default_if_missing(self) -> None:
self.assertEqual(ReviewSettings.objects.count(), 0)
settings: ReviewSettings = ReviewSettings.load()
self.assertEqual(ReviewSettings.objects.count(), 1)
self.assertEqual(settings.default_min_approvals, 1)
self.assertFalse(settings.allow_any_approver)
def test_load_returns_existing_singleton(self) -> None:
s1: ReviewSettings = ReviewSettings.load()
s2: ReviewSettings = ReviewSettings.load()
self.assertEqual(s1.pk, s2.pk)
self.assertEqual(ReviewSettings.objects.count(), 1)
def test_save_enforces_singleton(self) -> None:
s1: ReviewSettings = ReviewSettings.load()
s1.default_min_approvals = 3
s1.allow_any_approver = False
s1.save()
self.assertEqual(ReviewSettings.objects.count(), 1)
s1.refresh_from_db()
self.assertEqual(s1.default_min_approvals, 3)
self.assertFalse(s1.allow_any_approver)
def test_str_representation(self) -> None:
settings: ReviewSettings = ReviewSettings.load()
result = str(settings)
self.assertIn("ReviewSettings", result)
self.assertIn("min_approvals=1", result)
def test_updated_at_changes_on_save(self) -> None:
settings: ReviewSettings = ReviewSettings.load()
original_updated = settings.updated_at
settings.default_min_approvals = 5
settings.save()
settings.refresh_from_db()
self.assertGreaterEqual(settings.updated_at, original_updated)
class ReviewSettingsServiceTest(TestCase):
def test_update_min_approvals(self) -> None:
settings: ReviewSettings = review_settings_update(
default_min_approvals=3
)
self.assertEqual(settings.default_min_approvals, 3)
def test_update_allow_any_approver(self) -> None:
settings: ReviewSettings = review_settings_update(
allow_any_approver=False
)
self.assertFalse(settings.allow_any_approver)
def test_update_both_fields(self) -> None:
settings: ReviewSettings = review_settings_update(
default_min_approvals=5,
allow_any_approver=False,
)
self.assertEqual(settings.default_min_approvals, 5)
self.assertFalse(settings.allow_any_approver)
def test_update_partial_leaves_other_field(self) -> None:
review_settings_update(
default_min_approvals=3, allow_any_approver=False
)
settings: ReviewSettings = review_settings_update(
default_min_approvals=7
)
self.assertEqual(settings.default_min_approvals, 7)
self.assertFalse(settings.allow_any_approver)
def test_update_min_approvals_zero_raises(self) -> None:
with self.assertRaises(ValidationError):
review_settings_update(default_min_approvals=0)
def test_update_min_approvals_negative_raises(self) -> None:
with self.assertRaises(ValidationError):
review_settings_update(default_min_approvals=-1)
def test_no_op_update_returns_settings(self) -> None:
settings: ReviewSettings = review_settings_update()
self.assertIsInstance(settings, ReviewSettings)
class ReviewSettingsSelectorsTest(TestCase):
def test_review_settings_load_returns_instance(self) -> None:
settings: ReviewSettings = review_settings_load()
self.assertIsInstance(settings, ReviewSettings)
def test_review_settings_load_is_idempotent(self) -> None:
s1: ReviewSettings = review_settings_load()
s2: ReviewSettings = review_settings_load()
self.assertEqual(s1.pk, s2.pk)