svc-infra 0.1.185__tar.gz → 0.1.187__tar.gz

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 (86) hide show
  1. {svc_infra-0.1.185 → svc_infra-0.1.187}/PKG-INFO +1 -1
  2. {svc_infra-0.1.185 → svc_infra-0.1.187}/pyproject.toml +1 -1
  3. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/api/fastapi/db/add.py +1 -1
  4. svc_infra-0.1.187/src/svc_infra/api/fastapi/db/user/user_default.py +45 -0
  5. {svc_infra-0.1.185/src/svc_infra/auth → svc_infra-0.1.187/src/svc_infra/api/fastapi/db/user}/users.py +1 -1
  6. svc_infra-0.1.187/src/svc_infra/auth/__init__.py +5 -0
  7. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/auth/integration.py +4 -4
  8. svc_infra-0.1.187/src/svc_infra/db/uniq_hooks.py +108 -0
  9. svc_infra-0.1.187/src/svc_infra/py.typed +0 -0
  10. svc_infra-0.1.185/src/svc_infra/api/fastapi/db/uniq.py +0 -82
  11. svc_infra-0.1.185/src/svc_infra/auth/__init__.py +0 -7
  12. svc_infra-0.1.185/src/svc_infra/auth/user_default.py +0 -81
  13. {svc_infra-0.1.185 → svc_infra-0.1.187}/README.md +0 -0
  14. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/__init__.py +0 -0
  15. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/api/__init__.py +0 -0
  16. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/api/fastapi/__init__.py +0 -0
  17. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/api/fastapi/db/README.md +0 -0
  18. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/api/fastapi/db/__init__.py +0 -0
  19. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/api/fastapi/db/crud_router.py +0 -0
  20. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/api/fastapi/db/health.py +0 -0
  21. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/api/fastapi/db/http.py +0 -0
  22. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/api/fastapi/db/management.py +0 -0
  23. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/api/fastapi/db/repository.py +0 -0
  24. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/api/fastapi/db/resource.py +0 -0
  25. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/api/fastapi/db/service.py +0 -0
  26. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/api/fastapi/db/service_hooks.py +0 -0
  27. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/api/fastapi/db/session.py +0 -0
  28. {svc_infra-0.1.185/src/svc_infra/api/fastapi/middleware → svc_infra-0.1.187/src/svc_infra/api/fastapi/db/user}/__init__.py +0 -0
  29. {svc_infra-0.1.185/src/svc_infra/api/fastapi/middleware/errors → svc_infra-0.1.187/src/svc_infra/api/fastapi/middleware}/__init__.py +0 -0
  30. {svc_infra-0.1.185/src/svc_infra/cli/foundation → svc_infra-0.1.187/src/svc_infra/api/fastapi/middleware/errors}/__init__.py +0 -0
  31. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/api/fastapi/middleware/errors/catchall.py +0 -0
  32. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/api/fastapi/middleware/errors/error_handlers.py +0 -0
  33. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/api/fastapi/middleware/errors/exceptions.py +0 -0
  34. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/api/fastapi/routers/__init__.py +0 -0
  35. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/api/fastapi/routers/ping.py +0 -0
  36. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/api/fastapi/settings.py +0 -0
  37. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/app/__init__.py +0 -0
  38. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/app/env.py +0 -0
  39. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/app/logging.py +0 -0
  40. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/app/root.py +0 -0
  41. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/app/settings.py +0 -0
  42. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/auth/oauth_router.py +0 -0
  43. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/auth/providers.py +0 -0
  44. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/auth/settings.py +0 -0
  45. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/cli/__init__.py +0 -0
  46. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/cli/cmds/__init__.py +0 -0
  47. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/cli/cmds/alembic_cmds.py +0 -0
  48. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/cli/cmds/help.py +0 -0
  49. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/cli/cmds/scaffold_cmds.py +0 -0
  50. {svc_infra-0.1.185/src/svc_infra/db/templates/models_schemas → svc_infra-0.1.187/src/svc_infra/cli/foundation}/__init__.py +0 -0
  51. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/cli/foundation/runner.py +0 -0
  52. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/cli/foundation/typer_bootstrap.py +0 -0
  53. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/db/README.md +0 -0
  54. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/db/__init__.py +0 -0
  55. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/db/base.py +0 -0
  56. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/db/constants.py +0 -0
  57. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/db/core.py +0 -0
  58. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/db/scaffold.py +0 -0
  59. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/db/templates/__init__.py +0 -0
  60. {svc_infra-0.1.185/src/svc_infra/db/templates/setup → svc_infra-0.1.187/src/svc_infra/db/templates/models_schemas}/__init__.py +0 -0
  61. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/db/templates/models_schemas/auth/models.py.tmpl +0 -0
  62. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/db/templates/models_schemas/auth/schemas.py.tmpl +0 -0
  63. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/db/templates/models_schemas/entity/models.py.tmpl +0 -0
  64. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/db/templates/models_schemas/entity/schemas.py.tmpl +0 -0
  65. {svc_infra-0.1.185/src/svc_infra/observability/metrics → svc_infra-0.1.187/src/svc_infra/db/templates/setup}/__init__.py +0 -0
  66. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/db/templates/setup/alembic.ini.tmpl +0 -0
  67. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/db/templates/setup/env_async.py.tmpl +0 -0
  68. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/db/templates/setup/env_sync.py.tmpl +0 -0
  69. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/db/templates/setup/script.py.mako.tmpl +0 -0
  70. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/db/uniq.py +0 -0
  71. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/db/utils.py +0 -0
  72. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/mcp/__init__.py +0 -0
  73. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/mcp/svc_infra_mcp.py +0 -0
  74. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/observability/README.md +0 -0
  75. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/observability/__init__.py +0 -0
  76. /svc_infra-0.1.185/src/svc_infra/py.typed → /svc_infra-0.1.187/src/svc_infra/observability/metrics/__init__.py +0 -0
  77. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/observability/metrics/asgi.py +0 -0
  78. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/observability/metrics/base.py +0 -0
  79. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/observability/metrics/http.py +0 -0
  80. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/observability/metrics/sqlalchemy.py +0 -0
  81. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/observability/settings.py +0 -0
  82. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/observability/templates/grafana_dashboard.json +0 -0
  83. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/observability/templates/otel-collector.yaml +0 -0
  84. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/observability/templates/prometheus_rules.yml +0 -0
  85. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/observability/tracing/__init__.py +0 -0
  86. {svc_infra-0.1.185 → svc_infra-0.1.187}/src/svc_infra/observability/tracing/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: svc-infra
