From 76d0acf1a8c2625f2328ecc22fa6891de7408f96 Mon Sep 17 00:00:00 2001 From: Igor Benav Date: Sun, 15 Jun 2025 23:27:28 -0300 Subject: [PATCH] select_schema functional --- crudadmin/admin_interface/crud_admin.py | 27 ++ crudadmin/admin_interface/model_view.py | 21 +- docs/usage/adding-models.md | 275 ++++++++++++++++ pyproject.toml | 2 +- tests/crud/test_select_schema.py | 403 ++++++++++++++++++++++++ 5 files changed, 723 insertions(+), 5 deletions(-) create mode 100644 tests/crud/test_select_schema.py diff --git a/crudadmin/admin_interface/crud_admin.py b/crudadmin/admin_interface/crud_admin.py index 23e0bde..6ace63e 100644 --- a/crudadmin/admin_interface/crud_admin.py +++ b/crudadmin/admin_interface/crud_admin.py @@ -803,6 +803,7 @@ def add_view( update_schema: Type[BaseModel], update_internal_schema: Optional[Type[BaseModel]] = None, delete_schema: Optional[Type[BaseModel]] = None, + select_schema: Optional[Type[BaseModel]] = None, include_in_models: bool = True, allowed_actions: Optional[set[str]] = None, password_transformer: Optional[Any] = None, @@ -819,6 +820,7 @@ def add_view( update_schema: Pydantic schema for update operations update_internal_schema: Internal schema for special update cases delete_schema: Schema for delete operations + select_schema: Optional schema for read operations (excludes fields from queries) include_in_models: Show in models list in admin UI allowed_actions: **Set of allowed operations:** - **"view"**: Allow viewing records @@ -835,6 +837,7 @@ def add_view( Notes: - Forms are auto-generated with field types determined from Pydantic schemas - Actions controlled by allowed_actions parameter + - Use select_schema to exclude problematic fields (e.g., TSVector) from read operations - Use password_transformer for models with password fields that need hashing URL Routes: @@ -872,6 +875,29 @@ class UserUpdate(BaseModel): ) ``` + Excluding problematic fields (e.g., TSVector): + ```python + class DocumentCreate(BaseModel): + title: str + content: str + # TSVector field excluded from this schema + + class DocumentSelect(BaseModel): + id: int + title: str + content: str + created_at: datetime + # search_vector (TSVector) field excluded + + admin.add_view( + model=Document, + create_schema=DocumentCreate, + update_schema=DocumentCreate, + select_schema=DocumentSelect, # TSVector field excluded from reads + allowed_actions={"view", "create", "update"} + ) + ``` + User with password handling: ```python from crudadmin.admin_interface.model_view import PasswordTransformer @@ -1028,6 +1054,7 @@ class Config: update_schema=update_schema, update_internal_schema=update_internal_schema, delete_schema=delete_schema, + select_schema=select_schema, admin_site=self.admin_site, allowed_actions=allowed_actions, event_integration=self.event_integration, diff --git a/crudadmin/admin_interface/model_view.py b/crudadmin/admin_interface/model_view.py index 351d169..4c276d3 100644 --- a/crudadmin/admin_interface/model_view.py +++ b/crudadmin/admin_interface/model_view.py @@ -772,7 +772,10 @@ async def bulk_delete_endpoint_inner( f"{pk_name}__in": valid_ids } records_to_delete = await self.crud.get_multi( - db=db, limit=len(valid_ids), **cast(Any, filter_criteria) + db=db, + limit=len(valid_ids), + schema_to_select=self.select_schema, + **cast(Any, filter_criteria), ) request.state.deleted_records = records_to_delete.get("data", []) @@ -804,6 +807,7 @@ async def bulk_delete_endpoint_inner( db=db, offset=(adjusted_page - 1) * rows_per_page, limit=rows_per_page, + schema_to_select=self.select_schema, ) items: Dict[str, Any] = { @@ -947,6 +951,7 @@ async def get_model_admin_page_inner( limit=rows_per_page, sort_columns=sort_columns, sort_orders=sort_orders, + schema_to_select=self.select_schema, **cast(Any, filter_criteria), ) @@ -1053,7 +1058,9 @@ async def get_model_update_page_inner( db: AsyncSession = Depends(self.session), ) -> Response: """Show a form to update an existing record by `id`.""" - item = await self.crud.get(db=db, id=id) + item = await self.crud.get( + db=db, id=id, schema_to_select=self.select_schema + ) if not item: return JSONResponse( status_code=404, content={"message": f"Item with id {id} not found"} @@ -1112,7 +1119,9 @@ async def form_update_endpoint_inner( status_code=422, content={"message": "No id parameter provided"} ) - item = await self.crud.get(db=db, id=id) + item = await self.crud.get( + db=db, id=id, schema_to_select=self.select_schema + ) if not item: return JSONResponse( status_code=404, content={"message": f"Item with id {id} not found"} @@ -1283,7 +1292,11 @@ async def table_body_content_inner( filter_criteria[f"{search_column}__ilike"] = f"%{search_value}%" items_result = await self.crud.get_multi( - db=db, offset=offset, limit=limit, **cast(Any, filter_criteria) + db=db, + offset=offset, + limit=limit, + schema_to_select=self.select_schema, + **cast(Any, filter_criteria), ) items: Dict[str, Any] = { diff --git a/docs/usage/adding-models.md b/docs/usage/adding-models.md index c5dae06..6f7f63f 100644 --- a/docs/usage/adding-models.md +++ b/docs/usage/adding-models.md @@ -91,8 +91,41 @@ admin.add_view( update_schema=UserUpdate, allowed_actions={"view", "create", "update", "delete"} ) + +# With optional select_schema for read operations +admin.add_view( + model=User, + create_schema=UserCreate, + update_schema=UserUpdate, + select_schema=UserSelect, # Optional: controls which fields appear in list/update views + allowed_actions={"view", "create", "update", "delete"} +) ``` +### `add_view()` Parameters Overview + +The `add_view()` method accepts several parameters to configure your model's admin interface: + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `model` | SQLAlchemy Model | ✅ | The database model to manage | +| `create_schema` | Pydantic Schema | ✅ | Schema for creating new records | +| `update_schema` | Pydantic Schema | ✅ | Schema for updating existing records | +| `select_schema` | Pydantic Schema | ❌ | Schema for read operations - excludes problematic fields | +| `update_internal_schema` | Pydantic Schema | ❌ | Internal schema for system updates | +| `delete_schema` | Pydantic Schema | ❌ | Schema for deletion operations | +| `allowed_actions` | Set[str] | ❌ | Controls available operations ("view", "create", "update", "delete") | +| `include_in_models` | bool | ❌ | Whether to show in admin navigation (default: True) | +| `password_transformer` | PasswordTransformer | ❌ | For handling password fields | + +!!! tip "Key Benefits of select_schema" + Use `select_schema` when your model has: + + - **TSVector fields** that cause `NotImplementedError` + - **Large binary/text fields** that slow down list views + - **Sensitive fields** you want to hide from admin users + - **Complex computed fields** that break display formatting + --- ## Action Control @@ -411,6 +444,247 @@ class DocumentCreate(BaseModel): --- +## Handling Problematic Fields + +### Using `select_schema` to Exclude Fields from Read Operations + +Some database field types can cause issues in admin panels. The most common example is PostgreSQL's `TSVector` type used for full-text search, which can trigger `NotImplementedError` when trying to display records. + +The `select_schema` parameter allows you to exclude problematic fields from all read operations while keeping them available for create/update operations. + +??? info "When to Use select_schema" + Use `select_schema` when you encounter: + + - **TSVector fields** causing `NotImplementedError` in admin views + - **Large binary fields** that slow down list views + - **Computed fields** that don't need to be displayed + - **Sensitive fields** that should be hidden from admin users + - **Complex JSON fields** that break admin display formatting + +### Basic Example: Excluding TSVector Fields + +```python +from sqlalchemy import Column, Integer, String, Text +from sqlalchemy_utils import TSVectorType +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + +# SQLAlchemy model with TSVector for full-text search +class Document(Base): + __tablename__ = "documents" + + id = Column(Integer, primary_key=True) + title = Column(String(200), nullable=False) + content = Column(Text, nullable=False) + created_at = Column(DateTime, default=func.now()) + + # This field causes NotImplementedError in admin views + search_vector = Column(TSVectorType('title', 'content')) + +# Schemas for create/update (no search_vector) +class DocumentCreate(BaseModel): + title: str = Field(..., min_length=1, max_length=200) + content: str = Field(..., min_length=1) + +class DocumentUpdate(BaseModel): + title: Optional[str] = Field(None, min_length=1, max_length=200) + content: Optional[str] = None + +# Schema for read operations (excludes problematic field) +class DocumentSelect(BaseModel): + id: int + title: str + content: str + created_at: datetime + # search_vector field intentionally excluded! + +# Register with admin +admin.add_view( + model=Document, + create_schema=DocumentCreate, + update_schema=DocumentUpdate, + select_schema=DocumentSelect, # ✅ TSVector excluded from reads + allowed_actions={"view", "create", "update", "delete"} +) +``` + +### Advanced Example: Multiple Problematic Fields + +```python +from sqlalchemy import Column, Integer, String, Text, LargeBinary, JSON +from sqlalchemy_utils import TSVectorType +from pydantic import BaseModel +from typing import Optional, Dict, Any +from datetime import datetime + +class Article(Base): + __tablename__ = "articles" + + id = Column(Integer, primary_key=True) + title = Column(String(200), nullable=False) + content = Column(Text, nullable=False) + author_id = Column(Integer, nullable=False) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + # Problematic fields + search_vector = Column(TSVectorType('title', 'content')) # TSVector + thumbnail_data = Column(LargeBinary) # Large binary data + metadata = Column(JSON) # Complex JSON that breaks display + internal_notes = Column(Text) # Sensitive admin-only field + +# Create/Update schemas include only safe fields +class ArticleCreate(BaseModel): + title: str = Field(..., min_length=1, max_length=200) + content: str = Field(..., min_length=1) + author_id: int = Field(..., gt=0) + +class ArticleUpdate(BaseModel): + title: Optional[str] = Field(None, min_length=1, max_length=200) + content: Optional[str] = None + +# Select schema excludes all problematic fields +class ArticleSelect(BaseModel): + id: int + title: str + content: str + author_id: int + created_at: datetime + updated_at: Optional[datetime] = None + # Excluded: search_vector, thumbnail_data, metadata, internal_notes + +admin.add_view( + model=Article, + create_schema=ArticleCreate, + update_schema=ArticleUpdate, + select_schema=ArticleSelect, # Multiple problematic fields excluded + allowed_actions={"view", "create", "update", "delete"} +) +``` + +### Content-Heavy Models + +```python +class BlogPost(Base): + __tablename__ = "blog_posts" + + id = Column(Integer, primary_key=True) + title = Column(String(200)) + slug = Column(String(200), unique=True) + excerpt = Column(Text) # Short description for admin list + content = Column(Text) # Full content - too long for list view + published = Column(Boolean, default=False) + created_at = Column(DateTime, default=func.now()) + + # Large fields that slow down list views + full_content = Column(Text) # Very long article content + raw_html = Column(Text) # HTML version + search_data = Column(TSVectorType('title', 'excerpt', 'content')) + +# Lightweight schema for admin list views +class BlogPostSelect(BaseModel): + id: int + title: str + slug: str + excerpt: str # Show excerpt instead of full content + published: bool + created_at: datetime + # Excluded: content, full_content, raw_html, search_data + +class BlogPostCreate(BaseModel): + title: str = Field(..., min_length=1, max_length=200) + slug: str = Field(..., min_length=1, max_length=200) + excerpt: str = Field(..., min_length=1, max_length=500) + content: str = Field(..., min_length=1) + published: bool = False + +admin.add_view( + model=BlogPost, + create_schema=BlogPostCreate, + update_schema=BlogPostCreate, + select_schema=BlogPostSelect, # Fast loading for admin lists + allowed_actions={"view", "create", "update", "delete"} +) +``` + +### Security-Sensitive Fields + +```python +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True) + username = Column(String(50), unique=True) + email = Column(String(100), unique=True) + role = Column(String(20), default="user") + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=func.now()) + + # Sensitive fields to hide from admin views + hashed_password = Column(String(128)) # Password hash + reset_token = Column(String(128)) # Password reset token + login_attempts = Column(Integer, default=0) # Security tracking + last_login_ip = Column(String(45)) # IP address + +# Admin-safe schema excludes sensitive security fields +class UserAdminSelect(BaseModel): + id: int + username: str + email: str + role: str + is_active: bool + created_at: datetime + # Excluded: hashed_password, reset_token, login_attempts, last_login_ip + +class UserCreate(BaseModel): + username: str = Field(..., min_length=3, max_length=50) + email: EmailStr + role: str = Field(default="user", pattern="^(admin|user|moderator)$") + is_active: bool = True + password: str = Field(..., min_length=8) # Will be hashed automatically + +admin.add_view( + model=User, + create_schema=UserCreate, + update_schema=UserUpdate, + select_schema=UserAdminSelect, # Sensitive fields hidden + password_transformer=password_transformer, + allowed_actions={"view", "create", "update"} +) +``` + +### Key Benefits + +??? success "Advantages of using select_schema" + **Performance:** + - Faster list views by excluding large fields + - Reduced database query size and network transfer + + **Reliability:** + - Prevents `NotImplementedError` from problematic field types + - Avoids display issues with complex data structures + + **Security:** + - Hides sensitive fields from admin interface + - Maintains field access for create/update operations + + **User Experience:** + - Cleaner admin interface with relevant fields only + - Better responsive design without wide data columns + +### Best Practices + +!!! tip "select_schema Guidelines" + 1. **Always include primary key** (`id`) in select schemas + 2. **Include display-friendly fields** like names, titles, dates + 3. **Exclude large binary data** that slows down queries + 4. **Hide sensitive security fields** like password hashes + 5. **Test admin views** after adding select_schema to ensure proper display + 6. **Keep create/update schemas separate** to maintain full field access + +--- + ## Troubleshooting ### Common Issues @@ -466,5 +740,6 @@ Once you've successfully added models to your admin interface: - **[Configure Basic Settings](configuration.md)** to customize your admin interface - **[Manage Admin Users](admin-users.md)** to set up proper access control - **[Learn the Interface](interface.md)** to effectively use your new admin panel +- **[Handle Problematic Fields](#handling-problematic-fields)** to solve TSVector and performance issues For more advanced features like custom field widgets and complex relationships, see the [Advanced Section](../advanced/overview.md). \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 9228c5e..f9a6f91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "crudadmin" -version = "0.3.3" +version = "0.3.4" description = "FastAPI-based admin interface with authentication, event logging and CRUD operations" readme = "README.md" requires-python = ">=3.9.2" diff --git a/tests/crud/test_select_schema.py b/tests/crud/test_select_schema.py new file mode 100644 index 0000000..2055b74 --- /dev/null +++ b/tests/crud/test_select_schema.py @@ -0,0 +1,403 @@ +""" +Unit tests for the select_schema functionality in CRUDAdmin and ModelView. + +Tests verify that the select_schema parameter: +1. Is accepted by the add_view method +2. Is properly stored in ModelView instances +3. Is used in all CRUD read operations (get, get_multi) +4. Handles TSVector-like scenarios correctly +""" + +from typing import Optional +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from pydantic import BaseModel +from sqlalchemy import Column, Integer, String, Text +from sqlalchemy.orm import DeclarativeBase + +from crudadmin.admin_interface.crud_admin import CRUDAdmin +from crudadmin.admin_interface.model_view import ModelView + + +# Test models and schemas +class _TestBase(DeclarativeBase): + pass + + +class DocumentModel(_TestBase): + """Test model with a problematic field (simulating TSVector)""" + + __tablename__ = "test_document" + + id = Column(Integer, primary_key=True) + title = Column(String(200)) + content = Column(Text) + search_vector = Column( + Text + ) # Simulates TSVectorType that causes NotImplementedError + + +class DocumentCreate(BaseModel): + """Create schema without problematic field""" + + title: str + content: str + + +class DocumentUpdate(BaseModel): + """Update schema without problematic field""" + + title: Optional[str] = None + content: Optional[str] = None + + +class DocumentSelect(BaseModel): + """Select schema that excludes the problematic field""" + + id: int + title: str + content: str + # search_vector field intentionally excluded! + + +class DocumentSelectFull(BaseModel): + """Select schema that includes all fields (would cause issues)""" + + id: int + title: str + content: str + search_vector: str # This field causes problems in real scenarios + + +def create_test_db_config_with_unique_base(async_session): + """Create a test database config with unique admin base to avoid conflicts.""" + from crudadmin.core.db import DatabaseConfig + + # Create a unique base class for this test + class UniqueTestAdminBase(DeclarativeBase): + pass + + async def get_session(): + yield async_session + + return DatabaseConfig( + base=UniqueTestAdminBase, + session=get_session, + admin_db_url="sqlite+aiosqlite:///:memory:", + ) + + +@pytest.mark.asyncio +async def test_add_view_accepts_select_schema_parameter(async_session): + """Test that add_view method accepts the select_schema parameter.""" + secret_key = "test-secret-key-for-testing-only-32-chars" + db_config = create_test_db_config_with_unique_base(async_session) + + admin = CRUDAdmin( + session=async_session, + SECRET_KEY=secret_key, + db_config=db_config, + setup_on_initialization=False, + ) + + # Mock the admin_site to avoid complex initialization + admin.admin_site = Mock() + admin.admin_site.mount_path = "admin" + admin.app = Mock() + admin.app.include_router = Mock() + + # This should not raise an error + admin.add_view( + model=DocumentModel, + create_schema=DocumentCreate, + update_schema=DocumentUpdate, + select_schema=DocumentSelect, # This is the new parameter + allowed_actions={"view", "create", "update"}, + ) + + # Verify the router was included (indicating successful add_view) + admin.app.include_router.assert_called_once() + + +@pytest.mark.asyncio +async def test_model_view_stores_select_schema(async_session): + """Test that ModelView properly stores the select_schema parameter.""" + db_config = create_test_db_config_with_unique_base(async_session) + + # Mock templates to avoid template loading issues + templates = Mock() + + # Mock admin_site to avoid initialization issues + admin_site = Mock() + admin_site.admin_authentication.get_current_user.return_value = Mock() + + model_view = ModelView( + database_config=db_config, + templates=templates, + model=DocumentModel, + allowed_actions={"view", "create", "update"}, + create_schema=DocumentCreate, + update_schema=DocumentUpdate, + select_schema=DocumentSelect, + admin_site=admin_site, + ) + + # Verify the select_schema is stored + assert model_view.select_schema == DocumentSelect + + +@pytest.mark.asyncio +async def test_model_view_select_schema_none_by_default(async_session): + """Test that ModelView select_schema is None when not provided.""" + db_config = create_test_db_config_with_unique_base(async_session) + templates = Mock() + + # Mock admin_site to avoid initialization issues + admin_site = Mock() + admin_site.admin_authentication.get_current_user.return_value = Mock() + + model_view = ModelView( + database_config=db_config, + templates=templates, + model=DocumentModel, + allowed_actions={"view", "create", "update"}, + create_schema=DocumentCreate, + update_schema=DocumentUpdate, + admin_site=admin_site, + # select_schema not provided + ) + + # Verify the select_schema is None + assert model_view.select_schema is None + + +@pytest.mark.asyncio +async def test_get_multi_uses_select_schema_parameter(async_session): + """Test that get_multi calls include schema_to_select when select_schema is provided.""" + db_config = create_test_db_config_with_unique_base(async_session) + templates = Mock() + + model_view = ModelView( + database_config=db_config, + templates=templates, + model=DocumentModel, + allowed_actions={"view"}, + create_schema=DocumentCreate, + update_schema=DocumentUpdate, + select_schema=DocumentSelect, + ) + + # Mock the CRUD get_multi method to capture its call + model_view.crud.get_multi = AsyncMock( + return_value={ + "data": [{"id": 1, "title": "Test", "content": "Test content"}], + "total_count": 1, + } + ) + + # Call get_multi directly to test the parameter passing + await model_view.crud.get_multi( + db=Mock(), schema_to_select=model_view.select_schema, offset=0, limit=10 + ) + + # Verify get_multi was called with the select_schema + model_view.crud.get_multi.assert_called_once() + call_kwargs = model_view.crud.get_multi.call_args.kwargs + assert call_kwargs["schema_to_select"] == DocumentSelect + + +@pytest.mark.asyncio +async def test_get_uses_select_schema_parameter(async_session): + """Test that get calls include schema_to_select when select_schema is provided.""" + db_config = create_test_db_config_with_unique_base(async_session) + templates = Mock() + + # Mock admin_site to avoid initialization issues + admin_site = Mock() + admin_site.admin_authentication.get_current_user.return_value = Mock() + + model_view = ModelView( + database_config=db_config, + templates=templates, + model=DocumentModel, + allowed_actions={"update"}, + create_schema=DocumentCreate, + update_schema=DocumentUpdate, + select_schema=DocumentSelect, + admin_site=admin_site, + ) + + # Mock the CRUD get method + model_view.crud.get = AsyncMock( + return_value={"id": 1, "title": "Test", "content": "Test content"} + ) + + # Call get directly to test the parameter passing + await model_view.crud.get( + db=Mock(), id=1, schema_to_select=model_view.select_schema + ) + + # Verify get was called with the select_schema + model_view.crud.get.assert_called_once() + call_kwargs = model_view.crud.get.call_args.kwargs + assert call_kwargs["schema_to_select"] == DocumentSelect + + +@pytest.mark.asyncio +async def test_crud_operations_pass_none_when_no_select_schema(async_session): + """Test that CRUD operations pass None for schema_to_select when select_schema is None.""" + db_config = create_test_db_config_with_unique_base(async_session) + templates = Mock() + + # Mock admin_site to avoid initialization issues + admin_site = Mock() + admin_site.admin_authentication.get_current_user.return_value = Mock() + + model_view = ModelView( + database_config=db_config, + templates=templates, + model=DocumentModel, + allowed_actions={"view", "update"}, + create_schema=DocumentCreate, + update_schema=DocumentUpdate, + admin_site=admin_site, + # select_schema=None (default) + ) + + # Mock CRUD operations + model_view.crud.get_multi = AsyncMock(return_value={"data": [], "total_count": 0}) + model_view.crud.get = AsyncMock(return_value={"id": 1, "title": "Test"}) + + # Test get_multi + await model_view.crud.get_multi( + db=Mock(), schema_to_select=model_view.select_schema, offset=0, limit=10 + ) + + # Verify get_multi was called with schema_to_select=None + call_kwargs = model_view.crud.get_multi.call_args.kwargs + assert call_kwargs["schema_to_select"] is None + + # Test get + await model_view.crud.get( + db=Mock(), id=1, schema_to_select=model_view.select_schema + ) + + # Verify get was called with schema_to_select=None + call_kwargs = model_view.crud.get.call_args.kwargs + assert call_kwargs["schema_to_select"] is None + + +@pytest.mark.asyncio +async def test_add_view_passes_select_schema_to_model_view(async_session): + """Test that add_view properly passes select_schema to ModelView constructor.""" + secret_key = "test-secret-key-for-testing-only-32-chars" + db_config = create_test_db_config_with_unique_base(async_session) + + admin = CRUDAdmin( + session=async_session, + SECRET_KEY=secret_key, + db_config=db_config, + setup_on_initialization=False, + ) + + # Mock admin_site and app to avoid complex initialization + admin.admin_site = Mock() + admin.admin_site.mount_path = "admin" + admin.app = Mock() + admin.app.include_router = Mock() + + # Mock ModelView to capture constructor arguments + with patch("crudadmin.admin_interface.crud_admin.ModelView") as mock_model_view: + mock_instance = Mock() + mock_instance.router = Mock() + mock_model_view.return_value = mock_instance + + # Call add_view with select_schema + admin.add_view( + model=DocumentModel, + create_schema=DocumentCreate, + update_schema=DocumentUpdate, + select_schema=DocumentSelect, + allowed_actions={"view", "create", "update"}, + ) + + # Verify ModelView was called with select_schema + mock_model_view.assert_called_once() + call_kwargs = mock_model_view.call_args.kwargs + assert call_kwargs["select_schema"] == DocumentSelect + + +def test_select_schema_excludes_problematic_fields(): + """Test that DocumentSelect schema properly excludes the problematic field.""" + # Test that DocumentSelect excludes search_vector field + select_fields = set(DocumentSelect.model_fields.keys()) + expected_fields = {"id", "title", "content"} + + assert select_fields == expected_fields + assert "search_vector" not in select_fields + + # Test that DocumentSelectFull includes all fields (problematic scenario) + full_fields = set(DocumentSelectFull.model_fields.keys()) + expected_full_fields = {"id", "title", "content", "search_vector"} + + assert full_fields == expected_full_fields + assert "search_vector" in full_fields + + +def test_tsvector_scenario_documentation(): + """Integration test demonstrating TSVector scenario - how select_schema solves the problem.""" + + # The key benefit: select_schema excludes problematic fields from read operations + # while still allowing create/update operations to work normally + assert set(DocumentSelect.model_fields.keys()) == {"id", "title", "content"} + assert "search_vector" not in DocumentSelect.model_fields + + # This is how you would use it in practice: + # Without select_schema: TSVector field causes NotImplementedError in admin reads + # With select_schema: TSVector field excluded from admin reads, no errors + + # Verify the solution excludes the problematic field + excluded_fields = {"search_vector"} # TSVector field + safe_fields = set(DocumentSelect.model_fields.keys()) + + assert excluded_fields.isdisjoint(safe_fields), ( + "Problematic fields should be excluded" + ) + + +@pytest.mark.asyncio +async def test_add_view_with_select_schema_integration(async_session): + """Integration test for the full add_view workflow with select_schema.""" + secret_key = "test-secret-key-for-testing-only-32-chars" + db_config = create_test_db_config_with_unique_base(async_session) + + admin = CRUDAdmin( + session=async_session, + SECRET_KEY=secret_key, + db_config=db_config, + setup_on_initialization=False, + ) + + # Mock minimal dependencies + admin.admin_site = Mock() + admin.admin_site.mount_path = "admin" + admin.app = Mock() + admin.app.include_router = Mock() + + # Test: Add view with select_schema + admin.add_view( + model=DocumentModel, + create_schema=DocumentCreate, + update_schema=DocumentUpdate, + select_schema=DocumentSelect, # Key parameter being tested + allowed_actions={"view", "create", "update"}, + ) + + # Verify successful integration + admin.app.include_router.assert_called_once() + + # The model should be added to models dict since include_in_models defaults to True + assert DocumentModel.__name__ in admin.models + model_config = admin.models[DocumentModel.__name__] + assert model_config["model"] == DocumentModel