fast-platform 0.12.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- admin/__init__.py +31 -0
- admin/abstraction.py +14 -0
- admin/abstractions.py +24 -0
- admin/audit_hooks.py +98 -0
- admin/crud.py +203 -0
- admin/repositories.py +105 -0
- admin/router.py +98 -0
- admin/schemas.py +49 -0
- analytics/__init__.py +31 -0
- analytics/abstraction.py +14 -0
- analytics/base.py +57 -0
- analytics/buffer.py +86 -0
- analytics/http_sink.py +89 -0
- analytics/middleware.py +89 -0
- analytics/pii.py +84 -0
- analytics/rate_limit.py +60 -0
- analytics/schema_registry.py +70 -0
- analytics/validating_backend.py +38 -0
- cache/__init__.py +17 -0
- cache/abstraction.py +14 -0
- cache/backend.py +222 -0
- channels/__init__.py +49 -0
- channels/abstraction.py +14 -0
- channels/acl.py +61 -0
- channels/base.py +25 -0
- channels/config_loader.py +52 -0
- channels/dto.py +20 -0
- channels/heartbeat.py +40 -0
- channels/hub.py +182 -0
- channels/kafka_backend.py +18 -0
- channels/metrics.py +53 -0
- channels/presence.py +123 -0
- channels/py.typed +0 -0
- channels/redis_backend.py +32 -0
- channels/subscriber_counters.py +96 -0
- configuration/__init__.py +92 -0
- configuration/abstraction.py +77 -0
- configuration/analytics.py +16 -0
- configuration/cache.py +16 -0
- configuration/datadog.py +16 -0
- configuration/db.py +16 -0
- configuration/events.py +16 -0
- configuration/feature_flags.py +16 -0
- configuration/identity_providers.py +16 -0
- configuration/jobs.py +16 -0
- configuration/kafka.py +54 -0
- configuration/llm.py +16 -0
- configuration/notifications.py +69 -0
- configuration/payments.py +60 -0
- configuration/queues.py +16 -0
- configuration/realtime.py +16 -0
- configuration/search.py +16 -0
- configuration/secrets.py +16 -0
- configuration/storage.py +16 -0
- configuration/streams.py +16 -0
- configuration/telemetry.py +16 -0
- configuration/vectors.py +16 -0
- datastores/__init__.py +25 -0
- datastores/abstraction.py +14 -0
- datastores/cassandra.py +72 -0
- datastores/cosmos.py +168 -0
- datastores/dynamo.py +119 -0
- datastores/elasticsearch.py +90 -0
- datastores/interfaces.py +175 -0
- datastores/mongo.py +86 -0
- datastores/py.typed +0 -0
- datastores/redis_kv.py +85 -0
- datastores/scylla.py +73 -0
- db/__init__.py +101 -0
- db/abstraction.py +14 -0
- db/async_dependency.py +39 -0
- db/async_engine.py +228 -0
- db/dependency.py +24 -0
- db/engine.py +153 -0
- db/migration_lock.py +69 -0
- db/replica.py +135 -0
- db/table.py +15 -0
- db/url.py +38 -0
- dtos/__init__.py +97 -0
- dtos/abstraction.py +14 -0
- dtos/analytics.py +13 -0
- dtos/aws_secrets.py +16 -0
- dtos/cache.py +16 -0
- dtos/celery_jobs.py +15 -0
- dtos/datadog.py +14 -0
- dtos/db.py +27 -0
- dtos/dramatiq_jobs.py +14 -0
- dtos/event_bridge.py +18 -0
- dtos/event_hubs.py +14 -0
- dtos/events.py +19 -0
- dtos/feature_flags.py +17 -0
- dtos/feature_flags_snapshot.py +13 -0
- dtos/gcp_secrets.py +14 -0
- dtos/http_sink.py +14 -0
- dtos/identity_providers.py +15 -0
- dtos/jobs.py +22 -0
- dtos/kafka.py +49 -0
- dtos/kafka_event.py +13 -0
- dtos/launchdarkly_feature_flags.py +14 -0
- dtos/llm.py +80 -0
- dtos/meilisearch.py +14 -0
- dtos/nats_config.py +16 -0
- dtos/notifications.py +120 -0
- dtos/oauth_provider.py +21 -0
- dtos/payments.py +59 -0
- dtos/pinecone_config.py +15 -0
- dtos/qdrant_config.py +14 -0
- dtos/queues.py +19 -0
- dtos/rabbit_mq_config.py +15 -0
- dtos/realtime.py +13 -0
- dtos/rq_jobs.py +15 -0
- dtos/s3_storage.py +16 -0
- dtos/scheduler_jobs.py +12 -0
- dtos/search.py +48 -0
- dtos/secrets.py +17 -0
- dtos/service_bus_config.py +14 -0
- dtos/sns_notification.py +16 -0
- dtos/sqs_config.py +16 -0
- dtos/storage.py +14 -0
- dtos/streams.py +15 -0
- dtos/telemetry.py +15 -0
- dtos/unleash_feature_flags.py +16 -0
- dtos/vault_secrets.py +15 -0
- dtos/vectors.py +17 -0
- dtos/weaviate_config.py +14 -0
- dtos/webrtc_ice_config.py +18 -0
- dtos/webrtc_ice_server.py +16 -0
- errors/__init__.py +44 -0
- errors/abstraction.py +7 -0
- errors/bad_input_error.py +27 -0
- errors/conflict_error.py +23 -0
- errors/crypto_configuration_error.py +22 -0
- errors/error.py +74 -0
- errors/forbidden_error.py +23 -0
- errors/llm_dependency_error.py +32 -0
- errors/llm_feature_not_available_error.py +22 -0
- errors/not_found_error.py +27 -0
- errors/rate_limit_error.py +22 -0
- errors/service_unavailable_error.py +23 -0
- errors/token_budget_exceeded_error.py +19 -0
- errors/unauthorized_error.py +22 -0
- errors/unexpected_response_error.py +27 -0
- errors/unsupported_llm_provider_error.py +22 -0
- events/__init__.py +15 -0
- events/abstraction.py +14 -0
- events/bus.py +238 -0
- fast_platform/__init__.py +15 -0
- fast_platform/abstraction.py +14 -0
- fast_platform/py.typed +0 -0
- fast_platform/taxonomy.py +159 -0
- fast_platform-0.12.2.dist-info/METADATA +208 -0
- fast_platform-0.12.2.dist-info/RECORD +372 -0
- fast_platform-0.12.2.dist-info/WHEEL +5 -0
- fast_platform-0.12.2.dist-info/licenses/LICENSE +9 -0
- fast_platform-0.12.2.dist-info/top_level.txt +35 -0
- features/__init__.py +36 -0
- features/abstraction.py +119 -0
- features/evaluation.py +30 -0
- features/flags.py +432 -0
- features/kill_switch.py +80 -0
- features/launchdarkly_client.py +69 -0
- features/request_context.py +58 -0
- features/snapshot.py +120 -0
- features/streaming.py +69 -0
- features/unleash_client.py +52 -0
- identity/__init__.py +42 -0
- identity/abstraction.py +14 -0
- identity/api_key.py +63 -0
- identity/claims_normalize.py +106 -0
- identity/jwks_cache.py +67 -0
- identity/multi_issuer_jwks.py +45 -0
- identity/providers.py +204 -0
- jobs/__init__.py +41 -0
- jobs/abstraction.py +14 -0
- jobs/cancel.py +75 -0
- jobs/celery_app.py +51 -0
- jobs/enqueue.py +244 -0
- jobs/result.py +224 -0
- jobs/schedule.py +76 -0
- jobs/timeout.py +40 -0
- kafka/__init__.py +58 -0
- kafka/abstraction.py +14 -0
- kafka/consumer.py +93 -0
- kafka/dlq.py +42 -0
- kafka/health.py +56 -0
- kafka/idempotent.py +56 -0
- kafka/lag.py +101 -0
- kafka/outbox.py +91 -0
- kafka/producer.py +104 -0
- kafka/serde.py +41 -0
- kafka/worker.py +33 -0
- llm/__init__.py +107 -0
- llm/abstraction.py +172 -0
- llm/budget.py +56 -0
- llm/caching.py +77 -0
- llm/constants.py +11 -0
- llm/instrumented.py +147 -0
- llm/providers/__init__.py +27 -0
- llm/providers/anthropic_llm_service.py +42 -0
- llm/providers/factory.py +59 -0
- llm/providers/gemini_llm_service.py +52 -0
- llm/providers/groq_llm_service.py +19 -0
- llm/providers/illm_service.py +14 -0
- llm/providers/mistral_llm_service.py +19 -0
- llm/providers/ollama_llm_service.py +41 -0
- llm/providers/openai_llm_service.py +34 -0
- llm/streaming.py +102 -0
- llm/token_usage.py +54 -0
- llm/tools.py +97 -0
- media/__init__.py +46 -0
- media/abstraction.py +85 -0
- media/generator.py +75 -0
- media/memory_store.py +58 -0
- media/pipeline.py +117 -0
- media/upload.py +53 -0
- media/variants.py +55 -0
- media/virus_scan.py +123 -0
- notifications/__init__.py +65 -0
- notifications/abstraction.py +14 -0
- notifications/digest.py +88 -0
- notifications/fanout.py +129 -0
- notifications/idempotency.py +77 -0
- notifications/preferences.py +36 -0
- notifications/push.py +64 -0
- notifications/retry_policy.py +44 -0
- notifications/service.py +60 -0
- notifications/templating.py +25 -0
- notifications/webhook_retry_compat.py +34 -0
- observability/__init__.py +24 -0
- observability/abstraction.py +14 -0
- observability/audit.py +342 -0
- observability/datadog.py +51 -0
- observability/logging.py +246 -0
- observability/metrics.py +345 -0
- observability/otel.py +85 -0
- observability/py.typed +0 -0
- observability/tracing.py +358 -0
- otel/__init__.py +11 -0
- otel/abstraction.py +14 -0
- otel/bridge.py +87 -0
- payments/__init__.py +49 -0
- payments/abstraction.py +91 -0
- payments/reconciliation.py +127 -0
- payments/sca.py +54 -0
- payments/subscription_events.py +37 -0
- payments/webhook_idempotency.py +52 -0
- queues/__init__.py +61 -0
- queues/abstraction.py +14 -0
- queues/broker.py +249 -0
- queues/dlq.py +124 -0
- queues/envelope.py +115 -0
- resilience/__init__.py +39 -0
- resilience/abstraction.py +14 -0
- resilience/circuit_breaker.py +253 -0
- resilience/py.typed +0 -0
- resilience/retry.py +276 -0
- search/__init__.py +28 -0
- search/abstraction.py +14 -0
- search/base.py +160 -0
- search/bulk.py +85 -0
- search/dto.py +7 -0
- search/meilisearch_backend.py +105 -0
- search/opensearch_backend.py +152 -0
- search/rollover.py +26 -0
- search/suggest.py +25 -0
- search/typesense_backend.py +121 -0
- secrets/__init__.py +51 -0
- secrets/abstraction.py +14 -0
- secrets/aws_backend.py +49 -0
- secrets/base.py +78 -0
- secrets/cache.py +88 -0
- secrets/gcp_backend.py +56 -0
- secrets/lease.py +106 -0
- secrets/redact.py +64 -0
- secrets/vault_backend.py +42 -0
- security/__init__.py +57 -0
- security/abstraction.py +14 -0
- security/api_keys.py +395 -0
- security/encryption.py +196 -0
- security/llm_provider_keys.py +55 -0
- security/py.typed +0 -0
- security/webhooks.py +292 -0
- service/__init__.py +17 -0
- service/abstraction.py +14 -0
- service/crypto.py +134 -0
- storage/__init__.py +21 -0
- storage/abstraction.py +14 -0
- storage/azure_backend.py +90 -0
- storage/base.py +129 -0
- storage/gcs_backend.py +85 -0
- storage/local_backend.py +63 -0
- storage/multipart.py +138 -0
- storage/s3_backend.py +108 -0
- streams/__init__.py +11 -0
- streams/abstraction.py +14 -0
- streams/abstractions.py +68 -0
- streams/market.py +235 -0
- tenancy/__init__.py +57 -0
- tenancy/abstraction.py +14 -0
- tenancy/context.py +198 -0
- tenancy/middleware.py +149 -0
- tenancy/resolution.py +179 -0
- utils/__init__.py +70 -0
- utils/abstraction.py +26 -0
- utils/archive.py +158 -0
- utils/clock/__init__.py +15 -0
- utils/clock/frozen_clock.py +23 -0
- utils/clock/protocol.py +18 -0
- utils/clock/registry.py +40 -0
- utils/clock/system_clock.py +16 -0
- utils/currency.py +61 -0
- utils/datatype/__init__.py +18 -0
- utils/datatype/abstraction.py +14 -0
- utils/datatype/boolean.py +71 -0
- utils/datatype/integer.py +70 -0
- utils/datatype/string.py +54 -0
- utils/decimal.py +73 -0
- utils/digests.py +41 -0
- utils/encryption/__init__.py +18 -0
- utils/encryption/abstraction.py +27 -0
- utils/encryption/aes.py +50 -0
- utils/encryption/fernet.py +108 -0
- utils/hashing.py +168 -0
- utils/html/__init__.py +5 -0
- utils/html/html.py +125 -0
- utils/html/html_strip_tags_parser.py +35 -0
- utils/idempotency.py +59 -0
- utils/media/abstraction.py +14 -0
- utils/media/audio.py +93 -0
- utils/media/image.py +163 -0
- utils/media/pdf.py +141 -0
- utils/media/text.py +117 -0
- utils/media/video.py +96 -0
- utils/metrics/__init__.py +13 -0
- utils/metrics/counter.py +30 -0
- utils/metrics/histogram.py +28 -0
- utils/metrics/registry.py +42 -0
- utils/nutrition.py +19 -0
- utils/optional_imports.py +63 -0
- utils/request_id_context.py +39 -0
- utils/retry.py +54 -0
- utils/sanitization/abstraction.py +14 -0
- utils/sanitization/json.py +42 -0
- utils/structured_log/__init__.py +14 -0
- utils/structured_log/fields.py +19 -0
- utils/structured_log/log.py +63 -0
- utils/structured_log/sink.py +16 -0
- utils/time.py +40 -0
- vectors/__init__.py +28 -0
- vectors/abstraction.py +14 -0
- vectors/base.py +86 -0
- vectors/names.py +75 -0
- vectors/pinecone_backend.py +53 -0
- vectors/qdrant_backend.py +53 -0
- vectors/weaviate_backend.py +55 -0
- versioning/__init__.py +19 -0
- versioning/abstraction.py +14 -0
- versioning/router.py +340 -0
- webhooks/__init__.py +27 -0
- webhooks/abstraction.py +14 -0
- webhooks/delivery.py +102 -0
- webhooks/fastapi_deps.py +86 -0
- webhooks/signing.py +66 -0
- webrtc/__init__.py +37 -0
- webrtc/abstraction.py +14 -0
- webrtc/config_loader.py +54 -0
- webrtc/consent.py +38 -0
- webrtc/dto.py +24 -0
- webrtc/ice_config.py +42 -0
- webrtc/rooms.py +100 -0
- webrtc/signaling.py +129 -0
- webrtc/turn_twilio.py +78 -0
admin/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
fast_admin – CRUD API for admin resources (users, roles, audit log) for FastMVC.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .audit_hooks import (
|
|
6
|
+
AuditLogHook,
|
|
7
|
+
AuditTarget,
|
|
8
|
+
as_audit_hook,
|
|
9
|
+
audit_repository_hook,
|
|
10
|
+
)
|
|
11
|
+
from .crud import crud_router_from_model
|
|
12
|
+
from .repositories import IAdminRoleRepository, IAdminUserRepository, IAuditLogRepository
|
|
13
|
+
from .router import get_admin_router
|
|
14
|
+
from .schemas import AdminRoleSummary, AdminUserSummary, AuditLogEntry
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.1"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AdminUserSummary",
|
|
20
|
+
"AdminRoleSummary",
|
|
21
|
+
"AuditLogEntry",
|
|
22
|
+
"IAdminUserRepository",
|
|
23
|
+
"IAdminRoleRepository",
|
|
24
|
+
"IAuditLogRepository",
|
|
25
|
+
"get_admin_router",
|
|
26
|
+
"crud_router_from_model",
|
|
27
|
+
"AuditLogHook",
|
|
28
|
+
"AuditTarget",
|
|
29
|
+
"audit_repository_hook",
|
|
30
|
+
"as_audit_hook",
|
|
31
|
+
]
|
admin/abstraction.py
ADDED
admin/abstractions.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Admin resource abstractions: users, roles, audit log.
|
|
3
|
+
|
|
4
|
+
Prefer importing from :mod:`admin.schemas` and :mod:`admin.repositories`;
|
|
5
|
+
this module re-exports the same names for backward compatibility.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .repositories import (
|
|
11
|
+
IAdminRoleRepository,
|
|
12
|
+
IAdminUserRepository,
|
|
13
|
+
IAuditLogRepository,
|
|
14
|
+
)
|
|
15
|
+
from .schemas import AdminRoleSummary, AdminUserSummary, AuditLogEntry
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AdminRoleSummary",
|
|
19
|
+
"AdminUserSummary",
|
|
20
|
+
"AuditLogEntry",
|
|
21
|
+
"IAdminUserRepository",
|
|
22
|
+
"IAdminRoleRepository",
|
|
23
|
+
"IAuditLogRepository",
|
|
24
|
+
]
|
admin/audit_hooks.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Optional audit hooks for CRUD and admin flows.
|
|
3
|
+
|
|
4
|
+
Use :class:`AuditLogHook` with :func:`crud_router_from_model`, or wrap
|
|
5
|
+
:class:`~fast_admin.repositories.IAuditLogRepository` with
|
|
6
|
+
:func:`audit_repository_hook`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Callable, Optional, Protocol, Union, cast
|
|
12
|
+
|
|
13
|
+
from .abstractions import IAuditLogRepository
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuditLogHook(Protocol):
|
|
17
|
+
"""Async callback invoked after a successful create, update, or delete."""
|
|
18
|
+
|
|
19
|
+
async def __call__(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
action: str,
|
|
23
|
+
resource_type: str,
|
|
24
|
+
resource_id: Optional[str],
|
|
25
|
+
details: Optional[dict[str, Any]],
|
|
26
|
+
request: Optional[Any] = None,
|
|
27
|
+
) -> None: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
AuditTarget = Union[AuditLogHook, IAuditLogRepository, None]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def audit_repository_hook(
|
|
34
|
+
repo: IAuditLogRepository,
|
|
35
|
+
*,
|
|
36
|
+
get_actor_id: Optional[Callable[[Any], Optional[str]]] = None,
|
|
37
|
+
get_ip_address: Optional[Callable[[Any], Optional[str]]] = None,
|
|
38
|
+
get_user_agent: Optional[Callable[[Any], Optional[str]]] = None,
|
|
39
|
+
) -> AuditLogHook:
|
|
40
|
+
"""
|
|
41
|
+
Wrap an :class:`~fast_admin.repositories.IAuditLogRepository` as an
|
|
42
|
+
:class:`AuditLogHook`, mapping ``request`` through optional extractors.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
async def hook(
|
|
46
|
+
*,
|
|
47
|
+
action: str,
|
|
48
|
+
resource_type: str,
|
|
49
|
+
resource_id: Optional[str],
|
|
50
|
+
details: Optional[dict[str, Any]],
|
|
51
|
+
request: Optional[Any] = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
actor_id = get_actor_id(request) if get_actor_id and request else None
|
|
54
|
+
ip = get_ip_address(request) if get_ip_address and request else None
|
|
55
|
+
ua = get_user_agent(request) if get_user_agent and request else None
|
|
56
|
+
await repo.append(
|
|
57
|
+
action,
|
|
58
|
+
resource_type,
|
|
59
|
+
actor_id=actor_id,
|
|
60
|
+
resource_id=resource_id,
|
|
61
|
+
details=details,
|
|
62
|
+
ip_address=ip,
|
|
63
|
+
user_agent=ua,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return hook
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def as_audit_hook(
|
|
70
|
+
target: AuditTarget,
|
|
71
|
+
*,
|
|
72
|
+
get_actor_id: Optional[Callable[[Any], Optional[str]]] = None,
|
|
73
|
+
get_ip_address: Optional[Callable[[Any], Optional[str]]] = None,
|
|
74
|
+
get_user_agent: Optional[Callable[[Any], Optional[str]]] = None,
|
|
75
|
+
) -> Optional[AuditLogHook]:
|
|
76
|
+
"""
|
|
77
|
+
Normalize ``None``, a plain :class:`AuditLogHook`, or an
|
|
78
|
+
:class:`~fast_admin.repositories.IAuditLogRepository` into a single hook
|
|
79
|
+
(or ``None``).
|
|
80
|
+
"""
|
|
81
|
+
if target is None:
|
|
82
|
+
return None
|
|
83
|
+
if isinstance(target, IAuditLogRepository):
|
|
84
|
+
return audit_repository_hook(
|
|
85
|
+
cast("IAuditLogRepository", target),
|
|
86
|
+
get_actor_id=get_actor_id,
|
|
87
|
+
get_ip_address=get_ip_address,
|
|
88
|
+
get_user_agent=get_user_agent,
|
|
89
|
+
)
|
|
90
|
+
return cast("AuditLogHook", target)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
__all__ = [
|
|
94
|
+
"AuditLogHook",
|
|
95
|
+
"AuditTarget",
|
|
96
|
+
"audit_repository_hook",
|
|
97
|
+
"as_audit_hook",
|
|
98
|
+
]
|
admin/crud.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generic FastAPI CRUD router from a SQLAlchemy 2.0 model and Pydantic v2 schemas.
|
|
3
|
+
|
|
4
|
+
Requires SQLAlchemy (included with ``fast-platform``; install ``sqlalchemy`` if using a minimal env).
|
|
5
|
+
|
|
6
|
+
Note: ``from __future__ import annotations`` is omitted so FastAPI can resolve
|
|
7
|
+
dynamic ``create_schema`` / ``update_schema`` / ``read_schema`` types on routes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Any, Callable, Optional, Type
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from .audit_hooks import AuditLogHook, AuditTarget, as_audit_hook
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def crud_router_from_model(
|
|
19
|
+
model: Any,
|
|
20
|
+
*,
|
|
21
|
+
create_schema: Type[BaseModel],
|
|
22
|
+
update_schema: Type[BaseModel],
|
|
23
|
+
read_schema: Type[BaseModel],
|
|
24
|
+
session_dep: Callable[..., Any],
|
|
25
|
+
resource_type: Optional[str] = None,
|
|
26
|
+
prefix: str = "",
|
|
27
|
+
tags: Optional[list[str]] = None,
|
|
28
|
+
audit: AuditTarget = None,
|
|
29
|
+
get_actor_id_from_request: Optional[Callable[[Request], Optional[str]]] = None,
|
|
30
|
+
get_ip_from_request: Optional[Callable[[Request], Optional[str]]] = None,
|
|
31
|
+
get_user_agent_from_request: Optional[Callable[[Request], Optional[str]]] = None,
|
|
32
|
+
) -> APIRouter:
|
|
33
|
+
"""
|
|
34
|
+
Build an ``APIRouter`` with list/create/read/update/delete for a single table.
|
|
35
|
+
|
|
36
|
+
**Requirements**
|
|
37
|
+
|
|
38
|
+
- Model must use a **single-column** primary key.
|
|
39
|
+
- ``read_schema`` should use ``model_config = ConfigDict(from_attributes=True)``
|
|
40
|
+
so ORM instances serialize correctly.
|
|
41
|
+
- ``create_schema`` / ``update_schema`` field names should match mapped
|
|
42
|
+
attribute names on the model (excluding the primary key on create if
|
|
43
|
+
auto-generated).
|
|
44
|
+
|
|
45
|
+
**Audit**
|
|
46
|
+
|
|
47
|
+
Pass ``audit`` as an :class:`~fast_admin.audit_hooks.AuditLogHook` or
|
|
48
|
+
:class:`~fast_admin.repositories.IAuditLogRepository`. Hooks run **after**
|
|
49
|
+
a successful create, update, or delete. Use ``get_*_from_request`` when
|
|
50
|
+
``audit`` is a repository to populate actor / IP / user-agent.
|
|
51
|
+
|
|
52
|
+
**Session**
|
|
53
|
+
|
|
54
|
+
``session_dep`` is used as ``Depends(session_dep)`` and must yield or return
|
|
55
|
+
a SQLAlchemy :class:`~sqlalchemy.orm.Session`.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
from sqlalchemy import inspect as sa_inspect
|
|
59
|
+
from sqlalchemy import select
|
|
60
|
+
except ImportError as e: # pragma: no cover - exercised when sqlalchemy missing
|
|
61
|
+
raise RuntimeError(
|
|
62
|
+
"crud_router_from_model requires SQLAlchemy. Install: pip install sqlalchemy"
|
|
63
|
+
) from e
|
|
64
|
+
|
|
65
|
+
mapper = sa_inspect(model).mapper
|
|
66
|
+
if len(mapper.primary_key) != 1:
|
|
67
|
+
raise ValueError("crud_router_from_model requires exactly one primary key column")
|
|
68
|
+
|
|
69
|
+
pk_col = mapper.primary_key[0]
|
|
70
|
+
pk_name = pk_col.key
|
|
71
|
+
pk_python = getattr(pk_col.type, "python_type", None)
|
|
72
|
+
|
|
73
|
+
rtype = resource_type or getattr(model, "__tablename__", model.__name__).lower()
|
|
74
|
+
|
|
75
|
+
def _parse_id(raw: str) -> Any:
|
|
76
|
+
if pk_python is int:
|
|
77
|
+
try:
|
|
78
|
+
return int(raw)
|
|
79
|
+
except ValueError as err:
|
|
80
|
+
raise HTTPException(
|
|
81
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid id"
|
|
82
|
+
) from err
|
|
83
|
+
return raw
|
|
84
|
+
|
|
85
|
+
hook: Optional[AuditLogHook] = as_audit_hook(
|
|
86
|
+
audit,
|
|
87
|
+
get_actor_id=get_actor_id_from_request,
|
|
88
|
+
get_ip_address=get_ip_from_request,
|
|
89
|
+
get_user_agent=get_user_agent_from_request,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
async def _audit(
|
|
93
|
+
*,
|
|
94
|
+
action: str,
|
|
95
|
+
resource_id: Optional[str],
|
|
96
|
+
details: Optional[dict[str, Any]],
|
|
97
|
+
request: Optional[Request],
|
|
98
|
+
) -> None:
|
|
99
|
+
if hook is None:
|
|
100
|
+
return
|
|
101
|
+
await hook(
|
|
102
|
+
action=action,
|
|
103
|
+
resource_type=rtype,
|
|
104
|
+
resource_id=resource_id,
|
|
105
|
+
details=details,
|
|
106
|
+
request=request,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
tag_list = tags if tags is not None else [rtype]
|
|
110
|
+
router = APIRouter(prefix=prefix, tags=tag_list)
|
|
111
|
+
|
|
112
|
+
@router.get("", response_model=list[read_schema])
|
|
113
|
+
async def list_items(
|
|
114
|
+
skip: int = Query(0, ge=0),
|
|
115
|
+
limit: int = Query(50, ge=1, le=500),
|
|
116
|
+
db: Any = Depends(session_dep),
|
|
117
|
+
) -> list[BaseModel]:
|
|
118
|
+
rows = db.scalars(select(model).offset(skip).limit(limit)).all()
|
|
119
|
+
return [read_schema.model_validate(r) for r in rows]
|
|
120
|
+
|
|
121
|
+
@router.post("", response_model=read_schema, status_code=status.HTTP_201_CREATED)
|
|
122
|
+
async def create_item(
|
|
123
|
+
body: create_schema,
|
|
124
|
+
request: Request,
|
|
125
|
+
db: Any = Depends(session_dep),
|
|
126
|
+
) -> BaseModel:
|
|
127
|
+
data = body.model_dump(exclude_unset=True)
|
|
128
|
+
if pk_name in data:
|
|
129
|
+
del data[pk_name]
|
|
130
|
+
obj = model(**data)
|
|
131
|
+
db.add(obj)
|
|
132
|
+
db.commit()
|
|
133
|
+
db.refresh(obj)
|
|
134
|
+
pk_val = getattr(obj, pk_name)
|
|
135
|
+
await _audit(
|
|
136
|
+
action="create",
|
|
137
|
+
resource_id=str(pk_val),
|
|
138
|
+
details={"payload": body.model_dump()},
|
|
139
|
+
request=request,
|
|
140
|
+
)
|
|
141
|
+
return read_schema.model_validate(obj)
|
|
142
|
+
|
|
143
|
+
@router.get("/{item_id}", response_model=read_schema)
|
|
144
|
+
async def get_item(
|
|
145
|
+
item_id: str,
|
|
146
|
+
db: Any = Depends(session_dep),
|
|
147
|
+
) -> BaseModel:
|
|
148
|
+
pk = _parse_id(item_id)
|
|
149
|
+
obj = db.get(model, pk)
|
|
150
|
+
if obj is None:
|
|
151
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
|
152
|
+
return read_schema.model_validate(obj)
|
|
153
|
+
|
|
154
|
+
@router.patch("/{item_id}", response_model=read_schema)
|
|
155
|
+
async def update_item(
|
|
156
|
+
item_id: str,
|
|
157
|
+
body: update_schema,
|
|
158
|
+
request: Request,
|
|
159
|
+
db: Any = Depends(session_dep),
|
|
160
|
+
) -> BaseModel:
|
|
161
|
+
pk = _parse_id(item_id)
|
|
162
|
+
obj = db.get(model, pk)
|
|
163
|
+
if obj is None:
|
|
164
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
|
165
|
+
patch = body.model_dump(exclude_unset=True)
|
|
166
|
+
before = {k: getattr(obj, k, None) for k in patch}
|
|
167
|
+
for k, v in patch.items():
|
|
168
|
+
setattr(obj, k, v)
|
|
169
|
+
db.commit()
|
|
170
|
+
db.refresh(obj)
|
|
171
|
+
await _audit(
|
|
172
|
+
action="update",
|
|
173
|
+
resource_id=str(pk),
|
|
174
|
+
details={"before": before, "after": patch},
|
|
175
|
+
request=request,
|
|
176
|
+
)
|
|
177
|
+
return read_schema.model_validate(obj)
|
|
178
|
+
|
|
179
|
+
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
180
|
+
async def delete_item(
|
|
181
|
+
item_id: str,
|
|
182
|
+
request: Request,
|
|
183
|
+
db: Any = Depends(session_dep),
|
|
184
|
+
) -> None:
|
|
185
|
+
pk = _parse_id(item_id)
|
|
186
|
+
obj = db.get(model, pk)
|
|
187
|
+
if obj is None:
|
|
188
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
|
189
|
+
snapshot = read_schema.model_validate(obj).model_dump()
|
|
190
|
+
db.delete(obj)
|
|
191
|
+
db.commit()
|
|
192
|
+
await _audit(
|
|
193
|
+
action="delete",
|
|
194
|
+
resource_id=str(pk),
|
|
195
|
+
details={"snapshot": snapshot},
|
|
196
|
+
request=request,
|
|
197
|
+
)
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
return router
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
__all__ = ["crud_router_from_model"]
|
admin/repositories.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Repository interfaces for admin users, roles, and audit log.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
9
|
+
|
|
10
|
+
from .abstraction import IAdmin
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
|
|
15
|
+
from .schemas import AdminRoleSummary, AdminUserSummary, AuditLogEntry
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"IAdminRoleRepository",
|
|
19
|
+
"IAdminUserRepository",
|
|
20
|
+
"IAuditLogRepository",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class IAdminUserRepository(IAdmin, ABC):
|
|
25
|
+
"""Admin view over users (list, toggle active, assign roles)."""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
async def list_users(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
skip: int = 0,
|
|
32
|
+
limit: int = 50,
|
|
33
|
+
search: Optional[str] = None,
|
|
34
|
+
active_only: Optional[bool] = None,
|
|
35
|
+
) -> list[AdminUserSummary]:
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
async def get_user(self, user_id: str) -> Optional[AdminUserSummary]:
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
async def set_user_active(self, user_id: str, is_active: bool) -> None:
|
|
44
|
+
raise NotImplementedError
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
async def set_user_roles(self, user_id: str, role_ids: list[str]) -> None:
|
|
48
|
+
raise NotImplementedError
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class IAdminRoleRepository(IAdmin, ABC):
|
|
52
|
+
"""Admin CRUD for roles."""
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
async def list_roles(self) -> list[AdminRoleSummary]:
|
|
56
|
+
raise NotImplementedError
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
async def get_role(self, role_id: str) -> Optional[AdminRoleSummary]:
|
|
60
|
+
raise NotImplementedError
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
async def create_role(self, name: str, permissions: list[str]) -> AdminRoleSummary:
|
|
64
|
+
raise NotImplementedError
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
async def update_role(
|
|
68
|
+
self, role_id: str, name: Optional[str] = None, permissions: Optional[list[str]] = None
|
|
69
|
+
) -> Optional[AdminRoleSummary]:
|
|
70
|
+
raise NotImplementedError
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
async def delete_role(self, role_id: str) -> None:
|
|
74
|
+
raise NotImplementedError
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class IAuditLogRepository(IAdmin, ABC):
|
|
78
|
+
"""Append-only audit log for admin review."""
|
|
79
|
+
|
|
80
|
+
@abstractmethod
|
|
81
|
+
async def append(
|
|
82
|
+
self,
|
|
83
|
+
action: str,
|
|
84
|
+
resource_type: str,
|
|
85
|
+
*,
|
|
86
|
+
actor_id: Optional[str] = None,
|
|
87
|
+
resource_id: Optional[str] = None,
|
|
88
|
+
details: Optional[dict[str, Any]] = None,
|
|
89
|
+
ip_address: Optional[str] = None,
|
|
90
|
+
user_agent: Optional[str] = None,
|
|
91
|
+
) -> AuditLogEntry:
|
|
92
|
+
raise NotImplementedError
|
|
93
|
+
|
|
94
|
+
@abstractmethod
|
|
95
|
+
async def list_entries(
|
|
96
|
+
self,
|
|
97
|
+
*,
|
|
98
|
+
skip: int = 0,
|
|
99
|
+
limit: int = 100,
|
|
100
|
+
actor_id: Optional[str] = None,
|
|
101
|
+
resource_type: Optional[str] = None,
|
|
102
|
+
resource_id: Optional[str] = None,
|
|
103
|
+
since: Optional[datetime] = None,
|
|
104
|
+
) -> list[AuditLogEntry]:
|
|
105
|
+
raise NotImplementedError
|
admin/router.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Optional FastAPI router for admin API.
|
|
3
|
+
|
|
4
|
+
Mount under /admin and protect with your auth (e.g. require admin role).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Query
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from .repositories import IAdminRoleRepository, IAdminUserRepository, IAuditLogRepository
|
|
13
|
+
from .schemas import AdminRoleSummary, AdminUserSummary, AuditLogEntry
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_admin_router(
|
|
17
|
+
user_repo: Optional[IAdminUserRepository] = None,
|
|
18
|
+
role_repo: Optional[IAdminRoleRepository] = None,
|
|
19
|
+
audit_repo: Optional[IAuditLogRepository] = None,
|
|
20
|
+
prefix: str = "/admin",
|
|
21
|
+
) -> APIRouter:
|
|
22
|
+
"""
|
|
23
|
+
Build an admin API router. Pass repos for the sections you want to expose.
|
|
24
|
+
"""
|
|
25
|
+
router = APIRouter(prefix=prefix, tags=["admin"])
|
|
26
|
+
|
|
27
|
+
if user_repo is not None:
|
|
28
|
+
|
|
29
|
+
@router.get("/users", response_model=list[AdminUserSummary])
|
|
30
|
+
async def list_users(
|
|
31
|
+
skip: int = Query(0, ge=0),
|
|
32
|
+
limit: int = Query(50, ge=1, le=100),
|
|
33
|
+
search: Optional[str] = None,
|
|
34
|
+
active_only: Optional[bool] = None,
|
|
35
|
+
) -> list[AdminUserSummary]:
|
|
36
|
+
return await user_repo.list_users(
|
|
37
|
+
skip=skip, limit=limit, search=search, active_only=active_only
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
@router.get("/users/{user_id}", response_model=AdminUserSummary)
|
|
41
|
+
async def get_user(user_id: str) -> AdminUserSummary:
|
|
42
|
+
u = await user_repo.get_user(user_id)
|
|
43
|
+
if u is None:
|
|
44
|
+
from fastapi import HTTPException
|
|
45
|
+
|
|
46
|
+
raise HTTPException(404, "User not found")
|
|
47
|
+
return u
|
|
48
|
+
|
|
49
|
+
class ActiveBody(BaseModel):
|
|
50
|
+
is_active: bool
|
|
51
|
+
|
|
52
|
+
@router.patch("/users/{user_id}/active")
|
|
53
|
+
async def set_user_active(user_id: str, body: ActiveBody) -> dict[str, bool]:
|
|
54
|
+
await user_repo.set_user_active(user_id, body.is_active)
|
|
55
|
+
return {"ok": True}
|
|
56
|
+
|
|
57
|
+
class RolesBody(BaseModel):
|
|
58
|
+
role_ids: list[str] = []
|
|
59
|
+
|
|
60
|
+
@router.put("/users/{user_id}/roles")
|
|
61
|
+
async def set_user_roles(user_id: str, body: RolesBody) -> dict[str, bool]:
|
|
62
|
+
await user_repo.set_user_roles(user_id, body.role_ids)
|
|
63
|
+
return {"ok": True}
|
|
64
|
+
|
|
65
|
+
if role_repo is not None:
|
|
66
|
+
|
|
67
|
+
@router.get("/roles", response_model=list[AdminRoleSummary])
|
|
68
|
+
async def list_roles() -> list[AdminRoleSummary]:
|
|
69
|
+
return await role_repo.list_roles()
|
|
70
|
+
|
|
71
|
+
@router.get("/roles/{role_id}", response_model=AdminRoleSummary)
|
|
72
|
+
async def get_role(role_id: str) -> AdminRoleSummary:
|
|
73
|
+
r = await role_repo.get_role(role_id)
|
|
74
|
+
if r is None:
|
|
75
|
+
from fastapi import HTTPException
|
|
76
|
+
|
|
77
|
+
raise HTTPException(404, "Role not found")
|
|
78
|
+
return r
|
|
79
|
+
|
|
80
|
+
if audit_repo is not None:
|
|
81
|
+
|
|
82
|
+
@router.get("/audit", response_model=list[AuditLogEntry])
|
|
83
|
+
async def list_audit(
|
|
84
|
+
skip: int = Query(0, ge=0),
|
|
85
|
+
limit: int = Query(100, ge=1, le=500),
|
|
86
|
+
actor_id: Optional[str] = None,
|
|
87
|
+
resource_type: Optional[str] = None,
|
|
88
|
+
resource_id: Optional[str] = None,
|
|
89
|
+
) -> list[AuditLogEntry]:
|
|
90
|
+
return await audit_repo.list_entries(
|
|
91
|
+
skip=skip,
|
|
92
|
+
limit=limit,
|
|
93
|
+
actor_id=actor_id,
|
|
94
|
+
resource_type=resource_type,
|
|
95
|
+
resource_id=resource_id,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return router
|
admin/schemas.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pydantic DTOs for the admin API (users, roles, audit log).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from datetime import datetime # noqa: TC003
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AdminRoleSummary",
|
|
14
|
+
"AdminUserSummary",
|
|
15
|
+
"AuditLogEntry",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AdminUserSummary(BaseModel):
|
|
20
|
+
"""Summary of a user for admin listing."""
|
|
21
|
+
|
|
22
|
+
id: str
|
|
23
|
+
email: str
|
|
24
|
+
is_active: bool
|
|
25
|
+
created_at: datetime
|
|
26
|
+
roles: list[str] = []
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AdminRoleSummary(BaseModel):
|
|
30
|
+
"""Summary of a role for admin listing."""
|
|
31
|
+
|
|
32
|
+
id: str
|
|
33
|
+
name: str
|
|
34
|
+
permissions: list[str] = []
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AuditLogEntry(BaseModel):
|
|
38
|
+
"""Single audit log entry."""
|
|
39
|
+
|
|
40
|
+
id: str
|
|
41
|
+
actor_id: Optional[str] = None
|
|
42
|
+
actor_type: str = "user"
|
|
43
|
+
action: str
|
|
44
|
+
resource_type: str
|
|
45
|
+
resource_id: Optional[str] = None
|
|
46
|
+
details: dict[str, Any] = {}
|
|
47
|
+
ip_address: Optional[str] = None
|
|
48
|
+
user_agent: Optional[str] = None
|
|
49
|
+
created_at: datetime
|
analytics/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
fast_analytics – Analytics and event tracking for FastMVC.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from fast_platform import AnalyticsConfiguration, AnalyticsConfigurationDTO
|
|
6
|
+
|
|
7
|
+
from .base import IAnalyticsBackend, build_analytics_client
|
|
8
|
+
from .buffer import BufferedAnalyticsBackend
|
|
9
|
+
from .middleware import AnalyticsSamplingMiddleware, default_analytics_user_key
|
|
10
|
+
from .pii import ScrubbingAnalyticsBackend, scrub_pii_properties
|
|
11
|
+
from .rate_limit import RateLimitedAnalyticsBackend
|
|
12
|
+
from .schema_registry import EventSchemaRegistry, parse_versioned_event_name
|
|
13
|
+
from .validating_backend import ValidatingAnalyticsBackend
|
|
14
|
+
|
|
15
|
+
__version__ = "0.3.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AnalyticsSamplingMiddleware",
|
|
19
|
+
"BufferedAnalyticsBackend",
|
|
20
|
+
"EventSchemaRegistry",
|
|
21
|
+
"IAnalyticsBackend",
|
|
22
|
+
"RateLimitedAnalyticsBackend",
|
|
23
|
+
"ScrubbingAnalyticsBackend",
|
|
24
|
+
"ValidatingAnalyticsBackend",
|
|
25
|
+
"AnalyticsConfiguration",
|
|
26
|
+
"AnalyticsConfigurationDTO",
|
|
27
|
+
"build_analytics_client",
|
|
28
|
+
"default_analytics_user_key",
|
|
29
|
+
"parse_versioned_event_name",
|
|
30
|
+
"scrub_pii_properties",
|
|
31
|
+
]
|
analytics/abstraction.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""analytics package abstractions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class IAnalytics(ABC):
|
|
9
|
+
"""Marker base for concrete types in the ``analytics`` package."""
|
|
10
|
+
|
|
11
|
+
__slots__ = ()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__all__ = ["IAnalytics"]
|