feat: uniform extensibility across every module
Close the remaining places where an extension point was hardcoded, and expose a stable public import surface for every app. - files: register_storage_backend(...) / STORAGE_BACKENDS[name]["CLASS"]; unknown backends now fail loudly instead of silently using local - files: PipelineStepRegistry + @pipeline_step; PIPELINE_EXECUTOR setting - billing: INVOICE_PDF_BUILDER setting - security: TWO_FACTOR_SERVICE / TWO_FACTOR_RECOVERY_SERVICE settings - all app packages expose lazy public exports (PEP 562); infrasynth.shared re-exports its primitives eagerly - README documents the per-module extension-point table - tests/test_extensibility.py pins each hook plus the public surface
This commit is contained in:
parent
21731b9887
commit
5df0be1f5c
22 changed files with 1002 additions and 20 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -10,6 +10,18 @@ not hand-pick a version (see `../AGENTS.backend-packages.md` §8).
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Uniform extensibility across every module.** Storage backends can be
|
||||||
|
registered (`register_storage_backend` or `STORAGE_BACKENDS[name]["CLASS"]`),
|
||||||
|
pipeline steps via `PipelineStepRegistry`/`@pipeline_step`, the pipeline
|
||||||
|
executor via `PIPELINE_EXECUTOR`, the invoice PDF via `INVOICE_PDF_BUILDER`,
|
||||||
|
and the 2FA method via `TWO_FACTOR_SERVICE`/`TWO_FACTOR_RECOVERY_SERVICE` — all
|
||||||
|
through settings/registries, with no kit edits. The other modules already
|
||||||
|
resolved extensions through dotted paths (gateways, channels, tasks, inbound
|
||||||
|
handlers, scanner) and are now documented as such.
|
||||||
|
- **Lazy public API per app package.** `from infrasynth.security import
|
||||||
|
AuthorizationService`, `from infrasynth.billing import EntitlementService`,
|
||||||
|
etc. resolve via PEP 562 without importing models before the app registry is
|
||||||
|
ready; `infrasynth.shared` re-exports its primitives eagerly.
|
||||||
- **Composable per-endpoint gates** (`infrasynth.gates`): declare
|
- **Composable per-endpoint gates** (`infrasynth.gates`): declare
|
||||||
`infrasynth_gates = [...]` (and/or `@gated(...)` on a viewset action) with
|
`infrasynth_gates = [...]` (and/or `@gated(...)` on a viewset action) with
|
||||||
`TwoFactorGate`, `AltchaGate`, `EntitlementGate`, `FeatureGate`,
|
`TwoFactorGate`, `AltchaGate`, `EntitlementGate`, `FeatureGate`,
|
||||||
|
|
|
||||||
24
README.md
24
README.md
|
|
@ -73,7 +73,29 @@ class SlackChannel(BaseChannel):
|
||||||
def from_config(cls, config): return cls(**config)
|
def from_config(cls, config): return cls(**config)
|
||||||
```
|
```
|
||||||
|
|
||||||
Registries are populated in `apps.py:ready()`: `FeatureRegistry`, `EventRegistry`, `VariableResolverRegistry`, `DataValidatorRegistry`.
|
Registries are populated in `apps.py:ready()`: `FeatureRegistry`, `EventRegistry`, `VariableResolverRegistry`, `DataValidatorRegistry`, `PipelineStepRegistry`.
|
||||||
|
|
||||||
|
### Extension points, per module
|
||||||
|
|
||||||
|
Every module is swappable through settings/registries — no kit edits, no forks:
|
||||||
|
|
||||||
|
| Module | How a consumer extends it |
|
||||||
|
|---|---|
|
||||||
|
| `shared` | Use the primitives directly (`Result`, `encrypt/decrypt`, `get_setting`, protocols) |
|
||||||
|
| `api` | Override `renderer/pagination/exception handler` per view; add idempotency with `@idempotent` |
|
||||||
|
| `tenancy` | `TenantService`, scoped managers, `tenant_context`; configure `INFRASYNTH_TENANCY` |
|
||||||
|
| `security` | `AUTH_BACKEND_CLASS`; `TWO_FACTOR_SERVICE`/`TWO_FACTOR_RECOVERY_SERVICE`; `PasswordPolicyValidator`; custom permission classes; `infrasynth.gates` |
|
||||||
|
| `audit` | `EXCLUDED_MODELS`/`EXCLUDED_FIELDS`/`SENSITIVE_KEYS`/`STORE_IN_DB`; listen to `security_event_occurred` |
|
||||||
|
| `features` | `FeatureRegistry.register(...)` or `INFRASYNTH_FEATURES["FLAGS"]`; `FeatureFlagOverride` rows |
|
||||||
|
| `billing` | Gateway via `PaymentGateway.gateway_class`; `INVOICE_PDF_BUILDER`; `Entitlement`/`plan` |
|
||||||
|
| `files` | `register_storage_backend(...)` or `STORAGE_BACKENDS[name]["CLASS"]`; `PipelineStepRegistry.register(...)`; `PIPELINE_EXECUTOR`; `VIRUS_SCANNER` |
|
||||||
|
| `notifications` | `INFRASYNTH_NOTIFICATIONS["CHANNELS"]` dotted paths; `VariableResolverRegistry` |
|
||||||
|
| `webhooks` | `EventRegistry`; `InboundEndpoint.handler` dotted path; signature algorithm setting |
|
||||||
|
| `workflows` | `DataValidatorRegistry`; node/transition data; `WorkflowAwareModel` |
|
||||||
|
| `scheduler` | `ScheduledTask.task_path` dotted path (any Celery task or callable) |
|
||||||
|
|
||||||
|
A consuming app never imports another app's models directly — it uses the
|
||||||
|
services, registries, signals, and `infrasynth.gates` documented here.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,8 @@ INFRASYNTH_SECURITY = {
|
||||||
"TWO_FACTOR_ISSUER_NAME": "InfraSynth",
|
"TWO_FACTOR_ISSUER_NAME": "InfraSynth",
|
||||||
"TWO_FACTOR_RECOVERY_CODES_COUNT": 8,
|
"TWO_FACTOR_RECOVERY_CODES_COUNT": 8,
|
||||||
"TWO_FACTOR_TOTP_VALIDITY_WINDOW": 1,
|
"TWO_FACTOR_TOTP_VALIDITY_WINDOW": 1,
|
||||||
|
"TWO_FACTOR_SERVICE": "infrasynth.security.two_factor.services.TOTPService",
|
||||||
|
"TWO_FACTOR_RECOVERY_SERVICE": "infrasynth.security.two_factor.services.RecoveryCodeService",
|
||||||
"PRE_AUTH_TOKEN_LIFETIME_MINUTES": 5,
|
"PRE_AUTH_TOKEN_LIFETIME_MINUTES": 5,
|
||||||
"ALTCHA_DIFFICULTY": 10000,
|
"ALTCHA_DIFFICULTY": 10000,
|
||||||
"ALTCHA_CHALLENGE_EXPIRY_SECONDS": 300,
|
"ALTCHA_CHALLENGE_EXPIRY_SECONDS": 300,
|
||||||
|
|
@ -246,6 +248,7 @@ INFRASYNTH_FILES = {
|
||||||
"MAX_UPLOAD_SIZE_MB": 100,
|
"MAX_UPLOAD_SIZE_MB": 100,
|
||||||
"ENABLE_PROCESSING_PIPELINES": True,
|
"ENABLE_PROCESSING_PIPELINES": True,
|
||||||
"PROCESSING_BACKEND": "celery",
|
"PROCESSING_BACKEND": "celery",
|
||||||
|
"PIPELINE_EXECUTOR": "infrasynth.files.processing.PipelineExecutor",
|
||||||
"ENABLE_X_SENDFILE": False,
|
"ENABLE_X_SENDFILE": False,
|
||||||
"VIRUS_SCANNER": "noop",
|
"VIRUS_SCANNER": "noop",
|
||||||
"CLAMAV_SOCKET": "/var/run/clamav/clamd.ctl",
|
"CLAMAV_SOCKET": "/var/run/clamav/clamd.ctl",
|
||||||
|
|
@ -346,6 +349,7 @@ INFRASYNTH_TENANCY = {
|
||||||
|
|
||||||
INFRASYNTH_BILLING = {
|
INFRASYNTH_BILLING = {
|
||||||
"INVOICE_NUMBER_PREFIX": "INV-",
|
"INVOICE_NUMBER_PREFIX": "INV-",
|
||||||
|
"INVOICE_PDF_BUILDER": "infrasynth.billing.invoice_generator._build_invoice_pdf",
|
||||||
"GRACE_PERIOD_DAYS": 5,
|
"GRACE_PERIOD_DAYS": 5,
|
||||||
"MAX_RETRY_FAILED_PAYMENTS": 3,
|
"MAX_RETRY_FAILED_PAYMENTS": 3,
|
||||||
"DEFAULT_CURRENCY": "USD",
|
"DEFAULT_CURRENCY": "USD",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""Public surface of ``infrasynth.audit``.
|
||||||
|
|
||||||
|
The exports are resolved lazily (PEP 562) so importing this package never
|
||||||
|
triggers Django model imports before the app registry is ready. Import from
|
||||||
|
here::
|
||||||
|
|
||||||
|
from infrasynth.audit import ModelChangeLog, OptionalAuditableMixin
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"APIInteractionLog",
|
||||||
|
"ModelChangeLog",
|
||||||
|
"OptionalAuditableMixin",
|
||||||
|
"SecurityEvent",
|
||||||
|
"model_changed",
|
||||||
|
"security_event_occurred",
|
||||||
|
]
|
||||||
|
|
||||||
|
# public name -> module (relative to the ``infrasynth`` package) that defines it
|
||||||
|
_EXPORTS: dict[str, str] = {
|
||||||
|
"APIInteractionLog": "audit.models",
|
||||||
|
"ModelChangeLog": "audit.models",
|
||||||
|
"OptionalAuditableMixin": "audit.mixins",
|
||||||
|
"SecurityEvent": "audit.models",
|
||||||
|
"model_changed": "audit.signals",
|
||||||
|
"security_event_occurred": "audit.signals",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> Any:
|
||||||
|
module_path = _EXPORTS.get(name)
|
||||||
|
if module_path is not None:
|
||||||
|
return getattr(import_module(f"infrasynth.{module_path}"), name)
|
||||||
|
# Let ``infrasynth.<app>.<submodule>`` resolve as usual.
|
||||||
|
try:
|
||||||
|
return import_module(f"{__name__}.{name}")
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def __dir__() -> list[str]:
|
||||||
|
return sorted(set(__all__) | set(globals()))
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
"""Public surface of ``infrasynth.billing``.
|
||||||
|
|
||||||
|
The exports are resolved lazily (PEP 562) so importing this package never
|
||||||
|
triggers Django model imports before the app registry is ready. Import from
|
||||||
|
here::
|
||||||
|
|
||||||
|
from infrasynth.billing import EntitlementService, BillingService, Plan
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"App",
|
||||||
|
"BasePaymentGateway",
|
||||||
|
"BillingService",
|
||||||
|
"Entitlement",
|
||||||
|
"EntitlementService",
|
||||||
|
"Invoice",
|
||||||
|
"PaymentGateway",
|
||||||
|
"PaymentTransaction",
|
||||||
|
"Plan",
|
||||||
|
"Subscription",
|
||||||
|
]
|
||||||
|
|
||||||
|
# public name -> module (relative to the ``infrasynth`` package) that defines it
|
||||||
|
_EXPORTS: dict[str, str] = {
|
||||||
|
"App": "billing.models",
|
||||||
|
"BasePaymentGateway": "billing.gateways.base",
|
||||||
|
"BillingService": "billing.services",
|
||||||
|
"Entitlement": "billing.models",
|
||||||
|
"EntitlementService": "billing.entitlements",
|
||||||
|
"Invoice": "billing.models",
|
||||||
|
"PaymentGateway": "billing.models",
|
||||||
|
"PaymentTransaction": "billing.models",
|
||||||
|
"Plan": "billing.models",
|
||||||
|
"Subscription": "billing.models",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> Any:
|
||||||
|
module_path = _EXPORTS.get(name)
|
||||||
|
if module_path is not None:
|
||||||
|
return getattr(import_module(f"infrasynth.{module_path}"), name)
|
||||||
|
# Let ``infrasynth.<app>.<submodule>`` resolve as usual.
|
||||||
|
try:
|
||||||
|
return import_module(f"{__name__}.{name}")
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def __dir__() -> list[str]:
|
||||||
|
return sorted(set(__all__) | set(globals()))
|
||||||
|
|
@ -6,6 +6,20 @@ from celery import shared_task
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_invoice_pdf_builder():
|
||||||
|
"""Returns the configured PDF builder (``INFRASYNTH_BILLING["INVOICE_PDF_BUILDER"]``)."""
|
||||||
|
from django.utils.module_loading import import_string
|
||||||
|
|
||||||
|
from infrasynth.shared.settings_utils import get_setting
|
||||||
|
|
||||||
|
path = get_setting(
|
||||||
|
"INFRASYNTH_BILLING",
|
||||||
|
"INVOICE_PDF_BUILDER",
|
||||||
|
"infrasynth.billing.invoice_generator._build_invoice_pdf",
|
||||||
|
)
|
||||||
|
return import_string(path)
|
||||||
|
|
||||||
|
|
||||||
@shared_task(
|
@shared_task(
|
||||||
name="infrasynth.billing.generate_invoice_pdf",
|
name="infrasynth.billing.generate_invoice_pdf",
|
||||||
bind=True,
|
bind=True,
|
||||||
|
|
@ -30,7 +44,7 @@ def generate_invoice_pdf(self, invoice_id, tenant_id=None):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pdf_bytes = _build_invoice_pdf(invoice)
|
pdf_bytes = get_invoice_pdf_builder()(invoice)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.exception("PDF generation failed for invoice %s", invoice_id)
|
logger.exception("PDF generation failed for invoice %s", invoice_id)
|
||||||
raise self.retry(exc=exc) from exc
|
raise self.retry(exc=exc) from exc
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
"""Public surface of ``infrasynth.features``.
|
||||||
|
|
||||||
|
The exports are resolved lazily (PEP 562) so importing this package never
|
||||||
|
triggers Django model imports before the app registry is ready. Import from
|
||||||
|
here::
|
||||||
|
|
||||||
|
from infrasynth.features import FeatureService, FeatureRegistry, feature_required
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FeatureFlag",
|
||||||
|
"FeatureFlagOverride",
|
||||||
|
"FeatureRegistry",
|
||||||
|
"FeatureService",
|
||||||
|
"feature_required",
|
||||||
|
]
|
||||||
|
|
||||||
|
# public name -> module (relative to the ``infrasynth`` package) that defines it
|
||||||
|
_EXPORTS: dict[str, str] = {
|
||||||
|
"FeatureFlag": "features.models",
|
||||||
|
"FeatureFlagOverride": "features.models",
|
||||||
|
"FeatureRegistry": "features.registry",
|
||||||
|
"FeatureService": "features.services",
|
||||||
|
"feature_required": "features.decorators",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> Any:
|
||||||
|
module_path = _EXPORTS.get(name)
|
||||||
|
if module_path is not None:
|
||||||
|
return getattr(import_module(f"infrasynth.{module_path}"), name)
|
||||||
|
# Let ``infrasynth.<app>.<submodule>`` resolve as usual.
|
||||||
|
try:
|
||||||
|
return import_module(f"{__name__}.{name}")
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def __dir__() -> list[str]:
|
||||||
|
return sorted(set(__all__) | set(globals()))
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
"""Public surface of ``infrasynth.files``.
|
||||||
|
|
||||||
|
The exports are resolved lazily (PEP 562) so importing this package never
|
||||||
|
triggers Django model imports before the app registry is ready. Import from
|
||||||
|
here::
|
||||||
|
|
||||||
|
from infrasynth.files import FileService, get_storage_backend, PipelineStepRegistry
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FileCategory",
|
||||||
|
"FileService",
|
||||||
|
"PipelineExecution",
|
||||||
|
"PipelineExecutor",
|
||||||
|
"PipelineStepRegistry",
|
||||||
|
"ProcessingPipeline",
|
||||||
|
"ScanResult",
|
||||||
|
"StoredFile",
|
||||||
|
"VirusScanner",
|
||||||
|
"get_pipeline_executor",
|
||||||
|
"get_scanner",
|
||||||
|
"get_storage_backend",
|
||||||
|
"pipeline_step",
|
||||||
|
"register_storage_backend",
|
||||||
|
"save_file",
|
||||||
|
]
|
||||||
|
|
||||||
|
# public name -> module (relative to the ``infrasynth`` package) that defines it
|
||||||
|
_EXPORTS: dict[str, str] = {
|
||||||
|
"FileCategory": "files.models",
|
||||||
|
"FileService": "files.services",
|
||||||
|
"PipelineExecution": "files.models",
|
||||||
|
"PipelineExecutor": "files.processing",
|
||||||
|
"PipelineStepRegistry": "files.processing",
|
||||||
|
"ProcessingPipeline": "files.models",
|
||||||
|
"ScanResult": "files.scanner",
|
||||||
|
"StoredFile": "files.models",
|
||||||
|
"VirusScanner": "files.scanner",
|
||||||
|
"get_pipeline_executor": "files.processing",
|
||||||
|
"get_scanner": "files.scanner",
|
||||||
|
"get_storage_backend": "files.storage",
|
||||||
|
"pipeline_step": "files.processing",
|
||||||
|
"register_storage_backend": "files.storage",
|
||||||
|
"save_file": "files.storage",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> Any:
|
||||||
|
module_path = _EXPORTS.get(name)
|
||||||
|
if module_path is not None:
|
||||||
|
return getattr(import_module(f"infrasynth.{module_path}"), name)
|
||||||
|
# Let ``infrasynth.<app>.<submodule>`` resolve as usual.
|
||||||
|
try:
|
||||||
|
return import_module(f"{__name__}.{name}")
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def __dir__() -> list[str]:
|
||||||
|
return sorted(set(__all__) | set(globals()))
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
@ -7,6 +9,61 @@ from django.utils import timezone
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineStepRegistry:
|
||||||
|
"""Registry of processing steps so apps add steps without editing the kit.
|
||||||
|
|
||||||
|
Register in ``apps.py:ready()``::
|
||||||
|
|
||||||
|
from infrasynth.files.processing import PipelineStepRegistry
|
||||||
|
|
||||||
|
def strip_exif(data: bytes, mime_type: str, params: dict):
|
||||||
|
...
|
||||||
|
return data, mime_type
|
||||||
|
|
||||||
|
PipelineStepRegistry.register("strip_exif", strip_exif)
|
||||||
|
|
||||||
|
Or decorate: ``@pipeline_step("strip_exif")``. A registered step takes
|
||||||
|
``(data, mime_type, params)`` and returns ``(data, mime_type)``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_steps: dict[str, Callable] = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def register(cls, name: str, func: Callable | None = None):
|
||||||
|
if func is None:
|
||||||
|
|
||||||
|
def decorator(inner: Callable) -> Callable:
|
||||||
|
cls._steps[name] = inner
|
||||||
|
return inner
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
cls._steps[name] = func
|
||||||
|
return func
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls, name: str) -> Callable | None:
|
||||||
|
return cls._steps.get(name)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def all(cls) -> dict[str, Callable]:
|
||||||
|
return dict(cls._steps)
|
||||||
|
|
||||||
|
|
||||||
|
def pipeline_step(name: str):
|
||||||
|
"""Decorator form of :meth:`PipelineStepRegistry.register`."""
|
||||||
|
return PipelineStepRegistry.register(name)
|
||||||
|
|
||||||
|
|
||||||
|
def get_pipeline_executor() -> Any:
|
||||||
|
"""Returns an executor instance (``INFRASYNTH_FILES["PIPELINE_EXECUTOR"]``)."""
|
||||||
|
from django.utils.module_loading import import_string
|
||||||
|
|
||||||
|
from infrasynth.shared.settings_utils import get_setting
|
||||||
|
|
||||||
|
path = get_setting("INFRASYNTH_FILES", "PIPELINE_EXECUTOR", "infrasynth.files.processing.PipelineExecutor")
|
||||||
|
return import_string(path)()
|
||||||
|
|
||||||
|
|
||||||
class PipelineExecutor:
|
class PipelineExecutor:
|
||||||
"""Executes a processing pipeline over a stored file, step by step."""
|
"""Executes a processing pipeline over a stored file, step by step."""
|
||||||
|
|
||||||
|
|
@ -25,7 +82,8 @@ class PipelineExecutor:
|
||||||
for step in steps:
|
for step in steps:
|
||||||
step_type = step.get("type")
|
step_type = step.get("type")
|
||||||
params = step.get("params", {})
|
params = step.get("params", {})
|
||||||
handler = getattr(self, f"_step_{step_type}", None)
|
registered = PipelineStepRegistry.get(step_type)
|
||||||
|
handler = registered or getattr(self, f"_step_{step_type}", None)
|
||||||
if handler is None:
|
if handler is None:
|
||||||
raise ValueError(f"Unknown pipeline step type: '{step_type}'")
|
raise ValueError(f"Unknown pipeline step type: '{step_type}'")
|
||||||
data, mime_type = handler(data, mime_type, params)
|
data, mime_type = handler(data, mime_type, params)
|
||||||
|
|
@ -153,4 +211,4 @@ def run_pipeline_execution(self, execution_id, tenant_id=None):
|
||||||
|
|
||||||
tenant = Tenant.objects.filter(pk=tenant_id).first() if tenant_id else execution.tenant
|
tenant = Tenant.objects.filter(pk=tenant_id).first() if tenant_id else execution.tenant
|
||||||
with tenant_context(tenant):
|
with tenant_context(tenant):
|
||||||
return PipelineExecutor().execute(execution)
|
return get_pipeline_executor().execute(execution)
|
||||||
|
|
|
||||||
|
|
@ -195,9 +195,9 @@ class FileService:
|
||||||
)
|
)
|
||||||
backend = get_setting("INFRASYNTH_FILES", "PROCESSING_BACKEND", "celery")
|
backend = get_setting("INFRASYNTH_FILES", "PROCESSING_BACKEND", "celery")
|
||||||
if backend == "sync":
|
if backend == "sync":
|
||||||
from .processing import PipelineExecutor
|
from .processing import get_pipeline_executor
|
||||||
|
|
||||||
PipelineExecutor().execute(execution)
|
get_pipeline_executor().execute(execution)
|
||||||
else:
|
else:
|
||||||
from .processing import run_pipeline_execution
|
from .processing import run_pipeline_execution
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@ class _CloudinaryStorage:
|
||||||
return cloudinary.utils.cloudinary_url(name, sign_url=True, expires_at=expiry_seconds)[0]
|
return cloudinary.utils.cloudinary_url(name, sign_url=True, expires_at=expiry_seconds)[0]
|
||||||
|
|
||||||
|
|
||||||
_BACKEND_CLASSES = {
|
_BUILTIN_BACKENDS: dict[str, type] = {
|
||||||
"local": _LocalStorage,
|
"local": _LocalStorage,
|
||||||
"S3": _S3Storage,
|
"S3": _S3Storage,
|
||||||
"s3": _S3Storage,
|
"s3": _S3Storage,
|
||||||
|
|
@ -143,17 +143,51 @@ _BACKEND_CLASSES = {
|
||||||
"cloudinary": _CloudinaryStorage,
|
"cloudinary": _CloudinaryStorage,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Apps register their own backends at startup (``apps.py:ready()``) so a custom
|
||||||
|
# storage backend never requires editing the kit.
|
||||||
|
_CUSTOM_BACKENDS: dict[str, object] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register_storage_backend(name: str, factory: object) -> None:
|
||||||
|
"""Registers a storage backend factory/class under ``name``.
|
||||||
|
|
||||||
|
``factory`` is called with the backend's config dict and must implement the
|
||||||
|
storage protocol (``save/open/exists/url/delete/generate_signed_url``).
|
||||||
|
"""
|
||||||
|
_CUSTOM_BACKENDS[str(name)] = factory
|
||||||
|
|
||||||
|
|
||||||
def get_storage_backend(backend_name: str | None = None):
|
def get_storage_backend(backend_name: str | None = None):
|
||||||
"""Returns a storage backend instance by name (defaults to settings config)."""
|
"""Returns a storage backend instance by name (defaults to settings config).
|
||||||
|
|
||||||
|
Resolution order: ``STORAGE_BACKENDS[<name>]["CLASS"]`` (dotted path) →
|
||||||
|
a backend registered via :func:`register_storage_backend` → the built-in
|
||||||
|
backends. An unknown name raises rather than silently falling back to local.
|
||||||
|
"""
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
from django.utils.module_loading import import_string
|
||||||
|
|
||||||
config = getattr(settings, "INFRASYNTH_FILES", {})
|
config = getattr(settings, "INFRASYNTH_FILES", {})
|
||||||
if not backend_name:
|
if not backend_name:
|
||||||
backend_name = get_setting("INFRASYNTH_FILES", "DEFAULT_STORAGE_BACKEND", "local")
|
backend_name = get_setting("INFRASYNTH_FILES", "DEFAULT_STORAGE_BACKEND", "local")
|
||||||
backend_config = config.get("STORAGE_BACKENDS", {}).get(backend_name, {})
|
backend_name = str(backend_name)
|
||||||
backend_class = _BACKEND_CLASSES.get(str(backend_name), _LocalStorage)
|
backend_config = (config.get("STORAGE_BACKENDS", {}) or {}).get(backend_name, {}) or {}
|
||||||
return backend_class(backend_config or {})
|
|
||||||
|
class_path = (backend_config or {}).get("CLASS")
|
||||||
|
if class_path:
|
||||||
|
backend_class = import_string(class_path)
|
||||||
|
elif backend_name in _CUSTOM_BACKENDS:
|
||||||
|
backend_class = _CUSTOM_BACKENDS[backend_name]
|
||||||
|
else:
|
||||||
|
backend_class = _BUILTIN_BACKENDS.get(backend_name)
|
||||||
|
if backend_class is None:
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
f"Unknown storage backend '{backend_name}'. Register it with "
|
||||||
|
"infrasynth.files.storage.register_storage_backend or set "
|
||||||
|
f'INFRASYNTH_FILES["STORAGE_BACKENDS"]["{backend_name}"]["CLASS"].'
|
||||||
|
)
|
||||||
|
return backend_class(backend_config)
|
||||||
|
|
||||||
|
|
||||||
def save_file(file_obj, storage_key: str, backend: str = "local"):
|
def save_file(file_obj, storage_key: str, backend: str = "local"):
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""Public surface of ``infrasynth.notifications``.
|
||||||
|
|
||||||
|
The exports are resolved lazily (PEP 562) so importing this package never
|
||||||
|
triggers Django model imports before the app registry is ready. Import from
|
||||||
|
here::
|
||||||
|
|
||||||
|
from infrasynth.notifications import NotificationService, ChannelConfig, BaseChannel
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Attachment",
|
||||||
|
"BaseChannel",
|
||||||
|
"ChannelConfig",
|
||||||
|
"NotificationDispatch",
|
||||||
|
"NotificationService",
|
||||||
|
"NotificationTemplate",
|
||||||
|
"VariableResolverRegistry",
|
||||||
|
]
|
||||||
|
|
||||||
|
# public name -> module (relative to the ``infrasynth`` package) that defines it
|
||||||
|
_EXPORTS: dict[str, str] = {
|
||||||
|
"Attachment": "notifications.channels.base",
|
||||||
|
"BaseChannel": "notifications.channels.base",
|
||||||
|
"ChannelConfig": "notifications.models",
|
||||||
|
"NotificationDispatch": "notifications.models",
|
||||||
|
"NotificationService": "notifications.services",
|
||||||
|
"NotificationTemplate": "notifications.models",
|
||||||
|
"VariableResolverRegistry": "notifications.resolvers",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> Any:
|
||||||
|
module_path = _EXPORTS.get(name)
|
||||||
|
if module_path is not None:
|
||||||
|
return getattr(import_module(f"infrasynth.{module_path}"), name)
|
||||||
|
# Let ``infrasynth.<app>.<submodule>`` resolve as usual.
|
||||||
|
try:
|
||||||
|
return import_module(f"{__name__}.{name}")
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def __dir__() -> list[str]:
|
||||||
|
return sorted(set(__all__) | set(globals()))
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""Public surface of ``infrasynth.scheduler``.
|
||||||
|
|
||||||
|
The exports are resolved lazily (PEP 562) so importing this package never
|
||||||
|
triggers Django model imports before the app registry is ready. Import from
|
||||||
|
here::
|
||||||
|
|
||||||
|
from infrasynth.scheduler import TaskService, ScheduledTask, TaskExecution
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ScheduledTask",
|
||||||
|
"TaskExecution",
|
||||||
|
"TaskService",
|
||||||
|
]
|
||||||
|
|
||||||
|
# public name -> module (relative to the ``infrasynth`` package) that defines it
|
||||||
|
_EXPORTS: dict[str, str] = {
|
||||||
|
"ScheduledTask": "scheduler.models",
|
||||||
|
"TaskExecution": "scheduler.models",
|
||||||
|
"TaskService": "scheduler.services",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> Any:
|
||||||
|
module_path = _EXPORTS.get(name)
|
||||||
|
if module_path is not None:
|
||||||
|
return getattr(import_module(f"infrasynth.{module_path}"), name)
|
||||||
|
# Let ``infrasynth.<app>.<submodule>`` resolve as usual.
|
||||||
|
try:
|
||||||
|
return import_module(f"{__name__}.{name}")
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def __dir__() -> list[str]:
|
||||||
|
return sorted(set(__all__) | set(globals()))
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""Public surface of ``infrasynth.security``.
|
||||||
|
|
||||||
|
The exports are resolved lazily (PEP 562) so importing this package never
|
||||||
|
triggers Django model imports before the app registry is ready. Import from
|
||||||
|
here::
|
||||||
|
|
||||||
|
from infrasynth.security import AuthorizationService, HybridPermission, CookieJWTAuthentication
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ALTCHAService",
|
||||||
|
"APIKeyAuthentication",
|
||||||
|
"AuthorizationService",
|
||||||
|
"CookieJWTAuthentication",
|
||||||
|
"EmailOrUsernameBackend",
|
||||||
|
"HybridPermission",
|
||||||
|
"IsAuthenticatedAndPermitted",
|
||||||
|
"LoginAttemptGuard",
|
||||||
|
"PasswordPolicyValidator",
|
||||||
|
"RecoveryCodeService",
|
||||||
|
"SystemUser",
|
||||||
|
"TOTPService",
|
||||||
|
"get_recovery_code_service",
|
||||||
|
"get_two_factor_service",
|
||||||
|
"is_tenant_owner",
|
||||||
|
"require_permission",
|
||||||
|
]
|
||||||
|
|
||||||
|
# public name -> module (relative to the ``infrasynth`` package) that defines it
|
||||||
|
_EXPORTS: dict[str, str] = {
|
||||||
|
"ALTCHAService": "security.altcha.services",
|
||||||
|
"APIKeyAuthentication": "security.auth.api_keys",
|
||||||
|
"AuthorizationService": "security.services",
|
||||||
|
"CookieJWTAuthentication": "security.auth.cookies",
|
||||||
|
"EmailOrUsernameBackend": "security.auth.backends",
|
||||||
|
"HybridPermission": "security.permissions",
|
||||||
|
"IsAuthenticatedAndPermitted": "security.permissions",
|
||||||
|
"LoginAttemptGuard": "security.throttling",
|
||||||
|
"PasswordPolicyValidator": "security.password_validation",
|
||||||
|
"RecoveryCodeService": "security.two_factor.services",
|
||||||
|
"SystemUser": "security.auth.api_keys",
|
||||||
|
"TOTPService": "security.two_factor.services",
|
||||||
|
"get_recovery_code_service": "security.two_factor.services",
|
||||||
|
"get_two_factor_service": "security.two_factor.services",
|
||||||
|
"is_tenant_owner": "security.permissions",
|
||||||
|
"require_permission": "security.permissions",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> Any:
|
||||||
|
module_path = _EXPORTS.get(name)
|
||||||
|
if module_path is not None:
|
||||||
|
return getattr(import_module(f"infrasynth.{module_path}"), name)
|
||||||
|
# Let ``infrasynth.<app>.<submodule>`` resolve as usual.
|
||||||
|
try:
|
||||||
|
return import_module(f"{__name__}.{name}")
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def __dir__() -> list[str]:
|
||||||
|
return sorted(set(__all__) | set(globals()))
|
||||||
|
|
@ -9,6 +9,36 @@ from infrasynth.shared.crypto import decrypt, encrypt
|
||||||
from infrasynth.shared.settings_utils import get_setting
|
from infrasynth.shared.settings_utils import get_setting
|
||||||
|
|
||||||
|
|
||||||
|
def get_two_factor_service():
|
||||||
|
"""Returns the configured 2FA method service (swappable via settings).
|
||||||
|
|
||||||
|
Override ``INFRASYNTH_SECURITY["TWO_FACTOR_SERVICE"]`` with a dotted path to
|
||||||
|
plug an email/SMS OTP implementation; it must expose ``generate_secret``,
|
||||||
|
``get_provisioning_uri``, ``generate_qr_base64``, ``verify``,
|
||||||
|
``encrypt_secret`` and ``decrypt_secret``.
|
||||||
|
"""
|
||||||
|
from django.utils.module_loading import import_string
|
||||||
|
|
||||||
|
path = get_setting(
|
||||||
|
"INFRASYNTH_SECURITY",
|
||||||
|
"TWO_FACTOR_SERVICE",
|
||||||
|
"infrasynth.security.two_factor.services.TOTPService",
|
||||||
|
)
|
||||||
|
return import_string(path)()
|
||||||
|
|
||||||
|
|
||||||
|
def get_recovery_code_service():
|
||||||
|
"""Returns the configured recovery-code service (swappable via settings)."""
|
||||||
|
from django.utils.module_loading import import_string
|
||||||
|
|
||||||
|
path = get_setting(
|
||||||
|
"INFRASYNTH_SECURITY",
|
||||||
|
"TWO_FACTOR_RECOVERY_SERVICE",
|
||||||
|
"infrasynth.security.two_factor.services.RecoveryCodeService",
|
||||||
|
)
|
||||||
|
return import_string(path)()
|
||||||
|
|
||||||
|
|
||||||
class TOTPService:
|
class TOTPService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
issuer = get_setting("INFRASYNTH_SECURITY", "TWO_FACTOR_ISSUER_NAME", "InfraSynth")
|
issuer = get_setting("INFRASYNTH_SECURITY", "TWO_FACTOR_ISSUER_NAME", "InfraSynth")
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ from .signals import (
|
||||||
user_logged_out,
|
user_logged_out,
|
||||||
)
|
)
|
||||||
from .throttling import LoginAttemptGuard
|
from .throttling import LoginAttemptGuard
|
||||||
from .two_factor.services import RecoveryCodeService, TOTPService
|
from .two_factor.services import get_recovery_code_service, get_two_factor_service
|
||||||
from .two_factor.utils import generate_pre_auth_token
|
from .two_factor.utils import generate_pre_auth_token
|
||||||
|
|
||||||
UserModel = get_user_model()
|
UserModel = get_user_model()
|
||||||
|
|
@ -384,7 +384,7 @@ class TwoFactorViewSet(_AuthSupport, viewsets.GenericViewSet):
|
||||||
|
|
||||||
@action(detail=False, methods=["post"])
|
@action(detail=False, methods=["post"])
|
||||||
def setup(self, request): # type: ignore[override]
|
def setup(self, request): # type: ignore[override]
|
||||||
totp = TOTPService()
|
totp = get_two_factor_service()
|
||||||
secret = totp.generate_secret()
|
secret = totp.generate_secret()
|
||||||
qr_base64 = totp.generate_qr_base64(secret, request.user.email)
|
qr_base64 = totp.generate_qr_base64(secret, request.user.email)
|
||||||
provisioning_uri = totp.get_provisioning_uri(secret, request.user.email)
|
provisioning_uri = totp.get_provisioning_uri(secret, request.user.email)
|
||||||
|
|
@ -405,10 +405,10 @@ class TwoFactorViewSet(_AuthSupport, viewsets.GenericViewSet):
|
||||||
code = request.data.get("code")
|
code = request.data.get("code")
|
||||||
if not code:
|
if not code:
|
||||||
raise AuthenticationFailed("Code is required.")
|
raise AuthenticationFailed("Code is required.")
|
||||||
totp = TOTPService()
|
totp = get_two_factor_service()
|
||||||
if not totp.verify(secret, code):
|
if not totp.verify(secret, code):
|
||||||
raise AuthenticationFailed("Invalid code.")
|
raise AuthenticationFailed("Invalid code.")
|
||||||
rcs = RecoveryCodeService()
|
rcs = get_recovery_code_service()
|
||||||
recovery_codes = rcs.generate_codes()
|
recovery_codes = rcs.generate_codes()
|
||||||
config, _ = TwoFactorConfig.objects.get_or_create(user=request.user)
|
config, _ = TwoFactorConfig.objects.get_or_create(user=request.user)
|
||||||
config.is_enabled = True
|
config.is_enabled = True
|
||||||
|
|
@ -431,7 +431,7 @@ class TwoFactorViewSet(_AuthSupport, viewsets.GenericViewSet):
|
||||||
code = request.data.get("code")
|
code = request.data.get("code")
|
||||||
if not code:
|
if not code:
|
||||||
raise AuthenticationFailed("Code is required.")
|
raise AuthenticationFailed("Code is required.")
|
||||||
totp = TOTPService()
|
totp = get_two_factor_service()
|
||||||
secret = totp.decrypt_secret(config.secret_key_encrypted)
|
secret = totp.decrypt_secret(config.secret_key_encrypted)
|
||||||
if not totp.verify(secret, code):
|
if not totp.verify(secret, code):
|
||||||
raise AuthenticationFailed("Invalid code.")
|
raise AuthenticationFailed("Invalid code.")
|
||||||
|
|
@ -462,7 +462,7 @@ class TwoFactorViewSet(_AuthSupport, viewsets.GenericViewSet):
|
||||||
config = TwoFactorConfig.objects.get(user_id=user_id, is_enabled=True)
|
config = TwoFactorConfig.objects.get(user_id=user_id, is_enabled=True)
|
||||||
except TwoFactorConfig.DoesNotExist:
|
except TwoFactorConfig.DoesNotExist:
|
||||||
raise AuthenticationFailed("2FA not configured.")
|
raise AuthenticationFailed("2FA not configured.")
|
||||||
rcs = RecoveryCodeService()
|
rcs = get_recovery_code_service()
|
||||||
if not rcs.verify_code(recovery_code, config.recovery_codes_encrypted):
|
if not rcs.verify_code(recovery_code, config.recovery_codes_encrypted):
|
||||||
raise AuthenticationFailed("Invalid recovery code.")
|
raise AuthenticationFailed("Invalid recovery code.")
|
||||||
updated = rcs.remove_used_code(recovery_code, config.recovery_codes_encrypted)
|
updated = rcs.remove_used_code(recovery_code, config.recovery_codes_encrypted)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
"""Public surface of ``infrasynth.shared`` (zero-Django primitives).
|
||||||
|
|
||||||
|
Safe to import eagerly — nothing here imports Django models or app state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .crypto import decrypt, encrypt, generate_key, get_fernet, rotate_keys
|
||||||
|
from .enums import (
|
||||||
|
ApprovalStrategy,
|
||||||
|
AuditAction,
|
||||||
|
BillingInterval,
|
||||||
|
ChannelType,
|
||||||
|
EntitlementStatus,
|
||||||
|
EventSeverity,
|
||||||
|
InvoiceStatus,
|
||||||
|
MonetizationModel,
|
||||||
|
PlanInterval,
|
||||||
|
SubscriptionStatus,
|
||||||
|
)
|
||||||
|
from .exceptions import (
|
||||||
|
AppError,
|
||||||
|
AuthError,
|
||||||
|
ConflictError,
|
||||||
|
EntitlementError,
|
||||||
|
NotFoundError,
|
||||||
|
RateLimitError,
|
||||||
|
ServerError,
|
||||||
|
ValidationAppError,
|
||||||
|
)
|
||||||
|
from .protocols import AuditableProtocol, EventProtocol, TenantProtocol
|
||||||
|
from .results import Result
|
||||||
|
from .settings_utils import get_setting
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AppError",
|
||||||
|
"ApprovalStrategy",
|
||||||
|
"AuditAction",
|
||||||
|
"AuditableProtocol",
|
||||||
|
"AuthError",
|
||||||
|
"BillingInterval",
|
||||||
|
"ChannelType",
|
||||||
|
"ConflictError",
|
||||||
|
"EntitlementError",
|
||||||
|
"EntitlementStatus",
|
||||||
|
"EventProtocol",
|
||||||
|
"EventSeverity",
|
||||||
|
"InvoiceStatus",
|
||||||
|
"MonetizationModel",
|
||||||
|
"NotFoundError",
|
||||||
|
"PlanInterval",
|
||||||
|
"RateLimitError",
|
||||||
|
"Result",
|
||||||
|
"ServerError",
|
||||||
|
"SubscriptionStatus",
|
||||||
|
"TenantProtocol",
|
||||||
|
"ValidationAppError",
|
||||||
|
"decrypt",
|
||||||
|
"encrypt",
|
||||||
|
"generate_key",
|
||||||
|
"get_fernet",
|
||||||
|
"get_setting",
|
||||||
|
"rotate_keys",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""Public surface of ``infrasynth.tenancy``.
|
||||||
|
|
||||||
|
The exports are resolved lazily (PEP 562) so importing this package never
|
||||||
|
triggers Django model imports before the app registry is ready. Import from
|
||||||
|
here::
|
||||||
|
|
||||||
|
from infrasynth.tenancy import TenantService, TenantManager, current_tenant
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AllObjectsManager",
|
||||||
|
"GlobalOrTenantManager",
|
||||||
|
"GlobalOrTenantModel",
|
||||||
|
"PlatformStaff",
|
||||||
|
"Tenant",
|
||||||
|
"TenantInvitation",
|
||||||
|
"TenantManager",
|
||||||
|
"TenantMembership",
|
||||||
|
"TenantMiddleware",
|
||||||
|
"TenantOwnedModel",
|
||||||
|
"TenantService",
|
||||||
|
"current_tenant",
|
||||||
|
"get_current_tenant",
|
||||||
|
"reset_current_tenant",
|
||||||
|
"set_current_tenant",
|
||||||
|
"tenant_context",
|
||||||
|
]
|
||||||
|
|
||||||
|
# public name -> module (relative to the ``infrasynth`` package) that defines it
|
||||||
|
_EXPORTS: dict[str, str] = {
|
||||||
|
"AllObjectsManager": "tenancy.managers",
|
||||||
|
"GlobalOrTenantManager": "tenancy.managers",
|
||||||
|
"GlobalOrTenantModel": "tenancy.mixins",
|
||||||
|
"PlatformStaff": "tenancy.models",
|
||||||
|
"Tenant": "tenancy.models",
|
||||||
|
"TenantInvitation": "tenancy.models",
|
||||||
|
"TenantManager": "tenancy.managers",
|
||||||
|
"TenantMembership": "tenancy.models",
|
||||||
|
"TenantMiddleware": "tenancy.middleware",
|
||||||
|
"TenantOwnedModel": "tenancy.mixins",
|
||||||
|
"TenantService": "tenancy.services",
|
||||||
|
"current_tenant": "tenancy.context",
|
||||||
|
"get_current_tenant": "tenancy.context",
|
||||||
|
"reset_current_tenant": "tenancy.context",
|
||||||
|
"set_current_tenant": "tenancy.context",
|
||||||
|
"tenant_context": "tenancy.context",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> Any:
|
||||||
|
module_path = _EXPORTS.get(name)
|
||||||
|
if module_path is not None:
|
||||||
|
return getattr(import_module(f"infrasynth.{module_path}"), name)
|
||||||
|
# Let ``infrasynth.<app>.<submodule>`` resolve as usual.
|
||||||
|
try:
|
||||||
|
return import_module(f"{__name__}.{name}")
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def __dir__() -> list[str]:
|
||||||
|
return sorted(set(__all__) | set(globals()))
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
"""Public surface of ``infrasynth.webhooks``.
|
||||||
|
|
||||||
|
The exports are resolved lazily (PEP 562) so importing this package never
|
||||||
|
triggers Django model imports before the app registry is ready. Import from
|
||||||
|
here::
|
||||||
|
|
||||||
|
from infrasynth.webhooks import EventRegistry, sign_payload, BaseInboundHandler
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BaseInboundHandler",
|
||||||
|
"EventRegistry",
|
||||||
|
"HMACInboundHandler",
|
||||||
|
"InboundEndpoint",
|
||||||
|
"InboundEvent",
|
||||||
|
"OutboundDelivery",
|
||||||
|
"OutboundEndpoint",
|
||||||
|
"OutboundSubscription",
|
||||||
|
"deliver_webhook",
|
||||||
|
"process_inbound_event",
|
||||||
|
"sign_payload",
|
||||||
|
"verify_signature",
|
||||||
|
]
|
||||||
|
|
||||||
|
# public name -> module (relative to the ``infrasynth`` package) that defines it
|
||||||
|
_EXPORTS: dict[str, str] = {
|
||||||
|
"BaseInboundHandler": "webhooks.inbound.handlers",
|
||||||
|
"EventRegistry": "webhooks.registry",
|
||||||
|
"HMACInboundHandler": "webhooks.inbound.handlers",
|
||||||
|
"InboundEndpoint": "webhooks.models",
|
||||||
|
"InboundEvent": "webhooks.models",
|
||||||
|
"OutboundDelivery": "webhooks.models",
|
||||||
|
"OutboundEndpoint": "webhooks.models",
|
||||||
|
"OutboundSubscription": "webhooks.models",
|
||||||
|
"deliver_webhook": "webhooks.dispatch",
|
||||||
|
"process_inbound_event": "webhooks.dispatch",
|
||||||
|
"sign_payload": "webhooks.signature",
|
||||||
|
"verify_signature": "webhooks.signature",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> Any:
|
||||||
|
module_path = _EXPORTS.get(name)
|
||||||
|
if module_path is not None:
|
||||||
|
return getattr(import_module(f"infrasynth.{module_path}"), name)
|
||||||
|
# Let ``infrasynth.<app>.<submodule>`` resolve as usual.
|
||||||
|
try:
|
||||||
|
return import_module(f"{__name__}.{name}")
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def __dir__() -> list[str]:
|
||||||
|
return sorted(set(__all__) | set(globals()))
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
"""Public surface of ``infrasynth.workflows``.
|
||||||
|
|
||||||
|
The exports are resolved lazily (PEP 562) so importing this package never
|
||||||
|
triggers Django model imports before the app registry is ready. Import from
|
||||||
|
here::
|
||||||
|
|
||||||
|
from infrasynth.workflows import WorkflowEngine, DataValidatorRegistry, Workflow
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DataValidatorProtocol",
|
||||||
|
"DataValidatorRegistry",
|
||||||
|
"NodeAssignment",
|
||||||
|
"Transition",
|
||||||
|
"Workflow",
|
||||||
|
"WorkflowAwareModel",
|
||||||
|
"WorkflowEngine",
|
||||||
|
"WorkflowInstance",
|
||||||
|
"WorkflowNode",
|
||||||
|
"WorkflowObserver",
|
||||||
|
]
|
||||||
|
|
||||||
|
# public name -> module (relative to the ``infrasynth`` package) that defines it
|
||||||
|
_EXPORTS: dict[str, str] = {
|
||||||
|
"DataValidatorProtocol": "workflows.validators",
|
||||||
|
"DataValidatorRegistry": "workflows.validators",
|
||||||
|
"NodeAssignment": "workflows.models",
|
||||||
|
"Transition": "workflows.models",
|
||||||
|
"Workflow": "workflows.models",
|
||||||
|
"WorkflowAwareModel": "workflows.models",
|
||||||
|
"WorkflowEngine": "workflows.engine",
|
||||||
|
"WorkflowInstance": "workflows.models",
|
||||||
|
"WorkflowNode": "workflows.models",
|
||||||
|
"WorkflowObserver": "workflows.models",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> Any:
|
||||||
|
module_path = _EXPORTS.get(name)
|
||||||
|
if module_path is not None:
|
||||||
|
return getattr(import_module(f"infrasynth.{module_path}"), name)
|
||||||
|
# Let ``infrasynth.<app>.<submodule>`` resolve as usual.
|
||||||
|
try:
|
||||||
|
return import_module(f"{__name__}.{name}")
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def __dir__() -> list[str]:
|
||||||
|
return sorted(set(__all__) | set(globals()))
|
||||||
194
tests/test_extensibility.py
Normal file
194
tests/test_extensibility.py
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
"""Every module must be extensible without editing the kit.
|
||||||
|
|
||||||
|
These tests pin the extension points that let a pip-installed consumer add
|
||||||
|
storage backends, pipeline steps, an invoice builder, or a 2FA method purely
|
||||||
|
through settings/registries, and confirm the public import surface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
# --- fakes referenced by dotted path from settings --------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStorage:
|
||||||
|
def __init__(self, config):
|
||||||
|
self.config = config
|
||||||
|
self.saved = {}
|
||||||
|
|
||||||
|
def save(self, name, content):
|
||||||
|
self.saved[name] = content.read()
|
||||||
|
return name
|
||||||
|
|
||||||
|
def open(self, name, mode="rb"):
|
||||||
|
return io.BytesIO(self.saved[name])
|
||||||
|
|
||||||
|
def exists(self, name):
|
||||||
|
return name in self.saved
|
||||||
|
|
||||||
|
def url(self, name):
|
||||||
|
return f"/fake/{name}"
|
||||||
|
|
||||||
|
def delete(self, name):
|
||||||
|
self.saved.pop(name, None)
|
||||||
|
|
||||||
|
def generate_signed_url(self, name, expiry_seconds=3600):
|
||||||
|
return f"/fake/{name}?signed=1"
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingExecutor:
|
||||||
|
def execute(self, execution):
|
||||||
|
execution.status = "completed"
|
||||||
|
execution.save(update_fields=["status"])
|
||||||
|
return execution
|
||||||
|
|
||||||
|
|
||||||
|
def fake_invoice_builder(invoice):
|
||||||
|
return b"%PDF-1.4 custom-builder"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTwoFactorService:
|
||||||
|
def generate_secret(self):
|
||||||
|
return "FAKESECRET"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPublicImportSurface:
|
||||||
|
def test_app_packages_expose_public_names(self):
|
||||||
|
from infrasynth.audit import ModelChangeLog
|
||||||
|
from infrasynth.billing import EntitlementService, Plan
|
||||||
|
from infrasynth.features import FeatureService
|
||||||
|
from infrasynth.files import FileService, get_storage_backend
|
||||||
|
from infrasynth.gates import GatePermission
|
||||||
|
from infrasynth.notifications import NotificationService
|
||||||
|
from infrasynth.scheduler import TaskService
|
||||||
|
from infrasynth.security import AuthorizationService, HybridPermission
|
||||||
|
from infrasynth.shared import Result
|
||||||
|
from infrasynth.tenancy import TenantManager
|
||||||
|
from infrasynth.webhooks import EventRegistry
|
||||||
|
from infrasynth.workflows import WorkflowEngine
|
||||||
|
|
||||||
|
assert all(
|
||||||
|
x is not None
|
||||||
|
for x in (
|
||||||
|
ModelChangeLog,
|
||||||
|
EntitlementService,
|
||||||
|
Plan,
|
||||||
|
FeatureService,
|
||||||
|
FileService,
|
||||||
|
get_storage_backend,
|
||||||
|
NotificationService,
|
||||||
|
TaskService,
|
||||||
|
AuthorizationService,
|
||||||
|
HybridPermission,
|
||||||
|
Result,
|
||||||
|
TenantManager,
|
||||||
|
EventRegistry,
|
||||||
|
WorkflowEngine,
|
||||||
|
GatePermission,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unknown_attribute_raises(self):
|
||||||
|
import infrasynth.security as security
|
||||||
|
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
security.ThisDoesNotExist
|
||||||
|
|
||||||
|
|
||||||
|
class TestStorageExtension:
|
||||||
|
def test_custom_backend_via_settings_class(self, settings):
|
||||||
|
settings.INFRASYNTH_FILES = {
|
||||||
|
**settings.INFRASYNTH_FILES,
|
||||||
|
"STORAGE_BACKENDS": {
|
||||||
|
**settings.INFRASYNTH_FILES.get("STORAGE_BACKENDS", {}),
|
||||||
|
"fake": {"CLASS": "tests.test_extensibility.FakeStorage", "REGION": "x"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
storage = get_backend("fake")
|
||||||
|
assert isinstance(storage, FakeStorage)
|
||||||
|
assert storage.config.get("REGION") == "x"
|
||||||
|
|
||||||
|
def test_custom_backend_via_registry(self):
|
||||||
|
from infrasynth.files.storage import register_storage_backend
|
||||||
|
|
||||||
|
register_storage_backend("factory-made", FakeStorage)
|
||||||
|
assert isinstance(get_backend("factory-made"), FakeStorage)
|
||||||
|
|
||||||
|
def test_unknown_backend_raises(self):
|
||||||
|
from infrasynth.files.storage import get_storage_backend
|
||||||
|
|
||||||
|
with pytest.raises(ImproperlyConfigured):
|
||||||
|
get_storage_backend("does-not-exist")
|
||||||
|
|
||||||
|
|
||||||
|
class TestPipelineExtension:
|
||||||
|
def test_registered_step_is_used(self, settings, user, media_root):
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
|
||||||
|
from infrasynth.files.models import PipelineExecution, ProcessingPipeline
|
||||||
|
from infrasynth.files.processing import PipelineExecutor, pipeline_step
|
||||||
|
from infrasynth.files.services import FileService
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
@pipeline_step("test.tag")
|
||||||
|
def tag_step(data, mime_type, params):
|
||||||
|
calls.append(params)
|
||||||
|
return data, mime_type
|
||||||
|
|
||||||
|
uploaded = FileService().upload(
|
||||||
|
SimpleUploadedFile("a.txt", b"hello", content_type="text/plain"),
|
||||||
|
filename="a.txt",
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
pipeline = ProcessingPipeline.objects.create(
|
||||||
|
name="Tag", slug="tag", steps=[{"type": "test.tag", "params": {"k": "v"}}]
|
||||||
|
)
|
||||||
|
execution = PipelineExecution.objects.create(file=uploaded, pipeline=pipeline)
|
||||||
|
PipelineExecutor().execute(execution)
|
||||||
|
|
||||||
|
execution.refresh_from_db()
|
||||||
|
assert execution.status == PipelineExecution.Status.COMPLETED
|
||||||
|
assert calls == [{"k": "v"}]
|
||||||
|
|
||||||
|
def test_executor_is_swappable(self, settings):
|
||||||
|
from infrasynth.files.processing import get_pipeline_executor
|
||||||
|
|
||||||
|
settings.INFRASYNTH_FILES = {
|
||||||
|
**settings.INFRASYNTH_FILES,
|
||||||
|
"PIPELINE_EXECUTOR": "tests.test_extensibility.RecordingExecutor",
|
||||||
|
}
|
||||||
|
assert isinstance(get_pipeline_executor(), RecordingExecutor)
|
||||||
|
|
||||||
|
|
||||||
|
class TestInvoiceBuilderExtension:
|
||||||
|
def test_builder_is_swappable(self, settings):
|
||||||
|
from infrasynth.billing.invoice_generator import get_invoice_pdf_builder
|
||||||
|
|
||||||
|
settings.INFRASYNTH_BILLING = {
|
||||||
|
**settings.INFRASYNTH_BILLING,
|
||||||
|
"INVOICE_PDF_BUILDER": "tests.test_extensibility.fake_invoice_builder",
|
||||||
|
}
|
||||||
|
assert get_invoice_pdf_builder()(object()) == b"%PDF-1.4 custom-builder"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTwoFactorExtension:
|
||||||
|
def test_service_is_swappable(self, settings):
|
||||||
|
from infrasynth.security.two_factor.services import get_two_factor_service
|
||||||
|
|
||||||
|
settings.INFRASYNTH_SECURITY = {
|
||||||
|
**settings.INFRASYNTH_SECURITY,
|
||||||
|
"TWO_FACTOR_SERVICE": "tests.test_extensibility.FakeTwoFactorService",
|
||||||
|
}
|
||||||
|
assert get_two_factor_service().generate_secret() == "FAKESECRET"
|
||||||
|
|
||||||
|
|
||||||
|
def get_backend(name):
|
||||||
|
from infrasynth.files.storage import get_storage_backend
|
||||||
|
|
||||||
|
return get_storage_backend(name)
|
||||||
|
|
@ -44,9 +44,11 @@ class TestGetStorageBackend:
|
||||||
storage = get_storage_backend()
|
storage = get_storage_backend()
|
||||||
assert isinstance(storage, _LocalStorage)
|
assert isinstance(storage, _LocalStorage)
|
||||||
|
|
||||||
def test_unknown_backend_falls_back_to_local(self):
|
def test_unknown_backend_raises(self):
|
||||||
storage = get_storage_backend("nonexistent")
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
assert isinstance(storage, _LocalStorage)
|
|
||||||
|
with pytest.raises(ImproperlyConfigured):
|
||||||
|
get_storage_backend("nonexistent")
|
||||||
|
|
||||||
def test_local_backend_instantiation(self):
|
def test_local_backend_instantiation(self):
|
||||||
assert isinstance(get_storage_backend("local"), _LocalStorage)
|
assert isinstance(get_storage_backend("local"), _LocalStorage)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue