Skip to content

Commit 09608a7

Browse files
authored
Merge pull request #38 from benavlabs/fix-uuid-support
fix uuid, add test
2 parents 428b3ee + fc24de3 commit 09608a7

File tree

3 files changed

+448
-26
lines changed

3 files changed

+448
-26
lines changed

crudadmin/admin_interface/model_view.py

Lines changed: 49 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
Union,
1313
cast,
1414
)
15+
from uuid import UUID
1516

1617
from fastapi import APIRouter, Depends, Request, UploadFile
1718
from fastapi.responses import JSONResponse, RedirectResponse, Response
@@ -39,7 +40,7 @@
3940
class BulkDeleteRequest(BaseModel):
4041
"""Request model for bulk delete operations containing IDs to delete."""
4142

42-
ids: List[int]
43+
ids: List[Union[int, str]]
4344

4445

4546
class PasswordTransformer:
@@ -372,6 +373,11 @@ def __init__(
372373
self.model = model
373374
self.model_key = model.__name__
374375
self.router = APIRouter()
376+
self.admin_model = admin_model
377+
self.admin_site = admin_site
378+
self.allowed_actions = allowed_actions
379+
self.event_integration = event_integration
380+
self.password_transformer = password_transformer
375381

376382
get_session: Callable[[], AsyncGenerator[AsyncSession, None]]
377383
if self._model_is_admin_model(model):
@@ -388,12 +394,6 @@ def __init__(
388394
self.delete_schema = delete_schema
389395
self.select_schema = select_schema
390396

391-
self.admin_model = admin_model
392-
self.admin_site = admin_site
393-
self.allowed_actions = allowed_actions
394-
self.event_integration = event_integration
395-
self.password_transformer = password_transformer
396-
397397
self.user_service = (
398398
self.admin_site.admin_user_service if self.admin_site else None
399399
)
@@ -432,17 +432,32 @@ def get_url_prefix(self) -> str:
432432
return ""
433433

434434
def _model_is_admin_model(self, model: Type[DeclarativeBase]) -> bool:
435-
"""Check if the given model is one of the admin-specific models."""
436-
admin_model_names = [
437-
self.db_config.AdminUser.__name__,
438-
self.db_config.AdminSession.__name__,
439-
]
440-
if self.db_config.AdminEventLog:
441-
admin_model_names.append(self.db_config.AdminEventLog.__name__)
442-
if self.db_config.AdminAuditLog:
443-
admin_model_names.append(self.db_config.AdminAuditLog.__name__)
444-
445-
return model.__name__ in admin_model_names
435+
"""Check if a model is considered an admin model."""
436+
return self.admin_model or self.model_key.lower() in {"adminuser", "admin_user"}
437+
438+
def _convert_id_to_pk_type(
439+
self, id_value: Union[int, str]
440+
) -> Union[int, str, float]:
441+
"""Convert the ID value to the appropriate type based on the model's primary key type."""
442+
if id_value is None:
443+
return None
444+
445+
primary_key_info = self.db_config.get_primary_key_info(self.model)
446+
if not primary_key_info:
447+
return id_value
448+
449+
pk_type = primary_key_info.get("type")
450+
451+
if pk_type is int:
452+
return int(id_value) if isinstance(id_value, str) else id_value
453+
elif pk_type is str:
454+
return str(id_value)
455+
elif pk_type is float:
456+
return float(id_value) if isinstance(id_value, str) else id_value
457+
elif pk_type is UUID:
458+
return str(id_value)
459+
else:
460+
return str(id_value)
446461

447462
def setup_routes(self) -> None:
448463
"""
@@ -1063,12 +1078,14 @@ def get_model_update_page(self, template: str) -> EndpointCallable:
10631078

10641079
async def get_model_update_page_inner(
10651080
request: Request,
1066-
id: int,
1081+
id: Union[int, str],
10671082
db: AsyncSession = Depends(self.session),
10681083
) -> Response:
10691084
"""Show a form to update an existing record by `id`."""
1085+
converted_id = self._convert_id_to_pk_type(id)
1086+
10701087
item = await self.crud.get(
1071-
db=db, id=id, schema_to_select=self.select_schema
1088+
db=db, id=converted_id, schema_to_select=self.select_schema
10721089
)
10731090
if not item:
10741091
return JSONResponse(
@@ -1118,7 +1135,7 @@ async def form_update_endpoint_inner(
11181135
cast(Any, self.admin_site).admin_authentication.get_current_user()
11191136
),
11201137
event_integration=Depends(lambda: self.event_integration),
1121-
id: Optional[int] = None,
1138+
id: Optional[Union[int, str]] = None,
11221139
) -> Response:
11231140
"""Handle POST form submission to update an existing record."""
11241141
assert self.admin_site is not None
@@ -1128,8 +1145,10 @@ async def form_update_endpoint_inner(
11281145
status_code=422, content={"message": "No id parameter provided"}
11291146
)
11301147

1148+
converted_id = self._convert_id_to_pk_type(id)
1149+
11311150
item = await self.crud.get(
1132-
db=db, id=id, schema_to_select=self.select_schema
1151+
db=db, id=converted_id, schema_to_select=self.select_schema
11331152
)
11341153
if not item:
11351154
return JSONResponse(
@@ -1187,29 +1206,33 @@ async def form_update_endpoint_inner(
11871206
AdminUserUpdateInternal(**transformed_data)
11881207
)
11891208
await self.crud.update(
1190-
db=db, id=id, object=admin_update_schema
1209+
db=db, id=converted_id, object=admin_update_schema
11911210
)
11921211
else:
11931212
if self.update_internal_schema:
11941213
generic_update_schema = self.update_internal_schema(
11951214
**transformed_data
11961215
)
11971216
await self.crud.update(
1198-
db=db, id=id, object=generic_update_schema
1217+
db=db,
1218+
id=converted_id,
1219+
object=generic_update_schema,
11991220
)
12001221
else:
12011222
dynamic_update_schema = type(
12021223
"InternalSchema", (BaseModel,), {}
12031224
)(**transformed_data)
12041225
await self.crud.update(
1205-
db=db, id=id, object=dynamic_update_schema
1226+
db=db,
1227+
id=converted_id,
1228+
object=dynamic_update_schema,
12061229
)
12071230

12081231
await db.commit()
12091232
else:
12101233
update_schema_instance = self.update_schema(**update_data)
12111234
await self.crud.update(
1212-
db=db, id=id, object=update_schema_instance
1235+
db=db, id=converted_id, object=update_schema_instance
12131236
)
12141237
await db.commit()
12151238

tests/conftest.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import uuid
12
from collections.abc import AsyncGenerator
23
from contextlib import asynccontextmanager
34
from datetime import datetime, timedelta, timezone
@@ -10,12 +11,14 @@
1011
from fastapi.testclient import TestClient
1112
from pydantic import BaseModel, ConfigDict
1213
from sqlalchemy import (
14+
UUID,
1315
Boolean,
1416
Column,
1517
DateTime,
1618
ForeignKey,
1719
Integer,
1820
String,
21+
Text,
1922
make_url,
2023
)
2124
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
@@ -124,6 +127,79 @@ class UserUpdate(BaseModel):
124127
is_active: Optional[bool] = None
125128

126129

130+
class UUIDModel(Base):
131+
"""Test model with UUID primary key."""
132+
133+
__tablename__ = "uuid_test_model"
134+
id = Column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid.uuid4)
135+
name = Column(String(255))
136+
description = Column(Text)
137+
created_at = Column(DateTime(timezone=False), server_default=func.now())
138+
139+
140+
class EmailQueryConfig(Base):
141+
"""Model replicating the original user's error case."""
142+
143+
__tablename__ = "email_query_configs"
144+
id = Column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid.uuid4)
145+
template_id = Column(String(255))
146+
query_name = Column(String(255))
147+
query_text = Column(Text)
148+
query_type = Column(String(50))
149+
created_at = Column(DateTime(timezone=False), server_default=func.now())
150+
updated_at = Column(
151+
DateTime(timezone=False), server_default=func.now(), onupdate=func.now()
152+
)
153+
154+
155+
class UUIDModelCreate(BaseModel):
156+
model_config = ConfigDict(extra="forbid")
157+
name: str
158+
description: str
159+
160+
161+
class UUIDModelRead(BaseModel):
162+
id: uuid.UUID
163+
name: str
164+
description: str
165+
166+
167+
class UUIDModelUpdate(BaseModel):
168+
name: Optional[str] = None
169+
description: Optional[str] = None
170+
171+
172+
class UUIDModelUpdateInternal(UUIDModelUpdate):
173+
id: uuid.UUID
174+
175+
176+
class EmailQueryConfigCreate(BaseModel):
177+
model_config = ConfigDict(extra="forbid")
178+
template_id: str
179+
query_name: str
180+
query_text: str
181+
query_type: str
182+
183+
184+
class EmailQueryConfigRead(BaseModel):
185+
id: uuid.UUID
186+
template_id: str
187+
query_name: str
188+
query_text: str
189+
query_type: str
190+
191+
192+
class EmailQueryConfigUpdate(BaseModel):
193+
template_id: Optional[str] = None
194+
query_name: Optional[str] = None
195+
query_text: Optional[str] = None
196+
query_type: Optional[str] = None
197+
198+
199+
class EmailQueryConfigUpdateInternal(EmailQueryConfigUpdate):
200+
id: uuid.UUID
201+
202+
127203
def is_docker_running() -> bool:
128204
try:
129205
DockerClient()
@@ -307,6 +383,92 @@ def user_update_schema():
307383
return UserUpdate
308384

309385

386+
@pytest.fixture
387+
def uuid_model():
388+
return UUIDModel
389+
390+
391+
@pytest.fixture
392+
def email_query_config_model():
393+
return EmailQueryConfig
394+
395+
396+
@pytest.fixture
397+
def uuid_model_create_schema():
398+
return UUIDModelCreate
399+
400+
401+
@pytest.fixture
402+
def uuid_model_read_schema():
403+
return UUIDModelRead
404+
405+
406+
@pytest.fixture
407+
def uuid_model_update_schema():
408+
return UUIDModelUpdate
409+
410+
411+
@pytest.fixture
412+
def uuid_model_update_internal_schema():
413+
return UUIDModelUpdateInternal
414+
415+
416+
@pytest.fixture
417+
def email_query_config_create_schema():
418+
return EmailQueryConfigCreate
419+
420+
421+
@pytest.fixture
422+
def email_query_config_read_schema():
423+
return EmailQueryConfigRead
424+
425+
426+
@pytest.fixture
427+
def email_query_config_update_schema():
428+
return EmailQueryConfigUpdate
429+
430+
431+
@pytest.fixture
432+
def email_query_config_update_internal_schema():
433+
return EmailQueryConfigUpdateInternal
434+
435+
436+
@pytest.fixture(scope="function")
437+
def uuid_test_data() -> list[dict]:
438+
return [
439+
{
440+
"id": "93c025d9-5831-413c-9460-edb3a28cc729",
441+
"name": "Test Item 1",
442+
"description": "First test item with UUID",
443+
},
444+
{
445+
"id": "550e8400-e29b-41d4-a716-446655440000",
446+
"name": "Test Item 2",
447+
"description": "Second test item with UUID",
448+
},
449+
]
450+
451+
452+
@pytest.fixture(scope="function")
453+
def email_query_config_data() -> list[dict]:
454+
return [
455+
{
456+
"id": "93c025d9-5831-413c-9460-edb3a28cc729",
457+
"template_id": "template_1",
458+
"query_name": "User Query",
459+
"query_text": "SELECT * FROM users",
460+
"query_type": "select",
461+
},
462+
{
463+
"id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
464+
"template_id": "template_2",
465+
"query_name": "Admin Query",
466+
"query_text": "SELECT * FROM admin_users",
467+
"query_type": "select",
468+
},
469+
]
470+
471+
310472
@pytest_asyncio.fixture(scope="function")
311473
async def db_config(admin_async_session) -> AsyncGenerator[DatabaseConfig, None]:
312474
"""Create a DatabaseConfig instance for testing."""

0 commit comments

Comments
 (0)