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

also provided tests
This commit is contained in:
ITQ
2026-02-12 20:48:29 +03:00
parent cb9692089f
commit 613c99dce2
60 changed files with 5101 additions and 127 deletions
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
name = "api.v1.users"
label = "api_v1_users"
+192
View File
@@ -0,0 +1,192 @@
from http import HTTPStatus
from django.core.exceptions import ValidationError
from django.http import HttpRequest
from django.shortcuts import get_object_or_404
from ninja import Router
from api.v1.users.schemas import (
UserCreateIn,
UserListOut,
UserOut,
UserRoleAssignIn,
UserUpdateIn,
)
from apps.users.auth.bearer import jwt_bearer, require_admin
from apps.users.models import User, UserRole
from apps.users.selectors import user_list
from apps.users.services import (
user_assign_role,
user_create,
user_delete,
user_update,
)
router = Router(tags=["users"])
@router.get(
"",
response={HTTPStatus.OK: UserListOut},
auth=jwt_bearer,
summary="List users",
description=(
"Return a filtered, paginated list of platform users. Admin only."
),
)
@require_admin
def list_users(
request: HttpRequest,
role: str | None = None,
is_active: bool | None = None,
search: str | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[int, UserListOut]:
qs = user_list(role=role, is_active=is_active, search=search)
total = qs.count()
items = [
UserOut.model_validate(item) for item in qs[offset : offset + limit]
]
return HTTPStatus.OK, UserListOut(count=total, items=items)
@router.post(
"",
response={201: UserOut},
auth=jwt_bearer,
summary="Create user",
description=(
"Create a new platform user with the specified role. Admin only."
),
)
@require_admin
def create_user(
request: HttpRequest,
payload: UserCreateIn,
) -> tuple[int, UserOut]:
valid_roles = {choice[0] for choice in UserRole.choices}
if payload.role not in valid_roles:
raise ValidationError(
{
"role": (
f"Invalid role '{payload.role}'. "
f"Must be one of: {', '.join(sorted(valid_roles))}"
)
}
)
user = user_create(
username=payload.username,
email=payload.email,
password=payload.password,
role=payload.role,
first_name=payload.first_name,
last_name=payload.last_name,
)
return HTTPStatus.CREATED, UserOut.model_validate(user)
@router.get(
"/{user_id}",
response={HTTPStatus.OK: UserOut},
auth=jwt_bearer,
summary="Get user",
description="Retrieve a single user by their UUID. Admin only.",
)
@require_admin
def get_user(
request: HttpRequest,
user_id: str,
) -> tuple[int, UserOut]:
user = get_object_or_404(User, pk=user_id)
return HTTPStatus.OK, UserOut.model_validate(user)
@router.patch(
"/{user_id}",
response={HTTPStatus.OK: UserOut},
auth=jwt_bearer,
summary="Update user",
description=(
"Partially update an existing user. "
"Only non-null fields in the payload are applied. Admin only."
),
)
@require_admin
def update_user(
request: HttpRequest,
user_id: str,
payload: UserUpdateIn,
) -> tuple[int, UserOut]:
user = get_object_or_404(User, pk=user_id)
if payload.role is not None:
valid_roles = {choice[0] for choice in UserRole.choices}
if payload.role not in valid_roles:
raise ValidationError(
{
"role": (
f"Invalid role '{payload.role}'. "
f"Must be one of: {', '.join(sorted(valid_roles))}"
)
}
)
updated_user = user_update(
user=user,
username=payload.username,
email=payload.email,
password=payload.password,
role=payload.role,
is_active=payload.is_active,
first_name=payload.first_name,
last_name=payload.last_name,
)
return HTTPStatus.OK, UserOut.model_validate(updated_user)
@router.delete(
"/{user_id}",
response={204: None},
auth=jwt_bearer,
summary="Delete user",
description="Permanently delete a user. Admin only.",
)
@require_admin
def delete_user(
request: HttpRequest,
user_id: str,
) -> tuple[int, None]:
user = get_object_or_404(User, pk=user_id)
current_user = getattr(request, "auth", None)
if not isinstance(current_user, User):
raise ValidationError({"user": "Authentication required."})
if str(user.pk) == str(current_user.pk):
raise ValidationError({"user": "You cannot delete your own account."})
user_delete(user=user)
return HTTPStatus.NO_CONTENT, None
@router.post(
"/{user_id}/role",
response={HTTPStatus.OK: UserOut},
auth=jwt_bearer,
summary="Assign role",
description="Change the platform role of an existing user. Admin only.",
)
@require_admin
def assign_role(
request: HttpRequest,
user_id: str,
payload: UserRoleAssignIn,
) -> tuple[int, UserOut]:
user = get_object_or_404(User, pk=user_id)
updated_user = user_assign_role(user=user, role=payload.role)
return HTTPStatus.OK, UserOut.model_validate(updated_user)
+123
View File
@@ -0,0 +1,123 @@
from typing import ClassVar
from ninja import ModelSchema, Schema
from pydantic import Field
from apps.users.models import User
class UserOut(ModelSchema):
first_name: str = Field("", alias="firstName")
last_name: str = Field("", alias="lastName")
is_active: bool
class Meta:
model = User
fields: ClassVar[tuple[str, ...]] = (
User.id.field.name,
User.username.field.name,
User.email.field.name,
User.role.field.name,
User.first_name.field.name,
User.last_name.field.name,
User._meta.get_field("is_active").name,
)
class UserCreateIn(Schema):
username: str = Field(
...,
min_length=1,
max_length=150,
description="Unique username for the new account.",
)
email: str = Field(
...,
min_length=1,
max_length=254,
description="Email address.",
)
password: str = Field(
...,
min_length=8,
max_length=128,
description="Account password (min 8 characters).",
)
role: str = Field(
"viewer",
description=(
"Platform role to assign. "
"One of: admin, experimenter, approver, viewer."
),
)
first_name: str = Field(
"",
alias="firstName",
max_length=150,
description="First name.",
)
last_name: str = Field(
"",
alias="lastName",
max_length=150,
description="Last name.",
)
class UserUpdateIn(Schema):
username: str | None = Field(
None,
min_length=1,
max_length=150,
description="New username.",
)
email: str | None = Field(
None,
min_length=1,
max_length=254,
description="New email address.",
)
password: str | None = Field(
None,
min_length=8,
max_length=128,
description="New password (min 8 characters).",
)
role: str | None = Field(
None,
description=(
"New platform role. One of: admin, experimenter, approver, viewer."
),
)
first_name: str | None = Field(
None,
alias="firstName",
max_length=150,
description="New first name.",
)
last_name: str | None = Field(
None,
alias="lastName",
max_length=150,
description="New last name.",
)
is_active: bool | None = Field(
None,
alias="isActive",
description="Set active/inactive status.",
)
class UserRoleAssignIn(Schema):
role: str = Field(
...,
description=(
"Platform role to assign. "
"One of: admin, experimenter, approver, viewer."
),
)
class UserListOut(Schema):
count: int
items: list[UserOut]
@@ -0,0 +1,23 @@
from django.test import Client, TestCase
from apps.users.models import User, UserRole
from apps.users.tests._helpers import _auth_header, _make_user
class BaseUsersAPITest(TestCase):
def setUp(self) -> None:
self.client = Client()
self.admin: User = _make_user(
username="mgmt_admin",
email="mgmt_admin@x.com",
password="adminpass1",
role=UserRole.ADMIN,
)
self.viewer: User = _make_user(
username="mgmt_viewer",
email="mgmt_viewer@x.com",
password="viewerpass",
role=UserRole.VIEWER,
)
self.admin_auth: str = _auth_header(self.admin)
self.viewer_auth: str = _auth_header(self.viewer)
@@ -0,0 +1,96 @@
import json
import uuid
from django.urls import reverse
from apps.users.models import User, UserRole
from apps.users.tests._helpers import _make_user
from ._crud_base import BaseUsersAPITest
class UsersAPIDeleteAssignRoleTest(BaseUsersAPITest):
def test_delete_user_admin(self) -> None:
target: User = _make_user(
username="to_delete",
email="del@lotty.local",
role=UserRole.VIEWER,
)
resp = self.client.delete(
reverse("api-1:delete_user", kwargs={"user_id": str(target.pk)}),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 204)
self.assertFalse(User.objects.filter(pk=target.pk).exists())
def test_delete_self_denied(self) -> None:
resp = self.client.delete(
reverse(
"api-1:delete_user", kwargs={"user_id": str(self.admin.pk)}
),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertIn(resp.status_code, [422, 400])
def test_delete_user_viewer_denied(self) -> None:
resp = self.client.delete(
reverse(
"api-1:delete_user", kwargs={"user_id": str(self.admin.pk)}
),
HTTP_AUTHORIZATION=self.viewer_auth,
)
self.assertEqual(resp.status_code, 403)
def test_delete_user_not_found(self) -> None:
resp = self.client.delete(
reverse(
"api-1:delete_user", kwargs={"user_id": str(uuid.uuid4())}
),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 404)
def test_assign_role_admin(self) -> None:
resp = self.client.post(
reverse(
"api-1:assign_role", kwargs={"user_id": str(self.viewer.pk)}
),
data=json.dumps({"role": "experimenter"}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()["role"], "experimenter")
def test_assign_role_invalid(self) -> None:
resp = self.client.post(
reverse(
"api-1:assign_role", kwargs={"user_id": str(self.viewer.pk)}
),
data=json.dumps({"role": "megaboss"}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertIn(resp.status_code, [422, 400])
def test_assign_role_viewer_denied(self) -> None:
resp = self.client.post(
reverse(
"api-1:assign_role", kwargs={"user_id": str(self.admin.pk)}
),
data=json.dumps({"role": "viewer"}),
content_type="application/json",
HTTP_AUTHORIZATION=self.viewer_auth,
)
self.assertEqual(resp.status_code, 403)
def test_assign_role_not_found(self) -> None:
resp = self.client.post(
reverse(
"api-1:assign_role", kwargs={"user_id": str(uuid.uuid4())}
),
data=json.dumps({"role": "admin"}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 404)
@@ -0,0 +1,125 @@
import json
from django.urls import reverse
from ._crud_base import BaseUsersAPITest
class UsersAPIListCreateTest(BaseUsersAPITest):
def test_list_users_admin(self) -> None:
resp = self.client.get(
reverse("api-1:list_users"),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertIn("count", data)
self.assertIn("items", data)
self.assertEqual(data["count"], 2)
def test_list_users_viewer_denied(self) -> None:
resp = self.client.get(
reverse("api-1:list_users"),
HTTP_AUTHORIZATION=self.viewer_auth,
)
self.assertEqual(resp.status_code, 403)
def test_list_users_unauthenticated(self) -> None:
resp = self.client.get(reverse("api-1:list_users"))
self.assertEqual(resp.status_code, 401)
def test_list_users_filter_by_role(self) -> None:
resp = self.client.get(
reverse("api-1:list_users"),
data={"role": "admin"},
HTTP_AUTHORIZATION=self.admin_auth,
)
data = resp.json()
self.assertEqual(data["count"], 1)
self.assertEqual(data["items"][0]["role"], "admin")
def test_list_users_search(self) -> None:
resp = self.client.get(
reverse("api-1:list_users"),
data={"search": "mgmt_viewer"},
HTTP_AUTHORIZATION=self.admin_auth,
)
data = resp.json()
self.assertEqual(data["count"], 1)
def test_list_users_pagination(self) -> None:
resp = self.client.get(
reverse("api-1:list_users"),
data={"limit": 1, "offset": 0},
HTTP_AUTHORIZATION=self.admin_auth,
)
data = resp.json()
self.assertEqual(len(data["items"]), 1)
self.assertEqual(data["count"], 2)
def test_create_user_admin(self) -> None:
resp = self.client.post(
reverse("api-1:create_user"),
data=json.dumps(
{
"username": "newuser",
"email": "new@lotty.local",
"password": "newpass123",
"role": "experimenter",
"firstName": "New",
"lastName": "User",
}
),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 201)
data = resp.json()
self.assertEqual(data["username"], "newuser")
self.assertEqual(data["role"], "experimenter")
def test_create_user_invalid_role(self) -> None:
resp = self.client.post(
reverse("api-1:create_user"),
data=json.dumps(
{
"username": "bad_role",
"email": "bad@lotty.local",
"password": "password1",
"role": "superadmin",
}
),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertIn(resp.status_code, [422, 400])
def test_create_user_viewer_denied(self) -> None:
resp = self.client.post(
reverse("api-1:create_user"),
data=json.dumps(
{
"username": "denied",
"email": "denied@lotty.local",
"password": "password1",
}
),
content_type="application/json",
HTTP_AUTHORIZATION=self.viewer_auth,
)
self.assertEqual(resp.status_code, 403)
def test_create_user_duplicate_username(self) -> None:
resp = self.client.post(
reverse("api-1:create_user"),
data=json.dumps(
{
"username": "mgmt_admin",
"email": "other@lotty.local",
"password": "password1",
}
),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertIn(resp.status_code, [409, 422, 400, 500])
@@ -0,0 +1,93 @@
import json
import uuid
from django.urls import reverse
from ._crud_base import BaseUsersAPITest
class UsersAPIReadUpdateTest(BaseUsersAPITest):
def test_get_user_admin(self) -> None:
resp = self.client.get(
reverse("api-1:get_user", kwargs={"user_id": str(self.viewer.pk)}),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()["username"], "mgmt_viewer")
def test_get_user_not_found(self) -> None:
resp = self.client.get(
reverse("api-1:get_user", kwargs={"user_id": str(uuid.uuid4())}),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 404)
def test_get_user_viewer_denied(self) -> None:
resp = self.client.get(
reverse("api-1:get_user", kwargs={"user_id": str(self.admin.pk)}),
HTTP_AUTHORIZATION=self.viewer_auth,
)
self.assertEqual(resp.status_code, 403)
def test_update_user_admin(self) -> None:
resp = self.client.patch(
reverse(
"api-1:update_user", kwargs={"user_id": str(self.viewer.pk)}
),
data=json.dumps(
{"username": "renamed_viewer", "role": "approver"}
),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data["username"], "renamed_viewer")
self.assertEqual(data["role"], "approver")
def test_update_user_partial(self) -> None:
original_role = self.viewer.role
resp = self.client.patch(
reverse(
"api-1:update_user", kwargs={"user_id": str(self.viewer.pk)}
),
data=json.dumps({"firstName": "Updated"}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data["role"], original_role)
def test_update_user_invalid_role(self) -> None:
resp = self.client.patch(
reverse(
"api-1:update_user", kwargs={"user_id": str(self.viewer.pk)}
),
data=json.dumps({"role": "superadmin"}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertIn(resp.status_code, [422, 400])
def test_update_user_viewer_denied(self) -> None:
resp = self.client.patch(
reverse(
"api-1:update_user", kwargs={"user_id": str(self.admin.pk)}
),
data=json.dumps({"firstName": "Hacked"}),
content_type="application/json",
HTTP_AUTHORIZATION=self.viewer_auth,
)
self.assertEqual(resp.status_code, 403)
def test_update_user_not_found(self) -> None:
resp = self.client.patch(
reverse(
"api-1:update_user", kwargs={"user_id": str(uuid.uuid4())}
),
data=json.dumps({"firstName": "Ghost"}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 404)
@@ -0,0 +1,100 @@
import json
from django.test import Client, TestCase
from django.urls import reverse
from apps.users.models import User, UserRole
from apps.users.tests._helpers import _auth_header, _make_user
class RoleBasedAccessControlTest(TestCase):
def setUp(self) -> None:
self.client = Client()
self.roles = {}
for role_val in [
UserRole.ADMIN,
UserRole.EXPERIMENTER,
UserRole.APPROVER,
UserRole.VIEWER,
]:
user: User = _make_user(
username=f"rbac_{role_val}",
email=f"rbac_{role_val}@x.com",
password="password1",
role=role_val,
)
self.roles[role_val] = {
"user": user,
"auth": _auth_header(user),
}
def test_admin_can_list(self) -> None:
resp = self.client.get(
reverse("api-1:list_users"),
HTTP_AUTHORIZATION=self.roles[UserRole.ADMIN]["auth"],
)
self.assertEqual(resp.status_code, 200)
def test_experimenter_cannot_list(self) -> None:
resp = self.client.get(
reverse("api-1:list_users"),
HTTP_AUTHORIZATION=self.roles[UserRole.EXPERIMENTER]["auth"],
)
self.assertEqual(resp.status_code, 403)
def test_approver_cannot_list(self) -> None:
resp = self.client.get(
reverse("api-1:list_users"),
HTTP_AUTHORIZATION=self.roles[UserRole.APPROVER]["auth"],
)
self.assertEqual(resp.status_code, 403)
def test_viewer_cannot_list(self) -> None:
resp = self.client.get(
reverse("api-1:list_users"),
HTTP_AUTHORIZATION=self.roles[UserRole.VIEWER]["auth"],
)
self.assertEqual(resp.status_code, 403)
def test_experimenter_cannot_create(self) -> None:
resp = self.client.post(
reverse("api-1:create_user"),
data=json.dumps(
{
"username": "blocked",
"email": "blocked@x.com",
"password": "password1",
}
),
content_type="application/json",
HTTP_AUTHORIZATION=self.roles[UserRole.EXPERIMENTER]["auth"],
)
self.assertEqual(resp.status_code, 403)
def test_approver_cannot_delete(self) -> None:
target = self.roles[UserRole.VIEWER]["user"]
resp = self.client.delete(
reverse("api-1:delete_user", kwargs={"user_id": str(target.pk)}),
HTTP_AUTHORIZATION=self.roles[UserRole.APPROVER]["auth"],
)
self.assertEqual(resp.status_code, 403)
def test_viewer_cannot_assign_role(self) -> None:
target = self.roles[UserRole.EXPERIMENTER]["user"]
resp = self.client.post(
reverse("api-1:assign_role", kwargs={"user_id": str(target.pk)}),
data=json.dumps({"role": "admin"}),
content_type="application/json",
HTTP_AUTHORIZATION=self.roles[UserRole.VIEWER]["auth"],
)
self.assertEqual(resp.status_code, 403)
def test_experimenter_cannot_update(self) -> None:
target = self.roles[UserRole.VIEWER]["user"]
resp = self.client.patch(
reverse("api-1:update_user", kwargs={"user_id": str(target.pk)}),
data=json.dumps({"firstName": "Nope"}),
content_type="application/json",
HTTP_AUTHORIZATION=self.roles[UserRole.EXPERIMENTER]["auth"],
)
self.assertEqual(resp.status_code, 403)