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.
- apps/__init__.py +1 -0
- apps/contacts/__init__.py +0 -0
- apps/contacts/admin.py +18 -0
- apps/contacts/api.py +86 -0
- apps/contacts/apps.py +8 -0
- apps/contacts/migrations/0001_initial.py +48 -0
- apps/contacts/migrations/__init__.py +0 -0
- apps/contacts/models.py +40 -0
- apps/contacts/schemas.py +43 -0
- apps/contacts/services.py +72 -0
- apps/contacts/tests/__init__.py +0 -0
- apps/contacts/tests/test_contacts_api.py +100 -0
- apps/core/__init__.py +0 -0
- apps/core/apps.py +8 -0
- apps/core/auth.py +86 -0
- apps/core/exceptions.py +80 -0
- apps/core/logging.py +101 -0
- apps/core/management/__init__.py +0 -0
- apps/core/management/commands/__init__.py +0 -0
- apps/core/management/commands/seed_roles.py +18 -0
- apps/core/middleware.py +138 -0
- apps/core/models.py +85 -0
- apps/core/pagination.py +21 -0
- apps/core/permissions.py +49 -0
- apps/core/schemas.py +25 -0
- apps/core/sms.py +48 -0
- apps/core/tests/__init__.py +0 -0
- apps/core/tests/test_health.py +18 -0
- apps/core/tests/test_translatable.py +42 -0
- apps/core/throttling.py +25 -0
- apps/core/utils.py +5 -0
- apps/events/__init__.py +0 -0
- apps/events/admin.py +18 -0
- apps/events/api.py +28 -0
- apps/events/apps.py +8 -0
- apps/events/migrations/0001_initial.py +33 -0
- apps/events/migrations/__init__.py +0 -0
- apps/events/models.py +34 -0
- apps/events/schemas.py +12 -0
- apps/events/services.py +26 -0
- apps/events/tests/__init__.py +0 -0
- apps/events/tests/test_events_api.py +88 -0
- apps/realtime/__init__.py +0 -0
- apps/realtime/apps.py +8 -0
- apps/realtime/auth.py +48 -0
- apps/realtime/server.py +62 -0
- apps/realtime/tests/__init__.py +0 -0
- apps/roles/__init__.py +0 -0
- apps/roles/admin.py +18 -0
- apps/roles/api.py +85 -0
- apps/roles/apps.py +8 -0
- apps/roles/migrations/0001_initial.py +32 -0
- apps/roles/migrations/0002_alter_role_table.py +17 -0
- apps/roles/migrations/__init__.py +0 -0
- apps/roles/models.py +30 -0
- apps/roles/schemas.py +44 -0
- apps/roles/services.py +94 -0
- apps/roles/tests/__init__.py +0 -0
- apps/roles/tests/test_roles_api.py +111 -0
- apps/users/__init__.py +0 -0
- apps/users/admin.py +77 -0
- apps/users/api.py +119 -0
- apps/users/apps.py +8 -0
- apps/users/managers.py +47 -0
- apps/users/migrations/0001_initial.py +45 -0
- apps/users/migrations/0002_alter_user_options_alter_user_table.py +21 -0
- apps/users/migrations/0003_user_avatar.py +18 -0
- apps/users/migrations/0004_user_phone_alter_user_email_otpcode.py +41 -0
- apps/users/migrations/0005_otpcode_purpose.py +26 -0
- apps/users/migrations/__init__.py +0 -0
- apps/users/models.py +95 -0
- apps/users/schemas.py +165 -0
- apps/users/services.py +241 -0
- apps/users/tests/__init__.py +0 -0
- apps/users/tests/test_auth_api.py +130 -0
- apps/users/tests/test_otp_api.py +103 -0
- apps/users/tests/test_password_otp_api.py +162 -0
- apps/users/tests/test_profile_api.py +53 -0
- core_package-0.1.0.dist-info/METADATA +50 -0
- core_package-0.1.0.dist-info/RECORD +83 -0
- core_package-0.1.0.dist-info/WHEEL +5 -0
- core_package-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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,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
|
apps/contacts/models.py
ADDED
|
@@ -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}>"
|
apps/contacts/schemas.py
ADDED
|
@@ -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
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()
|
apps/core/exceptions.py
ADDED
|
@@ -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
|
+
)
|