platform-context 0.5.0__tar.gz

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 (35) hide show
  1. platform_context-0.5.0/.gitignore +50 -0
  2. platform_context-0.5.0/.trigger-ci +1 -0
  3. platform_context-0.5.0/CHANGELOG.md +56 -0
  4. platform_context-0.5.0/PKG-INFO +88 -0
  5. platform_context-0.5.0/README.md +56 -0
  6. platform_context-0.5.0/pyproject.toml +61 -0
  7. platform_context-0.5.0/src/platform_context/__init__.py +84 -0
  8. platform_context-0.5.0/src/platform_context/apps.py +11 -0
  9. platform_context-0.5.0/src/platform_context/audit.py +76 -0
  10. platform_context-0.5.0/src/platform_context/context.py +79 -0
  11. platform_context-0.5.0/src/platform_context/context_processors.py +76 -0
  12. platform_context-0.5.0/src/platform_context/db.py +51 -0
  13. platform_context-0.5.0/src/platform_context/exceptions.py +159 -0
  14. platform_context-0.5.0/src/platform_context/htmx.py +107 -0
  15. platform_context-0.5.0/src/platform_context/middleware.py +116 -0
  16. platform_context-0.5.0/src/platform_context/outbox.py +59 -0
  17. platform_context-0.5.0/src/platform_context/static/platform/css/pui-tokens.css +270 -0
  18. platform_context-0.5.0/src/platform_context/temporal_client.py +49 -0
  19. platform_context-0.5.0/src/platform_context/tenant_utils/__init__.py +25 -0
  20. platform_context-0.5.0/src/platform_context/tenant_utils/celery.py +87 -0
  21. platform_context-0.5.0/src/platform_context/tenant_utils/http_client.py +93 -0
  22. platform_context-0.5.0/src/platform_context/tenant_utils/middleware.py +57 -0
  23. platform_context-0.5.0/src/platform_context/tenant_utils/provisioning.py +171 -0
  24. platform_context-0.5.0/src/platform_context/tenant_utils/testing.py +96 -0
  25. platform_context-0.5.0/src/platform_context/testing/__init__.py +41 -0
  26. platform_context-0.5.0/src/platform_context/testing/assertions.py +300 -0
  27. platform_context-0.5.0/src/platform_context/testing/fixtures.py +149 -0
  28. platform_context-0.5.0/tests/__init__.py +0 -0
  29. platform_context-0.5.0/tests/settings.py +18 -0
  30. platform_context-0.5.0/tests/test_context.py +83 -0
  31. platform_context-0.5.0/tests/test_htmx.py +135 -0
  32. platform_context-0.5.0/tests/test_middleware.py +25 -0
  33. platform_context-0.5.0/tests/test_temporal_client.py +91 -0
  34. platform_context-0.5.0/tests/test_tenant_utils.py +223 -0
  35. platform_context-0.5.0/tests/test_testing.py +158 -0
