platform-context 0.5.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.
- platform_context/__init__.py +84 -0
- platform_context/apps.py +11 -0
- platform_context/audit.py +76 -0
- platform_context/context.py +79 -0
- platform_context/context_processors.py +76 -0
- platform_context/db.py +51 -0
- platform_context/exceptions.py +159 -0
- platform_context/htmx.py +107 -0
- platform_context/middleware.py +116 -0
- platform_context/outbox.py +59 -0
- platform_context/static/platform/css/pui-tokens.css +270 -0
- platform_context/temporal_client.py +49 -0
- platform_context/tenant_utils/__init__.py +25 -0
- platform_context/tenant_utils/celery.py +87 -0
- platform_context/tenant_utils/http_client.py +93 -0
- platform_context/tenant_utils/middleware.py +57 -0
- platform_context/tenant_utils/provisioning.py +171 -0
- platform_context/tenant_utils/testing.py +96 -0
- platform_context/testing/__init__.py +41 -0
- platform_context/testing/assertions.py +300 -0
- platform_context/testing/fixtures.py +149 -0
- platform_context-0.5.0.dist-info/METADATA +88 -0
- platform_context-0.5.0.dist-info/RECORD +24 -0
- platform_context-0.5.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
platform-context: Shared platform foundation for all Django projects.
|
|
3
|
+
|
|
4
|
+
Provides:
|
|
5
|
+
- Request context management (tenant, user, request_id)
|
|
6
|
+
- Multi-tenancy middleware (subdomain-based)
|
|
7
|
+
- Postgres RLS helpers
|
|
8
|
+
- Audit event logging (model-agnostic)
|
|
9
|
+
- Outbox pattern for reliable events
|
|
10
|
+
- Exception hierarchy
|
|
11
|
+
- Django template context processors
|
|
12
|
+
- Shared test helpers (platform_context.testing)
|
|
13
|
+
- Multi-tenancy utilities (platform_context.tenant_utils) — ADR-056
|
|
14
|
+
- Temporal Client singleton (platform_context.temporal_client) — ADR-077
|
|
15
|
+
|
|
16
|
+
Usage::
|
|
17
|
+
|
|
18
|
+
from platform_context import get_context, set_tenant, set_user_id
|
|
19
|
+
from platform_context.middleware import SubdomainTenantMiddleware
|
|
20
|
+
from platform_context.audit import emit_audit_event
|
|
21
|
+
|
|
22
|
+
Test helpers (install with platform-context[testing])::
|
|
23
|
+
|
|
24
|
+
# conftest.py
|
|
25
|
+
from platform_context.testing.fixtures import user, admin_user, auth_client # noqa: F401
|
|
26
|
+
|
|
27
|
+
# tests
|
|
28
|
+
from platform_context.testing.assertions import assert_htmx_fragment, assert_login_required
|
|
29
|
+
|
|
30
|
+
Multi-tenancy utilities (ADR-056, requires django-tenants in consuming service)::
|
|
31
|
+
|
|
32
|
+
from platform_context.tenant_utils.http_client import TenantAwareHttpClient
|
|
33
|
+
from platform_context.tenant_utils.middleware import TenantPropagationMiddleware
|
|
34
|
+
from platform_context.tenant_utils.celery import TenantAwareTask, send_cross_service_task
|
|
35
|
+
from platform_context.tenant_utils.provisioning import provision_tenant
|
|
36
|
+
|
|
37
|
+
# In conftest.py:
|
|
38
|
+
from platform_context.tenant_utils.testing import tenant_a, tenant_b # noqa: F401
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from platform_context.context import (
|
|
42
|
+
RequestContext,
|
|
43
|
+
clear_context,
|
|
44
|
+
get_context,
|
|
45
|
+
set_request_id,
|
|
46
|
+
set_tenant,
|
|
47
|
+
set_user_id,
|
|
48
|
+
)
|
|
49
|
+
from platform_context.db import get_db_tenant, set_db_tenant
|
|
50
|
+
from platform_context.htmx import (
|
|
51
|
+
HtmxErrorMiddleware,
|
|
52
|
+
HtmxResponseMixin,
|
|
53
|
+
is_htmx_request,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
__version__ = "0.5.0"
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
# Context
|
|
60
|
+
"RequestContext",
|
|
61
|
+
"clear_context",
|
|
62
|
+
"get_context",
|
|
63
|
+
"set_request_id",
|
|
64
|
+
"set_tenant",
|
|
65
|
+
"set_user_id",
|
|
66
|
+
# DB
|
|
67
|
+
"get_db_tenant",
|
|
68
|
+
"set_db_tenant",
|
|
69
|
+
# HTMX (ADR-048)
|
|
70
|
+
"HtmxErrorMiddleware",
|
|
71
|
+
"HtmxResponseMixin",
|
|
72
|
+
"is_htmx_request",
|
|
73
|
+
# Testing helpers (platform-context[testing])
|
|
74
|
+
# Import via: from platform_context.testing.assertions import ...
|
|
75
|
+
# Import via: from platform_context.testing.fixtures import ...
|
|
76
|
+
# Multi-tenancy utilities (ADR-056, requires django-tenants)
|
|
77
|
+
# Import via: from platform_context.tenant_utils.http_client import TenantAwareHttpClient
|
|
78
|
+
# Import via: from platform_context.tenant_utils.middleware import TenantPropagationMiddleware
|
|
79
|
+
# Import via: from platform_context.tenant_utils.celery import TenantAwareTask
|
|
80
|
+
# Import via: from platform_context.tenant_utils.provisioning import provision_tenant
|
|
81
|
+
# Import via: from platform_context.tenant_utils.testing import tenant_a, tenant_b
|
|
82
|
+
# Temporal Client (ADR-077, requires platform-context[temporal])
|
|
83
|
+
# Import via: from platform_context.temporal_client import get_temporal_client
|
|
84
|
+
]
|
platform_context/apps.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Django app configuration for platform_context."""
|
|
2
|
+
|
|
3
|
+
from django.apps import AppConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PlatformContextConfig(AppConfig):
|
|
7
|
+
"""Django app config for platform-context."""
|
|
8
|
+
|
|
9
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
10
|
+
name = "platform_context"
|
|
11
|
+
verbose_name = "Platform Context"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Audit event logging for compliance.
|
|
3
|
+
|
|
4
|
+
Provides structured audit trail for all risk-relevant mutations.
|
|
5
|
+
Model-agnostic: configure PLATFORM_AUDIT_MODEL in Django settings.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any
|
|
10
|
+
from uuid import UUID
|
|
11
|
+
|
|
12
|
+
from platform_context.context import get_context
|
|
13
|
+
|
|
14
|
+
_logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def emit_audit_event(
|
|
18
|
+
*,
|
|
19
|
+
tenant_id: UUID,
|
|
20
|
+
category: str,
|
|
21
|
+
action: str,
|
|
22
|
+
entity_type: str,
|
|
23
|
+
entity_id: UUID,
|
|
24
|
+
payload: dict[str, Any],
|
|
25
|
+
) -> None:
|
|
26
|
+
"""
|
|
27
|
+
Emit an audit event for a mutation.
|
|
28
|
+
|
|
29
|
+
The actor_user_id and request_id are automatically populated
|
|
30
|
+
from the current request context.
|
|
31
|
+
|
|
32
|
+
Requires PLATFORM_AUDIT_MODEL to be set in Django settings
|
|
33
|
+
(e.g., "bfagent_core.AuditEvent").
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
tenant_id: The tenant this event belongs to
|
|
37
|
+
category: Event category (e.g., "risk.assessment")
|
|
38
|
+
action: Action performed (e.g., "created", "approved")
|
|
39
|
+
entity_type: Full entity type (e.g., "risk.Assessment")
|
|
40
|
+
entity_id: UUID of the affected entity
|
|
41
|
+
payload: Additional data to store
|
|
42
|
+
"""
|
|
43
|
+
from django.conf import settings
|
|
44
|
+
|
|
45
|
+
model_path = getattr(settings, "PLATFORM_AUDIT_MODEL", None)
|
|
46
|
+
if not model_path:
|
|
47
|
+
_logger.warning(
|
|
48
|
+
"PLATFORM_AUDIT_MODEL not configured, audit event dropped: "
|
|
49
|
+
"%s.%s on %s",
|
|
50
|
+
category,
|
|
51
|
+
action,
|
|
52
|
+
entity_type,
|
|
53
|
+
)
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
from django.apps import apps
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
app_label, model_name = model_path.rsplit(".", 1)
|
|
60
|
+
AuditModel = apps.get_model(app_label, model_name)
|
|
61
|
+
except (LookupError, ValueError) as exc:
|
|
62
|
+
_logger.error("Cannot resolve PLATFORM_AUDIT_MODEL=%s: %s", model_path, exc)
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
ctx = get_context()
|
|
66
|
+
|
|
67
|
+
AuditModel.objects.create(
|
|
68
|
+
tenant_id=tenant_id,
|
|
69
|
+
actor_user_id=ctx.user_id,
|
|
70
|
+
category=category,
|
|
71
|
+
action=action,
|
|
72
|
+
entity_type=entity_type,
|
|
73
|
+
entity_id=entity_id,
|
|
74
|
+
payload=payload,
|
|
75
|
+
request_id=ctx.request_id,
|
|
76
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Request context management using contextvars.
|
|
3
|
+
|
|
4
|
+
Thread-safe context propagation for:
|
|
5
|
+
- tenant_id / tenant_slug
|
|
6
|
+
- user_id
|
|
7
|
+
- request_id (correlation ID)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import contextvars
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from uuid import UUID
|
|
13
|
+
|
|
14
|
+
_request_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
|
15
|
+
"request_id", default=None
|
|
16
|
+
)
|
|
17
|
+
_current_tenant_id: contextvars.ContextVar[UUID | None] = contextvars.ContextVar(
|
|
18
|
+
"tenant_id", default=None
|
|
19
|
+
)
|
|
20
|
+
_current_tenant_slug: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
|
21
|
+
"tenant_slug", default=None
|
|
22
|
+
)
|
|
23
|
+
_current_user_id: contextvars.ContextVar[UUID | None] = contextvars.ContextVar(
|
|
24
|
+
"user_id", default=None
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class RequestContext:
|
|
30
|
+
"""Immutable snapshot of the current request context."""
|
|
31
|
+
|
|
32
|
+
request_id: str | None
|
|
33
|
+
tenant_id: UUID | None
|
|
34
|
+
tenant_slug: str | None
|
|
35
|
+
user_id: UUID | None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def is_authenticated(self) -> bool:
|
|
39
|
+
"""Check if a user is set in context."""
|
|
40
|
+
return self.user_id is not None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def has_tenant(self) -> bool:
|
|
44
|
+
"""Check if a tenant is set in context."""
|
|
45
|
+
return self.tenant_id is not None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def set_request_id(value: str | None) -> None:
|
|
49
|
+
"""Set the request/correlation ID for the current context."""
|
|
50
|
+
_request_id.set(value)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def set_tenant(tenant_id: UUID | None, tenant_slug: str | None) -> None:
|
|
54
|
+
"""Set the current tenant for the context."""
|
|
55
|
+
_current_tenant_id.set(tenant_id)
|
|
56
|
+
_current_tenant_slug.set(tenant_slug)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def set_user_id(value: UUID | None) -> None:
|
|
60
|
+
"""Set the current user ID for the context."""
|
|
61
|
+
_current_user_id.set(value)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_context() -> RequestContext:
|
|
65
|
+
"""Get the current request context snapshot."""
|
|
66
|
+
return RequestContext(
|
|
67
|
+
request_id=_request_id.get(),
|
|
68
|
+
tenant_id=_current_tenant_id.get(),
|
|
69
|
+
tenant_slug=_current_tenant_slug.get(),
|
|
70
|
+
user_id=_current_user_id.get(),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def clear_context() -> None:
|
|
75
|
+
"""Clear all context variables. Useful for testing."""
|
|
76
|
+
_request_id.set(None)
|
|
77
|
+
_current_tenant_id.set(None)
|
|
78
|
+
_current_tenant_slug.set(None)
|
|
79
|
+
_current_user_id.set(None)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Django template context processors."""
|
|
2
|
+
|
|
3
|
+
from django.http import HttpRequest
|
|
4
|
+
|
|
5
|
+
from platform_context.context import get_context
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def tenant_context(request: HttpRequest) -> dict:
|
|
9
|
+
"""
|
|
10
|
+
Add tenant information to template context.
|
|
11
|
+
|
|
12
|
+
Usage in settings.py:
|
|
13
|
+
TEMPLATES = [{
|
|
14
|
+
...
|
|
15
|
+
"OPTIONS": {
|
|
16
|
+
"context_processors": [
|
|
17
|
+
...
|
|
18
|
+
"platform_context.context_processors.tenant_context",
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
}]
|
|
22
|
+
|
|
23
|
+
Available in templates:
|
|
24
|
+
{{ tenant_id }}
|
|
25
|
+
{{ tenant_slug }}
|
|
26
|
+
{{ request_id }}
|
|
27
|
+
"""
|
|
28
|
+
ctx = get_context()
|
|
29
|
+
return {
|
|
30
|
+
"tenant_id": ctx.tenant_id,
|
|
31
|
+
"tenant_slug": ctx.tenant_slug,
|
|
32
|
+
"request_id": ctx.request_id,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def platform_context(request: HttpRequest) -> dict:
|
|
37
|
+
"""
|
|
38
|
+
Add platform context to templates (tenant, permissions, membership).
|
|
39
|
+
|
|
40
|
+
Usage in settings.py:
|
|
41
|
+
'platform_context.context_processors.platform_context',
|
|
42
|
+
|
|
43
|
+
Available in templates:
|
|
44
|
+
{{ tenant }} - Tenant object (if in tenant context)
|
|
45
|
+
{{ tenant_id }} - UUID
|
|
46
|
+
{{ tenant_slug }} - String
|
|
47
|
+
{{ membership }} - TenantMembership (if authenticated)
|
|
48
|
+
{{ permissions }} - FrozenSet of permission codes
|
|
49
|
+
{{ user_role }} - Role string (owner, admin, member, viewer)
|
|
50
|
+
{{ is_tenant_admin }} - Bool: owner or admin
|
|
51
|
+
{{ request_id }} - Correlation ID
|
|
52
|
+
|
|
53
|
+
Permission check in templates:
|
|
54
|
+
{% if 'stories.create' in permissions %}
|
|
55
|
+
<a href="...">Create Story</a>
|
|
56
|
+
{% endif %}
|
|
57
|
+
"""
|
|
58
|
+
ctx = get_context()
|
|
59
|
+
|
|
60
|
+
result = {
|
|
61
|
+
"tenant_id": ctx.tenant_id,
|
|
62
|
+
"tenant_slug": ctx.tenant_slug,
|
|
63
|
+
"request_id": ctx.request_id,
|
|
64
|
+
"tenant": getattr(request, "tenant", None),
|
|
65
|
+
"membership": getattr(request, "membership", None),
|
|
66
|
+
"permissions": getattr(request, "permissions", frozenset()),
|
|
67
|
+
"user_role": None,
|
|
68
|
+
"is_tenant_admin": False,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
membership = result["membership"]
|
|
72
|
+
if membership:
|
|
73
|
+
result["user_role"] = membership.role
|
|
74
|
+
result["is_tenant_admin"] = membership.role in ("owner", "admin")
|
|
75
|
+
|
|
76
|
+
return result
|
platform_context/db.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Database utilities for multi-tenancy.
|
|
3
|
+
|
|
4
|
+
Provides Postgres RLS (Row Level Security) session variable management.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def set_db_tenant(tenant_id: UUID | None) -> None:
|
|
11
|
+
"""
|
|
12
|
+
Set Postgres session variable for RLS policies.
|
|
13
|
+
|
|
14
|
+
This sets `app.current_tenant` which RLS policies use:
|
|
15
|
+
|
|
16
|
+
CREATE POLICY tenant_isolation ON my_table
|
|
17
|
+
USING (tenant_id = current_setting('app.current_tenant')::uuid);
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
tenant_id: The tenant UUID, or None to clear access
|
|
21
|
+
|
|
22
|
+
Note:
|
|
23
|
+
- Call this in middleware after resolving tenant
|
|
24
|
+
- Uses SET LOCAL so it's transaction-scoped
|
|
25
|
+
- Empty string = no tenant access (safe default when RLS enabled)
|
|
26
|
+
"""
|
|
27
|
+
from django.db import connection
|
|
28
|
+
|
|
29
|
+
value = "" if tenant_id is None else str(tenant_id)
|
|
30
|
+
with connection.cursor() as cursor:
|
|
31
|
+
cursor.execute("SELECT set_config('app.current_tenant', %s, true)", [value])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_db_tenant() -> UUID | None:
|
|
35
|
+
"""
|
|
36
|
+
Get the current tenant from Postgres session variable.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
The tenant UUID if set, None otherwise
|
|
40
|
+
"""
|
|
41
|
+
from django.db import connection
|
|
42
|
+
|
|
43
|
+
with connection.cursor() as cursor:
|
|
44
|
+
cursor.execute("SELECT current_setting('app.current_tenant', true)")
|
|
45
|
+
result = cursor.fetchone()
|
|
46
|
+
if result and result[0]:
|
|
47
|
+
try:
|
|
48
|
+
return UUID(result[0])
|
|
49
|
+
except ValueError:
|
|
50
|
+
return None
|
|
51
|
+
return None
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exception hierarchy for the platform.
|
|
3
|
+
|
|
4
|
+
All platform exceptions inherit from PlatformError.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PlatformError(Exception):
|
|
9
|
+
"""Base class for all platform errors."""
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
14
|
+
# TENANT ERRORS
|
|
15
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
16
|
+
|
|
17
|
+
class TenantError(PlatformError):
|
|
18
|
+
"""Base for tenant-related errors."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TenantNotFoundError(TenantError):
|
|
23
|
+
"""Tenant does not exist."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, tenant_id):
|
|
26
|
+
super().__init__(f"Tenant not found: {tenant_id}")
|
|
27
|
+
self.tenant_id = tenant_id
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TenantSlugExistsError(TenantError):
|
|
31
|
+
"""Tenant slug already exists."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, slug):
|
|
34
|
+
super().__init__(f"Tenant slug already exists: {slug}")
|
|
35
|
+
self.slug = slug
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TenantSuspendedError(TenantError):
|
|
39
|
+
"""Tenant is suspended."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, tenant_id):
|
|
42
|
+
super().__init__(f"Tenant is suspended: {tenant_id}")
|
|
43
|
+
self.tenant_id = tenant_id
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TenantDeletedError(TenantError):
|
|
47
|
+
"""Tenant is deleted."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, tenant_id):
|
|
50
|
+
super().__init__(f"Tenant is deleted: {tenant_id}")
|
|
51
|
+
self.tenant_id = tenant_id
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TenantInactiveError(TenantError):
|
|
55
|
+
"""Tenant is not active (suspended or deleted)."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, tenant_id, status):
|
|
58
|
+
super().__init__(f"Tenant {tenant_id} is {status}")
|
|
59
|
+
self.tenant_id = tenant_id
|
|
60
|
+
self.status = status
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
64
|
+
# MEMBERSHIP ERRORS
|
|
65
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
66
|
+
|
|
67
|
+
class MembershipError(PlatformError):
|
|
68
|
+
"""Base for membership-related errors."""
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class MembershipNotFoundError(MembershipError):
|
|
73
|
+
"""Membership does not exist."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, membership_id=None, tenant_id=None, user_id=None):
|
|
76
|
+
if membership_id:
|
|
77
|
+
msg = f"Membership not found: {membership_id}"
|
|
78
|
+
else:
|
|
79
|
+
msg = f"No membership for user {user_id} in tenant {tenant_id}"
|
|
80
|
+
super().__init__(msg)
|
|
81
|
+
self.membership_id = membership_id
|
|
82
|
+
self.tenant_id = tenant_id
|
|
83
|
+
self.user_id = user_id
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class MembershipExistsError(MembershipError):
|
|
87
|
+
"""User is already a member of the tenant."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, tenant_id, user_id):
|
|
90
|
+
super().__init__(f"User {user_id} is already member of tenant {tenant_id}")
|
|
91
|
+
self.tenant_id = tenant_id
|
|
92
|
+
self.user_id = user_id
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class InvitationExpiredError(MembershipError):
|
|
96
|
+
"""Invitation has expired."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, membership_id):
|
|
99
|
+
super().__init__(f"Invitation has expired: {membership_id}")
|
|
100
|
+
self.membership_id = membership_id
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class InvitationNotPendingError(MembershipError):
|
|
104
|
+
"""Invitation is not in pending state."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, membership_id, status):
|
|
107
|
+
super().__init__(f"Invitation {membership_id} is {status}, not pending")
|
|
108
|
+
self.membership_id = membership_id
|
|
109
|
+
self.status = status
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
113
|
+
# PERMISSION ERRORS
|
|
114
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
115
|
+
|
|
116
|
+
class PermissionError(PlatformError):
|
|
117
|
+
"""Base for permission-related errors."""
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class PermissionDeniedError(PermissionError):
|
|
122
|
+
"""User does not have required permission."""
|
|
123
|
+
|
|
124
|
+
def __init__(self, permission: str, message: str = None):
|
|
125
|
+
super().__init__(message or f"Permission denied: {permission}")
|
|
126
|
+
self.permission = permission
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class PermissionNotFoundError(PermissionError):
|
|
130
|
+
"""Permission code does not exist."""
|
|
131
|
+
|
|
132
|
+
def __init__(self, permission_code):
|
|
133
|
+
super().__init__(f"Permission not found: {permission_code}")
|
|
134
|
+
self.permission_code = permission_code
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class RoleNotFoundError(PermissionError):
|
|
138
|
+
"""Role does not exist."""
|
|
139
|
+
|
|
140
|
+
def __init__(self, role):
|
|
141
|
+
super().__init__(f"Role not found: {role}")
|
|
142
|
+
self.role = role
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
146
|
+
# USER ERRORS
|
|
147
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
148
|
+
|
|
149
|
+
class UserError(PlatformError):
|
|
150
|
+
"""Base for user-related errors."""
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class UserNotFoundError(UserError):
|
|
155
|
+
"""User does not exist."""
|
|
156
|
+
|
|
157
|
+
def __init__(self, user_id):
|
|
158
|
+
super().__init__(f"User not found: {user_id}")
|
|
159
|
+
self.user_id = user_id
|
platform_context/htmx.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""HTMX utilities for Django views (ADR-048).
|
|
2
|
+
|
|
3
|
+
Provides:
|
|
4
|
+
- is_htmx_request(): Portable HTMX detection (no django_htmx dependency)
|
|
5
|
+
- HtmxResponseMixin: CBV mixin for partial/full template switching
|
|
6
|
+
- HtmxErrorMiddleware: Convert 4xx/5xx into HTMX-safe toast notifications
|
|
7
|
+
|
|
8
|
+
All code uses raw header detection for portability across apps.
|
|
9
|
+
Apps with django_htmx installed MAY use request.htmx in app-specific code,
|
|
10
|
+
but shared middleware and mixins MUST NOT depend on it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
17
|
+
from django.http import HttpRequest, HttpResponse
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_htmx_request(request: HttpRequest) -> bool:
|
|
21
|
+
"""Portable HTMX detection. Works with or without django_htmx."""
|
|
22
|
+
return request.headers.get("HX-Request") == "true"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HtmxResponseMixin:
|
|
26
|
+
"""Mixin for CBVs that return partials for HTMX requests.
|
|
27
|
+
|
|
28
|
+
Set ``partial_template_name`` to the HTMX partial template.
|
|
29
|
+
The full template is resolved via standard ``get_template_names()``.
|
|
30
|
+
|
|
31
|
+
Example::
|
|
32
|
+
|
|
33
|
+
class TripListView(HtmxResponseMixin, LoginRequiredMixin, ListView):
|
|
34
|
+
model = Trip
|
|
35
|
+
template_name = "trips/trip_list.html"
|
|
36
|
+
partial_template_name = "trips/partials/_trip_list.html"
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
partial_template_name: str = ""
|
|
40
|
+
|
|
41
|
+
def get_template_names(self) -> list[str]:
|
|
42
|
+
"""Return partial template for HTMX requests, full otherwise."""
|
|
43
|
+
if is_htmx_request(self.request):
|
|
44
|
+
if not self.partial_template_name:
|
|
45
|
+
raise ImproperlyConfigured(
|
|
46
|
+
f"{self.__class__.__name__} requires partial_template_name"
|
|
47
|
+
)
|
|
48
|
+
return [self.partial_template_name]
|
|
49
|
+
return super().get_template_names()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
ERROR_MESSAGES: dict[int, str] = {
|
|
53
|
+
400: "Bad request.",
|
|
54
|
+
403: "Permission denied.",
|
|
55
|
+
404: "Resource not found.",
|
|
56
|
+
405: "Method not allowed.",
|
|
57
|
+
409: "Conflict.",
|
|
58
|
+
429: "Too many requests. Please wait.",
|
|
59
|
+
500: "Internal server error. Please try again.",
|
|
60
|
+
502: "Service temporarily unavailable.",
|
|
61
|
+
503: "Service temporarily unavailable.",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class HtmxErrorMiddleware:
|
|
66
|
+
"""Convert 4xx/5xx into HTMX-safe responses with toast notifications.
|
|
67
|
+
|
|
68
|
+
Works without django_htmx -- uses raw header detection.
|
|
69
|
+
Skips 422 responses (form validation errors, see HP-006).
|
|
70
|
+
|
|
71
|
+
Install AFTER TenantMiddleware and auth middleware::
|
|
72
|
+
|
|
73
|
+
MIDDLEWARE = [
|
|
74
|
+
"django.middleware.security.SecurityMiddleware",
|
|
75
|
+
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
76
|
+
# ... auth middleware ...
|
|
77
|
+
"platform_context.middleware.SubdomainTenantMiddleware",
|
|
78
|
+
"platform_context.htmx.HtmxErrorMiddleware",
|
|
79
|
+
# ...
|
|
80
|
+
]
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, get_response: Any) -> None:
|
|
84
|
+
self.get_response = get_response
|
|
85
|
+
|
|
86
|
+
def __call__(self, request: HttpRequest) -> HttpResponse:
|
|
87
|
+
"""Process request and intercept errors for HTMX requests."""
|
|
88
|
+
response = self.get_response(request)
|
|
89
|
+
|
|
90
|
+
if not is_htmx_request(request):
|
|
91
|
+
return response
|
|
92
|
+
|
|
93
|
+
if response.status_code == 422:
|
|
94
|
+
return response
|
|
95
|
+
|
|
96
|
+
if response.status_code >= 400:
|
|
97
|
+
response["HX-Reswap"] = "none"
|
|
98
|
+
response["HX-Trigger"] = json.dumps({
|
|
99
|
+
"showToast": {
|
|
100
|
+
"level": "error" if response.status_code >= 500 else "warning",
|
|
101
|
+
"message": ERROR_MESSAGES.get(
|
|
102
|
+
response.status_code, "An error occurred."
|
|
103
|
+
),
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
return response
|