core-framework 0.12.1__py3-none-any.whl → 0.12.3__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.
- core/__init__.py +66 -2
- core/app.py +65 -3
- core/auth/__init__.py +27 -2
- core/auth/base.py +146 -0
- core/auth/middleware.py +355 -0
- core/auth/models.py +138 -24
- core/auth/schemas.py +5 -1
- core/auth/views.py +168 -50
- core/config.py +27 -0
- core/middleware.py +779 -0
- core/migrations/engine.py +68 -2
- core/migrations/operations.py +88 -10
- core/views.py +453 -28
- {core_framework-0.12.1.dist-info → core_framework-0.12.3.dist-info}/METADATA +1 -1
- {core_framework-0.12.1.dist-info → core_framework-0.12.3.dist-info}/RECORD +17 -15
- {core_framework-0.12.1.dist-info → core_framework-0.12.3.dist-info}/WHEEL +0 -0
- {core_framework-0.12.1.dist-info → core_framework-0.12.3.dist-info}/entry_points.txt +0 -0
core/auth/middleware.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication Middleware for Core Framework.
|
|
3
|
+
|
|
4
|
+
Bug #8 Fix: Provides built-in middleware for populating request.state.user.
|
|
5
|
+
|
|
6
|
+
This middleware automatically authenticates requests using the configured
|
|
7
|
+
authentication backend and populates request.state.user.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
from core.auth.middleware import AuthenticationMiddleware
|
|
11
|
+
|
|
12
|
+
app = CoreApp(
|
|
13
|
+
middlewares=[(AuthenticationMiddleware, {})],
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# Or configure via configure_auth()
|
|
17
|
+
from core.auth import configure_auth
|
|
18
|
+
configure_auth(
|
|
19
|
+
user_model=User,
|
|
20
|
+
auto_middleware=True, # Automatically adds middleware
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
The middleware will:
|
|
24
|
+
1. Extract Bearer token from Authorization header
|
|
25
|
+
2. Validate the token
|
|
26
|
+
3. Fetch the user from database
|
|
27
|
+
4. Set request.state.user to the authenticated user (or None)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from typing import Any, TYPE_CHECKING
|
|
33
|
+
|
|
34
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
35
|
+
from starlette.requests import Request
|
|
36
|
+
from starlette.responses import Response
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from collections.abc import Callable, Awaitable
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AuthenticationMiddleware(BaseHTTPMiddleware):
|
|
43
|
+
"""
|
|
44
|
+
Middleware that authenticates requests and populates request.state.user.
|
|
45
|
+
|
|
46
|
+
This middleware:
|
|
47
|
+
1. Initializes request.state.user to None
|
|
48
|
+
2. Extracts Bearer token from Authorization header
|
|
49
|
+
3. Validates the token using configured backend
|
|
50
|
+
4. Fetches user from database
|
|
51
|
+
5. Sets request.state.user to authenticated user
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
from core.auth.middleware import AuthenticationMiddleware
|
|
55
|
+
|
|
56
|
+
app = CoreApp(
|
|
57
|
+
middlewares=[(AuthenticationMiddleware, {})],
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# In your view
|
|
61
|
+
@router.get("/me")
|
|
62
|
+
async def me(request: Request):
|
|
63
|
+
user = request.state.user # User or None
|
|
64
|
+
if user is None:
|
|
65
|
+
raise HTTPException(401, "Not authenticated")
|
|
66
|
+
return {"id": user.id, "email": user.email}
|
|
67
|
+
|
|
68
|
+
Configuration via kwargs:
|
|
69
|
+
- user_model: User model class (uses global config if None)
|
|
70
|
+
- header_name: Header to extract token from (default: "Authorization")
|
|
71
|
+
- scheme: Expected scheme (default: "Bearer")
|
|
72
|
+
- skip_paths: List of paths to skip authentication (e.g., ["/health"])
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
app: "Callable[[Request], Awaitable[Response]]",
|
|
78
|
+
user_model: type | None = None,
|
|
79
|
+
header_name: str = "Authorization",
|
|
80
|
+
scheme: str = "Bearer",
|
|
81
|
+
skip_paths: list[str] | None = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
super().__init__(app)
|
|
84
|
+
self._user_model = user_model
|
|
85
|
+
self.header_name = header_name
|
|
86
|
+
self.scheme = scheme
|
|
87
|
+
self.skip_paths = skip_paths or []
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def user_model(self) -> type | None:
|
|
91
|
+
"""Get user model from instance or global config."""
|
|
92
|
+
if self._user_model is not None:
|
|
93
|
+
return self._user_model
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
from core.auth.models import get_user_model
|
|
97
|
+
return get_user_model()
|
|
98
|
+
except Exception:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
async def dispatch(
|
|
102
|
+
self,
|
|
103
|
+
request: Request,
|
|
104
|
+
call_next: "Callable[[Request], Awaitable[Response]]",
|
|
105
|
+
) -> Response:
|
|
106
|
+
"""
|
|
107
|
+
Process request and authenticate user.
|
|
108
|
+
|
|
109
|
+
Always sets request.state.user (to User or None).
|
|
110
|
+
"""
|
|
111
|
+
# Initialize user to None
|
|
112
|
+
request.state.user = None
|
|
113
|
+
|
|
114
|
+
# Skip authentication for configured paths
|
|
115
|
+
if self._should_skip(request.url.path):
|
|
116
|
+
return await call_next(request)
|
|
117
|
+
|
|
118
|
+
# Try to authenticate
|
|
119
|
+
try:
|
|
120
|
+
user = await self._authenticate(request)
|
|
121
|
+
request.state.user = user
|
|
122
|
+
except Exception:
|
|
123
|
+
# Authentication failed, keep user as None
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
return await call_next(request)
|
|
127
|
+
|
|
128
|
+
def _should_skip(self, path: str) -> bool:
|
|
129
|
+
"""Check if path should skip authentication."""
|
|
130
|
+
for skip_path in self.skip_paths:
|
|
131
|
+
if path.startswith(skip_path):
|
|
132
|
+
return True
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
def _extract_token(self, request: Request) -> str | None:
|
|
136
|
+
"""Extract token from Authorization header."""
|
|
137
|
+
auth_header = request.headers.get(self.header_name)
|
|
138
|
+
|
|
139
|
+
if not auth_header:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
parts = auth_header.split()
|
|
143
|
+
|
|
144
|
+
if len(parts) != 2:
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
scheme, token = parts
|
|
148
|
+
|
|
149
|
+
if scheme.lower() != self.scheme.lower():
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
return token
|
|
153
|
+
|
|
154
|
+
async def _authenticate(self, request: Request) -> Any | None:
|
|
155
|
+
"""
|
|
156
|
+
Authenticate request and return user.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
User instance if authenticated, None otherwise
|
|
160
|
+
"""
|
|
161
|
+
token = self._extract_token(request)
|
|
162
|
+
|
|
163
|
+
if not token:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
# Verify token
|
|
167
|
+
from core.auth.tokens import verify_token
|
|
168
|
+
|
|
169
|
+
payload = verify_token(token, token_type="access")
|
|
170
|
+
|
|
171
|
+
if payload is None:
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
# Get user_id from token
|
|
175
|
+
user_id = payload.get("sub") or payload.get("user_id")
|
|
176
|
+
|
|
177
|
+
if not user_id:
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
# Get user from database
|
|
181
|
+
User = self.user_model
|
|
182
|
+
if User is None:
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
# Bug #3 & #4 Fix: Get database session correctly
|
|
186
|
+
# The middleware runs outside FastAPI DI context, so we need to
|
|
187
|
+
# handle database session creation carefully
|
|
188
|
+
db = await self._get_db_session()
|
|
189
|
+
if db is None:
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
# Convert user_id to correct type
|
|
194
|
+
user_id_converted = self._convert_user_id(user_id, User)
|
|
195
|
+
|
|
196
|
+
user = await User.objects.using(db).filter(id=user_id_converted).first()
|
|
197
|
+
|
|
198
|
+
if user is None:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
# Check if user is active
|
|
202
|
+
if hasattr(user, "is_active") and not user.is_active:
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
return user
|
|
206
|
+
except Exception:
|
|
207
|
+
return None
|
|
208
|
+
finally:
|
|
209
|
+
await db.close()
|
|
210
|
+
|
|
211
|
+
async def _get_db_session(self) -> Any | None:
|
|
212
|
+
"""
|
|
213
|
+
Get a database session for authentication.
|
|
214
|
+
|
|
215
|
+
Bug #3 & #4 Fix: Handles both initialized and uninitialized database states.
|
|
216
|
+
Creates session directly from engine if normal path fails.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
AsyncSession or None if database not available
|
|
220
|
+
"""
|
|
221
|
+
# Try 1: Use the standard get_read_session (if database is initialized)
|
|
222
|
+
try:
|
|
223
|
+
from core.database import get_read_session, _read_session_factory
|
|
224
|
+
|
|
225
|
+
if _read_session_factory is not None:
|
|
226
|
+
return _read_session_factory()
|
|
227
|
+
except (RuntimeError, ImportError):
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
# Try 2: Create session from settings (lazy initialization)
|
|
231
|
+
try:
|
|
232
|
+
from core.config import get_settings
|
|
233
|
+
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
|
234
|
+
|
|
235
|
+
settings = get_settings()
|
|
236
|
+
db_url = getattr(settings, 'database_read_url', None) or getattr(settings, 'database_url', None)
|
|
237
|
+
|
|
238
|
+
if db_url:
|
|
239
|
+
engine = create_async_engine(db_url, echo=False)
|
|
240
|
+
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
241
|
+
return session_factory()
|
|
242
|
+
except Exception:
|
|
243
|
+
pass
|
|
244
|
+
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
def _convert_user_id(self, user_id: str, User: type) -> Any:
|
|
248
|
+
"""
|
|
249
|
+
Convert user_id string to the correct type based on model.
|
|
250
|
+
|
|
251
|
+
Handles INTEGER, UUID, and string IDs.
|
|
252
|
+
"""
|
|
253
|
+
from uuid import UUID
|
|
254
|
+
|
|
255
|
+
# Try to detect PK type
|
|
256
|
+
try:
|
|
257
|
+
from core.auth.models import _get_pk_column_type
|
|
258
|
+
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
|
259
|
+
from sqlalchemy import Integer, BigInteger
|
|
260
|
+
|
|
261
|
+
pk_type = _get_pk_column_type(User)
|
|
262
|
+
|
|
263
|
+
if pk_type == PG_UUID:
|
|
264
|
+
return UUID(user_id)
|
|
265
|
+
elif pk_type in (Integer, BigInteger):
|
|
266
|
+
return int(user_id)
|
|
267
|
+
except Exception:
|
|
268
|
+
pass
|
|
269
|
+
|
|
270
|
+
# Try UUID first, then int, then string
|
|
271
|
+
try:
|
|
272
|
+
return UUID(user_id)
|
|
273
|
+
except (ValueError, TypeError):
|
|
274
|
+
pass
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
return int(user_id)
|
|
278
|
+
except (ValueError, TypeError):
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
return user_id
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class OptionalAuthenticationMiddleware(AuthenticationMiddleware):
|
|
285
|
+
"""
|
|
286
|
+
Same as AuthenticationMiddleware but never raises errors.
|
|
287
|
+
|
|
288
|
+
Always proceeds with the request, setting user to None on any failure.
|
|
289
|
+
Useful for endpoints that work both with and without authentication.
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
async def dispatch(
|
|
293
|
+
self,
|
|
294
|
+
request: Request,
|
|
295
|
+
call_next: "Callable[[Request], Awaitable[Response]]",
|
|
296
|
+
) -> Response:
|
|
297
|
+
"""Process request, never failing on auth errors."""
|
|
298
|
+
request.state.user = None
|
|
299
|
+
|
|
300
|
+
if not self._should_skip(request.url.path):
|
|
301
|
+
try:
|
|
302
|
+
user = await self._authenticate(request)
|
|
303
|
+
request.state.user = user
|
|
304
|
+
except Exception:
|
|
305
|
+
pass # Silently ignore all errors
|
|
306
|
+
|
|
307
|
+
return await call_next(request)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# =============================================================================
|
|
311
|
+
# Auto-configuration helper
|
|
312
|
+
# =============================================================================
|
|
313
|
+
|
|
314
|
+
_middleware_registered = False
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def ensure_auth_middleware(app: Any) -> None:
|
|
318
|
+
"""
|
|
319
|
+
Ensure AuthenticationMiddleware is registered on the app.
|
|
320
|
+
|
|
321
|
+
Call this from configure_auth() when auto_middleware=True.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
app: FastAPI or CoreApp instance
|
|
325
|
+
"""
|
|
326
|
+
global _middleware_registered
|
|
327
|
+
|
|
328
|
+
if _middleware_registered:
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
# Try to add middleware
|
|
332
|
+
try:
|
|
333
|
+
if hasattr(app, "add_middleware"):
|
|
334
|
+
app.add_middleware(AuthenticationMiddleware)
|
|
335
|
+
_middleware_registered = True
|
|
336
|
+
except Exception:
|
|
337
|
+
pass
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def reset_middleware_state() -> None:
|
|
341
|
+
"""Reset middleware registration state (for testing)."""
|
|
342
|
+
global _middleware_registered
|
|
343
|
+
_middleware_registered = False
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# =============================================================================
|
|
347
|
+
# Exports
|
|
348
|
+
# =============================================================================
|
|
349
|
+
|
|
350
|
+
__all__ = [
|
|
351
|
+
"AuthenticationMiddleware",
|
|
352
|
+
"OptionalAuthenticationMiddleware",
|
|
353
|
+
"ensure_auth_middleware",
|
|
354
|
+
"reset_middleware_state",
|
|
355
|
+
]
|
core/auth/models.py
CHANGED
|
@@ -27,12 +27,14 @@ Uso:
|
|
|
27
27
|
from __future__ import annotations
|
|
28
28
|
|
|
29
29
|
from typing import Any, ClassVar, TYPE_CHECKING
|
|
30
|
+
from uuid import UUID
|
|
30
31
|
|
|
31
32
|
from sqlalchemy import Table, Column, Integer, ForeignKey, inspect
|
|
32
33
|
from sqlalchemy.orm import Mapped, relationship, declared_attr
|
|
33
34
|
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
|
34
35
|
|
|
35
36
|
from core.models import Model, Field
|
|
37
|
+
from core.fields import AdvancedField
|
|
36
38
|
from core.auth.base import get_password_hasher, get_auth_config
|
|
37
39
|
from core.datetime import timezone, DateTime
|
|
38
40
|
|
|
@@ -47,11 +49,16 @@ if TYPE_CHECKING:
|
|
|
47
49
|
# Cache de tabelas criadas para evitar duplicação
|
|
48
50
|
_association_tables: dict[str, Table] = {}
|
|
49
51
|
|
|
52
|
+
# Cache de tipos de PK detectados
|
|
53
|
+
_pk_type_cache: dict[str, type] = {}
|
|
54
|
+
|
|
50
55
|
|
|
51
56
|
def _get_pk_column_type(model_class: type) -> type:
|
|
52
57
|
"""
|
|
53
58
|
Detecta o tipo da coluna PK de um modelo.
|
|
54
59
|
|
|
60
|
+
Bug #3 Fix: Detecção robusta do tipo de PK para FKs.
|
|
61
|
+
|
|
55
62
|
Suporta:
|
|
56
63
|
- Integer (int)
|
|
57
64
|
- BigInteger (int com bigint=True)
|
|
@@ -63,42 +70,83 @@ def _get_pk_column_type(model_class: type) -> type:
|
|
|
63
70
|
"""
|
|
64
71
|
from sqlalchemy import Integer, BigInteger, String
|
|
65
72
|
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
|
66
|
-
import
|
|
73
|
+
from sqlalchemy import Uuid
|
|
74
|
+
|
|
75
|
+
# Verifica cache primeiro
|
|
76
|
+
cache_key = f"{model_class.__module__}.{model_class.__name__}"
|
|
77
|
+
if cache_key in _pk_type_cache:
|
|
78
|
+
return _pk_type_cache[cache_key]
|
|
67
79
|
|
|
68
|
-
|
|
80
|
+
detected_type = Integer # Default
|
|
81
|
+
|
|
82
|
+
# Método 1: Tenta obter da tabela já mapeada
|
|
69
83
|
if hasattr(model_class, "__table__"):
|
|
70
|
-
# Modelo já mapeado - pega direto da tabela
|
|
71
84
|
pk_columns = [c for c in model_class.__table__.columns if c.primary_key]
|
|
72
85
|
if pk_columns:
|
|
73
|
-
|
|
86
|
+
pk_col_type = pk_columns[0].type
|
|
87
|
+
# Verifica se é UUID
|
|
88
|
+
if isinstance(pk_col_type, (PG_UUID, Uuid)):
|
|
89
|
+
detected_type = PG_UUID
|
|
90
|
+
elif isinstance(pk_col_type, BigInteger):
|
|
91
|
+
detected_type = BigInteger
|
|
92
|
+
elif isinstance(pk_col_type, String):
|
|
93
|
+
detected_type = String
|
|
94
|
+
elif isinstance(pk_col_type, Integer):
|
|
95
|
+
detected_type = Integer
|
|
96
|
+
else:
|
|
97
|
+
# Tenta pelo nome do tipo
|
|
98
|
+
type_name = type(pk_col_type).__name__.upper()
|
|
99
|
+
if "UUID" in type_name:
|
|
100
|
+
detected_type = PG_UUID
|
|
101
|
+
|
|
102
|
+
_pk_type_cache[cache_key] = detected_type
|
|
103
|
+
return detected_type
|
|
74
104
|
|
|
75
|
-
# Tenta via annotations
|
|
105
|
+
# Método 2: Tenta via annotations
|
|
76
106
|
annotations = getattr(model_class, "__annotations__", {})
|
|
77
107
|
|
|
78
108
|
if "id" in annotations:
|
|
79
109
|
ann = annotations["id"]
|
|
80
110
|
ann_str = str(ann)
|
|
81
111
|
|
|
82
|
-
# Detecta UUID
|
|
83
|
-
if "UUID" in ann_str or "uuid" in ann_str:
|
|
84
|
-
|
|
85
|
-
|
|
112
|
+
# Detecta UUID (vários formatos)
|
|
113
|
+
if "UUID" in ann_str or "uuid" in ann_str or "Uuid" in ann_str:
|
|
114
|
+
detected_type = PG_UUID
|
|
86
115
|
# Detecta int
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
116
|
+
elif "int" in ann_str.lower() and "uuid" not in ann_str.lower():
|
|
117
|
+
detected_type = Integer
|
|
90
118
|
# Detecta str
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
#
|
|
95
|
-
|
|
119
|
+
elif "str" in ann_str.lower() and "uuid" not in ann_str.lower():
|
|
120
|
+
detected_type = String
|
|
121
|
+
|
|
122
|
+
# Método 3: Verifica campos na classe (declared_attr ou column_property)
|
|
123
|
+
for attr_name in dir(model_class):
|
|
124
|
+
if attr_name == "id":
|
|
125
|
+
attr = getattr(model_class, attr_name, None)
|
|
126
|
+
if attr is not None:
|
|
127
|
+
# Pode ser um InstrumentedAttribute
|
|
128
|
+
if hasattr(attr, "type"):
|
|
129
|
+
attr_type = attr.type
|
|
130
|
+
if isinstance(attr_type, (PG_UUID, Uuid)):
|
|
131
|
+
detected_type = PG_UUID
|
|
132
|
+
break
|
|
133
|
+
# Pode ser um mapped_column
|
|
134
|
+
if hasattr(attr, "property") and hasattr(attr.property, "columns"):
|
|
135
|
+
for col in attr.property.columns:
|
|
136
|
+
if isinstance(col.type, (PG_UUID, Uuid)):
|
|
137
|
+
detected_type = PG_UUID
|
|
138
|
+
break
|
|
139
|
+
|
|
140
|
+
_pk_type_cache[cache_key] = detected_type
|
|
141
|
+
return detected_type
|
|
96
142
|
|
|
97
143
|
|
|
98
144
|
def _create_user_fk_column(user_tablename: str, user_model: type | None = None):
|
|
99
145
|
"""
|
|
100
146
|
Cria coluna FK para user_id detectando automaticamente o tipo.
|
|
101
147
|
|
|
148
|
+
Bug #3 Fix: Suporte correto a UUID em FKs.
|
|
149
|
+
|
|
102
150
|
Args:
|
|
103
151
|
user_tablename: Nome da tabela do usuário
|
|
104
152
|
user_model: Classe do modelo (opcional, para detecção de tipo)
|
|
@@ -108,9 +156,10 @@ def _create_user_fk_column(user_tablename: str, user_model: type | None = None):
|
|
|
108
156
|
"""
|
|
109
157
|
from sqlalchemy import Integer, BigInteger, String
|
|
110
158
|
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
|
159
|
+
from sqlalchemy import Uuid
|
|
111
160
|
|
|
112
161
|
# Tenta detectar o tipo do modelo
|
|
113
|
-
col_type = Integer # Default
|
|
162
|
+
col_type: Any = Integer # Default
|
|
114
163
|
|
|
115
164
|
if user_model is not None:
|
|
116
165
|
detected = _get_pk_column_type(user_model)
|
|
@@ -122,6 +171,16 @@ def _create_user_fk_column(user_tablename: str, user_model: type | None = None):
|
|
|
122
171
|
col_type = String(36) # UUID como string
|
|
123
172
|
else:
|
|
124
173
|
col_type = Integer
|
|
174
|
+
else:
|
|
175
|
+
# Sem modelo, tenta inferir da configuração global
|
|
176
|
+
try:
|
|
177
|
+
config = get_auth_config()
|
|
178
|
+
if config.user_model is not None:
|
|
179
|
+
detected = _get_pk_column_type(config.user_model)
|
|
180
|
+
if detected == PG_UUID:
|
|
181
|
+
col_type = PG_UUID(as_uuid=True)
|
|
182
|
+
except Exception:
|
|
183
|
+
pass
|
|
125
184
|
|
|
126
185
|
return Column(
|
|
127
186
|
"user_id",
|
|
@@ -131,6 +190,17 @@ def _create_user_fk_column(user_tablename: str, user_model: type | None = None):
|
|
|
131
190
|
)
|
|
132
191
|
|
|
133
192
|
|
|
193
|
+
def clear_association_table_cache() -> None:
|
|
194
|
+
"""
|
|
195
|
+
Limpa o cache de tabelas de associação.
|
|
196
|
+
|
|
197
|
+
Útil para testes ou quando o modelo de usuário muda.
|
|
198
|
+
"""
|
|
199
|
+
global _association_tables, _pk_type_cache
|
|
200
|
+
_association_tables.clear()
|
|
201
|
+
_pk_type_cache.clear()
|
|
202
|
+
|
|
203
|
+
|
|
134
204
|
def get_user_groups_table(user_tablename: str = "auth_users", user_model: type | None = None) -> Table:
|
|
135
205
|
"""
|
|
136
206
|
Obtém ou cria tabela de associação user <-> groups.
|
|
@@ -433,17 +503,29 @@ class AbstractUser(Model):
|
|
|
433
503
|
"""
|
|
434
504
|
Modelo abstrato base para usuários.
|
|
435
505
|
|
|
506
|
+
Bug #4 Fix: Suporta tanto INTEGER quanto UUID como PK.
|
|
507
|
+
|
|
436
508
|
Herde desta classe para criar seu modelo de usuário customizado:
|
|
437
509
|
|
|
510
|
+
# Com INTEGER (padrão)
|
|
438
511
|
class User(AbstractUser, PermissionsMixin):
|
|
439
512
|
__tablename__ = "users"
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
513
|
+
|
|
514
|
+
# Com UUID - sobrescreva o campo id
|
|
515
|
+
from core.fields import AdvancedField
|
|
516
|
+
from uuid import UUID
|
|
517
|
+
|
|
518
|
+
class User(AbstractUser, PermissionsMixin):
|
|
519
|
+
__tablename__ = "users"
|
|
520
|
+
id: Mapped[UUID] = AdvancedField.uuid_pk() # Override para UUID
|
|
521
|
+
|
|
522
|
+
Ou use AbstractUUIDUser para UUID por padrão:
|
|
523
|
+
|
|
524
|
+
class User(AbstractUUIDUser, PermissionsMixin):
|
|
525
|
+
__tablename__ = "users"
|
|
444
526
|
|
|
445
527
|
Campos incluídos:
|
|
446
|
-
- id: Chave primária
|
|
528
|
+
- id: Chave primária (INTEGER por padrão, pode ser UUID)
|
|
447
529
|
- email: Email único (usado para login)
|
|
448
530
|
- password_hash: Hash da senha
|
|
449
531
|
- is_active: Se o usuário está ativo
|
|
@@ -455,7 +537,7 @@ class AbstractUser(Model):
|
|
|
455
537
|
|
|
456
538
|
__abstract__ = True
|
|
457
539
|
|
|
458
|
-
# Campos de autenticação
|
|
540
|
+
# Campos de autenticação - id pode ser sobrescrito por subclasses
|
|
459
541
|
id: Mapped[int] = Field.pk()
|
|
460
542
|
email: Mapped[str] = Field.string(max_length=255, unique=True, index=True)
|
|
461
543
|
password_hash: Mapped[str] = Field.string(max_length=255)
|
|
@@ -682,6 +764,38 @@ class AbstractUser(Model):
|
|
|
682
764
|
return await cls.objects.using(db).filter(email=email.lower()).first()
|
|
683
765
|
|
|
684
766
|
|
|
767
|
+
# =============================================================================
|
|
768
|
+
# AbstractUUIDUser Model (Bug #4 Fix)
|
|
769
|
+
# =============================================================================
|
|
770
|
+
|
|
771
|
+
class AbstractUUIDUser(AbstractUser):
|
|
772
|
+
"""
|
|
773
|
+
Modelo abstrato base para usuários com UUID como PK.
|
|
774
|
+
|
|
775
|
+
Bug #4 Fix: Fornece uma versão do AbstractUser que usa UUID por padrão.
|
|
776
|
+
|
|
777
|
+
Ideal para:
|
|
778
|
+
- Sistemas distribuídos
|
|
779
|
+
- APIs públicas (UUIDs são mais seguros que IDs sequenciais)
|
|
780
|
+
- Microservices
|
|
781
|
+
|
|
782
|
+
Exemplo:
|
|
783
|
+
from core.auth import AbstractUUIDUser, PermissionsMixin
|
|
784
|
+
|
|
785
|
+
class User(AbstractUUIDUser, PermissionsMixin):
|
|
786
|
+
__tablename__ = "users"
|
|
787
|
+
|
|
788
|
+
# Campos adicionais
|
|
789
|
+
name: Mapped[str] = Field.string(max_length=100)
|
|
790
|
+
"""
|
|
791
|
+
|
|
792
|
+
__abstract__ = True
|
|
793
|
+
|
|
794
|
+
# Override: usa UUID como PK
|
|
795
|
+
# UUID importado no topo do módulo para resolução correta de tipos
|
|
796
|
+
id: Mapped[UUID] = AdvancedField.uuid_pk()
|
|
797
|
+
|
|
798
|
+
|
|
685
799
|
# =============================================================================
|
|
686
800
|
# PermissionsMixin
|
|
687
801
|
# =============================================================================
|
core/auth/schemas.py
CHANGED
|
@@ -100,12 +100,16 @@ class BaseUserOutput(OutputSchema):
|
|
|
100
100
|
"""
|
|
101
101
|
Base user output schema.
|
|
102
102
|
|
|
103
|
+
Bug #4 Fix: Now supports both int and UUID ids.
|
|
104
|
+
|
|
103
105
|
Extend to add custom fields:
|
|
104
106
|
class UserOutput(BaseUserOutput):
|
|
105
107
|
phone: str | None = None
|
|
106
108
|
avatar_url: str | None = None
|
|
109
|
+
|
|
110
|
+
For UUID users, the id will be automatically serialized to string.
|
|
107
111
|
"""
|
|
108
|
-
id: int
|
|
112
|
+
id: int | str # Supports both INTEGER and UUID (serialized as string)
|
|
109
113
|
email: str
|
|
110
114
|
is_active: bool = True
|
|
111
115
|
is_staff: bool = False
|