core-package 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. apps/__init__.py +1 -0
  2. apps/contacts/__init__.py +0 -0
  3. apps/contacts/admin.py +18 -0
  4. apps/contacts/api.py +86 -0
  5. apps/contacts/apps.py +8 -0
  6. apps/contacts/migrations/0001_initial.py +48 -0
  7. apps/contacts/migrations/__init__.py +0 -0
  8. apps/contacts/models.py +40 -0
  9. apps/contacts/schemas.py +43 -0
  10. apps/contacts/services.py +72 -0
  11. apps/contacts/tests/__init__.py +0 -0
  12. apps/contacts/tests/test_contacts_api.py +100 -0
  13. apps/core/__init__.py +0 -0
  14. apps/core/apps.py +8 -0
  15. apps/core/auth.py +86 -0
  16. apps/core/exceptions.py +80 -0
  17. apps/core/logging.py +101 -0
  18. apps/core/management/__init__.py +0 -0
  19. apps/core/management/commands/__init__.py +0 -0
  20. apps/core/management/commands/seed_roles.py +18 -0
  21. apps/core/middleware.py +138 -0
  22. apps/core/models.py +85 -0
  23. apps/core/pagination.py +21 -0
  24. apps/core/permissions.py +49 -0
  25. apps/core/schemas.py +25 -0
  26. apps/core/sms.py +48 -0
  27. apps/core/tests/__init__.py +0 -0
  28. apps/core/tests/test_health.py +18 -0
  29. apps/core/tests/test_translatable.py +42 -0
  30. apps/core/throttling.py +25 -0
  31. apps/core/utils.py +5 -0
  32. apps/events/__init__.py +0 -0
  33. apps/events/admin.py +18 -0
  34. apps/events/api.py +28 -0
  35. apps/events/apps.py +8 -0
  36. apps/events/migrations/0001_initial.py +33 -0
  37. apps/events/migrations/__init__.py +0 -0
  38. apps/events/models.py +34 -0
  39. apps/events/schemas.py +12 -0
  40. apps/events/services.py +26 -0
  41. apps/events/tests/__init__.py +0 -0
  42. apps/events/tests/test_events_api.py +88 -0
  43. apps/realtime/__init__.py +0 -0
  44. apps/realtime/apps.py +8 -0
  45. apps/realtime/auth.py +48 -0
  46. apps/realtime/server.py +62 -0
  47. apps/realtime/tests/__init__.py +0 -0
  48. apps/roles/__init__.py +0 -0
  49. apps/roles/admin.py +18 -0
  50. apps/roles/api.py +85 -0
  51. apps/roles/apps.py +8 -0
  52. apps/roles/migrations/0001_initial.py +32 -0
  53. apps/roles/migrations/0002_alter_role_table.py +17 -0
  54. apps/roles/migrations/__init__.py +0 -0
  55. apps/roles/models.py +30 -0
  56. apps/roles/schemas.py +44 -0
  57. apps/roles/services.py +94 -0
  58. apps/roles/tests/__init__.py +0 -0
  59. apps/roles/tests/test_roles_api.py +111 -0
  60. apps/users/__init__.py +0 -0
  61. apps/users/admin.py +77 -0
  62. apps/users/api.py +119 -0
  63. apps/users/apps.py +8 -0
  64. apps/users/managers.py +47 -0
  65. apps/users/migrations/0001_initial.py +45 -0
  66. apps/users/migrations/0002_alter_user_options_alter_user_table.py +21 -0
  67. apps/users/migrations/0003_user_avatar.py +18 -0
  68. apps/users/migrations/0004_user_phone_alter_user_email_otpcode.py +41 -0
  69. apps/users/migrations/0005_otpcode_purpose.py +26 -0
  70. apps/users/migrations/__init__.py +0 -0
  71. apps/users/models.py +95 -0
  72. apps/users/schemas.py +165 -0
  73. apps/users/services.py +241 -0
  74. apps/users/tests/__init__.py +0 -0
  75. apps/users/tests/test_auth_api.py +130 -0
  76. apps/users/tests/test_otp_api.py +103 -0
  77. apps/users/tests/test_password_otp_api.py +162 -0
  78. apps/users/tests/test_profile_api.py +53 -0
  79. core_package-0.1.0.dist-info/METADATA +50 -0
  80. core_package-0.1.0.dist-info/RECORD +83 -0
  81. core_package-0.1.0.dist-info/WHEEL +5 -0
  82. core_package-0.1.0.dist-info/licenses/LICENSE +21 -0
  83. core_package-0.1.0.dist-info/top_level.txt +1 -0
