Skip to content

Conversation

@sergei-vasilev-dev
Copy link

@sergei-vasilev-dev sergei-vasilev-dev commented May 9, 2025

#912

I think better to perform template context on the model level. Please, take a look and say what you think.
Thanks!

@aminalaee
Copy link
Owner

Hey @sergey-vasiliev-dualboot ,
I think this is nice, Can you please update the docs and explain the example use-case there?

@mmzeynalli
Copy link
Contributor

@sergey-vasiliev-dualboot it is nice addition, any updates? This is related to my issue #942

@sergei-vasilev-dev
Copy link
Author

Hi! @mmzeynalli, plan to update till the end of this week.

@mmzeynalli
Copy link
Contributor

Okay, let us know when it is ready to be reviewed

@sergei-vasilev-dev
Copy link
Author

Enhanced Filtering System and ModelView Improvements

Summary

This PR introduces significant enhancements to SQLAdmin's filtering capabilities and query optimization. All changes are fully backward compatible and extend existing functionality without breaking changes.

🎯 New Features

Advanced Filter Types

1. UniqueValuesFilter (Enhanced)

Improved version with support for numeric types and custom formatting:

  • ✅ Integer and Float column support
  • ✅ Custom value display with lookups_ui_method
  • ✅ Float rounding with float_round_method
  • ✅ Custom ordering with lookups_order
  • ✅ Multiple value selection
from sqladmin.filters import UniqueValuesFilter
import math

UniqueValuesFilter(
    Product.price,
    lookups_ui_method=lambda v: f"${v:.2f}",
    float_round_method=lambda v: math.floor(v),
    lookups_order=Product.price
)

2. ManyToManyFilter (NEW)

Filter through many-to-many relationships using junction tables:

from sqladmin.filters import ManyToManyFilter

ManyToManyFilter(
    column=User.id,
    link_model=UserRole,
    local_field="user_id",
    foreign_field="role_id",
    foreign_model=Role,
    foreign_display_field=Role.name
)

3. RelatedModelFilter (NEW)

Filter by columns in related models through automatic JOIN:

from sqladmin.filters import RelatedModelFilter

RelatedModelFilter(
    column=User.address,
    foreign_column=Address.city,
    foreign_model=Address
)

4. DateRangeFilter (NEW)

Filter by date/datetime ranges with start and end values:

from sqladmin.filters import DateRangeFilter

DateRangeFilter(
    Order.created_at,
    title="Order Date"
)

5. Enhanced ForeignKeyFilter

Now supports multiple value selection and custom ordering:

ForeignKeyFilter(
    Product.category_id,
    Category.name,
    foreign_model=Category,
    lookups_order=Category.name  # NEW: Sort alphabetically
)

ModelView Query Optimization

_safe_join()

Prevents duplicate JOINs in complex queries with related models:

  • Automatically used in search_query() and sort_query()
  • Significantly improves query performance
  • No configuration needed - works automatically

add_relation_loads()

Optimizes relationship loading using selectinload:

  • Reduces N+1 query problems
  • Automatically applied in list() and get_model_objects()
  • Works transparently

async_search_query()

Optional async search for custom implementations:

class UserAdmin(ModelView, model=User):
    async_search = True
    
    async def async_search_query(self, stmt, term, request):
        # Custom async search logic
        return stmt.filter(...)

Pretty Export Enhancements

JSON Export with Formatting

Pretty export now supports JSON format:

  • Applies column labels as keys
  • Uses custom formatters
  • Maintains all formatting logic
class OrderAdmin(ModelView, model=Order):
    use_pretty_export = True
    export_types = ["csv", "json"]

Better Relation Handling

  • Support for related_model_relations attribute
  • Improved list handling in related fields

📊 Testing

Test Coverage

  • 248 tests passing (added 18 new tests)
  • ✅ All existing tests still pass
  • ✅ No regression issues

New Tests Added

  • test_unique_values_filter_* (6 tests)
  • test_many_to_many_filter_* (2 tests)
  • test_related_model_filter_* (1 test)
  • test_date_range_filter_* (2 tests)
  • test_safe_join_* (2 tests)
  • test_add_relation_loads (1 test)
  • test_async_search_query_* (1 test)
  • test_*_uses_safe_join (2 tests)
  • test_pretty_export_json_* (1 test)

