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.
@@ -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
+ ]
@@ -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
@@ -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