@@ -0,0 +1,50 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *\.class
5
+ .venv/
6
+ venv/
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ # Django
11
+ *.log
12
+ db.sqlite3
13
+ media/
14
+ staticfiles/
15
+ # Environment
16
+ .env
17
+ .env.local
18
+ .env.prod
19
+
20
+ # Secrets (ADR-045) — NEVER commit plaintext
21
+ secrets.env
22
+ !secrets.enc.env
23
+ # IDE
24
+ .idea/
25
+ .vscode/
26
+ *.swp
27
+ # OS
28
+ .DS_Store
29
+ Thumbs.db
30
+ # Large files
31
+ *.sqlite3
32
+ *.log
33
+ output_batch/
34
+ app_backup/
35
+ docs_legacy/
36
+ # Sphinx build output
37
+ docs/_build/
38
+ *:Zone.Identifier
39
+ # Local Windsurf/MCP config — machine-specific, never commit
40
+ .windsurf/mcp_config.json
41
+ # Local infra plaintext secrets — NEVER commit
42
+ infra/*.env
43
+ infra/*.key
44
+ # Validate-ports CI draft (not yet integrated)
45
+ .github/workflows/validate-ports.yml
46
+ # Markdownlint local config override
47
+ .markdownlint.json
48
+ # Concept review drafts (local working copies, not for VCS)
49
+ docs/concepts/REVIEW-*
50
+ env_loader.py
@@ -0,0 +1 @@
1
+ triggered at 2026-02-20T18:25
@@ -0,0 +1,56 @@
1
+ # Changelog — platform-context
2
+
3
+ All notable changes to this package are documented here.
4
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
5
+
6
+ ---
7
+
8
+ ## [0.5.0] — current
9
+
10
+ ### Added
11
+ - `temporal_client.py` — Temporal workflow engine client integration (ADR-079)
12
+ - `tenant_utils/` — Shared tenant utility helpers
13
+ - `testing/` — Shared test fixtures and helpers for downstream packages (ADR-084)
14
+ - `outbox.py` — Transactional outbox pattern for reliable event publishing
15
+
16
+ ### Changed
17
+ - `middleware.py` — RequestContextMiddleware now sets tenant context per request
18
+ - `htmx.py` — HtmxErrorMiddleware hardened; handles non-HTMX requests gracefully
19
+ - `exceptions.py` — Platform-wide exception hierarchy extended
20
+
21
+ ---
22
+
23
+ ## [0.4.x]
24
+
25
+ ### Added
26
+ - `audit.py` — Shared audit log base model and mixin
27
+ - `db.py` — Database utilities (connection helpers, vendor detection)
28
+ - `context_processors.py` — Django context processors for platform-wide template vars
29
+ - `context.py` — Request context holder (thread-local + async-safe)
30
+
31
+ ### Changed
32
+ - `apps.py` — AppConfig registered as `platform_context`
33
+
34
+ ---
35
+
36
+ ## [0.3.x]
37
+
38
+ ### Added
39
+ - Initial package structure with `src/` layout
40
+ - Django 5.x compatibility (requires `Django>=5.0,<6.0`)
41
+ - Optional extras: `tenants` (django-tenants), `temporal` (temporalio)
42
+
43
+ ---
44
+
45
+ ## [0.2.x]
46
+
47
+ ### Added
48
+ - First stable release as shared foundation package
49
+ - Extracted from bfagent monolith (ADR-028)
50
+
51
+ ---
52
+
53
+ ## [0.1.0]
54
+
55
+ ### Added
56
+ - Initial extraction from bfagent-core (ADR-028: Platform Context Consolidation)
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: platform-context
3
+ Version: 0.5.0
4
+ Summary: Shared platform foundation for all Django projects (Context, Tenancy, Audit, Outbox)
5
+ Author-email: Achim Dehnert <achim@dehnert.com>
6
+ License: MIT
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Framework :: Django :: 5.0
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: django<6.0,>=5.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: factory-boy>=3.3; extra == 'dev'
17
+ Requires-Dist: pytest-django>=4.8; extra == 'dev'
18
+ Requires-Dist: pytest-mock>=3.12; extra == 'dev'
19
+ Requires-Dist: pytest>=8.0; extra == 'dev'
20
+ Provides-Extra: temporal
21
+ Requires-Dist: temporalio>=1.7.0; extra == 'temporal'
22
+ Provides-Extra: tenants
23
+ Requires-Dist: django-tenants>=3.6; extra == 'tenants'
24
+ Requires-Dist: httpx>=0.27; extra == 'tenants'
25
+ Requires-Dist: tenant-schemas-celery>=2.0; extra == 'tenants'
26
+ Provides-Extra: testing
27
+ Requires-Dist: factory-boy>=3.3; extra == 'testing'
28
+ Requires-Dist: pytest-django>=4.8; extra == 'testing'
29
+ Requires-Dist: pytest-mock>=3.12; extra == 'testing'
30
+ Requires-Dist: pytest>=8.0; extra == 'testing'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # platform-context
34
+
35
+ Shared platform foundation for all Django projects in the iil.pet ecosystem.
36
+
37
+ ## What it provides
38
+
39
+ - **Request Context** — Thread-safe context propagation (tenant_id, user_id, request_id)
40
+ - **Multi-Tenancy Middleware** — Subdomain-based tenant resolution + Postgres RLS
41
+ - **Audit Event Logging** — Structured audit trail (model-agnostic)
42
+ - **Outbox Pattern** — Reliable event publishing within transactions
43
+ - **Exception Hierarchy** — Structured platform exceptions
44
+ - **Template Context Processors** — Tenant/permission info in Django templates
45
+
46
+ ## Installation
47
+
48
+ ```bash
49
+ pip install -e packages/platform-context
50
+ ```
51
+
52
+ Add to `INSTALLED_APPS`:
53
+
54
+ ```python
55
+ INSTALLED_APPS = [
56
+ ...
57
+ "platform_context",
58
+ ]
59
+ ```
60
+
61
+ ## Usage
62
+
63
+ ```python
64
+ from platform_context import get_context, set_tenant, set_user_id
65
+ from platform_context.middleware import SubdomainTenantMiddleware
66
+ from platform_context.audit import emit_audit_event
67
+ from platform_context.exceptions import TenantNotFoundError
68
+ ```
69
+
70
+ ## Configuration
71
+
72
+ | Setting | Default | Description |
73
+ |---------|---------|-------------|
74
+ | `TENANT_BASE_DOMAIN` | `"localhost"` | Base domain for subdomain resolution |
75
+ | `TENANT_MODEL` | `"tenancy.Organization"` | Dotted path to tenant model |
76
+ | `TENANT_SLUG_FIELD` | `"slug"` | Field name for slug lookup |
77
+ | `TENANT_ID_FIELD` | `"tenant_id"` | Field name for tenant_id |
78
+ | `TENANT_ALLOW_LOCALHOST` | `False` | Allow admin access without tenant (dev) |
79
+ | `PLATFORM_AUDIT_MODEL` | `None` | Dotted path to AuditEvent model |
80
+ | `PLATFORM_OUTBOX_MODEL` | `None` | Dotted path to OutboxMessage model |
81
+
82
+ ## Relation to bfagent-core
83
+
84
+ `platform-context` is the framework-agnostic foundation extracted from
85
+ `bfagent-core` (see ADR-028). `bfagent-core` now depends on
86
+ `platform-context` and re-exports its public API for backward compatibility.
87
+
88
+ New projects should depend on `platform-context` directly.
@@ -0,0 +1,56 @@
1
+ # platform-context
2
+
3
+ Shared platform foundation for all Django projects in the iil.pet ecosystem.
4
+
5
+ ## What it provides
6
+
7
+ - **Request Context** — Thread-safe context propagation (tenant_id, user_id, request_id)
8
+ - **Multi-Tenancy Middleware** — Subdomain-based tenant resolution + Postgres RLS
9
+ - **Audit Event Logging** — Structured audit trail (model-agnostic)
10
+ - **Outbox Pattern** — Reliable event publishing within transactions
11
+ - **Exception Hierarchy** — Structured platform exceptions
12
+ - **Template Context Processors** — Tenant/permission info in Django templates
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pip install -e packages/platform-context
18
+ ```
19
+
20
+ Add to `INSTALLED_APPS`:
21
+
22
+ ```python
23
+ INSTALLED_APPS = [
24
+ ...
25
+ "platform_context",
26
+ ]
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```python
32
+ from platform_context import get_context, set_tenant, set_user_id
33
+ from platform_context.middleware import SubdomainTenantMiddleware
34
+ from platform_context.audit import emit_audit_event
35
+ from platform_context.exceptions import TenantNotFoundError
36
+ ```
37
+
38
+ ## Configuration
39
+
40
+ | Setting | Default | Description |
41
+ |---------|---------|-------------|
42
+ | `TENANT_BASE_DOMAIN` | `"localhost"` | Base domain for subdomain resolution |
43
+ | `TENANT_MODEL` | `"tenancy.Organization"` | Dotted path to tenant model |
44
+ | `TENANT_SLUG_FIELD` | `"slug"` | Field name for slug lookup |
45
+ | `TENANT_ID_FIELD` | `"tenant_id"` | Field name for tenant_id |
46
+ | `TENANT_ALLOW_LOCALHOST` | `False` | Allow admin access without tenant (dev) |
47
+ | `PLATFORM_AUDIT_MODEL` | `None` | Dotted path to AuditEvent model |
48
+ | `PLATFORM_OUTBOX_MODEL` | `None` | Dotted path to OutboxMessage model |
49
+
50
+ ## Relation to bfagent-core
51
+
52
+ `platform-context` is the framework-agnostic foundation extracted from
53
+ `bfagent-core` (see ADR-028). `bfagent-core` now depends on
54
+ `platform-context` and re-exports its public API for backward compatibility.
55
+
56
+ New projects should depend on `platform-context` directly.
@@ -0,0 +1,61 @@
1
+ [project]
2
+ name = "platform-context"
3
+ version = "0.5.0"
4
+ description = "Shared platform foundation for all Django projects (Context, Tenancy, Audit, Outbox)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = {text = "MIT"}
8
+ authors = [
9
+ {name = "Achim Dehnert", email = "achim@dehnert.com"}
10
+ ]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Framework :: Django :: 5.0",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ ]
19
+
20
+ dependencies = [
21
+ "Django>=5.0,<6.0",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ dev = [
26
+ "pytest>=8.0",
27
+ "pytest-django>=4.8",
28
+ "pytest-mock>=3.12",
29
+ "factory-boy>=3.3",
30
+ ]
31
+ testing = [
32
+ "pytest>=8.0",
33
+ "pytest-django>=4.8",
34
+ "pytest-mock>=3.12",
35
+ "factory-boy>=3.3",
36
+ ]
37
+ tenants = [
38
+ "django-tenants>=3.6",
39
+ "tenant-schemas-celery>=2.0",
40
+ "httpx>=0.27",
41
+ ]
42
+ temporal = [
43
+ "temporalio>=1.7.0",
44
+ ]
45
+
46
+ [build-system]
47
+ requires = ["hatchling"]
48
+ build-backend = "hatchling.build"
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["src/platform_context"]
52
+
53
+ [tool.pytest.ini_options]
54
+ DJANGO_SETTINGS_MODULE = "tests.settings"
55
+ python_files = ["test_*.py"]
56
+ pythonpath = ["."]
57
+ testpaths = ["tests"]
58
+
59
+ [tool.ruff]
60
+ line-length = 100
61
+ target-version = "py311"
@@ -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
@@ -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