apps/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
File without changes
apps/contacts/admin.py ADDED
@@ -0,0 +1,18 @@
1
+ from django.contrib import admin
2
+ from unfold.admin import ModelAdmin
3
+
4
+ from apps.contacts.models import Contact, ContactForm
5
+
6
+
7
+ @admin.register(Contact)
8
+ class ContactAdmin(ModelAdmin):
9
+ list_display = ("name", "email", "phone", "created_at")
10
+ search_fields = ("name", "email", "phone", "address")
11
+ readonly_fields = ("id", "created_at", "updated_at")
12
+
13
+
14
+ @admin.register(ContactForm)
15
+ class ContactFormAdmin(ModelAdmin):
16
+ list_display = ("name", "email", "phone", "site", "created_at")
17
+ search_fields = ("name", "email", "phone", "site", "text")
18
+ readonly_fields = ("id", "created_at", "updated_at")
apps/contacts/api.py ADDED
@@ -0,0 +1,86 @@
1
+ import uuid
2
+
3
+ from ninja import Router, Status
4
+
5
+ from apps.contacts import services
6
+ from apps.contacts.schemas import ContactFormIn, ContactFormOut, ContactIn, ContactOut, ContactUpdateIn
7
+ from apps.contacts.services import contact_form_queryset, contact_queryset
8
+ from apps.core.pagination import PageParams, paginate_queryset
9
+ from apps.core.permissions import require_role
10
+ from apps.core.schemas import PagedResponse
11
+ from apps.core.throttling import check_rate_limit
12
+ from apps.core.utils import get_client_ip
13
+
14
+ router = Router(tags=["contacts"])
15
+
16
+ # NOTE: literal-path routes (/forms, /forms/{form_id}) are registered before
17
+ # the "/{contact_id}" wildcard routes below. See AGENT.md §9 point 5: a
18
+ # wildcard route registered first would swallow "/forms" and return 405.
19
+
20
+
21
+ @router.post("/forms", response={201: ContactFormOut}, auth=None)
22
+ async def submit_contact_form(request, payload: ContactFormIn):
23
+ await check_rate_limit(f"contact_form:{get_client_ip(request)}", limit=5, window_seconds=60)
24
+ form = await services.submit_contact_form(
25
+ name=payload.name, email=payload.email, phone=payload.phone, site=payload.site, text=payload.text
26
+ )
27
+ return Status(201, form)
28
+
29
+
30
+ @router.get("/forms", response=PagedResponse[ContactFormOut])
31
+ @require_role("admin")
32
+ async def list_contact_forms(request, page: PageParams = PageParams()): # noqa: B008 (ninja query-schema idiom)
33
+ items, meta = await paginate_queryset(contact_form_queryset(), page)
34
+ return {"items": items, "meta": meta}
35
+
36
+
37
+ @router.get("/forms/{form_id}", response=ContactFormOut)
38
+ @require_role("admin")
39
+ async def get_contact_form(request, form_id: uuid.UUID):
40
+ return await services.get_contact_form_or_404(form_id)
41
+
42
+
43
+ @router.delete("/forms/{form_id}", response={204: None})
44
+ @require_role("admin")
45
+ async def delete_contact_form(request, form_id: uuid.UUID):
46
+ await services.delete_contact_form(request.auth, form_id)
47
+ return Status(204, None)
48
+
49
+
50
+ @router.get("/", response=PagedResponse[ContactOut])
51
+ @require_role("admin")
52
+ async def list_contacts(request, page: PageParams = PageParams()): # noqa: B008 (ninja query-schema idiom)
53
+ items, meta = await paginate_queryset(contact_queryset(), page)
54
+ return {"items": items, "meta": meta}
55
+
56
+
57
+ @router.post("/", response={201: ContactOut})
58
+ @require_role("admin")
59
+ async def create_contact(request, payload: ContactIn):
60
+ contact = await services.create_contact(
61
+ actor=request.auth,
62
+ name=payload.name,
63
+ email=payload.email,
64
+ phone=payload.phone,
65
+ address=payload.address,
66
+ )
67
+ return Status(201, contact)
68
+
69
+
70
+ @router.get("/{contact_id}", response=ContactOut)
71
+ @require_role("admin")
72
+ async def get_contact(request, contact_id: uuid.UUID):
73
+ return await services.get_contact_or_404(contact_id)
74
+
75
+
76
+ @router.patch("/{contact_id}", response=ContactOut)
77
+ @require_role("admin")
78
+ async def update_contact(request, contact_id: uuid.UUID, payload: ContactUpdateIn):
79
+ return await services.update_contact(request.auth, contact_id, **payload.model_dump())
80
+
81
+
82
+ @router.delete("/{contact_id}", response={204: None})
83
+ @require_role("admin")
84
+ async def delete_contact(request, contact_id: uuid.UUID):
85
+ await services.delete_contact(request.auth, contact_id)
86
+ return Status(204, None)
apps/contacts/apps.py ADDED
@@ -0,0 +1,8 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class ContactsConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "apps.contacts"
7
+ label = "contacts"
8
+ verbose_name = "Contacts"
@@ -0,0 +1,48 @@
1
+ # Generated by Django 5.2.17 on 2026-09-16 09:44
2
+
3
+ import uuid
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ initial = True
10
+
11
+ dependencies = [
12
+ ]
13
+
14
+ operations = [
15
+ migrations.CreateModel(
16
+ name='Contact',
17
+ fields=[
18
+ ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
19
+ ('updated_at', models.DateTimeField(auto_now=True)),
20
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
21
+ ('name', models.CharField(max_length=150)),
22
+ ('email', models.EmailField(blank=True, default='', max_length=254)),
23
+ ('phone', models.CharField(blank=True, default='', max_length=32)),
24
+ ('address', models.TextField(blank=True, default='')),
25
+ ],
26
+ options={
27
+ 'db_table': 'contacts',
28
+ 'ordering': ['name'],
29
+ },
30
+ ),
31
+ migrations.CreateModel(
32
+ name='ContactForm',
33
+ fields=[
34
+ ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
35
+ ('updated_at', models.DateTimeField(auto_now=True)),
36
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
37
+ ('name', models.CharField(max_length=150)),
38
+ ('email', models.EmailField(max_length=254)),
39
+ ('phone', models.CharField(blank=True, default='', max_length=32)),
40
+ ('site', models.URLField(blank=True, default='')),
41
+ ('text', models.TextField()),
42
+ ],
43
+ options={
44
+ 'db_table': 'contact_forms',
45
+ 'ordering': ['-created_at'],
46
+ },
47
+ ),
48
+ ]
File without changes
@@ -0,0 +1,40 @@
1
+ from django.db import models
2
+
3
+ from apps.core.models import BaseModel
4
+
5
+
6
+ class Contact(BaseModel):
7
+ """An address-book style contact record.
8
+
9
+ Distinct from `ContactForm`: this is a directly-managed entry (e.g. an
10
+ admin's own contact book), not something a site visitor submits.
11
+ """
12
+
13
+ name = models.CharField(max_length=150)
14
+ email = models.EmailField(blank=True, default="")
15
+ phone = models.CharField(max_length=32, blank=True, default="")
16
+ address = models.TextField(blank=True, default="")
17
+
18
+ class Meta:
19
+ db_table = "contacts"
20
+ ordering = ["name"]
21
+
22
+ def __str__(self) -> str:
23
+ return self.name
24
+
25
+
26
+ class ContactForm(BaseModel):
27
+ """A submission from a public "contact us" form."""
28
+
29
+ name = models.CharField(max_length=150)
30
+ email = models.EmailField()
31
+ phone = models.CharField(max_length=32, blank=True, default="")
32
+ site = models.URLField(blank=True, default="")
33
+ text = models.TextField()
34
+
35
+ class Meta:
36
+ db_table = "contact_forms"
37
+ ordering = ["-created_at"]
38
+
39
+ def __str__(self) -> str:
40
+ return f"{self.name} <{self.email}>"
@@ -0,0 +1,43 @@
1
+ import uuid
2
+
3
+ from ninja import Schema
4
+ from pydantic import EmailStr
5
+
6
+
7
+ class ContactOut(Schema):
8
+ id: uuid.UUID
9
+ name: str
10
+ email: str
11
+ phone: str
12
+ address: str
13
+
14
+
15
+ class ContactIn(Schema):
16
+ name: str
17
+ email: str = ""
18
+ phone: str = ""
19
+ address: str = ""
20
+
21
+
22
+ class ContactUpdateIn(Schema):
23
+ name: str | None = None
24
+ email: str | None = None
25
+ phone: str | None = None
26
+ address: str | None = None
27
+
28
+
29
+ class ContactFormOut(Schema):
30
+ id: uuid.UUID
31
+ name: str
32
+ email: str
33
+ phone: str
34
+ site: str
35
+ text: str
36
+
37
+
38
+ class ContactFormIn(Schema):
39
+ name: str
40
+ email: EmailStr
41
+ phone: str = ""
42
+ site: str = ""
43
+ text: str
@@ -0,0 +1,72 @@
1
+ import uuid
2
+
3
+ from apps.contacts.models import Contact, ContactForm
4
+ from apps.core.exceptions import NotFoundError
5
+ from apps.core.logging import log_action
6
+ from apps.events.services import record_event
7
+
8
+
9
+ def contact_queryset():
10
+ return Contact.objects.all()
11
+
12
+
13
+ async def get_contact_or_404(contact_id: uuid.UUID) -> Contact:
14
+ try:
15
+ return await contact_queryset().aget(id=contact_id)
16
+ except Contact.DoesNotExist as exc:
17
+ raise NotFoundError("Contact not found.") from exc
18
+
19
+
20
+ async def create_contact(actor, name: str, email: str, phone: str, address: str) -> Contact:
21
+ contact = Contact(name=name, email=email, phone=phone, address=address)
22
+ await contact.asave()
23
+ log_action(actor=actor.email, action="contact_created", target=contact.name)
24
+ await record_event(user=actor, action="create_contact", target=str(contact.id))
25
+ return contact
26
+
27
+
28
+ async def update_contact(actor, contact_id: uuid.UUID, **fields) -> Contact:
29
+ contact = await get_contact_or_404(contact_id)
30
+ for field, value in fields.items():
31
+ if value is not None:
32
+ setattr(contact, field, value)
33
+ await contact.asave()
34
+ log_action(actor=actor.email, action="contact_updated", target=contact.name)
35
+ await record_event(user=actor, action="update_contact", target=str(contact.id))
36
+ return contact
37
+
38
+
39
+ async def delete_contact(actor, contact_id: uuid.UUID) -> None:
40
+ contact = await get_contact_or_404(contact_id)
41
+ name = contact.name
42
+ await contact.adelete()
43
+ log_action(actor=actor.email, action="contact_deleted", target=name)
44
+ await record_event(user=actor, action="delete_contact", target=str(contact_id))
45
+
46
+
47
+ def contact_form_queryset():
48
+ return ContactForm.objects.all()
49
+
50
+
51
+ async def get_contact_form_or_404(form_id: uuid.UUID) -> ContactForm:
52
+ try:
53
+ return await contact_form_queryset().aget(id=form_id)
54
+ except ContactForm.DoesNotExist as exc:
55
+ raise NotFoundError("Contact form submission not found.") from exc
56
+
57
+
58
+ async def submit_contact_form(name: str, email: str, phone: str, site: str, text: str) -> ContactForm:
59
+ form = ContactForm(name=name, email=email, phone=phone, site=site, text=text)
60
+ await form.asave()
61
+ log_action(actor="anonymous", action="contact_form_submitted", target=form.email)
62
+ await record_event(user=None, action="create_contact_form", target=str(form.id))
63
+ return form
64
+
65
+
66
+ async def delete_contact_form(actor, form_id: uuid.UUID) -> None:
67
+ form = await get_contact_form_or_404(form_id)
68
+ email = form.email
69
+ form_id_str = str(form.id)
70
+ await form.adelete()
71
+ log_action(actor=actor.email, action="contact_form_deleted", target=email)
72
+ await record_event(user=actor, action="delete_contact_form", target=form_id_str)
File without changes
@@ -0,0 +1,100 @@
1
+ import pytest
2
+ from asgiref.sync import sync_to_async
3
+ from django.test import AsyncClient
4
+
5
+ from apps.contacts.models import Contact, ContactForm
6
+ from apps.users.models import User
7
+
8
+ pytestmark = pytest.mark.django_db(transaction=True)
9
+
10
+
11
+ async def _login_as(client, email, password):
12
+ return await client.post(
13
+ "/api/auth/login", data={"email": email, "password": password}, content_type="application/json"
14
+ )
15
+
16
+
17
+ async def make_superuser(email="admin@example.com", password="AdminPass123"):
18
+ return await sync_to_async(User.objects.create_superuser)(email=email, password=password)
19
+
20
+
21
+ async def test_anyone_can_submit_contact_form():
22
+ client = AsyncClient()
23
+ payload = {
24
+ "name": "Jane Visitor",
25
+ "email": "jane@example.com",
26
+ "phone": "555-0100",
27
+ "site": "https://example.com",
28
+ "text": "I'd like a quote.",
29
+ }
30
+ response = await client.post("/api/contacts/forms", data=payload, content_type="application/json")
31
+ assert response.status_code == 201
32
+ assert response.json()["email"] == "jane@example.com"
33
+ assert await ContactForm.objects.filter(email="jane@example.com").aexists()
34
+
35
+
36
+ async def test_anonymous_cannot_list_contact_forms():
37
+ client = AsyncClient()
38
+ response = await client.get("/api/contacts/forms")
39
+ assert response.status_code == 401
40
+
41
+
42
+ async def test_non_admin_cannot_list_contacts(default_user_role):
43
+ client = AsyncClient()
44
+ payload = {"email": "plain@example.com", "password": "StrongPass123"}
45
+ await client.post("/api/auth/register", data=payload, content_type="application/json")
46
+ await _login_as(client, **payload)
47
+
48
+ response = await client.get("/api/contacts/")
49
+ assert response.status_code == 403
50
+
51
+
52
+ async def test_superuser_can_crud_contacts(default_user_role):
53
+ await make_superuser()
54
+ client = AsyncClient()
55
+ await _login_as(client, "admin@example.com", "AdminPass123")
56
+
57
+ create_resp = await client.post(
58
+ "/api/contacts/",
59
+ data={"name": "Acme Corp", "email": "hello@acme.test", "phone": "555-0101", "address": "1 Main St"},
60
+ content_type="application/json",
61
+ )
62
+ assert create_resp.status_code == 201
63
+ contact_id = create_resp.json()["id"]
64
+
65
+ list_resp = await client.get("/api/contacts/")
66
+ assert list_resp.status_code == 200
67
+ assert list_resp.json()["meta"]["total"] == 1
68
+
69
+ update_resp = await client.patch(
70
+ f"/api/contacts/{contact_id}",
71
+ data={"phone": "555-0199"},
72
+ content_type="application/json",
73
+ )
74
+ assert update_resp.status_code == 200
75
+ assert update_resp.json()["phone"] == "555-0199"
76
+
77
+ delete_resp = await client.delete(f"/api/contacts/{contact_id}")
78
+ assert delete_resp.status_code == 204
79
+ assert not await Contact.objects.filter(id=contact_id).aexists()
80
+
81
+
82
+ async def test_superuser_can_review_and_delete_contact_form_submissions(default_user_role):
83
+ await make_superuser()
84
+ admin_client = AsyncClient()
85
+ await _login_as(admin_client, "admin@example.com", "AdminPass123")
86
+
87
+ submit_resp = await AsyncClient().post(
88
+ "/api/contacts/forms",
89
+ data={"name": "Jane Visitor", "email": "jane@example.com", "text": "Hello there."},
90
+ content_type="application/json",
91
+ )
92
+ form_id = submit_resp.json()["id"]
93
+
94
+ get_resp = await admin_client.get(f"/api/contacts/forms/{form_id}")
95
+ assert get_resp.status_code == 200
96
+ assert get_resp.json()["text"] == "Hello there."
97
+
98
+ delete_resp = await admin_client.delete(f"/api/contacts/forms/{form_id}")
99
+ assert delete_resp.status_code == 204
100
+ assert not await ContactForm.objects.filter(id=form_id).aexists()
apps/core/__init__.py ADDED
File without changes
apps/core/apps.py ADDED
@@ -0,0 +1,8 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class CoreConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "apps.core"
7
+ label = "core"
8
+ verbose_name = "Core"
apps/core/auth.py ADDED
@@ -0,0 +1,86 @@
1
+ """Session-bound JWT authentication.
2
+
3
+ See AGENT.md §5 for the full rationale. Short version: the client only ever
4
+ holds an httponly session cookie. The server issues a JWT on login, stores it
5
+ in `request.session`, and validates it there on every request — giving JWT's
6
+ self-contained payload plus real server-side revocation via session flush.
7
+ """
8
+
9
+ import uuid
10
+ from datetime import datetime, timedelta, timezone
11
+
12
+ import jwt
13
+ from django.conf import settings
14
+ from ninja.utils import check_csrf
15
+
16
+ from apps.core.exceptions import AuthenticationError, PermissionDeniedError
17
+
18
+ ALGORITHM = "HS256"
19
+
20
+
21
+ def issue_token(user) -> tuple[str, str]:
22
+ """Create a signed JWT for `user`. Returns (token, jti)."""
23
+ jti = uuid.uuid4().hex
24
+ now = datetime.now(timezone.utc)
25
+ payload = {
26
+ "sub": str(user.pk),
27
+ "jti": jti,
28
+ "iat": now,
29
+ "exp": now + timedelta(minutes=settings.JWT_ACCESS_TOKEN_LIFETIME_MINUTES),
30
+ }
31
+ token = jwt.encode(payload, settings.JWT_SIGNING_KEY, algorithm=ALGORITHM)
32
+ return token, jti
33
+
34
+
35
+ def decode_token(token: str) -> dict:
36
+ try:
37
+ return jwt.decode(token, settings.JWT_SIGNING_KEY, algorithms=[ALGORITHM])
38
+ except jwt.PyJWTError as exc:
39
+ raise AuthenticationError("Invalid or expired session token.") from exc
40
+
41
+
42
+ class SessionTokenAuth:
43
+ """Ninja auth backend validating the JWT stored server-side in the session."""
44
+
45
+ openapi_security_schema = {
46
+ "type": "apiKey",
47
+ "in": "cookie",
48
+ "name": "sessionid",
49
+ "description": "Django session cookie, set by POST /api/auth/login.",
50
+ }
51
+
52
+ async def __call__(self, request):
53
+ # Cookie-based auth is vulnerable to CSRF unless every unsafe request
54
+ # proves it isn't a cross-site form/script (see AGENT.md §5, point 3).
55
+ if not getattr(request, "_ninja_csrf_exempt", False):
56
+ csrf_error = check_csrf(request)
57
+ if csrf_error:
58
+ raise PermissionDeniedError("CSRF check failed.")
59
+
60
+ # Session reads/writes must go through the async session API (aget/aset/
61
+ # etc.) — the sync dict-style API (`request.session["x"]`, `.get(...)`)
62
+ # does a synchronous DB/cache lookup on first access and raises
63
+ # SynchronousOnlyOperation when called from inside an async view.
64
+ token = await request.session.aget("auth_token")
65
+ if not token:
66
+ raise AuthenticationError("Not authenticated.")
67
+
68
+ payload = decode_token(token)
69
+ if payload.get("jti") != await request.session.aget("auth_token_jti"):
70
+ raise AuthenticationError("Session token has been revoked.")
71
+
72
+ from apps.users.models import User
73
+
74
+ try:
75
+ # `roles` is prefetched because handlers commonly return `request.auth`
76
+ # straight through a schema that serializes `.roles` — without this,
77
+ # that serialization would issue a synchronous query mid-async-request.
78
+ user = await User.objects.prefetch_related("roles").aget(pk=payload["sub"], is_active=True)
79
+ except User.DoesNotExist as exc:
80
+ raise AuthenticationError("User not found or inactive.") from exc
81
+
82
+ request.user = user
83
+ return user
84
+
85
+
86
+ session_token_auth = SessionTokenAuth()
@@ -0,0 +1,80 @@
1
+ from ninja import NinjaAPI
2
+ from ninja.errors import ValidationError as NinjaValidationError
3
+
4
+
5
+ class APIError(Exception):
6
+ """Base for all deliberately-raised API errors.
7
+
8
+ Carries an HTTP status code and a stable machine-readable `code` so
9
+ clients can branch on it without parsing message strings.
10
+ """
11
+
12
+ status_code = 400
13
+ code = "error"
14
+ default_detail = "An error occurred."
15
+
16
+ def __init__(self, detail: str | None = None):
17
+ self.detail = detail or self.default_detail
18
+ super().__init__(self.detail)
19
+
20
+
21
+ class NotFoundError(APIError):
22
+ status_code = 404
23
+ code = "not_found"
24
+ default_detail = "The requested resource was not found."
25
+
26
+
27
+ class PermissionDeniedError(APIError):
28
+ status_code = 403
29
+ code = "permission_denied"
30
+ default_detail = "You do not have permission to perform this action."
31
+
32
+
33
+ class AuthenticationError(APIError):
34
+ status_code = 401
35
+ code = "authentication_failed"
36
+ default_detail = "Authentication credentials were not provided or are invalid."
37
+
38
+
39
+ class ConflictError(APIError):
40
+ status_code = 409
41
+ code = "conflict"
42
+ default_detail = "The request conflicts with the current state of the resource."
43
+
44
+
45
+ class ThrottledError(APIError):
46
+ status_code = 429
47
+ code = "throttled"
48
+ default_detail = "Too many requests. Please try again later."
49
+
50
+
51
+ def register_exception_handlers(api: NinjaAPI) -> None:
52
+ @api.exception_handler(APIError)
53
+ def handle_api_error(request, exc: APIError):
54
+ return api.create_response(
55
+ request,
56
+ {"code": exc.code, "detail": exc.detail},
57
+ status=exc.status_code,
58
+ )
59
+
60
+ @api.exception_handler(NinjaValidationError)
61
+ def handle_validation_error(request, exc: NinjaValidationError):
62
+ return api.create_response(
63
+ request,
64
+ {"code": "validation_error", "detail": exc.errors},
65
+ status=422,
66
+ )
67
+
68
+ @api.exception_handler(Exception)
69
+ def handle_unhandled_exception(request, exc: Exception):
70
+ import logging
71
+
72
+ logging.getLogger("app").exception(
73
+ "Unhandled exception while processing request",
74
+ extra={"path": request.path, "method": request.method},
75
+ )
76
+ return api.create_response(
77
+ request,
78
+ {"code": "internal_error", "detail": "An unexpected error occurred."},
79
+ status=500,
80
+ )