3
- Version: 0.1.185
3
+ Version: 0.1.187
4
4
  Summary: Infrastructure for building and deploying prod-ready services
5
5
  License: MIT
6
6
  Keywords: fastapi,sqlalchemy,alembic,auth,infra,async,pydantic
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "svc-infra"
3
- version = "0.1.185"
3
+ version = "0.1.187"
4
4
  description = "Infrastructure for building and deploying prod-ready services"
5
5
  authors = ["Ali Khatami <aliikhatami94@gmail.com>"]
6
6
  license = "MIT"
@@ -4,7 +4,7 @@ from typing import Optional, Sequence
4
4
  from contextlib import asynccontextmanager
5
5
  from fastapi import FastAPI
6
6
 
7
- from svc_infra.auth.user_default import make_default_user_service
7
+ from svc_infra.api.fastapi.db.user.user_default import make_default_user_service
8
8
  from .repository import Repository
9
9
  from .service import Service
10
10
  from .crud_router import make_crud_router_plus
@@ -0,0 +1,45 @@
1
+ from typing import Any, Dict
2
+ from fastapi_users.password import PasswordHelper
3
+
4
+ from svc_infra.api.fastapi.db.repository import Repository
5
+ from svc_infra.db.uniq_hooks import dedupe_service
6
+
7
+ _pwd = PasswordHelper()
8
+
9
+ def make_default_user_service(repo: Repository):
10
+
11
+ def _user_pre_create(data: Dict[str, Any]) -> Dict[str, Any]:
12
+ data = dict(data)
13
+ if "password" in data:
14
+ data["password_hash"] = _pwd.hash(data.pop("password"))
15
+ if "metadata" in data:
16
+ data["extra"] = data.pop("metadata")
17
+ data.setdefault("roles", [])
18
+ return data
19
+
20
+ def _user_pre_update(data: Dict[str, Any]) -> Dict[str, Any]:
21
+ data = dict(data)
22
+ if "password" in data:
23
+ data["password_hash"] = _pwd.hash(data.pop("password"))
24
+ if "metadata" in data:
25
+ data["extra"] = data.pop("metadata")
26
+ return data
27
+
28
+ # First wrap with the general uniqueness service
29
+ SvcUniq = dedupe_service(
30
+ repo,
31
+ unique_ci=["email"],
32
+ tenant_field="tenant_id",
33
+ messages={("email",): "User with this email already exists."},
34
+ )
35
+
36
+ # Then inject your prehooks by subclassing once
37
+ class _Svc(SvcUniq.__class__): # keep the uniqueness behavior
38
+ async def pre_create(self, data): # type: ignore[override]
39
+ return _user_pre_create(data)
40
+
41
+ async def pre_update(self, data): # type: ignore[override]
42
+ return _user_pre_update(data)
43
+
44
+ # Instantiate with the same repo
45
+ return _Svc(repo)
@@ -7,7 +7,7 @@ from fastapi_users import FastAPIUsers
7
7
  from fastapi_users.authentication import AuthenticationBackend, BearerTransport, JWTStrategy
