svc-infra 0.1.186__py3-none-any.whl → 0.1.187__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.
- svc_infra/api/fastapi/db/add.py +1 -1
- svc_infra/api/fastapi/db/user/__init__.py +0 -0
- svc_infra/api/fastapi/db/user/user_default.py +45 -0
- svc_infra/{auth → api/fastapi/db/user}/users.py +1 -1
- svc_infra/auth/__init__.py +0 -2
- svc_infra/auth/integration.py +4 -4
- svc_infra/db/uniq_hooks.py +108 -0
- {svc_infra-0.1.186.dist-info → svc_infra-0.1.187.dist-info}/METADATA +1 -1
- {svc_infra-0.1.186.dist-info → svc_infra-0.1.187.dist-info}/RECORD +11 -10
- svc_infra/api/fastapi/db/uniq.py +0 -82
- svc_infra/auth/user_default.py +0 -92
- {svc_infra-0.1.186.dist-info → svc_infra-0.1.187.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.186.dist-info → svc_infra-0.1.187.dist-info}/entry_points.txt +0 -0
svc_infra/api/fastapi/db/add.py
CHANGED
@@ -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.
|
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
|
File without changes
|
@@ -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(
|
svc_infra/auth/__init__.py
CHANGED
svc_infra/auth/integration.py
CHANGED
@@ -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)
|
@@ -3,7 +3,7 @@ svc_infra/api/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
3
3
|
svc_infra/api/fastapi/__init__.py,sha256=-clEe_YA8P_VekTIrFN3RD4suas0CUpvvMnxwpfr4v0,5573
|
4
4
|
svc_infra/api/fastapi/db/README.md,sha256=NVD6r-3ArHNaYuo-rks3YbmIQP_hLqg5tBAbvDbdMJ8,3055
|
5
5
|
svc_infra/api/fastapi/db/__init__.py,sha256=Xsu7IpWtFsaUSkKq7yhETfkstG1KEd3XQ1Rh6kNPWf8,210
|
6
|
-
svc_infra/api/fastapi/db/add.py,sha256=
|
6
|
+
svc_infra/api/fastapi/db/add.py,sha256=L_Gs0kFjx6E0m3_x3H-UfcqoNxB6XuIQH83Hn2fv7GY,3330
|
7
7
|
svc_infra/api/fastapi/db/crud_router.py,sha256=e55Y_XHQnW5n_FoxIPcfaXPV3YhSr-QXWetbYrNirzM,4708
|
8
8
|
svc_infra/api/fastapi/db/health.py,sha256=rWSsQFlvut8oPheqM8SH4fo32z6rz5a2gN_M0CgONE0,715
|
9
9
|
svc_infra/api/fastapi/db/http.py,sha256=41iDMq_3Ws1fdXpfaftJii92mJO4ZGNx8ZteDtRTz8I,2276
|
@@ -13,7 +13,9 @@ svc_infra/api/fastapi/db/resource.py,sha256=h-kCjp0_Sw_lGthizkEaneKyIZZXX3SCs6LF
|
|
13
13
|
svc_infra/api/fastapi/db/service.py,sha256=w7WFzETndKuk-2wkOdjmGbznY8lJTzUP3781Dg42alE,2033
|
14
14
|
svc_infra/api/fastapi/db/service_hooks.py,sha256=KZzkKL-2y7hrPx4bfcDdwItAqPBksyVFUjnx1hGJwxw,655
|
15
15
|
svc_infra/api/fastapi/db/session.py,sha256=1ixBxRZLbdE8LXLP6R87u7pDzITZkZRYZ3Un8ugvJNY,1775
|
16
|
-
svc_infra/api/fastapi/db/
|
16
|
+
svc_infra/api/fastapi/db/user/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
17
|
+
svc_infra/api/fastapi/db/user/user_default.py,sha256=saoUkNq7_56pVKgew5N0c-VS2cP1Po_jorNLWM5_3_U,1524
|
18
|
+
svc_infra/api/fastapi/db/user/users.py,sha256=gm_ioiN_DQFLzMeFS7TN_LNs0I4oQbEVHz8djCAotz8,2112
|
17
19
|
svc_infra/api/fastapi/middleware/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
18
20
|
svc_infra/api/fastapi/middleware/errors/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
19
21
|
svc_infra/api/fastapi/middleware/errors/catchall.py,sha256=Kvu7yrHu6r8wlyEblB8sH4Mh-l8FYPmwAGJeSXugYOM,1713
|
@@ -27,13 +29,11 @@ svc_infra/app/env.py,sha256=7MCEGuKqYeFT-aq7hiOn0hXJlCIy9VRXAsNHqDMHzPo,4430
|
|
27
29
|
svc_infra/app/logging.py,sha256=xxRKbyYYrxkbjyZ9LKisad6IuBhiP5jyi6BUjkD4JRY,5863
|
28
30
|
svc_infra/app/root.py,sha256=i6Rw1tchhKDlUbZgXwN5ec6KNMflqVry_y_GFB3Oioo,1576
|
29
31
|
svc_infra/app/settings.py,sha256=6zmRRZxGvHxTNPaLiRdiWaq_YjleFNedbeIG81j7uOE,621
|
30
|
-
svc_infra/auth/__init__.py,sha256=
|
31
|
-
svc_infra/auth/integration.py,sha256=
|
32
|
+
svc_infra/auth/__init__.py,sha256=7_FrdHQoHjowh0tFu-kPIkZV49SRPpM0d8vNsQEKcGk,70
|
33
|
+
svc_infra/auth/integration.py,sha256=O1WcaLv-gk4UZzqLkinXEOMqrxVOi5LqwaRFz5Y-IQ8,1635
|
32
34
|
svc_infra/auth/oauth_router.py,sha256=JY3VQ9_0oS_a2gGg418IDG2TbK79sQWcpA9KPiOAfjY,4918
|
33
35
|
svc_infra/auth/providers.py,sha256=fiw0ouuGKtwcwMY0Zw7cEv-CXNaUYDnqo_NmiWiB8Lc,3185
|
34
36
|
svc_infra/auth/settings.py,sha256=wVCLQnpA9M6zfiWyUpSdn9fo4q-Z4WpVyC3KvkG3HYg,1742
|
35
|
-
svc_infra/auth/user_default.py,sha256=VXSpYGxABvHHYGvd7eXU7OPinhwQFr-oS-qeYIufYro,3856
|
36
|
-
svc_infra/auth/users.py,sha256=4rlTCELU-po7bF7u6z02Bd8pXLGcLI2Adj0VjA-gg5E,2098
|
37
37
|
svc_infra/cli/__init__.py,sha256=g47RSROuFW8LSsB6WFNSus5BnUl9z_DrPK9idYo3O6M,401
|
38
38
|
svc_infra/cli/cmds/__init__.py,sha256=263YKSg73Ik-SQeilyGePdc663BYi4RfBrc0wMFxeoU,212
|
39
39
|
svc_infra/cli/cmds/alembic_cmds.py,sha256=VziFYPvsKqyonrZ1aDvyox1uq6Mfjrzl7Ei5TmCFK1Y,6163
|
@@ -60,6 +60,7 @@ svc_infra/db/templates/setup/env_async.py.tmpl,sha256=05uDe_jiEkJOdXcSIgJovN3TxP
|
|
60
60
|
svc_infra/db/templates/setup/env_sync.py.tmpl,sha256=vlUKrhnQiBqAGDfbtx0D_CahF1Hjj8uTIQ5lNnx_l0Q,6273
|
61
61
|
svc_infra/db/templates/setup/script.py.mako.tmpl,sha256=EgltN1ghYU7rH1zAujsLWLe1NThI-41x9fabByne5II,509
|
62
62
|
svc_infra/db/uniq.py,sha256=Pre3tdgXHw6I7HU13xlJPcCe8ymSs8kAdEyAYRfvsto,2614
|
63
|
+
svc_infra/db/uniq_hooks.py,sha256=mGM21qXa1roeibJ8fVF_JvI6TBcflCJAPZJOmTncFjk,4641
|
63
64
|
svc_infra/db/utils.py,sha256=PxaZ6AesRlDyQk7tOzi9YaeF7Ge4zsZP83wqkKHu5-E,30838
|
64
65
|
svc_infra/mcp/__init__.py,sha256=SiJ50LO7J6AneYswZe1kUX3dKXiECPpsjGPjT0SGtuc,1272
|
65
66
|
svc_infra/mcp/svc_infra_mcp.py,sha256=VTU8esJ4FH9WNgxplufUWCVqGkxwscaLnBBtj-B5iCQ,1343
|
@@ -77,7 +78,7 @@ svc_infra/observability/templates/prometheus_rules.yml,sha256=sbVLm1h40FMkGSeWO4
|
|
77
78
|
svc_infra/observability/tracing/__init__.py,sha256=TOs2yCicqBdo4OfOHTMmqeHsn7DBRu5EdvF2L5f31Y0,237
|
78
79
|
svc_infra/observability/tracing/setup.py,sha256=21Ob276U4KZOs6M2o1O79wQXFHV0gY6YdMyjeqMrzMU,5042
|
79
80
|
svc_infra/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
80
|
-
svc_infra-0.1.
|
81
|
-
svc_infra-0.1.
|
82
|
-
svc_infra-0.1.
|
83
|
-
svc_infra-0.1.
|
81
|
+
svc_infra-0.1.187.dist-info/METADATA,sha256=E0qwjWlJXV2nsMES4BMhZ0aO4EiO9MxyFSWB2F_dCNs,4981
|
82
|
+
svc_infra-0.1.187.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
|
83
|
+
svc_infra-0.1.187.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
|
84
|
+
svc_infra-0.1.187.dist-info/RECORD,,
|
svc_infra/api/fastapi/db/uniq.py
DELETED
@@ -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
|
svc_infra/auth/user_default.py
DELETED
@@ -1,92 +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
|
-
from sqlalchemy.exc import IntegrityError
|
6
|
-
|
7
|
-
from svc_infra.api.fastapi.db.service_hooks import ServiceWithHooks
|
8
|
-
from svc_infra.api.fastapi.db.repository import Repository
|
9
|
-
|
10
|
-
_pwd = PasswordHelper()
|
11
|
-
|
12
|
-
def make_default_user_service(repo: Repository):
|
13
|
-
Model = repo.model # capture the mapped class
|
14
|
-
|
15
|
-
def _user_pre_create(data: Dict[str, Any]) -> Dict[str, Any]:
|
16
|
-
data = dict(data)
|
17
|
-
# normalize + map fields
|
18
|
-
if "password" in data:
|
19
|
-
data["password_hash"] = _pwd.hash(data.pop("password"))
|
20
|
-
if "metadata" in data:
|
21
|
-
data["extra"] = data.pop("metadata")
|
22
|
-
data.setdefault("roles", [])
|
23
|
-
|
24
|
-
# attach existence checker (case-insensitive email, tenant scoped)
|
25
|
-
email = data.get("email")
|
26
|
-
tenant_id = data.get("tenant_id")
|
27
|
-
if email is not None:
|
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
|
-
data["_precreate_exists_check"] = _exists
|
39
|
-
return data
|
40
|
-
|
41
|
-
def _user_pre_update(data: Dict[str, Any]) -> Dict[str, Any]:
|
42
|
-
data = dict(data)
|
43
|
-
if "password" in data:
|
44
|
-
data["password_hash"] = _pwd.hash(data.pop("password"))
|
45
|
-
if "metadata" in data:
|
46
|
-
data["extra"] = data.pop("metadata")
|
47
|
-
|
48
|
-
# optional: protect email change too
|
49
|
-
email = data.get("email")
|
50
|
-
tenant_id = data.get("tenant_id")
|
51
|
-
if email is not None:
|
52
|
-
where = [func.lower(Model.email) == func.lower(email)]
|
53
|
-
if hasattr(Model, "tenant_id"):
|
54
|
-
if tenant_id is None:
|
55
|
-
where.append(Model.tenant_id.is_(None))
|
56
|
-
else:
|
57
|
-
where.append(Model.tenant_id == tenant_id)
|
58
|
-
|
59
|
-
async def _exists(session):
|
60
|
-
return await repo.exists(session, where=where)
|
61
|
-
|
62
|
-
data["_preupdate_exists_check"] = _exists
|
63
|
-
return data
|
64
|
-
|
65
|
-
class _Svc(ServiceWithHooks):
|
66
|
-
async def create(self, session, data):
|
67
|
-
# IMPORTANT: run pre_create first
|
68
|
-
data = await self.pre_create(data)
|
69
|
-
exists_cb = data.pop("_precreate_exists_check", None)
|
70
|
-
if exists_cb and await exists_cb(session):
|
71
|
-
raise HTTPException(status_code=409, detail="User with this email already exists.")
|
72
|
-
try:
|
73
|
-
return await self.repo.create(session, data)
|
74
|
-
except IntegrityError as e:
|
75
|
-
# race-safety / fallback
|
76
|
-
if "uq_users_tenant_id" in str(e.orig) or "ci_email" in str(e.orig):
|
77
|
-
raise HTTPException(status_code=409, detail="User with this email already exists.") from e
|
78
|
-
raise
|
79
|
-
|
80
|
-
async def update(self, session, id_value, data):
|
81
|
-
data = await self.pre_update(data)
|
82
|
-
exists_cb = data.pop("_preupdate_exists_check", None)
|
83
|
-
if exists_cb and await exists_cb(session):
|
84
|
-
raise HTTPException(status_code=409, detail="User with this email already exists.")
|
85
|
-
try:
|
86
|
-
return await self.repo.update(session, id_value, data)
|
87
|
-
except IntegrityError as e:
|
88
|
-
if "uq_users_tenant_id" in str(e.orig) or "ci_email" in str(e.orig):
|
89
|
-
raise HTTPException(status_code=409, detail="User with this email already exists.") from e
|
90
|
-
raise
|
91
|
-
|
92
|
-
return _Svc(repo, pre_create=_user_pre_create, pre_update=_user_pre_update)
|
File without changes
|
File without changes
|