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]
|
||||
|
||||
### 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
|
||||
`infrasynth_gates = [...]` (and/or `@gated(...)` on a viewset action) with
|
||||
`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)
|
||||
```
|
||||
|
||||
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_RECOVERY_CODES_COUNT": 8,
|
||||
"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,
|
||||
"ALTCHA_DIFFICULTY": 10000,
|
||||
"ALTCHA_CHALLENGE_EXPIRY_SECONDS": 300,
|
||||
|
|
@ -246,6 +248,7 @@ INFRASYNTH_FILES = {
|
|||
"MAX_UPLOAD_SIZE_MB": 100,
|
||||
"ENABLE_PROCESSING_PIPELINES": True,
|
||||
"PROCESSING_BACKEND": "celery",
|
||||
"PIPELINE_EXECUTOR": "infrasynth.files.processing.PipelineExecutor",
|
||||
"ENABLE_X_SENDFILE": False,
|
||||
"VIRUS_SCANNER": "noop",
|
||||
"CLAMAV_SOCKET": "/var/run/clamav/clamd.ctl",
|
||||
|
|
@ -346,6 +349,7 @@ INFRASYNTH_TENANCY = {
|
|||
|
||||
INFRASYNTH_BILLING = {
|
||||
"INVOICE_NUMBER_PREFIX": "INV-",
|
||||
"INVOICE_PDF_BUILDER": "infrasynth.billing.invoice_generator._build_invoice_pdf",
|
||||
"GRACE_PERIOD_DAYS": 5,
|
||||
"MAX_RETRY_FAILED_PAYMENTS": 3,
|
||||
"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__)
|
||||
|
||||
|
||||
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(
|
||||
name="infrasynth.billing.generate_invoice_pdf",
|
||||
bind=True,
|
||||
|
|
@ -30,7 +44,7 @@ def generate_invoice_pdf(self, invoice_id, tenant_id=None):
|
|||
return None
|
||||
|
||||
try:
|
||||
pdf_bytes = _build_invoice_pdf(invoice)
|
||||
pdf_bytes = get_invoice_pdf_builder()(invoice)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("PDF generation failed for invoice %s", invoice_id)
|
||||
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 logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from celery import shared_task
|
||||
from django.utils import timezone
|
||||
|
|
@ -7,6 +9,61 @@ from django.utils import timezone
|
|||
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:
|
||||
"""Executes a processing pipeline over a stored file, step by step."""
|
||||
|
||||
|
|
@ -25,7 +82,8 @@ class PipelineExecutor:
|
|||
for step in steps:
|
||||
step_type = step.get("type")
|
||||
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:
|
||||
raise ValueError(f"Unknown pipeline step type: '{step_type}'")
|
||||
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
|
||||
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")
|
||||
if backend == "sync":
|
||||
from .processing import PipelineExecutor
|
||||
from .processing import get_pipeline_executor
|
||||
|
||||
PipelineExecutor().execute(execution)
|
||||
get_pipeline_executor().execute(execution)
|
||||
else:
|
||||
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]
|
||||
|
||||
|
||||
_BACKEND_CLASSES = {
|
||||
_BUILTIN_BACKENDS: dict[str, type] = {
|
||||
"local": _LocalStorage,
|
||||
"S3": _S3Storage,
|
||||
"s3": _S3Storage,
|
||||
|
|
@ -143,17 +143,51 @@ _BACKEND_CLASSES = {
|
|||
"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):
|
||||
"""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.core.exceptions import ImproperlyConfigured
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
config = getattr(settings, "INFRASYNTH_FILES", {})
|
||||
if not backend_name:
|
||||
backend_name = get_setting("INFRASYNTH_FILES", "DEFAULT_STORAGE_BACKEND", "local")
|
||||
backend_config = config.get("STORAGE_BACKENDS", {}).get(backend_name, {})
|
||||
backend_class = _BACKEND_CLASSES.get(str(backend_name), _LocalStorage)
|
||||
return backend_class(backend_config or {})
|
||||
backend_name = str(backend_name)
|
||||
backend_config = (config.get("STORAGE_BACKENDS", {}) or {}).get(backend_name, {}) 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"):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
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:
|
||||
def __init__(self):
|
||||
issuer = get_setting("INFRASYNTH_SECURITY", "TWO_FACTOR_ISSUER_NAME", "InfraSynth")
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ from .signals import (
|
|||
user_logged_out,
|
||||
)
|
||||
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
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
|
@ -384,7 +384,7 @@ class TwoFactorViewSet(_AuthSupport, viewsets.GenericViewSet):
|
|||
|
||||
@action(detail=False, methods=["post"])
|
||||
def setup(self, request): # type: ignore[override]
|
||||
totp = TOTPService()
|
||||
totp = get_two_factor_service()
|
||||
secret = totp.generate_secret()
|
||||
qr_base64 = totp.generate_qr_base64(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")
|
||||
if not code:
|
||||
raise AuthenticationFailed("Code is required.")
|
||||
totp = TOTPService()
|
||||
totp = get_two_factor_service()
|
||||
if not totp.verify(secret, code):
|
||||
raise AuthenticationFailed("Invalid code.")
|
||||
rcs = RecoveryCodeService()
|
||||
rcs = get_recovery_code_service()
|
||||
recovery_codes = rcs.generate_codes()
|
||||
config, _ = TwoFactorConfig.objects.get_or_create(user=request.user)
|
||||
config.is_enabled = True
|
||||
|
|
@ -431,7 +431,7 @@ class TwoFactorViewSet(_AuthSupport, viewsets.GenericViewSet):
|
|||
code = request.data.get("code")
|
||||
if not code:
|
||||
raise AuthenticationFailed("Code is required.")
|
||||
totp = TOTPService()
|
||||
totp = get_two_factor_service()
|
||||
secret = totp.decrypt_secret(config.secret_key_encrypted)
|
||||
if not totp.verify(secret, 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)
|
||||
except TwoFactorConfig.DoesNotExist:
|
||||
raise AuthenticationFailed("2FA not configured.")
|
||||
rcs = RecoveryCodeService()
|
||||
rcs = get_recovery_code_service()
|
||||
if not rcs.verify_code(recovery_code, config.recovery_codes_encrypted):
|
||||
raise AuthenticationFailed("Invalid recovery code.")
|
||||
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()
|
||||
assert isinstance(storage, _LocalStorage)
|
||||
|
||||
def test_unknown_backend_falls_back_to_local(self):
|
||||
storage = get_storage_backend("nonexistent")
|
||||
assert isinstance(storage, _LocalStorage)
|
||||
def test_unknown_backend_raises(self):
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
with pytest.raises(ImproperlyConfigured):
|
||||
get_storage_backend("nonexistent")
|
||||
|
||||
def test_local_backend_instantiation(self):
|
||||
assert isinstance(get_storage_backend("local"), _LocalStorage)
|
||||
|
|
|
|||
Loading…
Reference in a new issue