📚 Documentation

New Documentation

  • docs/cookbook/advanced_filtering.md - Comprehensive filtering guide with examples
  • docs/cookbook/readonly_views.md - Read-only view patterns and best practices

Updated Documentation

  • docs/configurations.md - Added "Advanced Filters" and "Async Search" sections
  • docs/working_with_templates.md - Examples for perform_*_context methods
  • README.md - Mentioned advanced filtering capabilities

🔧 Code Quality

  • ✅ Linter checks pass (ruff)
  • ✅ Type checking passes (mypy)
  • ✅ Code formatted per project standards
  • ✅ All docstrings concise and meaningful
  • ✅ No imports inside functions

⚠️ Breaking Changes

None! All changes are fully backward compatible:

  • New methods are optional
  • New attributes have sensible defaults
  • Existing API unchanged
  • All existing code works without modification

🔄 Migration Guide

No migration needed! Simply start using new features:

from sqladmin import ModelView
from sqladmin.filters import UniqueValuesFilter, ManyToManyFilter

class MyAdmin(ModelView, model=MyModel):
    column_filters = [
        UniqueValuesFilter(MyModel.price),  # Just use it!
        ManyToManyFilter(...)  # Works alongside existing filters
    ]

📈 Performance Impact

Improvements

  • Safe JOIN - Prevents duplicate JOINs that slow down queries
  • Relation Loading - Eliminates N+1 query problems
  • Zero overhead - New features are opt-in only

No Negative Impact

  • Default behavior unchanged
  • Existing queries maintain same performance
  • No additional dependencies

🎯 Use Cases

E-commerce Platform

class ProductAdmin(ModelView, model=Product):
    column_filters = [
        UniqueValuesFilter(Product.price, lookups_ui_method=lambda v: f"${v:.2f}"),
        ManyToManyFilter(...),  # Filter by tags
        RelatedModelFilter(...),  # Filter by supplier country
        DateRangeFilter(Product.created_at)
    ]

Analytics Dashboard

class OrderAdmin(ModelView, model=Order):
    async_search = True
    use_pretty_export = True
    export_types = ["csv", "json"]

📝 Implementation Details

Files Modified

  • sqladmin/filters.py (+265 lines)
  • sqladmin/models.py (+82 lines)
  • sqladmin/pretty_export.py (+38 lines)
  • sqladmin/application.py (+8 lines)
  • tests/ (+272 lines)

✅ Checklist

  • Code follows project style guidelines
  • All tests pass (248 passing)
  • Linter checks pass
  • Type checking passes
  • Documentation updated
  • Backward compatibility maintained
  • No breaking changes
  • Examples provided in docs
  • Self-documenting code with minimal docstrings

🚀 Ready for Review

This PR has been thoroughly tested and documented. All changes maintain backward compatibility while significantly extending SQLAdmin's filtering and query optimization capabilities.


Looking forward to feedback!

…del filters

- Add UniqueValuesFilter with Integer/Float support and formatting
- Add ManyToManyFilter for filtering through junction tables
- Add RelatedModelFilter for filtering by related model columns
- Add DateRangeFilter for date/datetime range filtering
- Enhance ForeignKeyFilter with multiple value selection
- Add _safe_join() to prevent duplicate JOINs
- Add async_search_query() for custom async search
- Enhance pretty_export with JSON support
- Add comprehensive documentation with cookbook examples
- Add tests for UniqueValuesFilter with Integer/Float
- Add tests for enhanced ForeignKeyFilter
- Add tests for DateRangeFilter
- Add tests for _safe_join() preventing duplicate JOINs
- Add tests for add_relation_loads() optimization
- Add tests for async_search_query()
- Add test for pretty_export_json()
- Clean up verbose docstrings in favor of self-documenting code
- Move imports from functions to module level

