chore(): switched all fields in contract to snake_case and linting improvements

This commit is contained in:
ITQ
2026-02-12 22:17:55 +03:00
parent 102f425259
commit 362398d56b
32 changed files with 401 additions and 353 deletions
+5 -3
View File
@@ -1,4 +1,5 @@
import json import json
from typing import override
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
@@ -7,13 +8,14 @@ from apps.users.auth.jwt import (
create_token_pair, create_token_pair,
) )
from apps.users.models import User, UserRole from apps.users.models import User, UserRole
from apps.users.tests._helpers import _auth_header, _make_user from apps.users.tests.helpers import auth_header, make_user
class AuthAPITest(TestCase): class AuthAPITest(TestCase):
@override
def setUp(self) -> None: def setUp(self) -> None:
self.client = Client() self.client = Client()
self.user: User = _make_user( self.user: User = make_user(
username="api_auth", username="api_auth",
email="api_auth@x.com", email="api_auth@x.com",
password="testpass123", password="testpass123",
@@ -92,7 +94,7 @@ class AuthAPITest(TestCase):
def test_me_authenticated(self) -> None: def test_me_authenticated(self) -> None:
resp = self.client.get( resp = self.client.get(
reverse("api-1:me"), reverse("api-1:me"),
HTTP_AUTHORIZATION=_auth_header(self.user), HTTP_AUTHORIZATION=auth_header(self.user),
) )
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
data = resp.json() data = resp.json()
+3 -3
View File
@@ -47,7 +47,7 @@ def handle_validation_error(
{ {
"field": field, "field": field,
"issue": error.get("msg", "Unknown error"), "issue": error.get("msg", "Unknown error"),
"rejectedValue": error.get("input"), "rejected_value": error.get("input"),
} }
) )
@@ -77,7 +77,7 @@ def handle_django_validation_error(
{ {
"field": field, "field": field,
"issue": str(error.message), "issue": str(error.message),
"rejectedValue": None, "rejected_value": None,
} }
for error in errors for error in errors
) )
@@ -86,7 +86,7 @@ def handle_django_validation_error(
{ {
"field": "non_field_error", "field": "non_field_error",
"issue": str(error.message), "issue": str(error.message),
"rejectedValue": None, "rejected_value": None,
} }
for error in exc.error_list for error in exc.error_list
) )
-11
View File
@@ -9,8 +9,6 @@ from apps.users.models import User
class ApproverOut(ModelSchema): class ApproverOut(ModelSchema):
first_name: str = Field("", alias="firstName")
last_name: str = Field("", alias="lastName")
class Meta: class Meta:
model = User model = User
@@ -55,12 +53,10 @@ class ApproverGroupOut(ModelSchema):
class ApproverGroupCreateIn(Schema): class ApproverGroupCreateIn(Schema):
experimenter_id: str = Field( experimenter_id: str = Field(
..., ...,
alias="experimenterId",
description="UUID of the experimenter user this group belongs to.", description="UUID of the experimenter user this group belongs to.",
) )
approver_ids: list[str] = Field( approver_ids: list[str] = Field(
default_factory=list, default_factory=list,
alias="approverIds",
description=( description=(
"List of user UUIDs to add as approvers. " "List of user UUIDs to add as approvers. "
"Each user must have the 'approver' role." "Each user must have the 'approver' role."
@@ -68,7 +64,6 @@ class ApproverGroupCreateIn(Schema):
) )
min_approvals: int = Field( min_approvals: int = Field(
1, 1,
alias="minApprovals",
ge=1, ge=1,
description=( description=(
"Number of distinct approvals required. " "Number of distinct approvals required. "
@@ -80,7 +75,6 @@ class ApproverGroupCreateIn(Schema):
class ApproverGroupUpdateIn(Schema): class ApproverGroupUpdateIn(Schema):
approver_ids: list[str] | None = Field( approver_ids: list[str] | None = Field(
None, None,
alias="approverIds",
description=( description=(
"If provided, replaces the current set of approvers. " "If provided, replaces the current set of approvers. "
"Each user must have the 'approver' role." "Each user must have the 'approver' role."
@@ -88,7 +82,6 @@ class ApproverGroupUpdateIn(Schema):
) )
min_approvals: int | None = Field( min_approvals: int | None = Field(
None, None,
alias="minApprovals",
ge=1, ge=1,
description="New minimum approval threshold.", description="New minimum approval threshold.",
) )
@@ -102,7 +95,6 @@ class ApproverGroupListOut(Schema):
class ApproverGroupAddApproverIn(Schema): class ApproverGroupAddApproverIn(Schema):
approver_id: str = Field( approver_id: str = Field(
..., ...,
alias="approverId",
description="UUID of the user to add as an approver.", description="UUID of the user to add as an approver.",
) )
@@ -110,7 +102,6 @@ class ApproverGroupAddApproverIn(Schema):
class ApproverGroupRemoveApproverIn(Schema): class ApproverGroupRemoveApproverIn(Schema):
approver_id: str = Field( approver_id: str = Field(
..., ...,
alias="approverId",
description="UUID of the approver to remove.", description="UUID of the approver to remove.",
) )
@@ -133,13 +124,11 @@ class ReviewSettingsOut(ModelSchema):
class ReviewSettingsUpdateIn(Schema): class ReviewSettingsUpdateIn(Schema):
default_min_approvals: int | None = Field( default_min_approvals: int | None = Field(
None, None,
alias="defaultMinApprovals",
ge=1, ge=1,
description="New default minimum approval threshold.", description="New default minimum approval threshold.",
) )
allow_any_approver: bool | None = Field( allow_any_approver: bool | None = Field(
None, None,
alias="allowAnyApprover",
description="New fallback policy for approver eligibility.", description="New fallback policy for approver eligibility.",
) )
@@ -1,34 +1,35 @@
import json import json
import uuid import uuid
from typing import override
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from apps.reviews.models import ApproverGroup from apps.reviews.models import ApproverGroup
from apps.reviews.services import approver_group_create, review_settings_update from apps.reviews.services import approver_group_create, review_settings_update
from apps.reviews.tests._helpers import ( from apps.reviews.tests.helpers import (
_get, make_admin,
_make_admin, make_approver,
_make_approver, make_experimenter,
_make_experimenter, make_viewer,
_make_viewer,
) )
from apps.users.models import User from apps.users.models import User
from apps.users.tests._helpers import _auth_header from apps.users.tests.helpers import auth_header
class ApproverGroupAPITest(TestCase): class ApproverGroupAPITest(TestCase):
@override
def setUp(self) -> None: def setUp(self) -> None:
self.client = Client() self.client = Client()
self.admin: User = _make_admin("_api") self.admin: User = make_admin("_api")
self.viewer: User = _make_viewer("_api") self.viewer: User = make_viewer("_api")
self.experimenter: User = _make_experimenter("_api") self.experimenter: User = make_experimenter("_api")
self.approver1: User = _make_approver("_api1") self.approver1: User = make_approver("_api1")
self.approver2: User = _make_approver("_api2") self.approver2: User = make_approver("_api2")
self.approver3: User = _make_approver("_api3") self.approver3: User = make_approver("_api3")
self.admin_auth: str = _auth_header(self.admin) self.admin_auth: str = auth_header(self.admin)
self.viewer_auth: str = _auth_header(self.viewer) self.viewer_auth: str = auth_header(self.viewer)
self.exp_auth: str = _auth_header(self.experimenter) self.exp_auth: str = auth_header(self.experimenter)
def test_list_groups_admin(self) -> None: def test_list_groups_admin(self) -> None:
approver_group_create( approver_group_create(
@@ -57,7 +58,7 @@ class ApproverGroupAPITest(TestCase):
self.assertEqual(resp.status_code, 401) self.assertEqual(resp.status_code, 401)
def test_list_groups_pagination(self) -> None: def test_list_groups_pagination(self) -> None:
exp2: User = _make_experimenter("_api2") exp2: User = make_experimenter("_api2")
approver_group_create(experimenter=self.experimenter) approver_group_create(experimenter=self.experimenter)
approver_group_create(experimenter=exp2) approver_group_create(experimenter=exp2)
resp = self.client.get( resp = self.client.get(
@@ -74,12 +75,12 @@ class ApproverGroupAPITest(TestCase):
reverse("api-1:create_approver_group"), reverse("api-1:create_approver_group"),
data=json.dumps( data=json.dumps(
{ {
"experimenterId": str(self.experimenter.pk), "experimenter_id": str(self.experimenter.pk),
"approverIds": [ "approver_ids": [
str(self.approver1.pk), str(self.approver1.pk),
str(self.approver2.pk), str(self.approver2.pk),
], ],
"minApprovals": 2, "min_approvals": 2,
} }
), ),
content_type="application/json", content_type="application/json",
@@ -87,7 +88,7 @@ class ApproverGroupAPITest(TestCase):
) )
self.assertEqual(resp.status_code, 201) self.assertEqual(resp.status_code, 201)
data = resp.json() data = resp.json()
self.assertEqual(_get(data, "minApprovals", "min_approvals"), 2) self.assertEqual(data["min_approvals"], 2)
self.assertEqual(len(data["approvers"]), 2) self.assertEqual(len(data["approvers"]), 2)
self.assertEqual(data["experimenter"]["id"], str(self.experimenter.pk)) self.assertEqual(data["experimenter"]["id"], str(self.experimenter.pk))
@@ -96,8 +97,8 @@ class ApproverGroupAPITest(TestCase):
reverse("api-1:create_approver_group"), reverse("api-1:create_approver_group"),
data=json.dumps( data=json.dumps(
{ {
"experimenterId": str(self.experimenter.pk), "experimenter_id": str(self.experimenter.pk),
"minApprovals": 1, "min_approvals": 1,
} }
), ),
content_type="application/json", content_type="application/json",
@@ -112,8 +113,8 @@ class ApproverGroupAPITest(TestCase):
reverse("api-1:create_approver_group"), reverse("api-1:create_approver_group"),
data=json.dumps( data=json.dumps(
{ {
"experimenterId": str(self.experimenter.pk), "experimenter_id": str(self.experimenter.pk),
"minApprovals": 1, "min_approvals": 1,
} }
), ),
content_type="application/json", content_type="application/json",
@@ -126,8 +127,8 @@ class ApproverGroupAPITest(TestCase):
reverse("api-1:create_approver_group"), reverse("api-1:create_approver_group"),
data=json.dumps( data=json.dumps(
{ {
"experimenterId": str(self.experimenter.pk), "experimenter_id": str(self.experimenter.pk),
"minApprovals": 1, "min_approvals": 1,
} }
), ),
content_type="application/json", content_type="application/json",
@@ -140,8 +141,8 @@ class ApproverGroupAPITest(TestCase):
reverse("api-1:create_approver_group"), reverse("api-1:create_approver_group"),
data=json.dumps( data=json.dumps(
{ {
"experimenterId": str(uuid.uuid4()), "experimenter_id": str(uuid.uuid4()),
"minApprovals": 1, "min_approvals": 1,
} }
), ),
content_type="application/json", content_type="application/json",
@@ -154,8 +155,8 @@ class ApproverGroupAPITest(TestCase):
reverse("api-1:create_approver_group"), reverse("api-1:create_approver_group"),
data=json.dumps( data=json.dumps(
{ {
"experimenterId": str(self.viewer.pk), "experimenter_id": str(self.viewer.pk),
"minApprovals": 1, "min_approvals": 1,
} }
), ),
content_type="application/json", content_type="application/json",
@@ -168,8 +169,8 @@ class ApproverGroupAPITest(TestCase):
reverse("api-1:create_approver_group"), reverse("api-1:create_approver_group"),
data=json.dumps( data=json.dumps(
{ {
"experimenterId": str(self.experimenter.pk), "experimenter_id": str(self.experimenter.pk),
"minApprovals": 1, "min_approvals": 1,
} }
), ),
content_type="application/json", content_type="application/json",
@@ -179,8 +180,8 @@ class ApproverGroupAPITest(TestCase):
reverse("api-1:create_approver_group"), reverse("api-1:create_approver_group"),
data=json.dumps( data=json.dumps(
{ {
"experimenterId": str(self.experimenter.pk), "experimenter_id": str(self.experimenter.pk),
"minApprovals": 1, "min_approvals": 1,
} }
), ),
content_type="application/json", content_type="application/json",
@@ -193,9 +194,9 @@ class ApproverGroupAPITest(TestCase):
reverse("api-1:create_approver_group"), reverse("api-1:create_approver_group"),
data=json.dumps( data=json.dumps(
{ {
"experimenterId": str(self.experimenter.pk), "experimenter_id": str(self.experimenter.pk),
"approverIds": [str(self.viewer.pk)], "approver_ids": [str(self.viewer.pk)],
"minApprovals": 1, "min_approvals": 1,
} }
), ),
content_type="application/json", content_type="application/json",
@@ -203,7 +204,7 @@ class ApproverGroupAPITest(TestCase):
) )
self.assertIn(resp.status_code, [422, 400]) self.assertIn(resp.status_code, [422, 400])
def test_get_group_admin(self) -> None: def testget_group_admin(self) -> None:
group: ApproverGroup = approver_group_create( group: ApproverGroup = approver_group_create(
experimenter=self.experimenter, experimenter=self.experimenter,
approver_ids=[str(self.approver1.pk)], approver_ids=[str(self.approver1.pk)],
@@ -220,7 +221,7 @@ class ApproverGroupAPITest(TestCase):
self.assertEqual(data["id"], str(group.pk)) self.assertEqual(data["id"], str(group.pk))
self.assertEqual(len(data["approvers"]), 1) self.assertEqual(len(data["approvers"]), 1)
def test_get_group_not_found(self) -> None: def testget_group_not_found(self) -> None:
resp = self.client.get( resp = self.client.get(
reverse( reverse(
"api-1:get_approver_group", "api-1:get_approver_group",
@@ -230,7 +231,7 @@ class ApproverGroupAPITest(TestCase):
) )
self.assertEqual(resp.status_code, 404) self.assertEqual(resp.status_code, 404)
def test_get_group_viewer_denied(self) -> None: def testget_group_viewer_denied(self) -> None:
group: ApproverGroup = approver_group_create( group: ApproverGroup = approver_group_create(
experimenter=self.experimenter experimenter=self.experimenter
) )
@@ -256,11 +257,11 @@ class ApproverGroupAPITest(TestCase):
), ),
data=json.dumps( data=json.dumps(
{ {
"approverIds": [ "approver_ids": [
str(self.approver1.pk), str(self.approver1.pk),
str(self.approver2.pk), str(self.approver2.pk),
], ],
"minApprovals": 2, "min_approvals": 2,
} }
), ),
content_type="application/json", content_type="application/json",
@@ -268,7 +269,7 @@ class ApproverGroupAPITest(TestCase):
) )
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
data = resp.json() data = resp.json()
self.assertEqual(_get(data, "minApprovals", "min_approvals"), 2) self.assertEqual(data["min_approvals"], 2)
self.assertEqual(len(data["approvers"]), 2) self.assertEqual(len(data["approvers"]), 2)
def test_update_group_partial_min_approvals(self) -> None: def test_update_group_partial_min_approvals(self) -> None:
@@ -285,13 +286,13 @@ class ApproverGroupAPITest(TestCase):
"api-1:update_approver_group", "api-1:update_approver_group",
kwargs={"group_id": str(group.pk)}, kwargs={"group_id": str(group.pk)},
), ),
data=json.dumps({"minApprovals": 2}), data=json.dumps({"min_approvals": 2}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth, HTTP_AUTHORIZATION=self.admin_auth,
) )
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
data = resp.json() data = resp.json()
self.assertEqual(_get(data, "minApprovals", "min_approvals"), 2) self.assertEqual(data["min_approvals"], 2)
def test_update_group_not_found(self) -> None: def test_update_group_not_found(self) -> None:
resp = self.client.patch( resp = self.client.patch(
@@ -299,7 +300,7 @@ class ApproverGroupAPITest(TestCase):
"api-1:update_approver_group", "api-1:update_approver_group",
kwargs={"group_id": str(uuid.uuid4())}, kwargs={"group_id": str(uuid.uuid4())},
), ),
data=json.dumps({"minApprovals": 1}), data=json.dumps({"min_approvals": 1}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth, HTTP_AUTHORIZATION=self.admin_auth,
) )
@@ -314,7 +315,7 @@ class ApproverGroupAPITest(TestCase):
"api-1:update_approver_group", "api-1:update_approver_group",
kwargs={"group_id": str(group.pk)}, kwargs={"group_id": str(group.pk)},
), ),
data=json.dumps({"minApprovals": 1}), data=json.dumps({"min_approvals": 1}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=self.viewer_auth, HTTP_AUTHORIZATION=self.viewer_auth,
) )
@@ -368,7 +369,7 @@ class ApproverGroupAPITest(TestCase):
"api-1:add_approver_to_group", "api-1:add_approver_to_group",
kwargs={"group_id": str(group.pk)}, kwargs={"group_id": str(group.pk)},
), ),
data=json.dumps({"approverId": str(self.approver2.pk)}), data=json.dumps({"approver_id": str(self.approver2.pk)}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth, HTTP_AUTHORIZATION=self.admin_auth,
) )
@@ -381,7 +382,7 @@ class ApproverGroupAPITest(TestCase):
"api-1:add_approver_to_group", "api-1:add_approver_to_group",
kwargs={"group_id": str(uuid.uuid4())}, kwargs={"group_id": str(uuid.uuid4())},
), ),
data=json.dumps({"approverId": str(self.approver1.pk)}), data=json.dumps({"approver_id": str(self.approver1.pk)}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth, HTTP_AUTHORIZATION=self.admin_auth,
) )
@@ -396,7 +397,7 @@ class ApproverGroupAPITest(TestCase):
"api-1:add_approver_to_group", "api-1:add_approver_to_group",
kwargs={"group_id": str(group.pk)}, kwargs={"group_id": str(group.pk)},
), ),
data=json.dumps({"approverId": str(uuid.uuid4())}), data=json.dumps({"approver_id": str(uuid.uuid4())}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth, HTTP_AUTHORIZATION=self.admin_auth,
) )
@@ -411,7 +412,7 @@ class ApproverGroupAPITest(TestCase):
"api-1:add_approver_to_group", "api-1:add_approver_to_group",
kwargs={"group_id": str(group.pk)}, kwargs={"group_id": str(group.pk)},
), ),
data=json.dumps({"approverId": str(self.viewer.pk)}), data=json.dumps({"approver_id": str(self.viewer.pk)}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth, HTTP_AUTHORIZATION=self.admin_auth,
) )
@@ -427,7 +428,7 @@ class ApproverGroupAPITest(TestCase):
"api-1:add_approver_to_group", "api-1:add_approver_to_group",
kwargs={"group_id": str(group.pk)}, kwargs={"group_id": str(group.pk)},
), ),
data=json.dumps({"approverId": str(self.approver1.pk)}), data=json.dumps({"approver_id": str(self.approver1.pk)}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth, HTTP_AUTHORIZATION=self.admin_auth,
) )
@@ -442,7 +443,7 @@ class ApproverGroupAPITest(TestCase):
"api-1:add_approver_to_group", "api-1:add_approver_to_group",
kwargs={"group_id": str(group.pk)}, kwargs={"group_id": str(group.pk)},
), ),
data=json.dumps({"approverId": str(self.approver1.pk)}), data=json.dumps({"approver_id": str(self.approver1.pk)}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=self.viewer_auth, HTTP_AUTHORIZATION=self.viewer_auth,
) )
@@ -462,7 +463,7 @@ class ApproverGroupAPITest(TestCase):
"api-1:remove_approver_from_group", "api-1:remove_approver_from_group",
kwargs={"group_id": str(group.pk)}, kwargs={"group_id": str(group.pk)},
), ),
data=json.dumps({"approverId": str(self.approver2.pk)}), data=json.dumps({"approver_id": str(self.approver2.pk)}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth, HTTP_AUTHORIZATION=self.admin_auth,
) )
@@ -480,7 +481,7 @@ class ApproverGroupAPITest(TestCase):
"api-1:remove_approver_from_group", "api-1:remove_approver_from_group",
kwargs={"group_id": str(group.pk)}, kwargs={"group_id": str(group.pk)},
), ),
data=json.dumps({"approverId": str(self.approver1.pk)}), data=json.dumps({"approver_id": str(self.approver1.pk)}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth, HTTP_AUTHORIZATION=self.admin_auth,
) )
@@ -496,7 +497,7 @@ class ApproverGroupAPITest(TestCase):
"api-1:remove_approver_from_group", "api-1:remove_approver_from_group",
kwargs={"group_id": str(group.pk)}, kwargs={"group_id": str(group.pk)},
), ),
data=json.dumps({"approverId": str(self.approver2.pk)}), data=json.dumps({"approver_id": str(self.approver2.pk)}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth, HTTP_AUTHORIZATION=self.admin_auth,
) )
@@ -515,7 +516,7 @@ class ApproverGroupAPITest(TestCase):
"api-1:remove_approver_from_group", "api-1:remove_approver_from_group",
kwargs={"group_id": str(group.pk)}, kwargs={"group_id": str(group.pk)},
), ),
data=json.dumps({"approverId": str(self.approver1.pk)}), data=json.dumps({"approver_id": str(self.approver1.pk)}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=self.viewer_auth, HTTP_AUTHORIZATION=self.viewer_auth,
) )
@@ -523,14 +524,15 @@ class ApproverGroupAPITest(TestCase):
class ApproverGroupByExperimenterAPITest(TestCase): class ApproverGroupByExperimenterAPITest(TestCase):
@override
def setUp(self) -> None: def setUp(self) -> None:
self.client = Client() self.client = Client()
self.admin: User = _make_admin("_byexp") self.admin: User = make_admin("_byexp")
self.experimenter: User = _make_experimenter("_byexp") self.experimenter: User = make_experimenter("_byexp")
self.approver: User = _make_approver("_byexp") self.approver: User = make_approver("_byexp")
self.admin_auth: str = _auth_header(self.admin) self.admin_auth: str = auth_header(self.admin)
def test_get_by_experimenter_found(self) -> None: def testget_by_experimenter_found(self) -> None:
group: ApproverGroup = approver_group_create( group: ApproverGroup = approver_group_create(
experimenter=self.experimenter, experimenter=self.experimenter,
approver_ids=[str(self.approver.pk)], approver_ids=[str(self.approver.pk)],
@@ -545,8 +547,8 @@ class ApproverGroupByExperimenterAPITest(TestCase):
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()["id"], str(group.pk)) self.assertEqual(resp.json()["id"], str(group.pk))
def test_get_by_experimenter_not_found(self) -> None: def testget_by_experimenter_not_found(self) -> None:
exp2: User = _make_experimenter("_byexp2") exp2: User = make_experimenter("_byexp2")
resp = self.client.get( resp = self.client.get(
reverse( reverse(
"api-1:get_approver_group_by_experimenter", "api-1:get_approver_group_by_experimenter",
@@ -556,7 +558,7 @@ class ApproverGroupByExperimenterAPITest(TestCase):
) )
self.assertEqual(resp.status_code, 404) self.assertEqual(resp.status_code, 404)
def test_get_by_experimenter_invalid_id(self) -> None: def testget_by_experimenter_invalid_id(self) -> None:
resp = self.client.get( resp = self.client.get(
reverse( reverse(
"api-1:get_approver_group_by_experimenter", "api-1:get_approver_group_by_experimenter",
@@ -566,7 +568,7 @@ class ApproverGroupByExperimenterAPITest(TestCase):
) )
self.assertEqual(resp.status_code, 404) self.assertEqual(resp.status_code, 404)
def test_get_by_experimenter_unauthenticated(self) -> None: def testget_by_experimenter_unauthenticated(self) -> None:
resp = self.client.get( resp = self.client.get(
reverse( reverse(
"api-1:get_approver_group_by_experimenter", "api-1:get_approver_group_by_experimenter",
@@ -577,30 +579,29 @@ class ApproverGroupByExperimenterAPITest(TestCase):
class ReviewSettingsAPITest(TestCase): class ReviewSettingsAPITest(TestCase):
@override
def setUp(self) -> None: def setUp(self) -> None:
self.client = Client() self.client = Client()
self.admin: User = _make_admin("_rs") self.admin: User = make_admin("_rs")
self.viewer: User = _make_viewer("_rs") self.viewer: User = make_viewer("_rs")
self.experimenter: User = _make_experimenter("_rs") self.experimenter: User = make_experimenter("_rs")
self.approver: User = _make_approver("_rs") self.approver: User = make_approver("_rs")
self.admin_auth: str = _auth_header(self.admin) self.admin_auth: str = auth_header(self.admin)
self.viewer_auth: str = _auth_header(self.viewer) self.viewer_auth: str = auth_header(self.viewer)
self.exp_auth: str = _auth_header(self.experimenter) self.exp_auth: str = auth_header(self.experimenter)
self.appr_auth: str = _auth_header(self.approver) self.appr_auth: str = auth_header(self.approver)
def test_get_settings_admin(self) -> None: def testget_settings_admin(self) -> None:
resp = self.client.get( resp = self.client.get(
reverse("api-1:get_review_settings"), reverse("api-1:get_review_settings"),
HTTP_AUTHORIZATION=self.admin_auth, HTTP_AUTHORIZATION=self.admin_auth,
) )
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
data = resp.json() data = resp.json()
min_val = _get(data, "defaultMinApprovals", "default_min_approvals") self.assertEqual(data["default_min_approvals"], 1)
any_val = _get(data, "allowAnyApprover", "allow_any_approver") self.assertFalse(data["allow_any_approver"])
self.assertEqual(min_val, 1)
self.assertFalse(any_val)
def test_get_settings_any_authenticated_role(self) -> None: def testget_settings_any_authenticated_role(self) -> None:
for auth in [self.viewer_auth, self.exp_auth, self.appr_auth]: for auth in [self.viewer_auth, self.exp_auth, self.appr_auth]:
resp = self.client.get( resp = self.client.get(
reverse("api-1:get_review_settings"), reverse("api-1:get_review_settings"),
@@ -608,7 +609,7 @@ class ReviewSettingsAPITest(TestCase):
) )
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
def test_get_settings_unauthenticated(self) -> None: def testget_settings_unauthenticated(self) -> None:
resp = self.client.get(reverse("api-1:get_review_settings")) resp = self.client.get(reverse("api-1:get_review_settings"))
self.assertEqual(resp.status_code, 401) self.assertEqual(resp.status_code, 401)
@@ -616,34 +617,31 @@ class ReviewSettingsAPITest(TestCase):
resp = self.client.put( resp = self.client.put(
reverse("api-1:update_review_settings"), reverse("api-1:update_review_settings"),
data=json.dumps( data=json.dumps(
{"defaultMinApprovals": 3, "allowAnyApprover": False} {"default_min_approvals": 3, "allow_any_approver": False}
), ),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth, HTTP_AUTHORIZATION=self.admin_auth,
) )
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
data = resp.json() data = resp.json()
min_val = _get(data, "defaultMinApprovals", "default_min_approvals") self.assertEqual(data["default_min_approvals"], 3)
any_val = _get(data, "allowAnyApprover", "allow_any_approver") self.assertFalse(data["allow_any_approver"])
self.assertEqual(min_val, 3)
self.assertFalse(any_val)
def test_update_settings_partial(self) -> None: def test_update_settings_partial(self) -> None:
resp = self.client.put( resp = self.client.put(
reverse("api-1:update_review_settings"), reverse("api-1:update_review_settings"),
data=json.dumps({"defaultMinApprovals": 5}), data=json.dumps({"default_min_approvals": 5}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth, HTTP_AUTHORIZATION=self.admin_auth,
) )
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
data = resp.json() data = resp.json()
min_val = _get(data, "defaultMinApprovals", "default_min_approvals") self.assertEqual(data["default_min_approvals"], 5)
self.assertEqual(min_val, 5)
def test_update_settings_viewer_denied(self) -> None: def test_update_settings_viewer_denied(self) -> None:
resp = self.client.put( resp = self.client.put(
reverse("api-1:update_review_settings"), reverse("api-1:update_review_settings"),
data=json.dumps({"defaultMinApprovals": 2}), data=json.dumps({"default_min_approvals": 2}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=self.viewer_auth, HTTP_AUTHORIZATION=self.viewer_auth,
) )
@@ -652,7 +650,7 @@ class ReviewSettingsAPITest(TestCase):
def test_update_settings_experimenter_denied(self) -> None: def test_update_settings_experimenter_denied(self) -> None:
resp = self.client.put( resp = self.client.put(
reverse("api-1:update_review_settings"), reverse("api-1:update_review_settings"),
data=json.dumps({"defaultMinApprovals": 2}), data=json.dumps({"default_min_approvals": 2}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=self.exp_auth, HTTP_AUTHORIZATION=self.exp_auth,
) )
@@ -661,7 +659,7 @@ class ReviewSettingsAPITest(TestCase):
def test_update_settings_approver_denied(self) -> None: def test_update_settings_approver_denied(self) -> None:
resp = self.client.put( resp = self.client.put(
reverse("api-1:update_review_settings"), reverse("api-1:update_review_settings"),
data=json.dumps({"defaultMinApprovals": 2}), data=json.dumps({"default_min_approvals": 2}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=self.appr_auth, HTTP_AUTHORIZATION=self.appr_auth,
) )
@@ -670,7 +668,7 @@ class ReviewSettingsAPITest(TestCase):
def test_update_settings_invalid_min_approvals(self) -> None: def test_update_settings_invalid_min_approvals(self) -> None:
resp = self.client.put( resp = self.client.put(
reverse("api-1:update_review_settings"), reverse("api-1:update_review_settings"),
data=json.dumps({"defaultMinApprovals": 0}), data=json.dumps({"default_min_approvals": 0}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth, HTTP_AUTHORIZATION=self.admin_auth,
) )
@@ -678,15 +676,16 @@ class ReviewSettingsAPITest(TestCase):
class EffectivePolicyAPITest(TestCase): class EffectivePolicyAPITest(TestCase):
@override
def setUp(self) -> None: def setUp(self) -> None:
self.client = Client() self.client = Client()
self.admin: User = _make_admin("_pol") self.admin: User = make_admin("_pol")
self.experimenter: User = _make_experimenter("_pol") self.experimenter: User = make_experimenter("_pol")
self.exp_no_group: User = _make_experimenter("_pol2") self.exp_no_group: User = make_experimenter("_pol2")
self.approver: User = _make_approver("_pol") self.approver: User = make_approver("_pol")
self.admin_auth: str = _auth_header(self.admin) self.admin_auth: str = auth_header(self.admin)
self.viewer: User = _make_viewer("_pol") self.viewer: User = make_viewer("_pol")
self.viewer_auth: str = _auth_header(self.viewer) self.viewer_auth: str = auth_header(self.viewer)
self.group: ApproverGroup = approver_group_create( self.group: ApproverGroup = approver_group_create(
experimenter=self.experimenter, experimenter=self.experimenter,
@@ -704,13 +703,10 @@ class EffectivePolicyAPITest(TestCase):
) )
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
data = resp.json() data = resp.json()
exp_id = _get(data, "experimenterId", "experimenter_id") self.assertEqual(data["experimenter_id"], str(self.experimenter.pk))
min_val = _get(data, "minApprovals", "min_approvals") self.assertEqual(data["min_approvals"], 1)
has_group = _get(data, "hasExplicitGroup", "has_explicit_group")
self.assertEqual(exp_id, str(self.experimenter.pk))
self.assertEqual(min_val, 1)
self.assertEqual(data["source"], "approver_group") self.assertEqual(data["source"], "approver_group")
self.assertTrue(has_group) self.assertTrue(data["has_explicit_group"])
self.assertEqual(len(data["approvers"]), 1) self.assertEqual(len(data["approvers"]), 1)
self.assertEqual(data["approvers"][0]["id"], str(self.approver.pk)) self.assertEqual(data["approvers"][0]["id"], str(self.approver.pk))
@@ -727,11 +723,9 @@ class EffectivePolicyAPITest(TestCase):
) )
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
data = resp.json() data = resp.json()
min_val = _get(data, "minApprovals", "min_approvals")
has_group = _get(data, "hasExplicitGroup", "has_explicit_group")
self.assertEqual(data["source"], "global_fallback") self.assertEqual(data["source"], "global_fallback")
self.assertFalse(has_group) self.assertFalse(data["has_explicit_group"])
self.assertEqual(min_val, 2) self.assertEqual(data["min_approvals"], 2)
def test_effective_policy_fallback_deny(self) -> None: def test_effective_policy_fallback_deny(self) -> None:
review_settings_update(allow_any_approver=False) review_settings_update(allow_any_approver=False)
@@ -1,4 +1,5 @@
import json import json
from typing import override
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test import Client, TestCase from django.test import Client, TestCase
@@ -15,27 +16,28 @@ from apps.reviews.services import (
approver_group_update, approver_group_update,
review_settings_update, review_settings_update,
) )
from apps.reviews.tests._helpers import ( from apps.reviews.tests.helpers import (
_make_admin, make_admin,
_make_approver, make_approver,
_make_experimenter, make_experimenter,
_make_viewer, make_viewer,
) )
from apps.users.models import User from apps.users.models import User
from apps.users.tests._helpers import _auth_header from apps.users.tests.helpers import auth_header
class ReviewRBACTest(TestCase): class ReviewRBACTest(TestCase):
@override
def setUp(self) -> None: def setUp(self) -> None:
self.client = Client() self.client = Client()
self.admin: User = _make_admin("_rbac") self.admin: User = make_admin("_rbac")
self.experimenter: User = _make_experimenter("_rbac") self.experimenter: User = make_experimenter("_rbac")
self.approver: User = _make_approver("_rbac") self.approver: User = make_approver("_rbac")
self.viewer: User = _make_viewer("_rbac") self.viewer: User = make_viewer("_rbac")
self.non_admin_users: dict[str, str] = { self.non_admin_users: dict[str, str] = {
"experimenter": _auth_header(self.experimenter), "experimenter": auth_header(self.experimenter),
"approver": _auth_header(self.approver), "approver": auth_header(self.approver),
"viewer": _auth_header(self.viewer), "viewer": auth_header(self.viewer),
} }
self.group: ApproverGroup = approver_group_create( self.group: ApproverGroup = approver_group_create(
experimenter=self.experimenter, experimenter=self.experimenter,
@@ -52,13 +54,13 @@ class ReviewRBACTest(TestCase):
def test_create_group_denied_for_non_admins(self) -> None: def test_create_group_denied_for_non_admins(self) -> None:
for role_name, auth in self.non_admin_users.items(): for role_name, auth in self.non_admin_users.items():
exp2: User = _make_experimenter(f"_rbac_cr_{role_name}") exp2: User = make_experimenter(f"_rbac_cr_{role_name}")
resp = self.client.post( resp = self.client.post(
reverse("api-1:create_approver_group"), reverse("api-1:create_approver_group"),
data=json.dumps( data=json.dumps(
{ {
"experimenterId": str(exp2.pk), "experimenter_id": str(exp2.pk),
"minApprovals": 1, "min_approvals": 1,
} }
), ),
content_type="application/json", content_type="application/json",
@@ -84,7 +86,7 @@ class ReviewRBACTest(TestCase):
"api-1:update_approver_group", "api-1:update_approver_group",
kwargs={"group_id": str(self.group.pk)}, kwargs={"group_id": str(self.group.pk)},
), ),
data=json.dumps({"minApprovals": 1}), data=json.dumps({"min_approvals": 1}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=auth, HTTP_AUTHORIZATION=auth,
) )
@@ -102,14 +104,14 @@ class ReviewRBACTest(TestCase):
self.assertEqual(resp.status_code, 403, role_name) self.assertEqual(resp.status_code, 403, role_name)
def test_add_approver_denied_for_non_admins(self) -> None: def test_add_approver_denied_for_non_admins(self) -> None:
appr2: User = _make_approver("_rbac_add") appr2: User = make_approver("_rbac_add")
for role_name, auth in self.non_admin_users.items(): for role_name, auth in self.non_admin_users.items():
resp = self.client.post( resp = self.client.post(
reverse( reverse(
"api-1:add_approver_to_group", "api-1:add_approver_to_group",
kwargs={"group_id": str(self.group.pk)}, kwargs={"group_id": str(self.group.pk)},
), ),
data=json.dumps({"approverId": str(appr2.pk)}), data=json.dumps({"approver_id": str(appr2.pk)}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=auth, HTTP_AUTHORIZATION=auth,
) )
@@ -122,7 +124,7 @@ class ReviewRBACTest(TestCase):
"api-1:remove_approver_from_group", "api-1:remove_approver_from_group",
kwargs={"group_id": str(self.group.pk)}, kwargs={"group_id": str(self.group.pk)},
), ),
data=json.dumps({"approverId": str(self.approver.pk)}), data=json.dumps({"approver_id": str(self.approver.pk)}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=auth, HTTP_AUTHORIZATION=auth,
) )
@@ -132,7 +134,7 @@ class ReviewRBACTest(TestCase):
for role_name, auth in self.non_admin_users.items(): for role_name, auth in self.non_admin_users.items():
resp = self.client.put( resp = self.client.put(
reverse("api-1:update_review_settings"), reverse("api-1:update_review_settings"),
data=json.dumps({"defaultMinApprovals": 99}), data=json.dumps({"default_min_approvals": 99}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=auth, HTTP_AUTHORIZATION=auth,
) )
@@ -140,14 +142,15 @@ class ReviewRBACTest(TestCase):
class ReviewEdgeCasesTest(TestCase): class ReviewEdgeCasesTest(TestCase):
@override
def setUp(self) -> None: def setUp(self) -> None:
self.client = Client() self.client = Client()
self.admin: User = _make_admin("_edge") self.admin: User = make_admin("_edge")
self.admin_auth: str = _auth_header(self.admin) self.admin_auth: str = auth_header(self.admin)
def test_delete_group_then_fallback_applies(self) -> None: def test_delete_group_then_fallback_applies(self) -> None:
exp: User = _make_experimenter("_edge1") exp: User = make_experimenter("_edge1")
appr: User = _make_approver("_edge1") appr: User = make_approver("_edge1")
group: ApproverGroup = approver_group_create( group: ApproverGroup = approver_group_create(
experimenter=exp, experimenter=exp,
approver_ids=[str(appr.pk)], approver_ids=[str(appr.pk)],
@@ -162,8 +165,8 @@ class ReviewEdgeCasesTest(TestCase):
self.assertEqual(min_app, 3) self.assertEqual(min_app, 3)
def test_inactive_approver_excluded_from_effective_policy(self) -> None: def test_inactive_approver_excluded_from_effective_policy(self) -> None:
exp: User = _make_experimenter("_edge2") exp: User = make_experimenter("_edge2")
appr: User = _make_approver("_edge2") appr: User = make_approver("_edge2")
approver_group_create( approver_group_create(
experimenter=exp, experimenter=exp,
approver_ids=[str(appr.pk)], approver_ids=[str(appr.pk)],
@@ -174,8 +177,8 @@ class ReviewEdgeCasesTest(TestCase):
self.assertEqual(approvers.count(), 0) self.assertEqual(approvers.count(), 0)
def test_create_group_with_all_three_approvers(self) -> None: def test_create_group_with_all_three_approvers(self) -> None:
exp: User = _make_experimenter("_edge3") exp: User = make_experimenter("_edge3")
apprs: list[User] = [_make_approver(f"_edge3_{i}") for i in range(3)] apprs: list[User] = [make_approver(f"_edge3_{i}") for i in range(3)]
group: ApproverGroup = approver_group_create( group: ApproverGroup = approver_group_create(
experimenter=exp, experimenter=exp,
approver_ids=[str(a.pk) for a in apprs], approver_ids=[str(a.pk) for a in apprs],
@@ -185,8 +188,8 @@ class ReviewEdgeCasesTest(TestCase):
self.assertEqual(group.min_approvals, 2) self.assertEqual(group.min_approvals, 2)
def test_update_group_to_empty_approvers(self) -> None: def test_update_group_to_empty_approvers(self) -> None:
exp: User = _make_experimenter("_edge4") exp: User = make_experimenter("_edge4")
appr: User = _make_approver("_edge4") appr: User = make_approver("_edge4")
group: ApproverGroup = approver_group_create( group: ApproverGroup = approver_group_create(
experimenter=exp, experimenter=exp,
approver_ids=[str(appr.pk)], approver_ids=[str(appr.pk)],
@@ -198,15 +201,15 @@ class ReviewEdgeCasesTest(TestCase):
) )
def test_api_output_format_approver_group(self) -> None: def test_api_output_format_approver_group(self) -> None:
exp: User = _make_experimenter("_edge5") exp: User = make_experimenter("_edge5")
appr: User = _make_approver("_edge5") appr: User = make_approver("_edge5")
resp = self.client.post( resp = self.client.post(
reverse("api-1:create_approver_group"), reverse("api-1:create_approver_group"),
data=json.dumps( data=json.dumps(
{ {
"experimenterId": str(exp.pk), "experimenter_id": str(exp.pk),
"approverIds": [str(appr.pk)], "approver_ids": [str(appr.pk)],
"minApprovals": 1, "min_approvals": 1,
} }
), ),
content_type="application/json", content_type="application/json",
@@ -217,9 +220,9 @@ class ReviewEdgeCasesTest(TestCase):
self.assertIn("id", data) self.assertIn("id", data)
self.assertIn("experimenter", data) self.assertIn("experimenter", data)
self.assertIn("approvers", data) self.assertIn("approvers", data)
self.assertTrue("minApprovals" in data or "min_approvals" in data) self.assertIn("min_approvals", data)
self.assertTrue("createdAt" in data or "created_at" in data) self.assertIn("created_at", data)
self.assertTrue("updatedAt" in data or "updated_at" in data) self.assertIn("updated_at", data)
self.assertIn("id", data["experimenter"]) self.assertIn("id", data["experimenter"])
self.assertIn("username", data["experimenter"]) self.assertIn("username", data["experimenter"])
self.assertIn("email", data["experimenter"]) self.assertIn("email", data["experimenter"])
@@ -237,17 +240,13 @@ class ReviewEdgeCasesTest(TestCase):
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
data = resp.json() data = resp.json()
self.assertIn("id", data) self.assertIn("id", data)
self.assertTrue( self.assertIn("default_min_approvals", data)
"defaultMinApprovals" in data or "default_min_approvals" in data self.assertIn("allow_any_approver", data)
) self.assertIn("updated_at", data)
self.assertTrue(
"allowAnyApprover" in data or "allow_any_approver" in data
)
self.assertTrue("updatedAt" in data or "updated_at" in data)
def test_api_output_format_effective_policy(self) -> None: def test_api_output_format_effective_policy(self) -> None:
exp: User = _make_experimenter("_edge6") exp: User = make_experimenter("_edge6")
appr: User = _make_approver("_edge6") appr: User = make_approver("_edge6")
approver_group_create( approver_group_create(
experimenter=exp, experimenter=exp,
approver_ids=[str(appr.pk)], approver_ids=[str(appr.pk)],
@@ -261,19 +260,17 @@ class ReviewEdgeCasesTest(TestCase):
) )
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
data = resp.json() data = resp.json()
self.assertTrue("experimenterId" in data or "experimenter_id" in data) self.assertIn("experimenter_id", data)
self.assertTrue("minApprovals" in data or "min_approvals" in data) self.assertIn("min_approvals", data)
self.assertIn("approvers", data) self.assertIn("approvers", data)
self.assertIn("source", data) self.assertIn("source", data)
self.assertTrue( self.assertIn("has_explicit_group", data)
"hasExplicitGroup" in data or "has_explicit_group" in data
)
def test_multiple_experimenters_independent_groups(self) -> None: def test_multiple_experimenters_independent_groups(self) -> None:
exp1: User = _make_experimenter("_edge_m1") exp1: User = make_experimenter("_edge_m1")
exp2: User = _make_experimenter("_edge_m2") exp2: User = make_experimenter("_edge_m2")
appr1: User = _make_approver("_edge_m1") appr1: User = make_approver("_edge_m1")
appr2: User = _make_approver("_edge_m2") appr2: User = make_approver("_edge_m2")
g1: ApproverGroup = approver_group_create( g1: ApproverGroup = approver_group_create(
experimenter=exp1, experimenter=exp1,
@@ -293,9 +290,9 @@ class ReviewEdgeCasesTest(TestCase):
self.assertFalse(g2.can_approve(appr1)) self.assertFalse(g2.can_approve(appr1))
def test_concurrent_fallback_and_explicit_group(self) -> None: def test_concurrent_fallback_and_explicit_group(self) -> None:
exp_with: User = _make_experimenter("_edge_c1") exp_with: User = make_experimenter("_edge_c1")
exp_without: User = _make_experimenter("_edge_c2") exp_without: User = make_experimenter("_edge_c2")
appr: User = _make_approver("_edge_c") appr: User = make_approver("_edge_c")
approver_group_create( approver_group_create(
experimenter=exp_with, experimenter=exp_with,
+7 -1
View File
@@ -8,6 +8,7 @@ from ninja.renderers import BaseRenderer
from api.v1 import handlers from api.v1 import handlers
from api.v1.auth.endpoints import router as auth_router from api.v1.auth.endpoints import router as auth_router
from api.v1.flags.endpoints import router as flags_router
from api.v1.reviews.endpoints import router as reviews_router from api.v1.reviews.endpoints import router as reviews_router
from api.v1.users.endpoints import router as users_router from api.v1.users.endpoints import router as users_router
@@ -23,7 +24,7 @@ class ORJSONRenderer(BaseRenderer):
def default(self, obj: Any) -> Any: def default(self, obj: Any) -> Any:
if isinstance(obj, Schema): if isinstance(obj, Schema):
return obj.model_dump(by_alias=True) return obj.model_dump()
raise TypeError raise TypeError
@@ -41,6 +42,11 @@ router.add_router(
auth_router, auth_router,
) )
router.add_router(
"flags",
flags_router,
)
router.add_router( router.add_router(
"users", "users",
users_router, users_router,
+3 -3
View File
@@ -12,18 +12,18 @@ class FieldError(Schema):
) )
issue: str = Field(..., description="Problem description") issue: str = Field(..., description="Problem description")
rejected_value: Any = Field( rejected_value: Any = Field(
None, alias="rejectedValue", description="Value that failed validation" None, description="Value that failed validation"
) )
class ApiError(Schema): class ApiError(Schema):
code: str code: str
message: str message: str
trace_id: str = Field(..., alias="traceId") trace_id: str
timestamp: datetime timestamp: datetime
path: str path: str
details: dict[str, Any] | None = None details: dict[str, Any] | None = None
class ValidationError(ApiError): class ValidationError(ApiError):
field_errors: list[FieldError] = Field(..., alias="fieldErrors") field_errors: list[FieldError]
+1 -1
View File
@@ -38,7 +38,7 @@ router = Router(tags=["users"])
def list_users( def list_users(
request: HttpRequest, request: HttpRequest,
role: str | None = None, role: str | None = None,
is_active: bool | None = None, is_active: bool | None = None, # noqa: FBT001
search: str | None = None, search: str | None = None,
limit: int = 50, limit: int = 50,
offset: int = 0, offset: int = 0,
+1 -7
View File
@@ -7,8 +7,7 @@ from apps.users.models import User
class UserOut(ModelSchema): class UserOut(ModelSchema):
first_name: str = Field("", alias="firstName")
last_name: str = Field("", alias="lastName")
is_active: bool is_active: bool
class Meta: class Meta:
@@ -52,13 +51,11 @@ class UserCreateIn(Schema):
) )
first_name: str = Field( first_name: str = Field(
"", "",
alias="firstName",
max_length=150, max_length=150,
description="First name.", description="First name.",
) )
last_name: str = Field( last_name: str = Field(
"", "",
alias="lastName",
max_length=150, max_length=150,
description="Last name.", description="Last name.",
) )
@@ -91,19 +88,16 @@ class UserUpdateIn(Schema):
) )
first_name: str | None = Field( first_name: str | None = Field(
None, None,
alias="firstName",
max_length=150, max_length=150,
description="New first name.", description="New first name.",
) )
last_name: str | None = Field( last_name: str | None = Field(
None, None,
alias="lastName",
max_length=150, max_length=150,
description="New last name.", description="New last name.",
) )
is_active: bool | None = Field( is_active: bool | None = Field(
None, None,
alias="isActive",
description="Set active/inactive status.", description="Set active/inactive status.",
) )
+8 -5
View File
@@ -1,23 +1,26 @@
from typing import override
from django.test import Client, TestCase from django.test import Client, TestCase
from apps.users.models import User, UserRole from apps.users.models import User, UserRole
from apps.users.tests._helpers import _auth_header, _make_user from apps.users.tests.helpers import auth_header, make_user
class BaseUsersAPITest(TestCase): class BaseUsersAPITest(TestCase):
@override
def setUp(self) -> None: def setUp(self) -> None:
self.client = Client() self.client = Client()
self.admin: User = _make_user( self.admin: User = make_user(
username="mgmt_admin", username="mgmt_admin",
email="mgmt_admin@x.com", email="mgmt_admin@x.com",
password="adminpass1", password="adminpass1",
role=UserRole.ADMIN, role=UserRole.ADMIN,
) )
self.viewer: User = _make_user( self.viewer: User = make_user(
username="mgmt_viewer", username="mgmt_viewer",
email="mgmt_viewer@x.com", email="mgmt_viewer@x.com",
password="viewerpass", password="viewerpass",
role=UserRole.VIEWER, role=UserRole.VIEWER,
) )
self.admin_auth: str = _auth_header(self.admin) self.admin_auth: str = auth_header(self.admin)
self.viewer_auth: str = _auth_header(self.viewer) self.viewer_auth: str = auth_header(self.viewer)
@@ -4,14 +4,14 @@ import uuid
from django.urls import reverse from django.urls import reverse
from apps.users.models import User, UserRole from apps.users.models import User, UserRole
from apps.users.tests._helpers import _make_user from apps.users.tests.helpers import make_user
from ._crud_base import BaseUsersAPITest from ._crud_base import BaseUsersAPITest
class UsersAPIDeleteAssignRoleTest(BaseUsersAPITest): class UsersAPIDeleteAssignRoleTest(BaseUsersAPITest):
def test_delete_user_admin(self) -> None: def test_delete_user_admin(self) -> None:
target: User = _make_user( target: User = make_user(
username="to_delete", username="to_delete",
email="del@lotty.local", email="del@lotty.local",
role=UserRole.VIEWER, role=UserRole.VIEWER,
@@ -66,8 +66,8 @@ class UsersAPIListCreateTest(BaseUsersAPITest):
"email": "new@lotty.local", "email": "new@lotty.local",
"password": "newpass123", "password": "newpass123",
"role": "experimenter", "role": "experimenter",
"firstName": "New", "first_name": "New",
"lastName": "User", "last_name": "User",
} }
), ),
content_type="application/json", content_type="application/json",
@@ -51,7 +51,7 @@ class UsersAPIReadUpdateTest(BaseUsersAPITest):
reverse( reverse(
"api-1:update_user", kwargs={"user_id": str(self.viewer.pk)} "api-1:update_user", kwargs={"user_id": str(self.viewer.pk)}
), ),
data=json.dumps({"firstName": "Updated"}), data=json.dumps({"first_name": "Updated"}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth, HTTP_AUTHORIZATION=self.admin_auth,
) )
@@ -75,7 +75,7 @@ class UsersAPIReadUpdateTest(BaseUsersAPITest):
reverse( reverse(
"api-1:update_user", kwargs={"user_id": str(self.admin.pk)} "api-1:update_user", kwargs={"user_id": str(self.admin.pk)}
), ),
data=json.dumps({"firstName": "Hacked"}), data=json.dumps({"first_name": "Hacked"}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=self.viewer_auth, HTTP_AUTHORIZATION=self.viewer_auth,
) )
@@ -86,7 +86,7 @@ class UsersAPIReadUpdateTest(BaseUsersAPITest):
reverse( reverse(
"api-1:update_user", kwargs={"user_id": str(uuid.uuid4())} "api-1:update_user", kwargs={"user_id": str(uuid.uuid4())}
), ),
data=json.dumps({"firstName": "Ghost"}), data=json.dumps({"first_name": "Ghost"}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth, HTTP_AUTHORIZATION=self.admin_auth,
) )
+6 -4
View File
@@ -1,13 +1,15 @@
import json import json
from typing import override
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from apps.users.models import User, UserRole from apps.users.models import User, UserRole
from apps.users.tests._helpers import _auth_header, _make_user from apps.users.tests.helpers import auth_header, make_user
class RoleBasedAccessControlTest(TestCase): class RoleBasedAccessControlTest(TestCase):
@override
def setUp(self) -> None: def setUp(self) -> None:
self.client = Client() self.client = Client()
self.roles = {} self.roles = {}
@@ -17,7 +19,7 @@ class RoleBasedAccessControlTest(TestCase):
UserRole.APPROVER, UserRole.APPROVER,
UserRole.VIEWER, UserRole.VIEWER,
]: ]:
user: User = _make_user( user: User = make_user(
username=f"rbac_{role_val}", username=f"rbac_{role_val}",
email=f"rbac_{role_val}@x.com", email=f"rbac_{role_val}@x.com",
password="password1", password="password1",
@@ -25,7 +27,7 @@ class RoleBasedAccessControlTest(TestCase):
) )
self.roles[role_val] = { self.roles[role_val] = {
"user": user, "user": user,
"auth": _auth_header(user), "auth": auth_header(user),
} }
def test_admin_can_list(self) -> None: def test_admin_can_list(self) -> None:
@@ -93,7 +95,7 @@ class RoleBasedAccessControlTest(TestCase):
target = self.roles[UserRole.VIEWER]["user"] target = self.roles[UserRole.VIEWER]["user"]
resp = self.client.patch( resp = self.client.patch(
reverse("api-1:update_user", kwargs={"user_id": str(target.pk)}), reverse("api-1:update_user", kwargs={"user_id": str(target.pk)}),
data=json.dumps({"firstName": "Nope"}), data=json.dumps({"first_name": "Nope"}),
content_type="application/json", content_type="application/json",
HTTP_AUTHORIZATION=self.roles[UserRole.EXPERIMENTER]["auth"], HTTP_AUTHORIZATION=self.roles[UserRole.EXPERIMENTER]["auth"],
) )
+4
View File
@@ -1,3 +1,5 @@
from typing import override
from django.contrib import admin from django.contrib import admin
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -34,11 +36,13 @@ class ReviewSettingsAdmin(admin.ModelAdmin):
), ),
) )
@override
def has_add_permission(self, request) -> bool: def has_add_permission(self, request) -> bool:
if ReviewSettings.objects.exists(): if ReviewSettings.objects.exists():
return False return False
return super().has_add_permission(request) return super().has_add_permission(request)
@override
def has_delete_permission(self, request, obj=None) -> bool: def has_delete_permission(self, request, obj=None) -> bool:
return False return False
@@ -0,0 +1,18 @@
# Generated by Django 5.2.11 on 2026-02-12 18:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('reviews', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='reviewsettings',
name='allow_any_approver',
field=models.BooleanField(default=False, 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'),
),
]
+6 -2
View File
@@ -1,4 +1,4 @@
from typing import Any from typing import Any, override
from django.conf import settings from django.conf import settings
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
@@ -40,12 +40,14 @@ class ReviewSettings(BaseModel):
verbose_name = _("review settings") verbose_name = _("review settings")
verbose_name_plural = _("review settings") verbose_name_plural = _("review settings")
@override
def __str__(self) -> str: def __str__(self) -> str:
return ( return (
f"ReviewSettings(min_approvals={self.default_min_approvals}, " f"ReviewSettings(min_approvals={self.default_min_approvals}, "
f"allow_any_approver={self.allow_any_approver})" f"allow_any_approver={self.allow_any_approver})"
) )
@override
def save(self, *args, **kwargs) -> None: def save(self, *args, **kwargs) -> None:
existing: ReviewSettings | None = ReviewSettings.objects.first() existing: ReviewSettings | None = ReviewSettings.objects.first()
if existing and existing.pk != self.pk: if existing and existing.pk != self.pk:
@@ -77,7 +79,8 @@ class ApproverGroup(BaseModel):
limit_choices_to={"role": "approver"}, limit_choices_to={"role": "approver"},
verbose_name=_("approvers"), verbose_name=_("approvers"),
help_text=_( help_text=_(
"Approver-role users who may approve this experimenter's experiments." "Approver-role users who may approve this "
"experimenter's experiments."
), ),
) )
min_approvals: models.PositiveIntegerField[Any, Any] = ( min_approvals: models.PositiveIntegerField[Any, Any] = (
@@ -104,6 +107,7 @@ class ApproverGroup(BaseModel):
verbose_name = _("approver group") verbose_name = _("approver group")
verbose_name_plural = _("approver groups") verbose_name_plural = _("approver groups")
@override
def __str__(self) -> str: def __str__(self) -> str:
return ( return (
f"ApproverGroup(experimenter={self.experimenter.pk}, " f"ApproverGroup(experimenter={self.experimenter.pk}, "
@@ -1,40 +1,34 @@
from apps.users.models import User, UserRole from apps.users.models import User, UserRole
from apps.users.tests._helpers import _make_user from apps.users.tests.helpers import make_user
def _make_experimenter(suffix="") -> User: def make_experimenter(suffix="") -> User:
return _make_user( return make_user(
username=f"exp{suffix}", username=f"exp{suffix}",
email=f"exp{suffix}@lotty.local", email=f"exp{suffix}@lotty.local",
role=UserRole.EXPERIMENTER, role=UserRole.EXPERIMENTER,
) )
def _make_approver(suffix="") -> User: def make_approver(suffix="") -> User:
return _make_user( return make_user(
username=f"appr{suffix}", username=f"appr{suffix}",
email=f"appr{suffix}@lotty.local", email=f"appr{suffix}@lotty.local",
role=UserRole.APPROVER, role=UserRole.APPROVER,
) )
def _make_admin(suffix="") -> User: def make_admin(suffix="") -> User:
return _make_user( return make_user(
username=f"admin{suffix}", username=f"admin{suffix}",
email=f"admin{suffix}@lotty.local", email=f"admin{suffix}@lotty.local",
role=UserRole.ADMIN, role=UserRole.ADMIN,
) )
def _make_viewer(suffix="") -> User: def make_viewer(suffix="") -> User:
return _make_user( return make_user(
username=f"viewer{suffix}", username=f"viewer{suffix}",
email=f"viewer{suffix}@lotty.local", email=f"viewer{suffix}@lotty.local",
role=UserRole.VIEWER, role=UserRole.VIEWER,
) )
def _get(data, camel_key, snake_key):
if camel_key in data:
return data[camel_key]
return data[snake_key]
@@ -1,5 +1,5 @@
import uuid import uuid
from typing import Any from typing import Any, override
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import QuerySet from django.db.models import QuerySet
@@ -22,18 +22,19 @@ from apps.reviews.services import (
) )
from apps.users.models import User from apps.users.models import User
from ._helpers import ( from .helpers import (
_make_admin, make_admin,
_make_approver, make_approver,
_make_experimenter, make_experimenter,
_make_viewer, make_viewer,
) )
class ApproverGroupModelTest(TestCase): class ApproverGroupModelTest(TestCase):
@override
def setUp(self) -> None: def setUp(self) -> None:
self.experimenter: User = _make_experimenter("_model") self.experimenter: User = make_experimenter("_model")
self.approver: User = _make_approver("_model") self.approver: User = make_approver("_model")
def test_create_group(self) -> None: def test_create_group(self) -> None:
group: ApproverGroup = approver_group_create( group: ApproverGroup = approver_group_create(
@@ -63,7 +64,7 @@ class ApproverGroupModelTest(TestCase):
self.assertTrue(group.can_approve(self.approver)) self.assertTrue(group.can_approve(self.approver))
def test_can_approve_false_not_in_group(self) -> None: def test_can_approve_false_not_in_group(self) -> None:
other_approver: User = _make_approver("_other") other_approver: User = make_approver("_other")
group: ApproverGroup = approver_group_create( group: ApproverGroup = approver_group_create(
experimenter=self.experimenter, experimenter=self.experimenter,
approver_ids=[str(self.approver.pk)], approver_ids=[str(self.approver.pk)],
@@ -80,10 +81,11 @@ class ApproverGroupModelTest(TestCase):
class ApproverGroupCreateServiceTest(TestCase): class ApproverGroupCreateServiceTest(TestCase):
@override
def setUp(self) -> None: def setUp(self) -> None:
self.experimenter: User = _make_experimenter("_create") self.experimenter: User = make_experimenter("_create")
self.approver1: User = _make_approver("_create1") self.approver1: User = make_approver("_create1")
self.approver2: User = _make_approver("_create2") self.approver2: User = make_approver("_create2")
def test_create_with_approvers(self) -> None: def test_create_with_approvers(self) -> None:
group: ApproverGroup = approver_group_create( group: ApproverGroup = approver_group_create(
@@ -109,17 +111,17 @@ class ApproverGroupCreateServiceTest(TestCase):
self.assertEqual(group.min_approvals, 1) self.assertEqual(group.min_approvals, 1)
def test_create_rejects_non_experimenter_user(self) -> None: def test_create_rejects_non_experimenter_user(self) -> None:
admin: User = _make_admin("_cre_admin") admin: User = make_admin("_cre_admin")
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
approver_group_create(experimenter=admin) approver_group_create(experimenter=admin)
def test_create_rejects_viewer_as_experimenter(self) -> None: def test_create_rejects_viewer_as_experimenter(self) -> None:
viewer: User = _make_viewer("_cre_viewer") viewer: User = make_viewer("_cre_viewer")
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
approver_group_create(experimenter=viewer) approver_group_create(experimenter=viewer)
def test_create_rejects_approver_as_experimenter(self) -> None: def test_create_rejects_approver_as_experimenter(self) -> None:
approver: User = _make_approver("_cre_as_exp") approver: User = make_approver("_cre_as_exp")
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
approver_group_create(experimenter=approver) approver_group_create(experimenter=approver)
@@ -130,7 +132,7 @@ class ApproverGroupCreateServiceTest(TestCase):
approver_group_create(experimenter=self.experimenter) approver_group_create(experimenter=self.experimenter)
def test_create_rejects_non_approver_in_approver_list(self) -> None: def test_create_rejects_non_approver_in_approver_list(self) -> None:
viewer: User = _make_viewer("_cre_bad_appr") viewer: User = make_viewer("_cre_bad_appr")
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
approver_group_create( approver_group_create(
experimenter=self.experimenter, experimenter=self.experimenter,
@@ -166,11 +168,12 @@ class ApproverGroupCreateServiceTest(TestCase):
class ApproverGroupUpdateServiceTest(TestCase): class ApproverGroupUpdateServiceTest(TestCase):
@override
def setUp(self) -> None: def setUp(self) -> None:
self.experimenter: User = _make_experimenter("_upd") self.experimenter: User = make_experimenter("_upd")
self.approver1: User = _make_approver("_upd1") self.approver1: User = make_approver("_upd1")
self.approver2: User = _make_approver("_upd2") self.approver2: User = make_approver("_upd2")
self.approver3: User = _make_approver("_upd3") self.approver3: User = make_approver("_upd3")
self.group: ApproverGroup = approver_group_create( self.group: ApproverGroup = approver_group_create(
experimenter=self.experimenter, experimenter=self.experimenter,
approver_ids=[str(self.approver1.pk), str(self.approver2.pk)], approver_ids=[str(self.approver1.pk), str(self.approver2.pk)],
@@ -225,7 +228,7 @@ class ApproverGroupUpdateServiceTest(TestCase):
approver_group_update(group=self.group, min_approvals=10) approver_group_update(group=self.group, min_approvals=10)
def test_update_rejects_non_approver_role(self) -> None: def test_update_rejects_non_approver_role(self) -> None:
viewer: User = _make_viewer("_upd_bad") viewer: User = make_viewer("_upd_bad")
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
approver_group_update( approver_group_update(
group=self.group, group=self.group,
@@ -242,14 +245,14 @@ class ApproverGroupUpdateServiceTest(TestCase):
class ApproverGroupDeleteServiceTest(TestCase): class ApproverGroupDeleteServiceTest(TestCase):
def test_delete_removes_group(self) -> None: def test_delete_removes_group(self) -> None:
exp: User = _make_experimenter("_del") exp: User = make_experimenter("_del")
group: ApproverGroup = approver_group_create(experimenter=exp) group: ApproverGroup = approver_group_create(experimenter=exp)
pk = group.pk pk = group.pk
approver_group_delete(group=group) approver_group_delete(group=group)
self.assertFalse(ApproverGroup.objects.filter(pk=pk).exists()) self.assertFalse(ApproverGroup.objects.filter(pk=pk).exists())
def test_delete_allows_recreating_group(self) -> None: def test_delete_allows_recreating_group(self) -> None:
exp: User = _make_experimenter("_del2") exp: User = make_experimenter("_del2")
group: ApproverGroup = approver_group_create(experimenter=exp) group: ApproverGroup = approver_group_create(experimenter=exp)
approver_group_delete(group=group) approver_group_delete(group=group)
new_group: ApproverGroup = approver_group_create(experimenter=exp) new_group: ApproverGroup = approver_group_create(experimenter=exp)
@@ -257,10 +260,11 @@ class ApproverGroupDeleteServiceTest(TestCase):
class ApproverGroupAddRemoveServiceTest(TestCase): class ApproverGroupAddRemoveServiceTest(TestCase):
@override
def setUp(self) -> None: def setUp(self) -> None:
self.experimenter: User = _make_experimenter("_ar") self.experimenter: User = make_experimenter("_ar")
self.approver1: User = _make_approver("_ar1") self.approver1: User = make_approver("_ar1")
self.approver2: User = _make_approver("_ar2") self.approver2: User = make_approver("_ar2")
self.group: ApproverGroup = approver_group_create( self.group: ApproverGroup = approver_group_create(
experimenter=self.experimenter, experimenter=self.experimenter,
approver_ids=[str(self.approver1.pk)], approver_ids=[str(self.approver1.pk)],
@@ -275,7 +279,7 @@ class ApproverGroupAddRemoveServiceTest(TestCase):
) )
def test_add_approver_rejects_non_approver_role(self) -> None: def test_add_approver_rejects_non_approver_role(self) -> None:
viewer: User = _make_viewer("_ar_bad") viewer: User = make_viewer("_ar_bad")
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
approver_group_add_approver(group=self.group, approver=viewer) approver_group_add_approver(group=self.group, approver=viewer)
@@ -309,11 +313,12 @@ class ApproverGroupAddRemoveServiceTest(TestCase):
class ApproverGroupSelectorsTest(TestCase): class ApproverGroupSelectorsTest(TestCase):
@override
def setUp(self) -> None: def setUp(self) -> None:
self.exp1: User = _make_experimenter("_sel1") self.exp1: User = make_experimenter("_sel1")
self.exp2: User = _make_experimenter("_sel2") self.exp2: User = make_experimenter("_sel2")
self.appr1: User = _make_approver("_sel1") self.appr1: User = make_approver("_sel1")
self.appr2: User = _make_approver("_sel2") self.appr2: User = make_approver("_sel2")
self.group1: ApproverGroup = approver_group_create( self.group1: ApproverGroup = approver_group_create(
experimenter=self.exp1, experimenter=self.exp1,
approver_ids=[str(self.appr1.pk)], approver_ids=[str(self.appr1.pk)],
@@ -344,7 +349,7 @@ class ApproverGroupSelectorsTest(TestCase):
self.assertEqual(found, self.group1) self.assertEqual(found, self.group1)
def test_get_by_experimenter_no_group(self) -> None: def test_get_by_experimenter_no_group(self) -> None:
exp3: User = _make_experimenter("_sel3") exp3: User = make_experimenter("_sel3")
self.assertIsNone(approver_group_get_by_experimenter(exp3)) self.assertIsNone(approver_group_get_by_experimenter(exp3))
def test_get_by_experimenter_id(self) -> None: def test_get_by_experimenter_id(self) -> None:
@@ -1,3 +1,5 @@
from typing import override
from django.test import TestCase from django.test import TestCase
from apps.reviews.models import ApproverGroup from apps.reviews.models import ApproverGroup
@@ -9,15 +11,16 @@ from apps.reviews.selectors import (
from apps.reviews.services import approver_group_create, review_settings_update from apps.reviews.services import approver_group_create, review_settings_update
from apps.users.models import User from apps.users.models import User
from ._helpers import _make_admin, _make_approver, _make_experimenter from .helpers import make_admin, make_approver, make_experimenter
class EffectiveReviewPolicyTest(TestCase): class EffectiveReviewPolicyTest(TestCase):
@override
def setUp(self) -> None: def setUp(self) -> None:
self.exp_with_group: User = _make_experimenter("_eff1") self.exp_with_group: User = make_experimenter("_eff1")
self.exp_without_group: User = _make_experimenter("_eff2") self.exp_without_group: User = make_experimenter("_eff2")
self.appr1: User = _make_approver("_eff1") self.appr1: User = make_approver("_eff1")
self.appr2: User = _make_approver("_eff2") self.appr2: User = make_approver("_eff2")
self.group: ApproverGroup = approver_group_create( self.group: ApproverGroup = approver_group_create(
experimenter=self.exp_with_group, experimenter=self.exp_with_group,
approver_ids=[str(self.appr1.pk)], approver_ids=[str(self.appr1.pk)],
@@ -63,10 +66,11 @@ class EffectiveReviewPolicyTest(TestCase):
class CanUserApproveTest(TestCase): class CanUserApproveTest(TestCase):
@override
def setUp(self) -> None: def setUp(self) -> None:
self.exp: User = _make_experimenter("_can") self.exp: User = make_experimenter("_can")
self.appr_in: User = _make_approver("_can_in") self.appr_in: User = make_approver("_can_in")
self.appr_out: User = _make_approver("_can_out") self.appr_out: User = make_approver("_can_out")
self.group: ApproverGroup = approver_group_create( self.group: ApproverGroup = approver_group_create(
experimenter=self.exp, experimenter=self.exp,
approver_ids=[str(self.appr_in.pk)], approver_ids=[str(self.appr_in.pk)],
@@ -81,7 +85,7 @@ class CanUserApproveTest(TestCase):
) )
def test_non_approver_role_cannot_approve(self) -> None: def test_non_approver_role_cannot_approve(self) -> None:
admin: User = _make_admin("_can_a") admin: User = make_admin("_can_a")
self.assertFalse(can_user_approve_experimenter(admin, self.exp)) self.assertFalse(can_user_approve_experimenter(admin, self.exp))
def test_inactive_approver_cannot_approve(self) -> None: def test_inactive_approver_cannot_approve(self) -> None:
@@ -90,11 +94,11 @@ class CanUserApproveTest(TestCase):
self.assertFalse(can_user_approve_experimenter(self.appr_in, self.exp)) self.assertFalse(can_user_approve_experimenter(self.appr_in, self.exp))
def test_fallback_any_approver_can_approve(self) -> None: def test_fallback_any_approver_can_approve(self) -> None:
exp2: User = _make_experimenter("_can2") exp2: User = make_experimenter("_can2")
review_settings_update(allow_any_approver=True) review_settings_update(allow_any_approver=True)
self.assertTrue(can_user_approve_experimenter(self.appr_out, exp2)) self.assertTrue(can_user_approve_experimenter(self.appr_out, exp2))
def test_fallback_deny_blocks_approval(self) -> None: def test_fallback_deny_blocks_approval(self) -> None:
exp2: User = _make_experimenter("_can3") exp2: User = make_experimenter("_can3")
review_settings_update(allow_any_approver=False) review_settings_update(allow_any_approver=False)
self.assertFalse(can_user_approve_experimenter(self.appr_out, exp2)) self.assertFalse(can_user_approve_experimenter(self.appr_out, exp2))
+2 -1
View File
@@ -1,7 +1,7 @@
import logging import logging
from collections.abc import Callable from collections.abc import Callable
from functools import wraps from functools import wraps
from typing import Any from typing import Any, override
from django.http import HttpRequest from django.http import HttpRequest
from ninja.security import HttpBearer from ninja.security import HttpBearer
@@ -14,6 +14,7 @@ logger: logging.Logger = logging.getLogger("lotty")
class JWTBearer(HttpBearer): class JWTBearer(HttpBearer):
@override
def authenticate( def authenticate(
self, self,
request: HttpRequest, request: HttpRequest,
@@ -1,3 +1,5 @@
from typing import override
from django.core.management.base import BaseCommand, CommandParser from django.core.management.base import BaseCommand, CommandParser
from apps.users.models import User, UserRole from apps.users.models import User, UserRole
@@ -59,6 +61,7 @@ class Command(BaseCommand):
"(admin, experimenter, approver, viewer)." "(admin, experimenter, approver, viewer)."
) )
@override
def add_arguments(self, parser: CommandParser) -> None: def add_arguments(self, parser: CommandParser) -> None:
parser.add_argument( parser.add_argument(
"--password", "--password",
@@ -79,6 +82,7 @@ class Command(BaseCommand):
), ),
) )
@override
def handle(self, *args, **options) -> None: def handle(self, *args, **options) -> None:
password: str = options["password"] password: str = options["password"]
force: bool = options["force"] force: bool = options["force"]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.11 on 2026-02-12 18:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='user',
name='role',
field=models.CharField(choices=[('admin', 'Admin'), ('experimenter', 'Experimenter'), ('approver', 'Approver'), ('viewer', 'Viewer')], db_index=True, default='viewer', help_text='Platform role that defines user permissions', max_length=20, verbose_name='role'),
),
]
@@ -3,7 +3,7 @@ from apps.users.models import User, UserRole
from apps.users.services import user_create from apps.users.services import user_create
def _make_user( def make_user(
username="testuser", username="testuser",
email="test@lotty.local", email="test@lotty.local",
password="testpass123", # noqa: S107 password="testpass123", # noqa: S107
@@ -19,6 +19,6 @@ def _make_user(
) )
def _auth_header(user) -> str: def auth_header(user) -> str:
token: str = create_access_token(user.pk, user.role) token: str = create_access_token(user.pk, user.role)
return f"Bearer {token}" return f"Bearer {token}"
+5 -3
View File
@@ -1,6 +1,6 @@
import uuid import uuid
from datetime import timedelta from datetime import timedelta
from typing import Any from typing import Any, override
from django.core.handlers.wsgi import WSGIRequest from django.core.handlers.wsgi import WSGIRequest
from django.test import RequestFactory, TestCase from django.test import RequestFactory, TestCase
@@ -17,7 +17,7 @@ from apps.users.auth.jwt import (
) )
from apps.users.models import User, UserRole from apps.users.models import User, UserRole
from ._helpers import _make_user from .helpers import make_user
class JWTCreateTest(TestCase): class JWTCreateTest(TestCase):
@@ -37,6 +37,7 @@ class JWTCreateTest(TestCase):
class JWTDecodeTest(TestCase): class JWTDecodeTest(TestCase):
@override
def setUp(self) -> None: def setUp(self) -> None:
self.uid: uuid.UUID = uuid.uuid4() self.uid: uuid.UUID = uuid.uuid4()
@@ -78,9 +79,10 @@ class JWTDecodeTest(TestCase):
class JWTBearerTest(TestCase): class JWTBearerTest(TestCase):
@override
def setUp(self) -> None: def setUp(self) -> None:
self.bearer = JWTBearer() self.bearer = JWTBearer()
self.user: User = _make_user( self.user: User = make_user(
username="bearer_user", username="bearer_user",
email="bearer@x.com", email="bearer@x.com",
role=UserRole.ADMIN, role=UserRole.ADMIN,
+8 -8
View File
@@ -4,7 +4,7 @@ from django.test import TestCase
from apps.users.models import User, UserRole from apps.users.models import User, UserRole
from ._helpers import _make_user from .helpers import make_user
class UserRoleChoicesTest(TestCase): class UserRoleChoicesTest(TestCase):
@@ -20,11 +20,11 @@ class UserRoleChoicesTest(TestCase):
class UserModelTest(TestCase): class UserModelTest(TestCase):
def test_default_role_is_viewer(self) -> None: def test_default_role_is_viewer(self) -> None:
user: User = _make_user() user: User = make_user()
self.assertEqual(user.role, UserRole.VIEWER) self.assertEqual(user.role, UserRole.VIEWER)
def test_role_properties(self) -> None: def test_role_properties(self) -> None:
admin: User = _make_user( admin: User = make_user(
username="a", email="a@x.com", role=UserRole.ADMIN username="a", email="a@x.com", role=UserRole.ADMIN
) )
self.assertTrue(admin.is_admin_role) self.assertTrue(admin.is_admin_role)
@@ -32,25 +32,25 @@ class UserModelTest(TestCase):
self.assertFalse(admin.is_approver) self.assertFalse(admin.is_approver)
self.assertFalse(admin.is_viewer) self.assertFalse(admin.is_viewer)
exp: User = _make_user( exp: User = make_user(
username="e", email="e@x.com", role=UserRole.EXPERIMENTER username="e", email="e@x.com", role=UserRole.EXPERIMENTER
) )
self.assertTrue(exp.is_experimenter) self.assertTrue(exp.is_experimenter)
appr: User = _make_user( appr: User = make_user(
username="ap", email="ap@x.com", role=UserRole.APPROVER username="ap", email="ap@x.com", role=UserRole.APPROVER
) )
self.assertTrue(appr.is_approver) self.assertTrue(appr.is_approver)
viewer: User = _make_user( viewer: User = make_user(
username="v", email="v@x.com", role=UserRole.VIEWER username="v", email="v@x.com", role=UserRole.VIEWER
) )
self.assertTrue(viewer.is_viewer) self.assertTrue(viewer.is_viewer)
def test_uuid_primary_key(self) -> None: def test_uuid_primary_key(self) -> None:
user: User = _make_user() user: User = make_user()
self.assertIsInstance(user.pk, uuid.UUID) self.assertIsInstance(user.pk, uuid.UUID)
def test_str_representation(self) -> None: def test_str_representation(self) -> None:
user: User = _make_user(username="hello") user: User = make_user(username="hello")
self.assertEqual(str(user), "hello") self.assertEqual(str(user), "hello")
@@ -1,3 +1,5 @@
from typing import override
from django.core.handlers.wsgi import WSGIRequest from django.core.handlers.wsgi import WSGIRequest
from django.test import RequestFactory, TestCase from django.test import RequestFactory, TestCase
@@ -5,15 +7,16 @@ from apps.users.auth.bearer import require_admin, require_roles
from apps.users.models import User, UserRole from apps.users.models import User, UserRole
from config.errors import ForbiddenError from config.errors import ForbiddenError
from ._helpers import _make_user from .helpers import make_user
class RequireRolesTest(TestCase): class RequireRolesTest(TestCase):
@override
def setUp(self) -> None: def setUp(self) -> None:
self.admin: User = _make_user( self.admin: User = make_user(
username="rr_admin", email="rr_admin@x.com", role=UserRole.ADMIN username="rr_admin", email="rr_admin@x.com", role=UserRole.ADMIN
) )
self.viewer: User = _make_user( self.viewer: User = make_user(
username="rr_viewer", email="rr_viewer@x.com", role=UserRole.VIEWER username="rr_viewer", email="rr_viewer@x.com", role=UserRole.VIEWER
) )
@@ -1,4 +1,5 @@
import uuid import uuid
from typing import override
from django.db.models import QuerySet from django.db.models import QuerySet
from django.test import TestCase from django.test import TestCase
@@ -18,27 +19,28 @@ from apps.users.selectors import (
user_list_viewers, user_list_viewers,
) )
from ._helpers import _make_user from .helpers import make_user
class UserSelectorsTest(TestCase): class UserSelectorsTest(TestCase):
@override
def setUp(self) -> None: def setUp(self) -> None:
self.admin: User = _make_user( self.admin: User = make_user(
username="sel_admin", username="sel_admin",
email="sel_admin@x.com", email="sel_admin@x.com",
role=UserRole.ADMIN, role=UserRole.ADMIN,
) )
self.exp: User = _make_user( self.exp: User = make_user(
username="sel_exp", username="sel_exp",
email="sel_exp@x.com", email="sel_exp@x.com",
role=UserRole.EXPERIMENTER, role=UserRole.EXPERIMENTER,
) )
self.appr: User = _make_user( self.appr: User = make_user(
username="sel_appr", username="sel_appr",
email="sel_appr@x.com", email="sel_appr@x.com",
role=UserRole.APPROVER, role=UserRole.APPROVER,
) )
self.viewer: User = _make_user( self.viewer: User = make_user(
username="sel_viewer", username="sel_viewer",
email="sel_viewer@x.com", email="sel_viewer@x.com",
role=UserRole.VIEWER, role=UserRole.VIEWER,
+10 -5
View File
@@ -1,3 +1,5 @@
from typing import override
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test import TestCase from django.test import TestCase
@@ -11,7 +13,7 @@ from apps.users.services import (
user_update, user_update,
) )
from ._helpers import _make_user from .helpers import make_user
class UserCreateServiceTest(TestCase): class UserCreateServiceTest(TestCase):
@@ -54,8 +56,9 @@ class UserCreateServiceTest(TestCase):
class UserUpdateServiceTest(TestCase): class UserUpdateServiceTest(TestCase):
@override
def setUp(self) -> None: def setUp(self) -> None:
self.user: User = _make_user() self.user: User = make_user()
def test_update_username(self) -> None: def test_update_username(self) -> None:
updated: User = user_update(user=self.user, username="newname") updated: User = user_update(user=self.user, username="newname")
@@ -91,8 +94,9 @@ class UserUpdateServiceTest(TestCase):
class UserAssignRoleServiceTest(TestCase): class UserAssignRoleServiceTest(TestCase):
@override
def setUp(self) -> None: def setUp(self) -> None:
self.user: User = _make_user() self.user: User = make_user()
def test_assign_valid_role(self) -> None: def test_assign_valid_role(self) -> None:
updated: User = user_assign_role( updated: User = user_assign_role(
@@ -107,15 +111,16 @@ class UserAssignRoleServiceTest(TestCase):
class UserDeleteServiceTest(TestCase): class UserDeleteServiceTest(TestCase):
def test_hard_delete(self) -> None: def test_hard_delete(self) -> None:
user: User = _make_user() user: User = make_user()
pk = user.pk pk = user.pk
user_delete(user=user) user_delete(user=user)
self.assertFalse(User.objects.filter(pk=pk).exists()) self.assertFalse(User.objects.filter(pk=pk).exists())
class UserActivateDeactivateServiceTest(TestCase): class UserActivateDeactivateServiceTest(TestCase):
@override
def setUp(self) -> None: def setUp(self) -> None:
self.user: User = _make_user() self.user: User = make_user()
def test_deactivate(self) -> None: def test_deactivate(self) -> None:
updated: User = user_deactivate(user=self.user) updated: User = user_deactivate(user=self.user)
+2 -2
View File
@@ -18,11 +18,11 @@ def build_error_payload(
payload = { payload = {
"code": code, "code": code,
"message": message, "message": message,
"traceId": str(trace_id), "trace_id": str(trace_id),
"timestamp": now(), "timestamp": now(),
"path": request.path, "path": request.path,
"details": details, "details": details,
} }
if field_errors is not None: if field_errors is not None:
payload["fieldErrors"] = field_errors payload["field_errors"] = field_errors
return payload return payload
+1 -10
View File
@@ -4,29 +4,21 @@ dependencies = [
"django-extensions>=4.1.0,<5.0.0", "django-extensions>=4.1.0,<5.0.0",
"django-stubs-ext>=5.1.3,<6.0.0", "django-stubs-ext>=5.1.3,<6.0.0",
"django-cors-headers>=4.7.0,<5.0.0", "django-cors-headers>=4.7.0,<5.0.0",
"django-ninja>=1.3.0,<2.0.0", "django-ninja>=1.3.0,<2.0.0",
"orjson>=3.10.15,<4.0.0", "orjson>=3.10.15,<4.0.0",
"pydantic>=2.10.5,<3.0.0", "pydantic>=2.10.5,<3.0.0",
"pyjwt>=2.10.1,<3.0.0", "pyjwt>=2.10.1,<3.0.0",
"django-redis>=6.0.0,<7.0.0", "django-redis>=6.0.0,<7.0.0",
"psycopg2-binary>=2.9.10,<3.0.0", "psycopg2-binary>=2.9.10,<3.0.0",
"redis>=6.2.0,<7.0.0", "redis>=6.2.0,<7.0.0",
"celery>=5.5.0,<6.0.0", "celery>=5.5.0,<6.0.0",
"django-storages[s3]>=1.14,<2.0", "django-storages[s3]>=1.14,<2.0",
"django-guid>=3.5.1,<4.0.0", "django-guid>=3.5.1,<4.0.0",
"django-health-check>=3.18.3,<4.0.0", "django-health-check>=3.18.3,<4.0.0",
"django-prometheus>=2.4.1,<3.0.0", "django-prometheus>=2.4.1,<3.0.0",
"django-silk[formatting]>=5.4.0,<6.0.0", "django-silk[formatting]>=5.4.0,<6.0.0",
"colorlog>=6.9.0,<7.0.0", "colorlog>=6.9.0,<7.0.0",
"python-json-logger>=3.2.1,<4.0.0", "python-json-logger>=3.2.1,<4.0.0",
"opentelemetry-api>=1.35.0", "opentelemetry-api>=1.35.0",
"opentelemetry-distro>=0.56b0", "opentelemetry-distro>=0.56b0",
"opentelemetry-exporter-otlp>=1.35.0", "opentelemetry-exporter-otlp>=1.35.0",
@@ -41,7 +33,6 @@ dependencies = [
"opentelemetry-instrumentation-urllib>=0.56b0", "opentelemetry-instrumentation-urllib>=0.56b0",
"opentelemetry-instrumentation-urllib3>=0.56b0", "opentelemetry-instrumentation-urllib3>=0.56b0",
"opentelemetry-instrumentation-wsgi>=0.56b0", "opentelemetry-instrumentation-wsgi>=0.56b0",
"gunicorn>=23.0.0,<24.0.0", "gunicorn>=23.0.0,<24.0.0",
"uvicorn[standard]>=0.34.0,<1.0.0", "uvicorn[standard]>=0.34.0,<1.0.0",
"uvicorn-worker>=0.2.0,<1.0.0", "uvicorn-worker>=0.2.0,<1.0.0",
@@ -191,7 +182,7 @@ quote-style = "double"
skip-magic-trailing-comma = false skip-magic-trailing-comma = false
[tool.mypy] [tool.mypy]
strict = true strict = false
strict_bytes = true strict_bytes = true
local_partial_types = true local_partial_types = true
warn_unreachable = true warn_unreachable = true
+44 -38
View File
@@ -378,15 +378,15 @@ wheels = [
[[package]] [[package]]
name = "django-health-check" name = "django-health-check"
version = "3.23.5" version = "3.24.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "django" }, { name = "django" },
{ name = "psutil" }, { name = "psutil" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/cd/90/1b7ee57c755ab551ffc1e50c0ddf5e4f3e57c8f127e444d0659c6a51bf81/django_health_check-3.23.5.tar.gz", hash = "sha256:b3be81841ae91aac11005e9ef594857339d4d7f317fd05243f6825755650c911", size = 20774, upload-time = "2026-02-06T14:19:51.069Z" } sdist = { url = "https://files.pythonhosted.org/packages/98/a6/f474f443b0a7f9e7e52a25a3773a31c5c061bdb28cf311b7801fc250db9b/django_health_check-3.24.0.tar.gz", hash = "sha256:b5e01d2013a254cc5a2c7b19c62f11ea6fd2624c87a9d990e11a1b3874df78b5", size = 20807, upload-time = "2026-02-12T10:02:18.482Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/b7/ab27628c23010e9f74c42a16ea47cdc43754fd9fbd9300ab49037f3e28e5/django_health_check-3.23.5-py3-none-any.whl", hash = "sha256:32bef7a2a4807137720cdf8d03713591251a43d3afc691d2b3461021b1035c76", size = 37856, upload-time = "2026-02-06T14:19:49.974Z" }, { url = "https://files.pythonhosted.org/packages/38/17/51aabb4908e007accf3c596b92a62ed7e456c581a1e45f06e0f2219dc6c0/django_health_check-3.24.0-py3-none-any.whl", hash = "sha256:bd568d7d3813980668dfbe730214aef5da7c8da73bc2aa60ec2eeca8a3788da6", size = 37964, upload-time = "2026-02-12T10:02:17.08Z" },
] ]
[[package]] [[package]]
@@ -639,43 +639,49 @@ wheels = [
[[package]] [[package]]
name = "librt" name = "librt"
version = "0.7.8" version = "0.8.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } sdist = { url = "https://files.pythonhosted.org/packages/8a/3f/4ca7dd7819bf8ff303aca39c3c60e5320e46e766ab7f7dd627d3b9c11bdf/librt-0.8.0.tar.gz", hash = "sha256:cb74cdcbc0103fc988e04e5c58b0b31e8e5dd2babb9182b6f9490488eb36324b", size = 177306, upload-time = "2026-02-12T14:53:54.743Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" }, { url = "https://files.pythonhosted.org/packages/6d/32/3edb0bcb4113a9c8bdcd1750663a54565d255027657a5df9d90f13ee07fa/librt-0.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7f820210e21e3a8bf8fde2ae3c3d10106d4de9ead28cbfdf6d0f0f41f5b12fa1", size = 66522, upload-time = "2026-02-12T14:52:48.219Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" }, { url = "https://files.pythonhosted.org/packages/30/ab/e8c3d05e281f5d405ebdcc5bc8ab36df23e1a4b40ac9da8c3eb9928b72b9/librt-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4831c44b8919e75ca0dfb52052897c1ef59fdae19d3589893fbd068f1e41afbf", size = 68658, upload-time = "2026-02-12T14:52:50.351Z" },
{ url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" }, { url = "https://files.pythonhosted.org/packages/7c/d3/74a206c47b7748bbc8c43942de3ed67de4c231156e148b4f9250869593df/librt-0.8.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:88c6e75540f1f10f5e0fc5e87b4b6c290f0e90d1db8c6734f670840494764af8", size = 199287, upload-time = "2026-02-12T14:52:51.938Z" },
{ url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" }, { url = "https://files.pythonhosted.org/packages/fa/29/ef98a9131cf12cb95771d24e4c411fda96c89dc78b09c2de4704877ebee4/librt-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9646178cd794704d722306c2c920c221abbf080fede3ba539d5afdec16c46dad", size = 210293, upload-time = "2026-02-12T14:52:53.128Z" },
{ url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" }, { url = "https://files.pythonhosted.org/packages/5b/3e/89b4968cb08c53d4c2d8b02517081dfe4b9e07a959ec143d333d76899f6c/librt-0.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e1af31a710e17891d9adf0dbd9a5fcd94901a3922a96499abdbf7ce658f4e01", size = 224801, upload-time = "2026-02-12T14:52:54.367Z" },
{ url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" }, { url = "https://files.pythonhosted.org/packages/6d/28/f38526d501f9513f8b48d78e6be4a241e15dd4b000056dc8b3f06ee9ce5d/librt-0.8.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:507e94f4bec00b2f590fbe55f48cd518a208e2474a3b90a60aa8f29136ddbada", size = 218090, upload-time = "2026-02-12T14:52:55.758Z" },
{ url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" }, { url = "https://files.pythonhosted.org/packages/02/ec/64e29887c5009c24dc9c397116c680caffc50286f62bd99c39e3875a2854/librt-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f1178e0de0c271231a660fbef9be6acdfa1d596803464706862bef6644cc1cae", size = 225483, upload-time = "2026-02-12T14:52:57.375Z" },
{ url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" }, { url = "https://files.pythonhosted.org/packages/ee/16/7850bdbc9f1a32d3feff2708d90c56fc0490b13f1012e438532781aa598c/librt-0.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:71fc517efc14f75c2f74b1f0a5d5eb4a8e06aa135c34d18eaf3522f4a53cd62d", size = 218226, upload-time = "2026-02-12T14:52:58.534Z" },
{ url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" }, { url = "https://files.pythonhosted.org/packages/1c/4a/166bffc992d65ddefa7c47052010a87c059b44a458ebaf8f5eba384b0533/librt-0.8.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0583aef7e9a720dd40f26a2ad5a1bf2ccbb90059dac2b32ac516df232c701db3", size = 218755, upload-time = "2026-02-12T14:52:59.701Z" },
{ url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" }, { url = "https://files.pythonhosted.org/packages/da/5d/9aeee038bcc72a9cfaaee934463fe9280a73c5440d36bd3175069d2cb97b/librt-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5d0f76fc73480d42285c609c0ea74d79856c160fa828ff9aceab574ea4ecfd7b", size = 241617, upload-time = "2026-02-12T14:53:00.966Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" }, { url = "https://files.pythonhosted.org/packages/64/ff/2bec6b0296b9d0402aa6ec8540aa19ebcb875d669c37800cb43d10d9c3a3/librt-0.8.0-cp313-cp313-win32.whl", hash = "sha256:e79dbc8f57de360f0ed987dc7de7be814b4803ef0e8fc6d3ff86e16798c99935", size = 54966, upload-time = "2026-02-12T14:53:02.042Z" },
{ url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" }, { url = "https://files.pythonhosted.org/packages/08/8d/bf44633b0182996b2c7ea69a03a5c529683fa1f6b8e45c03fe874ff40d56/librt-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:25b3e667cbfc9000c4740b282df599ebd91dbdcc1aa6785050e4c1d6be5329ab", size = 62000, upload-time = "2026-02-12T14:53:03.822Z" },
{ url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" }, { url = "https://files.pythonhosted.org/packages/5c/fd/c6472b8e0eac0925001f75e366cf5500bcb975357a65ef1f6b5749389d3a/librt-0.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:e9a3a38eb4134ad33122a6d575e6324831f930a771d951a15ce232e0237412c2", size = 52496, upload-time = "2026-02-12T14:53:04.889Z" },
{ url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" }, { url = "https://files.pythonhosted.org/packages/e0/13/79ebfe30cd273d7c0ce37a5f14dc489c5fb8b722a008983db2cfd57270bb/librt-0.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:421765e8c6b18e64d21c8ead315708a56fc24f44075059702e421d164575fdda", size = 66078, upload-time = "2026-02-12T14:53:06.085Z" },
{ url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" }, { url = "https://files.pythonhosted.org/packages/4b/8f/d11eca40b62a8d5e759239a80636386ef88adecb10d1a050b38cc0da9f9e/librt-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:48f84830a8f8ad7918afd743fd7c4eb558728bceab7b0e38fd5a5cf78206a556", size = 68309, upload-time = "2026-02-12T14:53:07.121Z" },
{ url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" }, { url = "https://files.pythonhosted.org/packages/9c/b4/f12ee70a3596db40ff3c88ec9eaa4e323f3b92f77505b4d900746706ec6a/librt-0.8.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9f09d4884f882baa39a7e36bbf3eae124c4ca2a223efb91e567381d1c55c6b06", size = 196804, upload-time = "2026-02-12T14:53:08.164Z" },
{ url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" }, { url = "https://files.pythonhosted.org/packages/8b/7e/70dbbdc0271fd626abe1671ad117bcd61a9a88cdc6a10ccfbfc703db1873/librt-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:693697133c3b32aa9b27f040e3691be210e9ac4d905061859a9ed519b1d5a376", size = 206915, upload-time = "2026-02-12T14:53:09.333Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" }, { url = "https://files.pythonhosted.org/packages/79/13/6b9e05a635d4327608d06b3c1702166e3b3e78315846373446cf90d7b0bf/librt-0.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5512aae4648152abaf4d48b59890503fcbe86e85abc12fb9b096fe948bdd816", size = 221200, upload-time = "2026-02-12T14:53:10.68Z" },
{ url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" }, { url = "https://files.pythonhosted.org/packages/35/6c/e19a3ac53e9414de43a73d7507d2d766cd22d8ca763d29a4e072d628db42/librt-0.8.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:995d24caa6bbb34bcdd4a41df98ac6d1af637cfa8975cb0790e47d6623e70e3e", size = 214640, upload-time = "2026-02-12T14:53:12.342Z" },
{ url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" }, { url = "https://files.pythonhosted.org/packages/30/f0/23a78464788619e8c70f090cfd099cce4973eed142c4dccb99fc322283fd/librt-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b9aef96d7593584e31ef6ac1eb9775355b0099fee7651fae3a15bc8657b67b52", size = 221980, upload-time = "2026-02-12T14:53:13.603Z" },
{ url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" }, { url = "https://files.pythonhosted.org/packages/03/32/38e21420c5d7aa8a8bd2c7a7d5252ab174a5a8aaec8b5551968979b747bf/librt-0.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4f6e975377fbc4c9567cb33ea9ab826031b6c7ec0515bfae66a4fb110d40d6da", size = 215146, upload-time = "2026-02-12T14:53:14.8Z" },
{ url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" }, { url = "https://files.pythonhosted.org/packages/bb/00/bd9ecf38b1824c25240b3ad982fb62c80f0a969e6679091ba2b3afb2b510/librt-0.8.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:daae5e955764be8fd70a93e9e5133c75297f8bce1e802e1d3683b98f77e1c5ab", size = 215203, upload-time = "2026-02-12T14:53:16.087Z" },
{ url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" }, { url = "https://files.pythonhosted.org/packages/b9/60/7559bcc5279d37810b98d4a52616febd7b8eef04391714fd6bdf629598b1/librt-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7bd68cebf3131bb920d5984f75fe302d758db33264e44b45ad139385662d7bc3", size = 237937, upload-time = "2026-02-12T14:53:17.236Z" },
{ url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" }, { url = "https://files.pythonhosted.org/packages/41/cc/be3e7da88f1abbe2642672af1dc00a0bccece11ca60241b1883f3018d8d5/librt-0.8.0-cp314-cp314-win32.whl", hash = "sha256:1e6811cac1dcb27ca4c74e0ca4a5917a8e06db0d8408d30daee3a41724bfde7a", size = 50685, upload-time = "2026-02-12T14:53:18.888Z" },
{ url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" }, { url = "https://files.pythonhosted.org/packages/38/27/e381d0df182a8f61ef1f6025d8b138b3318cc9d18ad4d5f47c3bf7492523/librt-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:178707cda89d910c3b28bf5aa5f69d3d4734e0f6ae102f753ad79edef83a83c7", size = 57872, upload-time = "2026-02-12T14:53:19.942Z" },
{ url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" }, { url = "https://files.pythonhosted.org/packages/c5/0c/ca9dfdf00554a44dea7d555001248269a4bab569e1590a91391feb863fa4/librt-0.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3e8b77b5f54d0937b26512774916041756c9eb3e66f1031971e626eea49d0bf4", size = 48056, upload-time = "2026-02-12T14:53:21.473Z" },
{ url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" }, { url = "https://files.pythonhosted.org/packages/f2/ed/6cc9c4ad24f90c8e782193c7b4a857408fd49540800613d1356c63567d7b/librt-0.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:789911e8fa40a2e82f41120c936b1965f3213c67f5a483fc5a41f5839a05dcbb", size = 68307, upload-time = "2026-02-12T14:53:22.498Z" },
{ url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" }, { url = "https://files.pythonhosted.org/packages/84/d8/0e94292c6b3e00b6eeea39dd44d5703d1ec29b6dafce7eea19dc8f1aedbd/librt-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2b37437e7e4ef5e15a297b36ba9e577f73e29564131d86dd75875705e97402b5", size = 70999, upload-time = "2026-02-12T14:53:23.603Z" },
{ url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" }, { url = "https://files.pythonhosted.org/packages/0e/f4/6be1afcbdeedbdbbf54a7c9d73ad43e1bf36897cebf3978308cd64922e02/librt-0.8.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:671a6152edf3b924d98a5ed5e6982ec9cb30894085482acadce0975f031d4c5c", size = 220782, upload-time = "2026-02-12T14:53:25.133Z" },
{ url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" }, { url = "https://files.pythonhosted.org/packages/f0/8d/f306e8caa93cfaf5c6c9e0d940908d75dc6af4fd856baa5535c922ee02b1/librt-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8992ca186a1678107b0af3d0c9303d8c7305981b9914989b9788319ed4d89546", size = 235420, upload-time = "2026-02-12T14:53:27.047Z" },
{ url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" }, { url = "https://files.pythonhosted.org/packages/d6/f2/65d86bd462e9c351326564ca805e8457442149f348496e25ccd94583ffa2/librt-0.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:001e5330093d887b8b9165823eca6c5c4db183fe4edea4fdc0680bbac5f46944", size = 246452, upload-time = "2026-02-12T14:53:28.341Z" },
{ url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" }, { url = "https://files.pythonhosted.org/packages/03/94/39c88b503b4cb3fcbdeb3caa29672b6b44ebee8dcc8a54d49839ac280f3f/librt-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d920789eca7ef71df7f31fd547ec0d3002e04d77f30ba6881e08a630e7b2c30e", size = 238891, upload-time = "2026-02-12T14:53:29.625Z" },
{ url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, { url = "https://files.pythonhosted.org/packages/e3/c6/6c0d68190893d01b71b9569b07a1c811e280c0065a791249921c83dc0290/librt-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:82fb4602d1b3e303a58bfe6165992b5a78d823ec646445356c332cd5f5bbaa61", size = 250249, upload-time = "2026-02-12T14:53:30.93Z" },
{ url = "https://files.pythonhosted.org/packages/52/7a/f715ed9e039035d0ea637579c3c0155ab3709a7046bc408c0fb05d337121/librt-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4d3e38797eb482485b486898f89415a6ab163bc291476bd95712e42cf4383c05", size = 240642, upload-time = "2026-02-12T14:53:32.174Z" },
{ url = "https://files.pythonhosted.org/packages/c2/3c/609000a333debf5992efe087edc6467c1fdbdddca5b610355569bbea9589/librt-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a905091a13e0884701226860836d0386b88c72ce5c2fdfba6618e14c72be9f25", size = 239621, upload-time = "2026-02-12T14:53:33.39Z" },
{ url = "https://files.pythonhosted.org/packages/b9/df/87b0673d5c395a8f34f38569c116c93142d4dc7e04af2510620772d6bd4f/librt-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:375eda7acfce1f15f5ed56cfc960669eefa1ec8732e3e9087c3c4c3f2066759c", size = 262986, upload-time = "2026-02-12T14:53:34.617Z" },
{ url = "https://files.pythonhosted.org/packages/09/7f/6bbbe9dcda649684773aaea78b87fff4d7e59550fbc2877faa83612087a3/librt-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:2ccdd20d9a72c562ffb73098ac411de351b53a6fbb3390903b2d33078ef90447", size = 51328, upload-time = "2026-02-12T14:53:36.15Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f3/e1981ab6fa9b41be0396648b5850267888a752d025313a9e929c4856208e/librt-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:25e82d920d4d62ad741592fcf8d0f3bda0e3fc388a184cb7d2f566c681c5f7b9", size = 58719, upload-time = "2026-02-12T14:53:37.183Z" },
{ url = "https://files.pythonhosted.org/packages/94/d1/433b3c06e78f23486fe4fdd19bc134657eb30997d2054b0dbf52bbf3382e/librt-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:92249938ab744a5890580d3cb2b22042f0dce71cdaa7c1369823df62bedf7cbc", size = 48753, upload-time = "2026-02-12T14:53:38.539Z" },
] ]
[[package]] [[package]]