8
8
  from fastapi_users.manager import BaseUserManager, UUIDIDMixin
9
9
 
10
- from .settings import get_auth_settings
10
+ from svc_infra.auth.settings import get_auth_settings
11
11
 
12
12
 
13
13
  def get_fastapi_users(
@@ -0,0 +1,5 @@
1
+ from .integration import enable_auth
2
+
3
+ __all__ = [
4
+ "enable_auth",
5
+ ]
@@ -1,13 +1,13 @@
1
1
  from __future__ import annotations
2
+
2
3
  from fastapi import FastAPI
4
+ from pydantic import ValidationError
5
+
6
+ from svc_infra.api.fastapi.db.user.users import get_fastapi_users
3
7
 
4
- from .users import get_fastapi_users
5
8
  from .oauth_router import oauth_router_with_backend
6
9
  from .providers import providers_from_settings
7
-
8
10
  from .settings import get_auth_settings
9
- from pydantic import ValidationError
10
-
11
11
 
12
12
  def enable_auth(
13
13
  app: FastAPI,
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
3
+
4
+ from fastapi import HTTPException
5
+ from sqlalchemy import func
6
+ from sqlalchemy.exc import IntegrityError
7
+
8
+ from svc_infra.api.fastapi.db.repository import Repository
9
+ from svc_infra.api.fastapi.db.service_hooks import ServiceWithHooks
10
+
11
+ ColumnSpec = Union[str, Sequence[str]] # "email" or ("first_name","last_name")
12
+
13
+ def _as_tuple(spec: ColumnSpec) -> Tuple[str, ...]:
14
+ return (spec,) if isinstance(spec, str) else tuple(spec)
15
+
16
+ def _all_present(data: Dict[str, Any], fields: Sequence[str]) -> bool:
17
+ return all(f in data for f in fields)
18
+
19
+ def _nice_label(fields: Sequence[str], data: Dict[str, Any]) -> str:
20
+ # e.g. ("email",) -> email='a@b.com' ; ("first","last")-> (first='x', last='y')
21
+ if len(fields) == 1:
22
+ f = fields[0]
23
+ return f"{f}={data.get(f)!r}"
24
+ parts = [f"{f}={data.get(f)!r}" for f in fields]
25
+ return "(" + ", ".join(parts) + ")"
26
+
27
+ def dedupe_service(
28
+ repo: Repository,
29
+ *,
30
+ unique_cs: Iterable[ColumnSpec] = (),
31
+ unique_ci: Iterable[ColumnSpec] = (),
32
+ tenant_field: Optional[str] = None,
33
+ # Optional: customize the error message per spec. Keys are tuples of column names.
34
+ messages: Optional[dict[Tuple[str, ...], str]] = None,
35
+ ):
36
+ """
37
+ Create a Service instance that:
38
+ - Pre-checks duplicates for the provided uniqueness specs (case sensitive & insensitive)
39
+ - Returns HTTP 409 with a friendly message
40
+ - Still catches IntegrityError to 409 for race-safety
41
+ The arguments mirror `make_unique_indexes(...)`.
42
+ """
43
+ Model = repo.model
44
+ messages = messages or {}
45
+
46
+ # Build a helper that turns a spec into a WHERE clause based on provided data
47
+ def _build_where_from_spec(data: Dict[str, Any], spec: Tuple[str, ...], *, ci: bool, exclude_id: Any | None):
48
+ clauses: List[Any] = []
49
+
50
+ # spec columns
51
+ for col_name in spec:
52
+ col = getattr(Model, col_name)
53
+ val = data.get(col_name)
54
+ if ci:
55
+ clauses.append(func.lower(col) == func.lower(val))
56
+ else:
57
+ clauses.append(col == val)
58
+
59
+ # tenant scope (match your index semantics)
60
+ if tenant_field and hasattr(Model, tenant_field):
61
+ tcol = getattr(Model, tenant_field)
62
+ tval = data.get(tenant_field)
63
+ if tval is None:
64
+ clauses.append(tcol.is_(None))
65
+ else:
66
+ clauses.append(tcol == tval)
67
+
68
+ # exclude the current row on update
69
+ if exclude_id is not None and hasattr(Model, "id"):
70
+ clauses.append(getattr(Model, "id") != exclude_id)
71
+
72
+ return clauses
73
+
74
+ async def _precheck(session, data: Dict[str, Any], *, exclude_id: Any | None) -> None:
75
+ # Check in deterministic order: CI specs, then CS specs
76
+ for ci, spec_list in ((True, unique_ci), (False, unique_cs)):
77
+ for spec in spec_list:
78
+ fields = _as_tuple(spec)
79
+ # only run if ALL fields (and tenant if used) are present in payload
80
+ needed = list(fields) + ([tenant_field] if tenant_field else [])
81
+ if not _all_present(data, needed):
82
+ continue
83
+ where = _build_where_from_spec(data, fields, ci=ci, exclude_id=exclude_id)
84
+ if await repo.exists(session, where=where):
85
+ msg = messages.get(fields) or f"Resource with {_nice_label(fields, data)} already exists."
86
+ raise HTTPException(status_code=409, detail=msg)
87
+
88
+ class _Svc(ServiceWithHooks):
89
+ async def create(self, session, data):
90
+ data = await self.pre_create(data)
91
+ # Run prechecks — exclude_id=None on create
92
+ await _precheck(session, data, exclude_id=None)
93
+ try:
94
+ return await self.repo.create(session, data)
95
+ except IntegrityError as e:
96
+ # fall back to 409 for any of these uniqueness violations
97
+ raise HTTPException(status_code=409, detail="Resource already exists.") from e
98
+
99
+ async def update(self, session, id_value, data):
100
+ data = await self.pre_update(data)
101
+ # only run if payload contains some unique-relevant fields
102
+ await _precheck(session, data, exclude_id=id_value)
103
+ try:
104
+ return await self.repo.update(session, id_value, data)
105
+ except IntegrityError as e:
106
+ raise HTTPException(status_code=409, detail="Resource already exists.") from e
107
+
108
+ return _Svc(repo)
File without changes
@@ -1,82 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from typing import Iterable, Optional, Dict, Any, Callable
4
- from sqlalchemy import func, and_
5
- from sqlalchemy.ext.asyncio import AsyncSession
6
-
7
- from .repository import Repository
8
- from svc_infra.db.uniq import ColumnSpec, _as_tuple
9
-
10
- def make_uniqueness_hooks(
11
- *,
12
- model: type,
13
- repo: Repository,
14
- unique_cs: Iterable[ColumnSpec] = (),
15
- unique_ci: Iterable[ColumnSpec] = (),
16
- tenant_field: Optional[str] = None,
17
- duplicate_message: str = "Duplicate resource violates uniqueness policy.",
18
- ) -> tuple[
19
- Callable[[Dict[str, Any]], Any], # async pre_create(data) -> data
20
- Callable[[Dict[str, Any]], Any], # async pre_update(data) -> data
21
- ]:
22
- """Return (pre_create, pre_update) hooks that check duplicates *before* insert/update.
23
-
24
- Usage with ServiceWithHooks:
25
- pre_c, pre_u = make_uniqueness_hooks(model=User, repo=repo, unique_ci=["email"], tenant_field="tenant_id")
26
- svc = ServiceWithHooks(repo, pre_create=pre_c, pre_update=pre_u)
27
- """
28
- cs_specs = [_as_tuple(s) for s in unique_cs]
29
- ci_specs = [_as_tuple(s) for s in unique_ci]
30
-
31
- def _build_wheres(data: Dict[str, Any]):
32
- wheres = []
33
-
34
- def col(name: str):
35
- return getattr(model, name)
36
-
37
- tenant_val = data.get(tenant_field) if tenant_field else None
38
-
39
- # case-sensitive groups
40
- for spec in cs_specs:
41
- if not all(k in data for k in spec):
42
- continue
43
- parts = []
44
- if tenant_field:
45
- parts.append(col(tenant_field).is_(None) if tenant_val is None else col(tenant_field) == tenant_val)
46
- for k in spec:
47
- parts.append(col(k) == data[k])
48
- wheres.append(and_(*parts))
49
-
50
- # case-insensitive groups
51
- for spec in ci_specs:
52
- if not all(k in data for k in spec):
53
- continue
54
- parts = []
55
- if tenant_field:
56
- parts.append(col(tenant_field).is_(None) if tenant_val is None else col(tenant_field) == tenant_val)
57
- for k in spec:
58
- # compare lower(db_col) == lower(<value>) safely
59
- parts.append(func.lower(col(k)) == func.lower(func.cast(data[k], col(k).type)))
60
- wheres.append(and_(*parts))
61
-
62
- return wheres
63
-
64
- async def _deferred_check(session: AsyncSession, data: Dict[str, Any]):
65
- wheres = _build_wheres(data)
66
- for where in wheres:
67
- if await repo.exists(session, where=[where]):
68
- from fastapi import HTTPException
69
- raise HTTPException(status_code=409, detail=duplicate_message)
70
-
71
- async def _pre_create(data: Dict[str, Any]) -> Dict[str, Any]:
72
- # store a coroutine to be awaited when a session is available in Service.create
73
- data = dict(data)
74
- data.setdefault("__uniq_check__", _deferred_check)
75
- return data
76
-
77
- async def _pre_update(data: Dict[str, Any]) -> Dict[str, Any]:
78
- data = dict(data)
79
- data.setdefault("__uniq_check__", _deferred_check)
80
- return data
81
-
82
- return _pre_create, _pre_update
@@ -1,7 +0,0 @@
1
- from .integration import enable_auth
2
- from .user_default import make_default_user_service
3
-
4
- __all__ = [
5
- "enable_auth",
6
- "make_default_user_service"
7
- ]
@@ -1,81 +0,0 @@
1
- from typing import Any, Dict
2
- from fastapi import HTTPException
3
- from fastapi_users.password import PasswordHelper
4
- from sqlalchemy import func
5
-
6
- from svc_infra.api.fastapi.db.service_hooks import ServiceWithHooks
7
- from svc_infra.api.fastapi.db.repository import Repository
8
-
9
- _pwd = PasswordHelper()
10
-
11
- def make_default_user_service(repo: Repository):
12
- Model = repo.model # <-- capture the actual mapped model from the repo
13
-
14
- def _user_pre_create(data: Dict[str, Any]) -> Dict[str, Any]:
15
- data = dict(data)
16
- # normalize + map fields
17
- if "password" in data:
18
- data["password_hash"] = _pwd.hash(data.pop("password"))
19
- if "metadata" in data:
20
- data["extra"] = data.pop("metadata")
21
- data.setdefault("roles", [])
22
-
23
- # BEFORE insert: app-level guard (nice 409) aligned with DB uniqueness
24
- email = data.get("email")
25
- tenant_id = data.get("tenant_id")
26
- if email is not None:
27
- # case-insensitive email; scope by tenant (NULL => global)
28
- where = [func.lower(Model.email) == func.lower(email)]
29
- if hasattr(Model, "tenant_id"):
30
- if tenant_id is None:
31
- where.append(Model.tenant_id.is_(None))
32
- else:
33
- where.append(Model.tenant_id == tenant_id)
34
-
35
- async def _exists(session):
36
- return await repo.exists(session, where=where)
37
-
38
- # stash the callback so Service.create can await it (has session)
39
- data["_precreate_exists_check"] = _exists
40
-
41
- return data
42
-
43
- def _user_pre_update(data: Dict[str, Any]) -> Dict[str, Any]:
44
- data = dict(data)
45
- if "password" in data:
46
- data["password_hash"] = _pwd.hash(data.pop("password"))
47
- if "metadata" in data:
48
- data["extra"] = data.pop("metadata")
49
-
50
- # Optional: also protect email change from creating dupes
51
- email = data.get("email")
52
- tenant_id = data.get("tenant_id")
53
- if email is not None:
54
- where = [func.lower(Model.email) == func.lower(email)]
55
- if hasattr(Model, "tenant_id"):
56
- if tenant_id is None:
57
- where.append(Model.tenant_id.is_(None))
58
- else:
59
- where.append(Model.tenant_id == tenant_id)
60
-
61
- async def _exists(session):
62
- return await repo.exists(session, where=where)
63
-
64
- data["_precreate_exists_check"] = _exists # reuse same key
65
-
66
- return data
67
-
68
- class _Svc(ServiceWithHooks):
69
- async def create(self, session, data):
70
- exists_cb = data.pop("_precreate_exists_check", None)
71
- if exists_cb and await exists_cb(session):
72
- raise HTTPException(status_code=409, detail="User with this email already exists.")
73
- return await super().create(session, data)
74
-
75
- async def update(self, session, id_value, data):
76
- exists_cb = data.pop("_precreate_exists_check", None)
77
- if exists_cb and await exists_cb(session):
78
- raise HTTPException(status_code=409, detail="User with this email already exists.")
79
- return await super().update(session, id_value, data)
80
-
81
- return _Svc(repo, pre_create=_user_pre_create, pre_update=_user_pre_update)
File without changes