svc-infra 0.1.180__py3-none-any.whl → 0.1.182__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/auth/user_default.py +31 -5
- svc_infra/db/templates/models_schemas/auth/models.py.tmpl +26 -8
- svc_infra/db/templates/models_schemas/auth/schemas.py.tmpl +3 -1
- {svc_infra-0.1.180.dist-info → svc_infra-0.1.182.dist-info}/METADATA +1 -1
- {svc_infra-0.1.180.dist-info → svc_infra-0.1.182.dist-info}/RECORD +7 -7
- {svc_infra-0.1.180.dist-info → svc_infra-0.1.182.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.180.dist-info → svc_infra-0.1.182.dist-info}/entry_points.txt +0 -0
svc_infra/auth/user_default.py
CHANGED
@@ -1,21 +1,41 @@
|
|
1
1
|
from typing import Any, Dict
|
2
|
+
from fastapi import HTTPException
|
2
3
|
from fastapi_users.password import PasswordHelper
|
4
|
+
from sqlalchemy import func, and_, or_
|
3
5
|
|
4
6
|
from svc_infra.api.fastapi.db.service_hooks import ServiceWithHooks
|
5
7
|
from svc_infra.api.fastapi.db.repository import Repository
|
6
8
|
|
7
9
|
_pwd = PasswordHelper()
|
8
10
|
|
9
|
-
def
|
11
|
+
def _user_pre_create(data: Dict[str, Any]) -> Dict[str, Any]:
|
10
12
|
data = dict(data)
|
13
|
+
# normalize + map fields
|
11
14
|
if "password" in data:
|
12
15
|
data["password_hash"] = _pwd.hash(data.pop("password"))
|
13
|
-
if "metadata" in data:
|
16
|
+
if "metadata" in data:
|
14
17
|
data["extra"] = data.pop("metadata")
|
15
|
-
|
18
|
+
|
19
|
+
# BEFORE insert: application-level guard (nice error)
|
20
|
+
# case-insensitive email; handle tenant/null logic the same way as your unique index
|
21
|
+
email = data.get("email")
|
22
|
+
tenant_id = data.get("tenant_id")
|
23
|
+
if email:
|
24
|
+
where = [func.lower(Repository.model.email) == func.lower(email)]
|
25
|
+
if tenant_id is None:
|
26
|
+
where.append(Repository.model.tenant_id.is_(None))
|
27
|
+
else:
|
28
|
+
where.append(Repository.model.tenant_id == tenant_id)
|
29
|
+
|
30
|
+
# use a small “exists” helper on the repo if you have it
|
31
|
+
async def _exists(session):
|
32
|
+
return await Repository.exists(session, where=where)
|
33
|
+
|
34
|
+
data["_precreate_exists_check"] = _exists # stash callable for service.create to run
|
35
|
+
|
16
36
|
return data
|
17
37
|
|
18
|
-
def
|
38
|
+
def _user_pre_update(data: Dict[str, Any]) -> Dict[str, Any]:
|
19
39
|
data = dict(data)
|
20
40
|
if "password" in data:
|
21
41
|
data["password_hash"] = _pwd.hash(data.pop("password"))
|
@@ -24,4 +44,10 @@ def _pre_update(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
24
44
|
return data
|
25
45
|
|
26
46
|
def make_default_user_service(repo: Repository):
|
27
|
-
|
47
|
+
class _Svc(ServiceWithHooks):
|
48
|
+
async def create(self, session, data):
|
49
|
+
exists_cb = data.pop("_precreate_exists_check", None)
|
50
|
+
if exists_cb and await exists_cb(session):
|
51
|
+
raise HTTPException(status_code=409, detail="User with this email already exists.")
|
52
|
+
return await super().create(session, data)
|
53
|
+
return _Svc(repo, pre_create=_user_pre_create, pre_update=_user_pre_update)
|
@@ -4,15 +4,16 @@ from typing import Optional
|
|
4
4
|
import uuid
|
5
5
|
|
6
6
|
from sqlalchemy import (
|
7
|
-
String, Boolean, DateTime, JSON, Text, func,
|
7
|
+
String, Boolean, DateTime, JSON, Text, func, Index
|
8
8
|
)
|
9
9
|
from sqlalchemy.dialects.postgresql import UUID
|
10
10
|
from sqlalchemy.orm import Mapped, mapped_column
|
11
|
-
from sqlalchemy.ext.mutable import MutableDict, MutableList
|
11
|
+
from sqlalchemy.ext.mutable import MutableDict, MutableList
|
12
12
|
|
13
13
|
from svc_infra.db.base import ModelBase
|
14
14
|
from svc_infra.auth.user_default import _pwd
|
15
15
|
|
16
|
+
|
16
17
|
class User(ModelBase):
|
17
18
|
__tablename__ = "users"
|
18
19
|
|
@@ -24,10 +25,12 @@ class User(ModelBase):
|
|
24
25
|
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
25
26
|
is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
26
27
|
|
28
|
+
# auth state
|
27
29
|
password_hash: Mapped[str] = mapped_column(String(512), nullable=False)
|
28
30
|
|
31
|
+
# Write-only facade over password
|
29
32
|
@property
|
30
|
-
def password(self) -> str:
|
33
|
+
def password(self) -> str:
|
31
34
|
raise AttributeError("password is write-only")
|
32
35
|
|
33
36
|
@password.setter
|
@@ -53,12 +56,27 @@ class User(ModelBase):
|
|
53
56
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
54
57
|
)
|
55
58
|
|
56
|
-
__table_args__ = (
|
57
|
-
UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
|
58
|
-
)
|
59
|
-
|
60
59
|
def __repr__(self) -> str:
|
61
60
|
return f"<User id={self.id} email={self.email!r}>"
|
62
61
|
|
63
|
-
|
62
|
+
|
63
|
+
# ---------- Uniqueness: case-insensitive email per-tenant ----------
|
64
|
+
# Because tenant_id is Optional (nullable), use two partial unique indexes:
|
65
|
+
# - When tenant_id IS NULL: email (lowercased) must be globally unique
|
66
|
+
# - When tenant_id IS NOT NULL: (tenant_id, lower(email)) must be unique
|
67
|
+
Index(
|
68
|
+
"uq_users_email_lower_global",
|
69
|
+
func.lower(User.email),
|
70
|
+
unique=True,
|
71
|
+
postgresql_where=User.tenant_id.is_(None),
|
72
|
+
)
|
73
|
+
Index(
|
74
|
+
"uq_users_email_lower_per_tenant",
|
75
|
+
func.lower(User.email),
|
76
|
+
User.tenant_id,
|
77
|
+
unique=True,
|
78
|
+
postgresql_where=User.tenant_id.isnot(None),
|
79
|
+
)
|
80
|
+
|
81
|
+
# Optional helper index for case-insensitive lookups (non-unique)
|
64
82
|
Index("ix_users_email_lower", func.lower(User.email))
|
@@ -1,4 +1,6 @@
|
|
1
1
|
from __future__ import annotations
|
2
|
+
|
3
|
+
from uuid import UUID
|
2
4
|
from typing import Optional, Any, Dict, List
|
3
5
|
from datetime import datetime
|
4
6
|
from pydantic import BaseModel, EmailStr, Field, ConfigDict
|
@@ -21,7 +23,7 @@ class UserBase(BaseModel):
|
|
21
23
|
metadata: Optional[Dict[str, Any]] = Field(default=None, alias="extra") # <-- matches model.extra
|
22
24
|
|
23
25
|
class UserRead(UserBase, Timestamped):
|
24
|
-
id:
|
26
|
+
id: UUID
|
25
27
|
last_login: Optional[datetime] = None
|
26
28
|
disabled_reason: Optional[str] = None
|
27
29
|
|
@@ -31,7 +31,7 @@ svc_infra/auth/integration.py,sha256=L230xUw7vRM0AJIZwNzufRQrfNTPRdczp4BPd_NILeo
|
|
31
31
|
svc_infra/auth/oauth_router.py,sha256=JY3VQ9_0oS_a2gGg418IDG2TbK79sQWcpA9KPiOAfjY,4918
|
32
32
|
svc_infra/auth/providers.py,sha256=fiw0ouuGKtwcwMY0Zw7cEv-CXNaUYDnqo_NmiWiB8Lc,3185
|
33
33
|
svc_infra/auth/settings.py,sha256=wVCLQnpA9M6zfiWyUpSdn9fo4q-Z4WpVyC3KvkG3HYg,1742
|
34
|
-
svc_infra/auth/user_default.py,sha256=
|
34
|
+
svc_infra/auth/user_default.py,sha256=PFQSRTWnVo17jK43muF0ZxwSCtKM9VjVP10D_fpv6FE,2092
|
35
35
|
svc_infra/auth/users.py,sha256=4rlTCELU-po7bF7u6z02Bd8pXLGcLI2Adj0VjA-gg5E,2098
|
36
36
|
svc_infra/cli/__init__.py,sha256=g47RSROuFW8LSsB6WFNSus5BnUl9z_DrPK9idYo3O6M,401
|
37
37
|
svc_infra/cli/cmds/__init__.py,sha256=263YKSg73Ik-SQeilyGePdc663BYi4RfBrc0wMFxeoU,212
|
@@ -49,8 +49,8 @@ svc_infra/db/core.py,sha256=3Efm88fajfKJa8gDiiTVkhx2ci_UbrLiR4B8gMQYuwU,10745
|
|
49
49
|
svc_infra/db/scaffold.py,sha256=NBz8BJR8MPvntCGv5HKoqNNR41SMnCkb-DlCCIsu0fo,9878
|
50
50
|
svc_infra/db/templates/__init__.py,sha256=3PRa4v05RGyjX6LZXSOf-eMRir8vij7tET95limMips,45
|
51
51
|
svc_infra/db/templates/models_schemas/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
52
|
-
svc_infra/db/templates/models_schemas/auth/models.py.tmpl,sha256=
|
53
|
-
svc_infra/db/templates/models_schemas/auth/schemas.py.tmpl,sha256=
|
52
|
+
svc_infra/db/templates/models_schemas/auth/models.py.tmpl,sha256=fbsI1XNs840fashySKGSoGsojo0yOO8M4yZj7E9tUaE,3020
|
53
|
+
svc_infra/db/templates/models_schemas/auth/schemas.py.tmpl,sha256=w79j8GeRPpywg9OVBmEuoksPVZLf9hUfEcRS4hBbiJQ,1717
|
54
54
|
svc_infra/db/templates/models_schemas/entity/models.py.tmpl,sha256=dZk7a3WwpddSylPW81ARTyjbqJ-4rduX8-KAZ3Tlomo,1173
|
55
55
|
svc_infra/db/templates/models_schemas/entity/schemas.py.tmpl,sha256=zBhid1jFPwVN86OvkiIJkTFphN7LDcs_Hs9kxxeoNjE,1009
|
56
56
|
svc_infra/db/templates/setup/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
@@ -75,7 +75,7 @@ svc_infra/observability/templates/prometheus_rules.yml,sha256=sbVLm1h40FMkGSeWO4
|
|
75
75
|
svc_infra/observability/tracing/__init__.py,sha256=TOs2yCicqBdo4OfOHTMmqeHsn7DBRu5EdvF2L5f31Y0,237
|
76
76
|
svc_infra/observability/tracing/setup.py,sha256=21Ob276U4KZOs6M2o1O79wQXFHV0gY6YdMyjeqMrzMU,5042
|
77
77
|
svc_infra/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
78
|
-
svc_infra-0.1.
|
79
|
-
svc_infra-0.1.
|
80
|
-
svc_infra-0.1.
|
81
|
-
svc_infra-0.1.
|
78
|
+
svc_infra-0.1.182.dist-info/METADATA,sha256=j7UJi0Sz4eQCAC7vrRGcyq4XA-nAi58n5_AN6qUNelY,4981
|
79
|
+
svc_infra-0.1.182.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
|
80
|
+
svc_infra-0.1.182.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
|
81
|
+
svc_infra-0.1.182.dist-info/RECORD,,
|
File without changes
|
File without changes
|