Some additional work done with CRUDKit. May start this over with a better design for CRUDKit.

This commit is contained in:
Yaro Kasear 2025-08-29 16:04:41 -05:00
parent f47fb6b505
commit 7738f1c9c2
5 changed files with 67 additions and 53 deletions

View file

@ -56,7 +56,7 @@
</li> </li>
{% else %} {% else %}
<li class="page-item"> <li class="page-item">
<a class="page-link" hx-get="{{ _rows_url(model, page, per_page, sort, filters, fields_csv) }}" hx-target="#rows" <a class="page-link state-modifier" hx-get="{{ _rows_url(model, page, per_page, sort, filters, fields_csv) }}" hx-target="#rows"
hx-swap="innerHTML" data-page="{{ page }}" {{ attrs|safe }}> hx-swap="innerHTML" data-page="{{ page }}" {{ attrs|safe }}>
{{ label }} {{ label }}
</a> </a>
@ -103,18 +103,6 @@
</ul> </ul>
</nav> </nav>
{# keep hidden pager-state in sync with one handler instead of inline click spam #}
<script>
document.currentScript?.previousElementSibling?.addEventListener?.('click', function (e) {
const a = e.target.closest('a.page-link');
if (!a) return;
const targetPage = a.getAttribute('data-page');
if (!targetPage) return;
const inp = document.querySelector('#pager-state input[name=page]');
if (inp) inp.value = targetPage;
}, { capture: true });
</script>
{%- endmacro %} {%- endmacro %}
{% macro form(schema, action, method="POST", obj_id=None, hx=False, csrf_token=None) -%} {% macro form(schema, action, method="POST", obj_id=None, hx=False, csrf_token=None) -%}

View file

@ -56,7 +56,7 @@ def _apply_dotted_ordering(stmt, Model, sort_tokens):
rel = current_mapper.relationships.get(rel_name) rel = current_mapper.relationships.get(rel_name)
if rel is None: if rel is None:
# invalid sort key; skip quietly or raise # invalid sort key; skip quietly or raise
# raise ValueError(f"Unknown relationship {current_mapper.class_.__name__}.{rel_name}") print(f"Unknown relationship {current_mapper.class_.__name__}.{rel_name}")
entity = None entity = None
break break
@ -78,13 +78,20 @@ def _apply_dotted_ordering(stmt, Model, sort_tokens):
continue continue
col_name = parts[-1] col_name = parts[-1]
# Validate final column # # Validate final column
if col_name not in current_mapper.columns: # if col_name not in current_mapper.columns:
# raise ValueError(f"Unknown column {current_mapper.class_.__name__}.{col_name}") # print(f"Unknown column {current_mapper.class_.__name__}.{col_name}")
continue # continue
col = getattr(entity, col_name) if entity is not Model else getattr(Model, col_name) # col = getattr(entity, col_name) if entity is not Model else getattr(Model, col_name)
stmt = stmt.order_by(col.desc() if direction == "desc" else col.asc())
attr = getattr(entity, col_name, None)
if attr is None:
attr = getattr(current_mapper.class_, col_name, None)
if attr is None:
print(f"Unknown column {current_mapper.class_.__name__}.{col_name}")
continue
stmt = stmt.order_by(attr.desc() if direction == "desc" else attr.asc())
return stmt return stmt

View file

@ -29,22 +29,9 @@ worklog_images = image_links.worklog_images
User = users.User User = users.User
# Now its safe to configure mappers and set global eagerloads # Now its safe to configure mappers and set global eagerloads
from sqlalchemy.orm import configure_mappers, joinedload, selectinload from sqlalchemy.orm import configure_mappers
configure_mappers() configure_mappers()
User.ui_eagerload = (
joinedload(User.supervisor),
joinedload(User.location).joinedload(Room.room_function),
)
Room.ui_eagerload = (
joinedload(Room.area),
joinedload(Room.room_function),
selectinload(Room.inventory),
selectinload(Room.users)
)
registry = { registry = {
"area": Area, "area": Area,
"brand": Brand, "brand": Brand,

View file

@ -1,12 +1,13 @@
from typing import Optional, TYPE_CHECKING, List from typing import Optional, TYPE_CHECKING, List
if TYPE_CHECKING: if TYPE_CHECKING:
from .areas import Area from .areas import Area
from .room_functions import RoomFunction
from .inventory import Inventory from .inventory import Inventory
from .users import User from .users import User
from .room_functions import RoomFunction
from crudkit import CrudMixin from crudkit import CrudMixin
from sqlalchemy import ForeignKey, Identity, Integer, Unicode from sqlalchemy import ForeignKey, Identity, Integer, Unicode, func, select, literal
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db from . import db
@ -27,18 +28,6 @@ class Room(db.Model, CrudMixin):
ui_eagerload = tuple() ui_eagerload = tuple()
ui_extra_attrs = ('area_id', 'function_id') ui_extra_attrs = ('area_id', 'function_id')
@classmethod
def ui_update(cls, session, id_, payload):
print(payload)
obj = session.get(cls, id_)
if not obj:
return None
obj.name = payload.get("name", obj.name)
obj.area_id = payload.get("area_id", obj.area_id)
obj.function_id = payload.get("function_id", obj.function_id)
session.commit()
return obj
def __init__(self, name: Optional[str] = None, area_id: Optional[int] = None, function_id: Optional[int] = None): def __init__(self, name: Optional[str] = None, area_id: Optional[int] = None, function_id: Optional[int] = None):
self.name = name self.name = name
self.area_id = area_id self.area_id = area_id
@ -47,8 +36,22 @@ class Room(db.Model, CrudMixin):
def __repr__(self): def __repr__(self):
return f"<Room(id={self.id}, room={repr(self.name)}, area_id={self.area_id}, function_id={self.function_id})>" return f"<Room(id={self.id}, room={repr(self.name)}, area_id={self.area_id}, function_id={self.function_id})>"
@property @hybrid_property
def identifier(self): def identifier(self):
name = self.name or "" name = self.name or ""
func = self.room_function.description if self.room_function else "" function = self.room_function.description if self.room_function else ""
return f"{name} - {func}".strip(" -") return f"{name} - {function}".strip(" -")
@identifier.expression
def identifier(cls):
rf_desc = (
select(RoomFunction.description)
.where(RoomFunction.id == cls.function_id)
.correlate(cls)
.scalar_subquery()
)
return func.concat(
func.coalesce(cls.name, ''), # left part
literal(' - '), # separator
func.coalesce(rf_desc, '') # right part via correlated subquery
)

View file

@ -70,7 +70,9 @@ refresh_url=none, model=none, sort=none) %}
<thead class="sticky-top"> <thead class="sticky-top">
<tr> <tr>
{% for h in headers %} {% for h in headers %}
<th class="text-nowrap">{{ h }}</th> <th class="text-nowrap state-modifier" {% if fields %}
hx-get="/ui/{{ model }}/frag/rows?page={{ page }}&per_page={{ per_page }}&fields_csv={{ fields|join(',') }}&sort={{ fields[loop.index0] }}"
hx-target="#rows" hx-swap="innerHTML" hx-trigger="click" data-sort={{ fields[loop.index0] }}{% endif %}>{{ h }}</th>
{% endfor %} {% endfor %}
</tr> </tr>
</thead> </thead>
@ -82,7 +84,7 @@ refresh_url=none, model=none, sort=none) %}
<input type="hidden" name="page" value="{{ page }}"> <input type="hidden" name="page" value="{{ page }}">
<input type="hidden" name="per_page" value="{{ per_page }}"> <input type="hidden" name="per_page" value="{{ per_page }}">
<input type="hidden" name="fields_csv" value="{{ fields|join(',') }}"> <input type="hidden" name="fields_csv" value="{{ fields|join(',') }}">
{% if sort %}<input type="hidden" name="sort" value="{{ sort }}">{% endif %} <input type="hidden" name="sort" value="{{ sort }}">
{% for k,v in (filters or {}).items() %} {% for k,v in (filters or {}).items() %}
<input type="hidden" name="{{ k }}" value="{{ v }}"> <input type="hidden" name="{{ k }}" value="{{ v }}">
{% endfor %} {% endfor %}
@ -90,4 +92,31 @@ refresh_url=none, model=none, sort=none) %}
</div> </div>
<div id="pager" hx-get="/ui/{{ model }}/frag/pager" hx-include="#pager-state" <div id="pager" hx-get="/ui/{{ model }}/frag/pager" hx-include="#pager-state"
hx-trigger="load, htmx:afterSwap from:#rows" hx-target="#pager" hx-swap="innerHTML"></div> hx-trigger="load, htmx:afterSwap from:#rows" hx-target="#pager" hx-swap="innerHTML"></div>
{# keep hidden pager-state in sync with one handler instead of inline click spam #}
<script>
document.addEventListener('click', function (e) {
const a = e.target.closest('a.page-link.state-modifier');
const th = e.target.closest('th.state-modifier');
if (!a && !th) return;
// run BEFORE htmx handles the click
if (a) {
const targetPage = a.dataset.page;
if (targetPage) {
const inp = document.querySelector('#pager-state input[name=page]');
if (inp) inp.value = targetPage;
}
}
if (th) {
const targetSort = th.dataset.sort;
if (targetSort) {
const inp = document.querySelector('#pager-state input[name=sort]');
if (inp) inp.value = targetSort;
}
}
}, { capture: true });
</script>
{% endmacro %} {% endmacro %}