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>
{% else %}
<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 }}>
{{ label }}
</a>
@ -103,18 +103,6 @@
</ul>
</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 %}
{% 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)
if rel is None:
# 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
break
@ -78,13 +78,20 @@ def _apply_dotted_ordering(stmt, Model, sort_tokens):
continue
col_name = parts[-1]
# Validate final column
if col_name not in current_mapper.columns:
# raise ValueError(f"Unknown column {current_mapper.class_.__name__}.{col_name}")
continue
# # Validate final column
# if col_name not in current_mapper.columns:
# print(f"Unknown column {current_mapper.class_.__name__}.{col_name}")
# continue
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())
# col = getattr(entity, col_name) if entity is not Model else getattr(Model, col_name)
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

View file

@ -29,22 +29,9 @@ worklog_images = image_links.worklog_images
User = users.User
# 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()
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 = {
"area": Area,
"brand": Brand,

View file

@ -1,12 +1,13 @@
from typing import Optional, TYPE_CHECKING, List
if TYPE_CHECKING:
from .areas import Area
from .room_functions import RoomFunction
from .inventory import Inventory
from .users import User
from .room_functions import RoomFunction
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 . import db
@ -27,18 +28,6 @@ class Room(db.Model, CrudMixin):
ui_eagerload = tuple()
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):
self.name = name
self.area_id = area_id
@ -47,8 +36,22 @@ class Room(db.Model, CrudMixin):
def __repr__(self):
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):
name = self.name or ""
func = self.room_function.description if self.room_function else ""
return f"{name} - {func}".strip(" -")
function = self.room_function.description if self.room_function else ""
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">
<tr>
{% 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 %}
</tr>
</thead>
@ -82,7 +84,7 @@ refresh_url=none, model=none, sort=none) %}
<input type="hidden" name="page" value="{{ page }}">
<input type="hidden" name="per_page" value="{{ per_page }}">
<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() %}
<input type="hidden" name="{{ k }}" value="{{ v }}">
{% endfor %}
@ -90,4 +92,31 @@ refresh_url=none, model=none, sort=none) %}
</div>
<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>
{# 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 %}