diff --git a/crudkit/core/spec.py b/crudkit/core/spec.py index 4ec972f..d5c2480 100644 --- a/crudkit/core/spec.py +++ b/crudkit/core/spec.py @@ -1,5 +1,5 @@ -from typing import Any, List, Tuple, Set, Dict, Optional, Iterable -from sqlalchemy import and_, asc, desc, or_ +from typing import List, Tuple, Set, Dict, Optional, Iterable +from sqlalchemy import asc, desc from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import aliased, selectinload from sqlalchemy.orm.attributes import InstrumentedAttribute @@ -12,8 +12,6 @@ OPERATORS = { 'gte': lambda col, val: col >= val, 'ne': lambda col, val: col != val, 'icontains': lambda col, val: col.ilike(f"%{val}%"), - 'in': lambda col, val: col.in_(val if isinstance(val, (list, tuple, set)) else [val]), - 'nin': lambda col, val: ~col.in_(val if isinstance(val, (list, tuple, set)) else [val]), } class CRUDSpec: @@ -32,96 +30,6 @@ class CRUDSpec: self._collection_field_names: Dict[str, List[str]] = {} self.include_paths: Set[Tuple[str, ...]] = set() - def _split_path_and_op(self, key: str) -> tuple[str, str]: - if '__' in key: - path, op = key.rsplit('__', 1) - else: - path, op = key, 'eq' - return path, op - - def _resolve_many_columns(self, path: str) -> list[tuple[InstrumentedAttribute, Optional[tuple[str, ...]]]]: - """ - Accepts pipe-delimited paths like 'label|owner.label' - Returns a list of (column, join_path) pairs for every resolvable subpath. - """ - cols: list[tuple[InstrumentedAttribute, Optional[tuple[str, ...]]]] = [] - for sub in path.split('|'): - sub = sub.strip() - if not sub: - continue - col, join_path = self._resolve_column(sub) - if col is not None: - cols.append((col, join_path)) - return cols - - def _build_predicate_for(self, path: str, op: str, value: Any): - """ - Builds a SQLA BooleanClauseList or BinaryExpression for a single key. - If multiple subpaths are provided via pipe, returns an OR of them. - """ - if op not in OPERATORS: - return None - - pairs = self._resolve_many_columns(path) - if not pairs: - return None - - exprs = [] - for col, join_path in pairs: - # Track eager path for each involved relationship chain - if join_path: - self.eager_paths.add(join_path) - exprs.append(OPERATORS[op](col, value)) - - if not exprs: - return None - if len(exprs) == 1: - return exprs[0] - return or_(*exprs) - - def _collect_filters(self, params: dict) -> list: - """ - Recursively parse filters from 'param' into a flat list of SQLA expressions. - Supports $or / $and groups. Any other keys are parsed as normal filters. - """ - filters: list = [] - - for key, value in (params or {}).items(): - if key in ('sort', 'limit', 'offset', 'fields', 'include'): - continue - - if key == '$or': - # value should be a list of dicts - groups = [] - for group in value if isinstance(value, (list, tuple)) else []: - sub = self._collect_filters(group) - if not sub: - continue - groups.append(and_(*sub) if len(sub) > 1 else sub[0]) - if groups: - filters.append(or_(*groups)) - continue - - if key == '$and': - # value should be a list of dicts - parts = [] - for group in value if isinstance(value, (list, tuple)) else []: - sub = self._collect_filters(group) - if not sub: - continue - parts.append(and_(*sub) if len(sub) > 1 else sub[0]) - if parts: - filters.append(and_(*parts)) - continue - - # Normal key - path, op = self._split_path_and_op(key) - pred = self._build_predicate_for(path, op, value) - if pred is not None: - filters.append(pred) - - return filters - def _resolve_column(self, path: str): current_alias = self.root_alias parts = path.split('.') @@ -164,12 +72,24 @@ class CRUDSpec: if maybe: self.eager_paths.add(maybe) - def parse_filters(self, params: dict | None = None): - """ - Public entry: parse filters from given params or self.params. - Returns a list of SQLAlchemy filter expressions - """ - return self._collect_filters(params if params is not None else self.params) + def parse_filters(self): + filters = [] + for key, value in self.params.items(): + if key in ('sort', 'limit', 'offset'): + continue + if '__' in key: + path_op = key.rsplit('__', 1) + if len(path_op) != 2: + continue + path, op = path_op + else: + path, op = key, 'eq' + col, join_path = self._resolve_column(path) + if col and op in OPERATORS: + filters.append(OPERATORS[op](col, value)) + if join_path: + self.eager_paths.add(join_path) + return filters def parse_sort(self): sort_args = self.params.get('sort', '') diff --git a/inventory/__init__.py b/inventory/__init__.py index cea7eea..3e8f6d7 100644 --- a/inventory/__init__.py +++ b/inventory/__init__.py @@ -11,10 +11,9 @@ from crudkit.integrations.flask import init_app from .debug_pretty import init_pretty -from .routes.entry import init_entry_routes from .routes.index import init_index_routes from .routes.listing import init_listing_routes -from .routes.search import init_search_routes +from .routes.entry import init_entry_routes def create_app(config_cls=crudkit.DevConfig) -> Flask: app = Flask(__name__) @@ -69,10 +68,9 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask: app.register_blueprint(bp_reports) - init_entry_routes(app) init_index_routes(app) init_listing_routes(app) - init_search_routes(app) + init_entry_routes(app) @app.teardown_appcontext def _remove_session(_exc): diff --git a/inventory/routes/search.py b/inventory/routes/search.py deleted file mode 100644 index aae6e00..0000000 --- a/inventory/routes/search.py +++ /dev/null @@ -1,94 +0,0 @@ -from flask import Blueprint, render_template, request - -import crudkit - -from crudkit.ui.fragments import render_table - -from ..models import Inventory, User, WorkLog - -bp_search = Blueprint("search", __name__) - -def init_search_routes(app): - @bp_search.get("/search") - def search(): - q = request.args.get("q") - if not q: - return "Oh no!" - - inventory_service = crudkit.crud.get_service(Inventory) - user_service = crudkit.crud.get_service(User) - worklog_service = crudkit.crud.get_service(WorkLog) - - inventory_columns = [ - {"field": "label"}, - {"field": "barcode", "label": "Bar Code #"}, - {"field": "name"}, - {"field": "serial", "label": "Serial #"}, - {"field": "brand.name", "label": "Brand"}, - {"field": "model"}, - {"field": "device_type.description", "label": "Device Type"}, - {"field": "owner.label", "label": "Contact", - "link": {"endpoint": "entry.entry", "params": {"id": "{owner.id}", "model": "user"}}}, - {"field": "location.label", "label": "Location"}, - ] - inventory_results = inventory_service.list({ - 'notes|label|owner.label__icontains': q, - 'fields': [ - "label", - "name", - "barcode", - "serial", - "brand.name", - "model", - "device_type.description", - "owner.label", - "location.label", - ] - }) - - user_columns = [ - {"field": "last_name"}, - {"field": "first_name"}, - {"field": "title"}, - {"field": "supervisor.label", "label": "Supervisor", - "link": {"endpoint": "entry.entry", "params": {"id": "{supervisor.id}", "model": "user"}}}, - {"field": "location.label", "label": "Location"}, - ] - user_results = user_service.list({ - 'supervisor.label|label__icontains': q, - 'fields': [ - "last_name", - "first_name", - "title", - "supervisor.label", - "location.label", - ] - }) - - worklog_columns = [ - {"field": "contact.label", "label": "Contact", - "link": {"endpoint": "entry.entry", "params": {"id": "{contact.id}", "model": "user"}}}, - {"field": "work_item.label", "label": "Work Item", - "link": {"endpoint": "entry.entry", "params": {"id": "{work_item.id}", "model": "inventory"}}}, - {"field": "complete", "format": "yesno"}, - {"field": "start_time", "format": "datetime"}, - {"field": "end_time", "format": "datetime"} - ] - worklog_results = worklog_service.list({ - 'contact.label|work_item.label__icontains': q, - 'fields': [ - "contact.label", - "work_item.label", - "complete", - "start_time", - "end_time" - ] - }) - - inventory_results = render_table(inventory_results, inventory_columns, opts={"object_class": "inventory"}) - user_results = render_table(user_results, user_columns, opts={"object_class": "user"}) - worklog_results = render_table(worklog_results, worklog_columns, opts={"object_class": "worklog"}) - - return render_template('search.html', q=q, inventory_results=inventory_results, user_results=user_results, worklog_results=worklog_results) - - app.register_blueprint(bp_search) diff --git a/inventory/templates/base.html b/inventory/templates/base.html index e6fbfb8..a498bee 100644 --- a/inventory/templates/base.html +++ b/inventory/templates/base.html @@ -34,8 +34,8 @@ {% block header %} {% endblock %}
- - + +
@@ -68,17 +68,6 @@ {% endblock %}