All 248 tests passing.
@sergei-vasilev-dev sergei-vasilev-dev force-pushed the feature/sqladmin-912_perform_template_context branch from 9f0f4c9 to 0c6e7f8 Compare November 29, 2025 09:58
@sergei-vasilev-dev sergei-vasilev-dev changed the title [sqladmin-912] - Add perform context functions to the Modelview class. [sqladmin-912] - Add perform context functions, DataRange filter, JSON export, and other tweaks... Nov 29, 2025
- Add 40+ new tests for filter edge cases
- Add tests for all utility functions
- Add tests for DateRangeFilter with various inputs
- Add tests for RelatedModelFilter Boolean handling
- Add tests for pretty_export_json with formatters
- Add pragma: no cover for impossible-to-test cases

All 291 tests passing with 99% coverage.
@sergei-vasilev-dev sergei-vasilev-dev changed the title [sqladmin-912] - Add perform context functions, DataRange filter, JSON export, and other tweaks... [sqladmin-912] - Add perform context functions, DateRange filter, JSON export, and other tweaks... Nov 29, 2025
@sergei-vasilev-dev
Copy link
Author

I just want to mention that we performed a library for one of our projects, and it turned out that the code here was collected from several different PRs. But it is all covered by tests, so it might make sense to consider it as the main one.

@rusanpas
Copy link

rusanpas commented Dec 2, 2025

This seems like a great improvement, but I have a question.

Shouldn't the filters be accompanied by changes to the templates?

The date filter, without modifying the template to add, for example, a Flatpickr (range), would leave the filter incomplete unless a custom template is created, right?

@sergei-vasilev-dev
Copy link
Author

You're right. I forgot to add template updates.

Core Features:
- Add DateRangeFilter with datetime-local inputs UI
- Add hasattr to Jinja2 globals for cleaner templates
- Add SQLAlchemy Table object support in filters
- Handle DateRangeFilter params (_start, _end) in list()
- Fix ManyToManyFilter to work with Table objects

Demo Application:
- Add comprehensive demo_app/ with all features
- 8 models (User, Product, Order, etc) with FK and M2M
- 9 admin views showcasing all filter types
- Sample data for immediate testing
- Quick start guide and documentation

Testing:
- Add tests for Table object handling
- Add tests for DateRangeFilter integration
- All 292 tests passing, 99% coverage

DateRangeFilter and ManyToManyFilter now work with association tables!

To test: cd demo_app && pip install -e .. fastapi uvicorn sqlalchemy && python main.py
Then open: http://localhost:8000/admin
Comment on lines +73 to +79
def _get_filter_value(values_list: list[str], column_type: Any) -> list:
"""Convert list of string values to appropriate types based on column type."""
if isinstance(column_type, Integer):
return [int(item) for item in values_list]
if isinstance(column_type, Float):
return [float(item) for item in values_list]
return [item for item in values_list]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe:

def _get_filter_value(values_list: list[str], column_type: Any) -> list:
    """Convert list of string values to appropriate types based on column type."""
    if isinstance(column_type, Integer):
        conv = int
    elif isinstance(column_type, Float):
        conv = float
    else:
        conv = lambda x: x

    return [conv(item) for item in values_list]

foreign_model: Any = None,
title: Optional[str] = None,
parameter_name: Optional[str] = None,
lookups_order: MODEL_ATTR | None = None,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this be multiple? like [Model.count, Model.created_at]

]

async def get_filtered_query(self, query: Select, value: Any, model: Any) -> Select:
if value == "" or value == [""] or not value:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

value == "" is covered by not value. So, change pls:

if not value or value == [""]

Comment on lines +276 to +282
if isinstance(column_obj.type, Integer):
return [("", "All")] + [(str(value[0]), value[0]) for value in result]
if isinstance(column_obj.type, Float):
return [("", "All")] + self._build_float_lookups(result)

lookups = [("", "All")] + [(value[0], value[0]) for value in result]
return lookups
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe create another method for lookup generation. And use similar method that I suggested above

# Use simple filtering for filters without operators

# Handle DateRangeFilter specially
if hasattr(filter, "is_date_filter") and filter.is_date_filter:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replace with getattr(filter, 'has_date_filter', False)

