Compare commits
2 commits
4c56149f1b
...
9a39ae25df
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a39ae25df | ||
|
|
07512aee93 |
6 changed files with 241 additions and 25 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
from typing import List, Tuple, Set, Dict, Optional, Iterable
|
from typing import Any, List, Tuple, Set, Dict, Optional, Iterable
|
||||||
from sqlalchemy import asc, desc
|
from sqlalchemy import and_, asc, desc, or_
|
||||||
from sqlalchemy.ext.hybrid import hybrid_property
|
from sqlalchemy.ext.hybrid import hybrid_property
|
||||||
from sqlalchemy.orm import aliased, selectinload
|
from sqlalchemy.orm import aliased, selectinload
|
||||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||||
|
|
@ -12,6 +12,8 @@ OPERATORS = {
|
||||||
'gte': lambda col, val: col >= val,
|
'gte': lambda col, val: col >= val,
|
||||||
'ne': lambda col, val: col != val,
|
'ne': lambda col, val: col != val,
|
||||||
'icontains': lambda col, val: col.ilike(f"%{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:
|
class CRUDSpec:
|
||||||
|
|
@ -30,6 +32,96 @@ class CRUDSpec:
|
||||||
self._collection_field_names: Dict[str, List[str]] = {}
|
self._collection_field_names: Dict[str, List[str]] = {}
|
||||||
self.include_paths: Set[Tuple[str, ...]] = set()
|
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):
|
def _resolve_column(self, path: str):
|
||||||
current_alias = self.root_alias
|
current_alias = self.root_alias
|
||||||
parts = path.split('.')
|
parts = path.split('.')
|
||||||
|
|
@ -72,24 +164,12 @@ class CRUDSpec:
|
||||||
if maybe:
|
if maybe:
|
||||||
self.eager_paths.add(maybe)
|
self.eager_paths.add(maybe)
|
||||||
|
|
||||||
def parse_filters(self):
|
def parse_filters(self, params: dict | None = None):
|
||||||
filters = []
|
"""
|
||||||
for key, value in self.params.items():
|
Public entry: parse filters from given params or self.params.
|
||||||
if key in ('sort', 'limit', 'offset'):
|
Returns a list of SQLAlchemy filter expressions
|
||||||
continue
|
"""
|
||||||
if '__' in key:
|
return self._collect_filters(params if params is not None else self.params)
|
||||||
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):
|
def parse_sort(self):
|
||||||
sort_args = self.params.get('sort', '')
|
sort_args = self.params.get('sort', '')
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,10 @@ from crudkit.integrations.flask import init_app
|
||||||
|
|
||||||
from .debug_pretty import init_pretty
|
from .debug_pretty import init_pretty
|
||||||
|
|
||||||
|
from .routes.entry import init_entry_routes
|
||||||
from .routes.index import init_index_routes
|
from .routes.index import init_index_routes
|
||||||
from .routes.listing import init_listing_routes
|
from .routes.listing import init_listing_routes
|
||||||
from .routes.entry import init_entry_routes
|
from .routes.search import init_search_routes
|
||||||
|
|
||||||
def create_app(config_cls=crudkit.DevConfig) -> Flask:
|
def create_app(config_cls=crudkit.DevConfig) -> Flask:
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
@ -68,9 +69,10 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask:
|
||||||
|
|
||||||
app.register_blueprint(bp_reports)
|
app.register_blueprint(bp_reports)
|
||||||
|
|
||||||
|
init_entry_routes(app)
|
||||||
init_index_routes(app)
|
init_index_routes(app)
|
||||||
init_listing_routes(app)
|
init_listing_routes(app)
|
||||||
init_entry_routes(app)
|
init_search_routes(app)
|
||||||
|
|
||||||
@app.teardown_appcontext
|
@app.teardown_appcontext
|
||||||
def _remove_session(_exc):
|
def _remove_session(_exc):
|
||||||
|
|
|
||||||
94
inventory/routes/search.py
Normal file
94
inventory/routes/search.py
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
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)
|
||||||
|
|
@ -34,8 +34,8 @@
|
||||||
{% block header %}
|
{% block header %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
<input type="text" id="search" class="form-control me-3">
|
<input type="text" id="searchText" class="form-control me-3">
|
||||||
<button type="button" class="btn btn-primary" disabled>Search</button>
|
<button type="button" id="searchButton" class="btn btn-primary" disabled>Search</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -68,6 +68,17 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
searchText = document.getElementById('searchText');
|
||||||
|
searchButton = document.getElementById('searchButton');
|
||||||
|
|
||||||
|
searchText.addEventListener('input', () => {
|
||||||
|
searchButton.disabled = searchText.value == "";
|
||||||
|
});
|
||||||
|
|
||||||
|
searchButton.addEventListener('click', () => {
|
||||||
|
location.href=`{{ url_for('search.search') }}?q=${searchText.value}`;
|
||||||
|
});
|
||||||
|
|
||||||
{% block script %}
|
{% block script %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{% if rows %}
|
{% if rows %}
|
||||||
{% for row in rows %}
|
{% for row in rows %}
|
||||||
<tr onclick="location.href='{{ url_for( 'entry.entry', model=kwargs['object_class'], id=row.id) }}'" style="cursor: pointer;" class="{{ row.class or '' }}">
|
<tr{% if kwargs['object_class'] %} onclick="location.href='{{ url_for( 'entry.entry', model=kwargs['object_class'], id=row.id) }}'" style="cursor: pointer;" {% endif %} class="{{ row.class or '' }}">
|
||||||
{% for cell in row.cells %}
|
{% for cell in row.cells %}
|
||||||
{% if cell.href %}
|
{% if cell.href %}
|
||||||
<td class="{{ cell.class or '' }}"><a href="{{ cell.href }}" class="link-success link-underline link-underline-opacity-0 fw-semibold">{{ cell.text if cell.text is not none else '-' }}</a></td>
|
<td class="{{ cell.class or '' }}"><a href="{{ cell.href }}" class="link-success link-underline link-underline-opacity-0 fw-semibold">{{ cell.text if cell.text is not none else '-' }}</a></td>
|
||||||
|
|
|
||||||
29
inventory/templates/search.html
Normal file
29
inventory/templates/search.html
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="display-4 mb-3 text-center">
|
||||||
|
Search results for "{{ q }}"
|
||||||
|
</div>
|
||||||
|
<ul class="nav nav-pills nav-fill justify-content-center fw-bold px-5 mx-5 mb-3" id="resultsTab">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active user-select-none" data-bs-toggle="tab" data-bs-target="#inventory-tab-pane">Inventory</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link user-select-none" data-bs-toggle="tab" data-bs-target="#user-tab-pane">Users</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link user-select-none" data-bs-toggle="tab" data-bs-target="#worklog-tab-pane">Work Logs</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="tab-content" id="resultsContent">
|
||||||
|
<div class="tab-pane fade show active" id="inventory-tab-pane">
|
||||||
|
{{ inventory_results | safe }}
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="user-tab-pane">
|
||||||
|
{{ user_results | safe }}
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="worklog-tab-pane">
|
||||||
|
{{ worklog_results | safe }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue