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.
Files changed (58) hide show
  1. nlbone/adapters/auth/__init__.py +1 -1
  2. nlbone/adapters/auth/keycloak.py +6 -1
  3. nlbone/adapters/db/__init__.py +4 -3
  4. nlbone/adapters/db/postgres/__init__.py +4 -0
  5. nlbone/adapters/db/postgres/audit.py +148 -0
  6. nlbone/adapters/db/{sqlalchemy → postgres}/base.py +0 -2
  7. nlbone/adapters/db/{sqlalchemy → postgres}/engine.py +4 -3
  8. nlbone/adapters/db/{sqlalchemy → postgres}/query_builder.py +27 -11
  9. nlbone/adapters/db/{sqlalchemy → postgres}/repository.py +4 -2
  10. nlbone/adapters/db/{sqlalchemy → postgres}/schema.py +7 -7
  11. nlbone/adapters/db/{sqlalchemy → postgres}/uow.py +3 -2
  12. nlbone/adapters/db/redis/client.py +22 -0
  13. nlbone/adapters/http_clients/uploadchi.py +27 -11
  14. nlbone/adapters/http_clients/uploadchi_async.py +29 -7
  15. nlbone/adapters/messaging/event_bus.py +3 -0
  16. nlbone/adapters/percolation/__init__.py +1 -0
  17. nlbone/adapters/percolation/connection.py +12 -0
  18. nlbone/config/logging.py +76 -117
  19. nlbone/config/settings.py +20 -24
  20. nlbone/container.py +6 -5
  21. nlbone/core/application/base_worker.py +36 -0
  22. nlbone/core/application/events.py +5 -1
  23. nlbone/core/application/use_case.py +3 -1
  24. nlbone/core/domain/base.py +4 -0
  25. nlbone/core/domain/models.py +38 -0
  26. nlbone/core/ports/__init__.py +3 -3
  27. nlbone/core/ports/auth.py +1 -0
  28. nlbone/core/ports/event_bus.py +3 -0
  29. nlbone/core/ports/files.py +26 -5
  30. nlbone/core/ports/repo.py +5 -2
  31. nlbone/core/ports/uow.py +3 -0
  32. nlbone/interfaces/api/dependencies/__init__.py +11 -3
  33. nlbone/interfaces/api/dependencies/async_auth.py +61 -0
  34. nlbone/interfaces/api/dependencies/auth.py +5 -3
  35. nlbone/interfaces/api/dependencies/db.py +5 -3
  36. nlbone/interfaces/api/dependencies/uow.py +3 -2
  37. nlbone/interfaces/api/exception_handlers.py +17 -15
  38. nlbone/interfaces/api/exceptions.py +1 -2
  39. nlbone/interfaces/api/middleware/__init__.py +2 -2
  40. nlbone/interfaces/api/middleware/access_log.py +12 -8
  41. nlbone/interfaces/api/middleware/add_request_context.py +55 -52
  42. nlbone/interfaces/api/middleware/authentication.py +4 -1
  43. nlbone/interfaces/api/pagination/__init__.py +4 -5
  44. nlbone/interfaces/api/pagination/offset_base.py +0 -2
  45. nlbone/interfaces/cli/init_db.py +24 -13
  46. nlbone/interfaces/cli/main.py +29 -0
  47. nlbone/utils/context.py +14 -4
  48. nlbone/utils/redactor.py +32 -0
  49. nlbone/utils/time.py +41 -2
  50. {nlbone-0.4.0.dist-info → nlbone-0.4.2.dist-info}/METADATA +5 -9
  51. nlbone-0.4.2.dist-info/RECORD +78 -0
  52. nlbone-0.4.2.dist-info/entry_points.txt +2 -0
  53. nlbone/adapters/db/postgres.py +0 -0
  54. nlbone/adapters/db/sqlalchemy/__init__.py +0 -4
  55. nlbone-0.4.0.dist-info/RECORD +0 -71
  56. /nlbone/adapters/db/{memory.py → redis/__init__.py} +0 -0
  57. {nlbone-0.4.0.dist-info → nlbone-0.4.2.dist-info}/WHEEL +0 -0
  58. {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 typing import Any, MutableMapping, Optional
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
- # Context variable for request/correlation id
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
- rid = _request_id_var.get()
34
- # attach as record attribute; formatters can use %(request_id)s
35
- record.request_id = rid or "-"
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
- """Minimal JSON formatter with ISO8601 timestamps."""
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
- # Add extras (fields set via logger.bind-like approach: logger.info("x", extra={"k": "v"}))
52
- # Python's logging puts extras into record.__dict__ directly.
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
- # Avoid overriding our top-level keys
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
- payload["exc_type"] = record.exc_info[0].__name__ if record.exc_info[0] else None
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 _build_stream_handler(json_enabled: bool, level: int) -> logging.Handler:
93
- handler = logging.StreamHandler(stream=sys.stdout)
94
- handler.setLevel(level)
95
- handler.addFilter(RequestIdFilter())
96
- if json_enabled:
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
- # Clear existing handlers
126
- root = logging.getLogger()
127
- for h in list(root.handlers):
128
- root.removeHandler(h)
129
-
130
- root.setLevel(lvl)
131
- root.addHandler(_build_stream_handler(json_logs, lvl))
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
- # Common noisy loggers (optional tweaks)
134
- for noisy in ("asyncio", "httpx"):
135
- logging.getLogger(noisy).setLevel(logging.WARNING)
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 # bubble up to root
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
- def get_logger(name: str) -> logging.Logger:
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
- from pydantic import AnyHttpUrl, Field, SecretStr, AliasChoices
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 _is_production_env() -> bool:
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
- validation_alias=AliasChoices("NLBONE_KEYCLOAK_SERVER_URL",
54
- "KEYCLOAK_SERVER_URL"))
55
- KEYCLOAK_REALM_NAME: str = Field(default="numberland",
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 | None = Field(
87
- default=None,
88
- validation_alias=AliasChoices("NLBONE_UPLOADCHI_TOKEN", "UPLOADCHI_TOKEN"),
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="NLBONE_",
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 _is_production_env():
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.db.sqlalchemy import SqlAlchemyUnitOfWork, AsyncUnitOfWork
7
- from nlbone.adapters.db.sqlalchemy.engine import get_sync_session_factory, get_async_session_factory
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 FileServicePort, AsyncFileServicePort
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(AsyncUnitOfWork, session_factory=async_session_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: bus.publish(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: ...
@@ -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
 
@@ -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
+ )
@@ -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
@@ -1,5 +1,6 @@
1
1
  from typing import Protocol, runtime_checkable
2
2
 
3
+
3
4
  @runtime_checkable
4
5
  class AuthService(Protocol):
5
6
  def has_access(self, token: str, permissions: list[str]) -> bool: ...
@@ -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: ...
@@ -1,20 +1,41 @@
1
1
  from __future__ import annotations
2
- from typing import Protocol, runtime_checkable, AsyncIterator, Any
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(self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None) -> dict: ...
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(self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None, sort: list[tuple[str, str]] | None = None, token: str | None = None) -> dict: ...
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(self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None) -> dict: ...
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(self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None, sort: list[tuple[str, str]] | None = None, token: str | None = None) -> dict: ...
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
- from typing import Iterable, Optional, Protocol, TypeVar, List
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 .db import get_session, get_async_session
2
- from .auth import has_access, client_has_access, current_client_id, current_user_id, current_request, user_authenticated
3
- from .uow import get_uow, get_async_uow
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