</form>
</div>
</div>
{% elif hasattr(filter, 'is_date_filter') and filter.is_date_filter %}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dont now if getattr works here

Comment on lines 262 to 342
</form>
</div>
</div>
{% elif hasattr(filter, 'is_date_filter') and filter.is_date_filter %}
<!-- DateRangeFilter UI -->
<div class="mb-3">
<div class="fw-bold text-truncate fs-3 mb-2">{{ filter.title }}</div>
<form method="get" class="d-flex flex-column" style="gap: 8px;">
<!-- Preserve existing query parameters -->
{% for key, value in request.query_params.items() %}
{% if key != filter.parameter_name + '_start' and key != filter.parameter_name + '_end' %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endif %}
{% endfor %}

<label class="form-label mb-1" style="font-size: 0.875rem;">From:</label>
<input type="datetime-local"
name="{{ filter.parameter_name }}_start"
class="form-control form-control-sm"
value="{{ request.query_params.get(filter.parameter_name + '_start', '') }}">

<label class="form-label mb-1" style="font-size: 0.875rem;">To:</label>
<input type="datetime-local"
name="{{ filter.parameter_name }}_end"
class="form-control form-control-sm"
value="{{ request.query_params.get(filter.parameter_name + '_end', '') }}">

<div class="d-flex gap-2">
<a href="{{ request.url.remove_query_params(filter.parameter_name + '_start').remove_query_params(filter.parameter_name + '_end') }}"
class="btn btn-sm btn-outline-secondary flex-fill">Clear</a>
<button type="submit" class="btn btn-sm btn-outline-primary flex-fill">Apply</button>
</div>
</form>
</div>
{% else %}
<!-- Fallback for other filter types -->
<!-- Standard filter types (links for backward compatibility) -->
<div class="mb-3">
<div class="fw-bold text-truncate fs-3 mb-2">{{ filter.title }}</div>
<div>
{% for lookup in filter.lookups(request, model_view.model, model_view._run_arbitrary_query) %}
{% if request.query_params.get(filter.parameter_name) == lookup[0] %}
<div class="d-flex align-items-center justify-content-between bg-secondary-lt px-2 py-1 rounded">
<span class="text-truncate fw-bold text-dark">
{{ lookup[1] }}
</span>
<a href="{{ request.url.remove_query_params(filter.parameter_name) }}" class="text-decoration-none ms-2" title="Clear filter">
<i class="fa-solid fa-times"></i>
</a>
</div>
{% else %}
<a href="{{ request.url.include_query_params(**{filter.parameter_name: lookup[0]}) }}" class="d-block text-decoration-none text-truncate px-2 py-1">
{{ lookup[1] }}
</a>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% if model_view.can_delete %}
{% include 'sqladmin/modals/delete.html' %}
{% endif %}

{% for custom_action in model_view._custom_actions_in_list %}
{% if custom_action in model_view._custom_actions_confirmation %}
{% with confirmation_message = model_view._custom_actions_confirmation[custom_action], custom_action=custom_action,
url=model_view._url_for_action(request, custom_action) %}
{% include 'sqladmin/modals/list_action_confirmation.html' %}
{% endwith %}
{% endif %}
{% endfor %}
</div>

{% endblock %}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding this to different file. There is nice PR by @fd-oncodna which will clean this part

return stmt.join(target_model)

def add_relation_loads(self, stmt: Select) -> Select:
"""Add selectinload for all list relations."""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why selectinload instead of joinedload? Any specific reason?

)
return await export_method
elif export_type == "json":
if self.use_pretty_export:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove everything related to pretty_export: this was added with #938

client: AsyncClient, prepare_data: Any
) -> None:
response = await client.get("/admin/user/list?age=25&age=30")
assert response.status_code == 200
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe test if results indeed shows results 25 and 30.

return joined_query.filter(filter_condition)


class DateRangeFilter:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious: Do you think current design would support RangeFilter to other types as well? Let's say, I want all users between age 20 and 30. Or filter range of ForeignKey values (20 < User.profile.age < 30).

@mmzeynalli
Copy link
Contributor

@sergei-vasilev-dev any updates?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Change data in context on ModelView before template rendering

4 participants