-
Notifications
You must be signed in to change notification settings - Fork 268
[sqladmin-912] - Add perform context functions, DateRange filter, JSON export, and other tweaks... #913
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
|
Hey @sergey-vasiliev-dualboot , |
|
@sergey-vasiliev-dualboot it is nice addition, any updates? This is related to my issue #942 |
|
Hi! @mmzeynalli, plan to update till the end of this week. |
|
Okay, let us know when it is ready to be reviewed |
Enhanced Filtering System and ModelView ImprovementsSummaryThis 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 FeaturesAdvanced Filter Types1. UniqueValuesFilter (Enhanced)Improved version with support for numeric types and custom formatting:
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 ForeignKeyFilterNow 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:
add_relation_loads()Optimizes relationship loading using
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 EnhancementsJSON Export with FormattingPretty export now supports JSON format:
class OrderAdmin(ModelView, model=Order):
use_pretty_export = True
export_types = ["csv", "json"]Better Relation Handling
📊 TestingTest Coverage
New Tests Added
📚 DocumentationNew Documentation
Updated Documentation
🔧 Code Quality
|
…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.
9f0f4c9 to
0c6e7f8
Compare
- 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.
|
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. |
|
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? |
|
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
| 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] |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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 == [""]
| 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 |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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 %} |
There was a problem hiding this comment.
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
| </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 %} |
There was a problem hiding this comment.
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.""" |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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).
|
@sergei-vasilev-dev any updates? |
#912
I think better to perform template context on the model level. Please, take a look and say what you think.
Thanks!