nlbone 0.4.0__py3-none-any.whl → 0.4.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.
- nlbone/adapters/auth/__init__.py +1 -1
- nlbone/adapters/auth/keycloak.py +6 -1
- nlbone/adapters/db/__init__.py +4 -3
- nlbone/adapters/db/postgres/__init__.py +4 -0
- nlbone/adapters/db/postgres/audit.py +148 -0
- nlbone/adapters/db/{sqlalchemy → postgres}/base.py +0 -2
- nlbone/adapters/db/{sqlalchemy → postgres}/engine.py +4 -3
- nlbone/adapters/db/{sqlalchemy → postgres}/query_builder.py +27 -11
- nlbone/adapters/db/{sqlalchemy → postgres}/repository.py +4 -2
- nlbone/adapters/db/{sqlalchemy → postgres}/schema.py +7 -7
- nlbone/adapters/db/{sqlalchemy → postgres}/uow.py +3 -2
- nlbone/adapters/db/redis/client.py +22 -0
- nlbone/adapters/http_clients/uploadchi.py +27 -11
- nlbone/adapters/http_clients/uploadchi_async.py +29 -7
- nlbone/adapters/messaging/event_bus.py +3 -0
- nlbone/adapters/percolation/__init__.py +1 -0
- nlbone/adapters/percolation/connection.py +12 -0
- nlbone/config/logging.py +76 -117
- nlbone/config/settings.py +20 -24
- nlbone/container.py +6 -5
- nlbone/core/application/base_worker.py +36 -0
- nlbone/core/application/events.py +5 -1
- nlbone/core/application/use_case.py +3 -1
- nlbone/core/domain/base.py +4 -0
- nlbone/core/domain/models.py +38 -0
- nlbone/core/ports/__init__.py +3 -3
- nlbone/core/ports/auth.py +1 -0
- nlbone/core/ports/event_bus.py +3 -0
- nlbone/core/ports/files.py +26 -5
- nlbone/core/ports/repo.py +5 -2
- nlbone/core/ports/uow.py +3 -0
- nlbone/interfaces/api/dependencies/__init__.py +11 -3
- nlbone/interfaces/api/dependencies/async_auth.py +61 -0
- nlbone/interfaces/api/dependencies/auth.py +5 -3
- nlbone/interfaces/api/dependencies/db.py +5 -3
- nlbone/interfaces/api/dependencies/uow.py +3 -2
- nlbone/interfaces/api/exception_handlers.py +17 -15
- nlbone/interfaces/api/exceptions.py +1 -2
- nlbone/interfaces/api/middleware/__init__.py +2 -2
- nlbone/interfaces/api/middleware/access_log.py +12 -8
- nlbone/interfaces/api/middleware/add_request_context.py +55 -52
- nlbone/interfaces/api/middleware/authentication.py +4 -1
- nlbone/interfaces/api/pagination/__init__.py +4 -5
- nlbone/interfaces/api/pagination/offset_base.py +0 -2
- nlbone/interfaces/cli/init_db.py +24 -13
- nlbone/interfaces/cli/main.py +29 -0
- nlbone/utils/context.py +14 -4
- nlbone/utils/redactor.py +32 -0
- nlbone/utils/time.py +41 -2
- {nlbone-0.4.0.dist-info → nlbone-0.4.2.dist-info}/METADATA +5 -9
- nlbone-0.4.2.dist-info/RECORD +78 -0
- nlbone-0.4.2.dist-info/entry_points.txt +2 -0
- nlbone/adapters/db/postgres.py +0 -0
- nlbone/adapters/db/sqlalchemy/__init__.py +0 -4
- nlbone-0.4.0.dist-info/RECORD +0 -71
- /nlbone/adapters/db/{memory.py → redis/__init__.py} +0 -0
- {nlbone-0.4.0.dist-info → nlbone-0.4.2.dist-info}/WHEEL +0 -0
- {nlbone-0.4.0.dist-info → nlbone-0.4.2.dist-info}/licenses/LICENSE +0 -0
nlbone/config/logging.py
CHANGED
|
@@ -1,43 +1,33 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
1
|
import json
|
|
4
2
|
import logging
|
|
5
3
|
import sys
|
|
6
4
|
from datetime import datetime, timezone
|
|
7
|
-
from
|
|
8
|
-
|
|
9
|
-
import contextvars
|
|
5
|
+
from logging.config import dictConfig
|
|
6
|
+
from typing import Any, MutableMapping
|
|
10
7
|
|
|
11
8
|
from nlbone.config.settings import get_settings
|
|
9
|
+
from nlbone.utils.context import current_context_dict
|
|
10
|
+
from nlbone.utils.redactor import PiiRedactor
|
|
12
11
|
|
|
13
|
-
|
|
14
|
-
_request_id_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
|
|
15
|
-
"request_id", default=None
|
|
16
|
-
)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def set_request_id(request_id: Optional[str]) -> None:
|
|
20
|
-
"""
|
|
21
|
-
Set request id in context (e.g., per incoming HTTP request).
|
|
22
|
-
Example:
|
|
23
|
-
from nlbone.config.logging import set_request_id
|
|
24
|
-
set_request_id("abc-123")
|
|
25
|
-
"""
|
|
26
|
-
_request_id_var.set(request_id)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
class RequestIdFilter(logging.Filter):
|
|
30
|
-
"""Injects request_id from contextvars into record."""
|
|
12
|
+
settings = get_settings()
|
|
31
13
|
|
|
14
|
+
# ---------- Filters ----------
|
|
15
|
+
class ContextFilter(logging.Filter):
|
|
32
16
|
def filter(self, record: logging.LogRecord) -> bool:
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
record.
|
|
17
|
+
ctx = current_context_dict()
|
|
18
|
+
record.request_id = ctx.get("request_id")
|
|
19
|
+
record.user_id = ctx.get("user_id")
|
|
20
|
+
record.ip = ctx.get("ip")
|
|
21
|
+
record.user_agent = ctx.get("user_agent")
|
|
36
22
|
return True
|
|
37
23
|
|
|
38
|
-
|
|
24
|
+
# ---------- Formatter ----------
|
|
39
25
|
class JsonFormatter(logging.Formatter):
|
|
40
|
-
|
|
26
|
+
RESERVED = {
|
|
27
|
+
"args","asctime","created","exc_info","exc_text","filename","funcName","levelname","levelno",
|
|
28
|
+
"lineno","module","msecs","message","msg","name","pathname","process","processName",
|
|
29
|
+
"relativeCreated","stack_info","thread","threadName",
|
|
30
|
+
}
|
|
41
31
|
|
|
42
32
|
def format(self, record: logging.LogRecord) -> str:
|
|
43
33
|
payload: MutableMapping[str, Any] = {
|
|
@@ -45,101 +35,77 @@ class JsonFormatter(logging.Formatter):
|
|
|
45
35
|
"level": record.levelname,
|
|
46
36
|
"logger": record.name,
|
|
47
37
|
"msg": record.getMessage(),
|
|
48
|
-
"request_id": getattr(record, "request_id",
|
|
38
|
+
"request_id": getattr(record, "request_id", None),
|
|
39
|
+
"user_id": getattr(record, "user_id", None),
|
|
40
|
+
"ip": getattr(record, "ip", None),
|
|
41
|
+
"user_agent": getattr(record, "user_agent", None),
|
|
49
42
|
}
|
|
50
43
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
for key, value in record.__dict__.items():
|
|
54
|
-
if key in {
|
|
55
|
-
"args",
|
|
56
|
-
"asctime",
|
|
57
|
-
"created",
|
|
58
|
-
"exc_info",
|
|
59
|
-
"exc_text",
|
|
60
|
-
"filename",
|
|
61
|
-
"funcName",
|
|
62
|
-
"levelname",
|
|
63
|
-
"levelno",
|
|
64
|
-
"lineno",
|
|
65
|
-
"module",
|
|
66
|
-
"msecs",
|
|
67
|
-
"message",
|
|
68
|
-
"msg",
|
|
69
|
-
"name",
|
|
70
|
-
"pathname",
|
|
71
|
-
"process",
|
|
72
|
-
"processName",
|
|
73
|
-
"relativeCreated",
|
|
74
|
-
"stack_info",
|
|
75
|
-
"thread",
|
|
76
|
-
"threadName",
|
|
77
|
-
}:
|
|
44
|
+
for k, v in record.__dict__.items():
|
|
45
|
+
if k in self.RESERVED or k in payload:
|
|
78
46
|
continue
|
|
79
|
-
|
|
80
|
-
if key in payload:
|
|
81
|
-
continue
|
|
82
|
-
payload[key] = value
|
|
47
|
+
payload[k] = v
|
|
83
48
|
|
|
84
|
-
# Attach exception info if present
|
|
85
49
|
if record.exc_info:
|
|
86
|
-
|
|
50
|
+
etype = record.exc_info[0].__name__ if record.exc_info[0] else None
|
|
51
|
+
payload["exc_type"] = etype
|
|
87
52
|
payload["exc"] = self.formatException(record.exc_info)
|
|
88
53
|
|
|
89
54
|
return json.dumps(payload, ensure_ascii=False)
|
|
90
55
|
|
|
91
|
-
|
|
92
|
-
def
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
handler.setFormatter(JsonFormatter())
|
|
98
|
-
else:
|
|
99
|
-
# human-friendly text format
|
|
100
|
-
fmt = (
|
|
101
|
-
"%(asctime)s | %(levelname)s | %(name)s | rid=%(request_id)s | %(message)s"
|
|
56
|
+
class PlainFormatter(logging.Formatter):
|
|
57
|
+
def __init__(self):
|
|
58
|
+
super().__init__(
|
|
59
|
+
fmt="%(asctime)s | %(levelname)s | %(name)s | "
|
|
60
|
+
"req=%(request_id)s user=%(user_id)s ip=%(ip)s | %(message)s",
|
|
61
|
+
datefmt="%Y-%m-%dT%H:%M:%S%z",
|
|
102
62
|
)
|
|
103
|
-
datefmt = "%Y-%m-%dT%H:%M:%S%z"
|
|
104
|
-
handler.setFormatter(logging.Formatter(fmt=fmt, datefmt=datefmt))
|
|
105
|
-
return handler
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def setup_logging(
|
|
109
|
-
level: Optional[int] = None,
|
|
110
|
-
json_enabled: Optional[bool] = None,
|
|
111
|
-
silence_uvicorn_access: bool = True,
|
|
112
|
-
) -> None:
|
|
113
|
-
"""
|
|
114
|
-
Configure root logging once at app start.
|
|
115
|
-
Idempotent: safe to call multiple times.
|
|
116
|
-
|
|
117
|
-
Example:
|
|
118
|
-
from nlbone.config.logging import setup_logging
|
|
119
|
-
setup_logging()
|
|
120
|
-
"""
|
|
121
|
-
settings = get_settings()
|
|
122
|
-
lvl = level if level is not None else getattr(logging, settings.LOG_LEVEL, logging.INFO)
|
|
123
|
-
json_logs = settings.LOG_JSON if json_enabled is None else json_enabled
|
|
124
63
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
64
|
+
# ---------- Setup ----------
|
|
65
|
+
def setup_logging(*, log_json: bool=settings.LOG_JSON, log_level: str =settings.LOG_LEVEL,
|
|
66
|
+
log_file: str | None = None, silence_uvicorn_access: bool = True):
|
|
67
|
+
handlers = {
|
|
68
|
+
"console": {
|
|
69
|
+
"class": "logging.StreamHandler",
|
|
70
|
+
"stream": sys.stdout,
|
|
71
|
+
"filters": ["ctx", "pii"],
|
|
72
|
+
"formatter": "json" if log_json else "plain",
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if log_file:
|
|
76
|
+
handlers["file"] = {
|
|
77
|
+
"class": "logging.handlers.RotatingFileHandler",
|
|
78
|
+
"filename": log_file,
|
|
79
|
+
"maxBytes": 10 * 1024 * 1024,
|
|
80
|
+
"backupCount": 5,
|
|
81
|
+
"filters": ["ctx", "pii"],
|
|
82
|
+
"formatter": "json" if log_json else "plain",
|
|
83
|
+
}
|
|
132
84
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
85
|
+
dictConfig({
|
|
86
|
+
"version": 1,
|
|
87
|
+
"disable_existing_loggers": False,
|
|
88
|
+
"filters": {
|
|
89
|
+
"ctx": {"()": ContextFilter},
|
|
90
|
+
"pii": {"()": PiiRedactor},
|
|
91
|
+
},
|
|
92
|
+
"formatters": {
|
|
93
|
+
"json": {"()": JsonFormatter},
|
|
94
|
+
"plain": {"()": PlainFormatter},
|
|
95
|
+
},
|
|
96
|
+
"handlers": handlers,
|
|
97
|
+
"root": {
|
|
98
|
+
"level": log_level,
|
|
99
|
+
"handlers": list(handlers.keys()),
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
|
104
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
136
105
|
|
|
137
|
-
# Uvicorn / FastAPI compatibility (if used later)
|
|
138
|
-
# - application logs go through root
|
|
139
|
-
# - format access logs separately or silence them
|
|
140
106
|
uvicorn_error = logging.getLogger("uvicorn.error")
|
|
141
107
|
uvicorn_error.handlers = []
|
|
142
|
-
uvicorn_error.propagate = True
|
|
108
|
+
uvicorn_error.propagate = True
|
|
143
109
|
|
|
144
110
|
uvicorn_access = logging.getLogger("uvicorn.access")
|
|
145
111
|
if silence_uvicorn_access:
|
|
@@ -149,12 +115,5 @@ def setup_logging(
|
|
|
149
115
|
uvicorn_access.handlers = []
|
|
150
116
|
uvicorn_access.propagate = True
|
|
151
117
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
"""
|
|
155
|
-
Helper to get a configured logger.
|
|
156
|
-
Example:
|
|
157
|
-
logger = get_logger(__name__)
|
|
158
|
-
logger.info("hello", extra={"user_id": "42"})
|
|
159
|
-
"""
|
|
160
|
-
return logging.getLogger(name)
|
|
118
|
+
def get_logger(name: str | None = None) -> logging.Logger:
|
|
119
|
+
return logging.getLogger(name or "app")
|
nlbone/config/settings.py
CHANGED
|
@@ -2,7 +2,8 @@ import os
|
|
|
2
2
|
from functools import lru_cache
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from typing import Literal
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
from pydantic import AnyHttpUrl, Field, SecretStr
|
|
6
7
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
8
|
|
|
8
9
|
|
|
@@ -22,7 +23,7 @@ def _guess_env_file() -> str | None:
|
|
|
22
23
|
return str(f)
|
|
23
24
|
|
|
24
25
|
|
|
25
|
-
def
|
|
26
|
+
def is_production_env() -> bool:
|
|
26
27
|
raw = os.getenv("NLBONE_ENV") or os.getenv("ENV") or os.getenv("ENVIRONMENT")
|
|
27
28
|
if not raw:
|
|
28
29
|
return False
|
|
@@ -34,9 +35,7 @@ class Settings(BaseSettings):
|
|
|
34
35
|
# App
|
|
35
36
|
# ---------------------------
|
|
36
37
|
PORT: int = 8000
|
|
37
|
-
ENV: Literal["local", "dev", "staging", "prod"] = Field(default="local"
|
|
38
|
-
validation_alias=AliasChoices("NLBONE_ENV", "ENV",
|
|
39
|
-
"ENVIRONMENT"))
|
|
38
|
+
ENV: Literal["local", "dev", "staging", "prod"] = Field(default="local")
|
|
40
39
|
DEBUG: bool = Field(default=False)
|
|
41
40
|
LOG_LEVEL: Literal["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"] = Field(default="INFO")
|
|
42
41
|
LOG_JSON: bool = Field(default=True)
|
|
@@ -49,23 +48,15 @@ class Settings(BaseSettings):
|
|
|
49
48
|
# ---------------------------
|
|
50
49
|
# Keycloak / Auth
|
|
51
50
|
# ---------------------------
|
|
52
|
-
KEYCLOAK_SERVER_URL: AnyHttpUrl = Field(default="https://keycloak.local/auth"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
validation_alias=AliasChoices("NLBONE_KEYCLOAK_REALM_NAME", "KEYCLOAK_REALM_NAME"))
|
|
57
|
-
KEYCLOAK_CLIENT_ID: str = Field(default="nlbone",
|
|
58
|
-
validation_alias=AliasChoices("NLBONE_KEYCLOAK_CLIENT_ID", "KEYCLOAK_CLIENT_ID"))
|
|
59
|
-
KEYCLOAK_CLIENT_SECRET: SecretStr = Field(default=SecretStr("dev-secret"),
|
|
60
|
-
validation_alias=AliasChoices("NLBONE_KEYCLOAK_CLIENT_SECRET",
|
|
61
|
-
"KEYCLOAK_CLIENT_SECRET"))
|
|
51
|
+
KEYCLOAK_SERVER_URL: AnyHttpUrl = Field(default="https://keycloak.local/auth")
|
|
52
|
+
KEYCLOAK_REALM_NAME: str = Field(default="numberland")
|
|
53
|
+
KEYCLOAK_CLIENT_ID: str = Field(default="nlbone")
|
|
54
|
+
KEYCLOAK_CLIENT_SECRET: SecretStr = Field(default=SecretStr("dev-secret"))
|
|
62
55
|
|
|
63
56
|
# ---------------------------
|
|
64
57
|
# Database
|
|
65
58
|
# ---------------------------
|
|
66
|
-
POSTGRES_DB_DSN: str = Field(default="postgresql+asyncpg://user:pass@localhost:5432/nlbone"
|
|
67
|
-
validation_alias=AliasChoices("NLBONE_POSTGRES_DB_DSN",
|
|
68
|
-
"POSTGRES_DB_DSN", "DATABASE_URL", "DB_DSN"))
|
|
59
|
+
POSTGRES_DB_DSN: str = Field(default="postgresql+asyncpg://user:pass@localhost:5432/nlbone")
|
|
69
60
|
DB_ECHO: bool = Field(default=False)
|
|
70
61
|
DB_POOL_SIZE: int = Field(default=5)
|
|
71
62
|
DB_MAX_OVERFLOW: int = Field(default=10)
|
|
@@ -74,6 +65,7 @@ class Settings(BaseSettings):
|
|
|
74
65
|
# Messaging / Cache
|
|
75
66
|
# ---------------------------
|
|
76
67
|
REDIS_URL: str = Field(default="redis://localhost:6379/0")
|
|
68
|
+
|
|
77
69
|
# --- Event bus / Outbox ---
|
|
78
70
|
EVENT_BUS_BACKEND: Literal["inmemory"] = Field(default="inmemory")
|
|
79
71
|
OUTBOX_ENABLED: bool = Field(default=False)
|
|
@@ -83,13 +75,17 @@ class Settings(BaseSettings):
|
|
|
83
75
|
# UPLOADCHI
|
|
84
76
|
# ---------------------------
|
|
85
77
|
UPLOADCHI_BASE_URL: AnyHttpUrl = Field(default="https://uploadchi.numberland.ir/v1/files")
|
|
86
|
-
UPLOADCHI_TOKEN: SecretStr
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
78
|
+
UPLOADCHI_TOKEN: SecretStr = Field(default="")
|
|
79
|
+
|
|
80
|
+
# ---------------------------
|
|
81
|
+
# PERCOLATE
|
|
82
|
+
# ---------------------------
|
|
83
|
+
ELASTIC_PERCOLATE_URL: str = Field(default="http://localhost:9200")
|
|
84
|
+
ELASTIC_PERCOLATE_USER: str = Field(default="")
|
|
85
|
+
ELASTIC_PERCOLATE_PASS: SecretStr = Field(default="")
|
|
90
86
|
|
|
91
87
|
model_config = SettingsConfigDict(
|
|
92
|
-
env_prefix="
|
|
88
|
+
env_prefix="",
|
|
93
89
|
env_file=None,
|
|
94
90
|
env_file_encoding="utf-8",
|
|
95
91
|
extra="ignore",
|
|
@@ -97,7 +93,7 @@ class Settings(BaseSettings):
|
|
|
97
93
|
|
|
98
94
|
@classmethod
|
|
99
95
|
def load(cls, env_file: str | None = None) -> "Settings":
|
|
100
|
-
if
|
|
96
|
+
if is_production_env():
|
|
101
97
|
return cls()
|
|
102
98
|
return cls(_env_file=env_file or _guess_env_file())
|
|
103
99
|
|
nlbone/container.py
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
from typing import Any, Mapping, Optional
|
|
3
4
|
|
|
4
5
|
from dependency_injector import containers, providers
|
|
5
6
|
|
|
6
|
-
from nlbone.adapters.
|
|
7
|
-
from nlbone.adapters.db.
|
|
7
|
+
from nlbone.adapters.auth.keycloak import KeycloakAuthService
|
|
8
|
+
from nlbone.adapters.db.postgres import AsyncSqlAlchemyUnitOfWork, SqlAlchemyUnitOfWork
|
|
9
|
+
from nlbone.adapters.db.postgres.engine import get_async_session_factory, get_sync_session_factory
|
|
8
10
|
from nlbone.adapters.http_clients.uploadchi import UploadchiClient
|
|
9
11
|
from nlbone.adapters.http_clients.uploadchi_async import UploadchiAsyncClient
|
|
10
|
-
from nlbone.adapters.auth.keycloak import KeycloakAuthService
|
|
11
12
|
from nlbone.adapters.messaging import InMemoryEventBus
|
|
12
13
|
from nlbone.core.ports import EventBusPort
|
|
13
|
-
from nlbone.core.ports.files import
|
|
14
|
+
from nlbone.core.ports.files import AsyncFileServicePort, FileServicePort
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
class Container(containers.DeclarativeContainer):
|
|
@@ -21,7 +22,7 @@ class Container(containers.DeclarativeContainer):
|
|
|
21
22
|
|
|
22
23
|
# --- UoW ---
|
|
23
24
|
uow = providers.Factory(SqlAlchemyUnitOfWork, session_factory=sync_session_factory)
|
|
24
|
-
async_uow = providers.Factory(
|
|
25
|
+
async_uow = providers.Factory(AsyncSqlAlchemyUnitOfWork, session_factory=async_session_factory)
|
|
25
26
|
|
|
26
27
|
# --- Event bus ---
|
|
27
28
|
event_bus: providers.Singleton[EventBusPort] = providers.Singleton(InMemoryEventBus)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from nlbone.utils.time import TimeUtility
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseWorker(ABC):
|
|
9
|
+
def __init__(self, name, interval):
|
|
10
|
+
self.name = name
|
|
11
|
+
self.interval = interval
|
|
12
|
+
|
|
13
|
+
async def run(self, *args, **kwargs):
|
|
14
|
+
while True:
|
|
15
|
+
try:
|
|
16
|
+
print(f"[>>] {self.name} is running. Current time: {TimeUtility.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
|
17
|
+
|
|
18
|
+
await self.process(*args, **kwargs)
|
|
19
|
+
|
|
20
|
+
print(
|
|
21
|
+
f"[>>] {self.name} is sleeping. Current time: {TimeUtility.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
|
|
22
|
+
)
|
|
23
|
+
await asyncio.sleep(self.interval)
|
|
24
|
+
|
|
25
|
+
except asyncio.CancelledError:
|
|
26
|
+
print(f"[!!] {self.name} task was cancelled. Shutting down gracefully.\n")
|
|
27
|
+
break
|
|
28
|
+
|
|
29
|
+
except Exception as e:
|
|
30
|
+
print(f"[!!]An error occurred in {self.name}:\n{str(e)}\n")
|
|
31
|
+
print(f"[!!] Retrying in {self.interval / 2} seconds...\n")
|
|
32
|
+
await asyncio.sleep(self.interval / 2)
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
async def process(self, *args, **kwargs) -> Any:
|
|
36
|
+
pass
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
from typing import Iterable, Sequence
|
|
2
|
+
|
|
2
3
|
from nlbone.core.domain.base import AggregateRoot, DomainEvent
|
|
3
4
|
from nlbone.core.ports.event_bus import EventBusPort
|
|
4
5
|
|
|
6
|
+
|
|
5
7
|
def collect_events(*aggregates: Iterable[AggregateRoot]) -> list[DomainEvent]:
|
|
6
8
|
events: list[DomainEvent] = []
|
|
7
9
|
for agg in aggregates:
|
|
@@ -12,5 +14,7 @@ def collect_events(*aggregates: Iterable[AggregateRoot]) -> list[DomainEvent]:
|
|
|
12
14
|
events.extend(a.pull_events())
|
|
13
15
|
return events
|
|
14
16
|
|
|
17
|
+
|
|
15
18
|
def publish_events(bus: EventBusPort, events: Sequence[DomainEvent]) -> None:
|
|
16
|
-
if events:
|
|
19
|
+
if events:
|
|
20
|
+
bus.publish(events)
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
from typing import Protocol, TypeVar
|
|
2
2
|
|
|
3
|
+
InT = TypeVar("InT")
|
|
4
|
+
OutT = TypeVar("OutT")
|
|
3
5
|
|
|
4
|
-
InT = TypeVar("InT"); OutT = TypeVar("OutT")
|
|
5
6
|
|
|
6
7
|
class UseCase(Protocol):
|
|
7
8
|
def __call__(self, command: InT) -> OutT: ...
|
|
8
9
|
|
|
10
|
+
|
|
9
11
|
class AsyncUseCase(Protocol):
|
|
10
12
|
async def __call__(self, command: InT) -> OutT: ...
|
nlbone/core/domain/base.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
from dataclasses import dataclass
|
|
3
4
|
from datetime import datetime, timezone
|
|
4
5
|
from typing import Any, Generic, List, TypeVar
|
|
@@ -13,6 +14,7 @@ class DomainError(Exception):
|
|
|
13
14
|
@dataclass(frozen=True)
|
|
14
15
|
class DomainEvent:
|
|
15
16
|
"""Immutable domain event."""
|
|
17
|
+
|
|
16
18
|
occurred_at: datetime = datetime.now(timezone.utc)
|
|
17
19
|
|
|
18
20
|
@property
|
|
@@ -22,6 +24,7 @@ class DomainEvent:
|
|
|
22
24
|
|
|
23
25
|
class ValueObject:
|
|
24
26
|
"""Base for value objects (immutable in practice)."""
|
|
27
|
+
|
|
25
28
|
def __eq__(self, other: Any) -> bool:
|
|
26
29
|
return isinstance(other, self.__class__) and self.__dict__ == other.__dict__
|
|
27
30
|
|
|
@@ -35,6 +38,7 @@ class Entity(Generic[TId]):
|
|
|
35
38
|
|
|
36
39
|
class AggregateRoot(Entity[TId]):
|
|
37
40
|
"""Aggregate root with domain event collection."""
|
|
41
|
+
|
|
38
42
|
def __init__(self, *args, **kwargs) -> None:
|
|
39
43
|
self._domain_events: List[DomainEvent] = []
|
|
40
44
|
|
nlbone/core/domain/models.py
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from sqlalchemy import String, DateTime, Index, Text
|
|
4
|
+
from sqlalchemy import JSON as SA_JSON
|
|
5
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
6
|
+
from sqlalchemy.sql import func
|
|
7
|
+
|
|
8
|
+
from nlbone.adapters.db import Base
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
|
12
|
+
JSONType = JSONB
|
|
13
|
+
UUIDType = UUID(as_uuid=True)
|
|
14
|
+
except Exception:
|
|
15
|
+
JSONType = SA_JSON
|
|
16
|
+
UUIDType = String(36)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuditLog(Base):
|
|
20
|
+
__tablename__ = "audit_logs"
|
|
21
|
+
|
|
22
|
+
id: Mapped[uuid.UUID] = mapped_column(UUIDType, primary_key=True, default=uuid.uuid4)
|
|
23
|
+
entity: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
24
|
+
entity_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
25
|
+
operation: Mapped[str] = mapped_column(String(10), nullable=False) # INSERT/UPDATE/DELETE
|
|
26
|
+
changes: Mapped[dict | None] = mapped_column(JSONType, nullable=True)
|
|
27
|
+
|
|
28
|
+
actor_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
29
|
+
request_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
30
|
+
ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
|
31
|
+
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
32
|
+
|
|
33
|
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
|
34
|
+
|
|
35
|
+
__table_args__ = (
|
|
36
|
+
Index("ix_audit_entity_entityid", "entity", "entity_id"),
|
|
37
|
+
Index("ix_audit_created_at", "created_at"),
|
|
38
|
+
)
|
nlbone/core/ports/__init__.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from .auth import AuthService
|
|
2
|
-
from .repo import Repository, AsyncRepository
|
|
3
|
-
from .files import FileServicePort, AsyncFileServicePort
|
|
4
|
-
from .uow import UnitOfWork, AsyncUnitOfWork
|
|
5
2
|
from .event_bus import EventBusPort
|
|
3
|
+
from .files import AsyncFileServicePort, FileServicePort
|
|
4
|
+
from .repo import AsyncRepository, Repository
|
|
5
|
+
from .uow import AsyncUnitOfWork, UnitOfWork
|
nlbone/core/ports/auth.py
CHANGED
nlbone/core/ports/event_bus.py
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
from typing import Callable, Iterable, Protocol
|
|
4
|
+
|
|
3
5
|
from nlbone.core.domain.base import DomainEvent
|
|
4
6
|
|
|
7
|
+
|
|
5
8
|
class EventBusPort(Protocol):
|
|
6
9
|
def publish(self, events: Iterable[DomainEvent]) -> None: ...
|
|
7
10
|
def subscribe(self, event_name: str, handler: Callable[[DomainEvent], None]) -> None: ...
|
nlbone/core/ports/files.py
CHANGED
|
@@ -1,20 +1,41 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
from typing import Any, AsyncIterator, Protocol, runtime_checkable
|
|
4
|
+
|
|
3
5
|
|
|
4
6
|
@runtime_checkable
|
|
5
7
|
class FileServicePort(Protocol):
|
|
6
|
-
def upload_file(
|
|
8
|
+
def upload_file(
|
|
9
|
+
self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None
|
|
10
|
+
) -> dict: ...
|
|
7
11
|
def commit_file(self, file_id: int, client_id: str, token: str | None = None) -> None: ...
|
|
8
|
-
def list_files(
|
|
12
|
+
def list_files(
|
|
13
|
+
self,
|
|
14
|
+
limit: int = 10,
|
|
15
|
+
offset: int = 0,
|
|
16
|
+
filters: dict[str, Any] | None = None,
|
|
17
|
+
sort: list[tuple[str, str]] | None = None,
|
|
18
|
+
token: str | None = None,
|
|
19
|
+
) -> dict: ...
|
|
9
20
|
def get_file(self, file_id: int, token: str | None = None) -> dict: ...
|
|
10
21
|
def download_file(self, file_id: int, token: str | None = None) -> tuple[bytes, str, str]: ...
|
|
11
22
|
def delete_file(self, file_id: int, token: str | None = None) -> None: ...
|
|
12
23
|
|
|
24
|
+
|
|
13
25
|
@runtime_checkable
|
|
14
26
|
class AsyncFileServicePort(Protocol):
|
|
15
|
-
async def upload_file(
|
|
27
|
+
async def upload_file(
|
|
28
|
+
self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None
|
|
29
|
+
) -> dict: ...
|
|
16
30
|
async def commit_file(self, file_id: int, client_id: str, token: str | None = None) -> None: ...
|
|
17
|
-
async def list_files(
|
|
31
|
+
async def list_files(
|
|
32
|
+
self,
|
|
33
|
+
limit: int = 10,
|
|
34
|
+
offset: int = 0,
|
|
35
|
+
filters: dict[str, Any] | None = None,
|
|
36
|
+
sort: list[tuple[str, str]] | None = None,
|
|
37
|
+
token: str | None = None,
|
|
38
|
+
) -> dict: ...
|
|
18
39
|
async def get_file(self, file_id: int, token: str | None = None) -> dict: ...
|
|
19
40
|
async def download_file(self, file_id: int, token: str | None = None) -> tuple[AsyncIterator[bytes], str, str]: ...
|
|
20
41
|
async def delete_file(self, file_id: int, token: str | None = None) -> None: ...
|
nlbone/core/ports/repo.py
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
from typing import Iterable, List, Optional, Protocol, TypeVar
|
|
3
4
|
|
|
4
5
|
T = TypeVar("T")
|
|
5
6
|
|
|
7
|
+
|
|
6
8
|
class Repository(Protocol[T]): # ← نه Protocol, Generic[T]
|
|
7
9
|
def get(self, id) -> Optional[T]: ...
|
|
8
10
|
def add(self, obj: T) -> None: ...
|
|
9
11
|
def remove(self, obj: T) -> None: ...
|
|
10
12
|
def list(self, *, limit: int | None = None, offset: int = 0) -> Iterable[T]: ...
|
|
11
13
|
|
|
14
|
+
|
|
12
15
|
class AsyncRepository(Protocol[T]):
|
|
13
16
|
async def get(self, id) -> Optional[T]: ...
|
|
14
17
|
def add(self, obj: T) -> None: ...
|
|
15
18
|
async def remove(self, obj: T) -> None: ...
|
|
16
|
-
async def list(self, *, limit: int | None = None, offset: int = 0) -> List[T]: ...
|
|
19
|
+
async def list(self, *, limit: int | None = None, offset: int = 0) -> List[T]: ...
|
nlbone/core/ports/uow.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
from typing import Protocol, runtime_checkable
|
|
3
4
|
|
|
5
|
+
|
|
4
6
|
@runtime_checkable
|
|
5
7
|
class UnitOfWork(Protocol):
|
|
6
8
|
def __enter__(self) -> "UnitOfWork": ...
|
|
@@ -8,6 +10,7 @@ class UnitOfWork(Protocol):
|
|
|
8
10
|
def commit(self) -> None: ...
|
|
9
11
|
def rollback(self) -> None: ...
|
|
10
12
|
|
|
13
|
+
|
|
11
14
|
@runtime_checkable
|
|
12
15
|
class AsyncUnitOfWork(Protocol):
|
|
13
16
|
async def __aenter__(self) -> "AsyncUnitOfWork": ...
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
-
from .
|
|
2
|
-
from .auth import
|
|
3
|
-
|
|
1
|
+
from .async_auth import client_has_access, current_request, current_user_id, has_access, user_authenticated
|
|
2
|
+
from .auth import ( # noqa: F811
|
|
3
|
+
client_has_access,
|
|
4
|
+
current_client_id,
|
|
5
|
+
current_request,
|
|
6
|
+
current_user_id,
|
|
7
|
+
has_access,
|
|
8
|
+
user_authenticated,
|
|
9
|
+
)
|
|
10
|
+
from .db import get_async_session, get_session
|
|
11
|
+
from .uow import get_async_uow, get_uow
|