Adding some default CRUD behaviors.

This commit is contained in:
Yaro Kasear 2025-08-13 10:53:22 -05:00
parent c8190be21c
commit b2231f8ef9
6 changed files with 243 additions and 90 deletions

View file

@ -35,8 +35,10 @@ def create_app():
from .routes import main from .routes import main
from .routes.images import image_bp from .routes.images import image_bp
from .ui.blueprint import bp as ui_bp
app.register_blueprint(main) app.register_blueprint(main)
app.register_blueprint(image_bp) app.register_blueprint(image_bp)
app.register_blueprint(ui_bp)
from .routes.helpers import generate_breadcrumbs from .routes.helpers import generate_breadcrumbs
@app.context_processor @app.context_processor

View file

@ -23,13 +23,13 @@ class Area(ValidatableMixin, db.Model):
def __repr__(self): def __repr__(self):
return f"<Area(id={self.id}, name={repr(self.name)})>" return f"<Area(id={self.id}, name={repr(self.name)})>"
def serialize(self): def serialize(self):
return { return {
'id': self.id, 'id': self.id,
'name': self.name 'name': self.name
} }
@classmethod @classmethod
def sync_from_state(cls, submitted_items: list[dict]) -> dict[str, int]: def sync_from_state(cls, submitted_items: list[dict]) -> dict[str, int]:
""" """

View file

@ -127,3 +127,88 @@ const ComboBoxWidget = (() => {
createTempId createTempId
}; };
})(); })();
function ComboBox(cfg) {
return {
id: cfg.id,
createUrl: cfg.createUrl,
editUrl: cfg.editUrl,
deleteUrl: cfg.deleteUrl,
refreshUrl: cfg.refreshUrl,
query: '',
isEditing: false,
editingOption: null,
get hasSelection() { return this.$refs.list?.selectedOptions.length > 0 },
onListChange() {
const sel = this.$refs.list.selectedOptions;
if (sel.length === 1) {
this.query = sel[0].textContent.trim();
this.isEditing = true;
this.editingOption = sel[0];
} else {
this.cancelEdit();
}
},
cancelEdit() { this.isEditing = false; this.editingOption = null; },
async submitAddOrEdit() {
const name = (this.query || '').trim();
if (!name) return;
if (this.isEditing && this.editingOption && this.editUrl) {
// EDIT
const id = this.editingOption.value;
await this._post(this.editUrl, { id, name });
this.editingOption.textContent = name;
} else if (this.createUrl) {
// CREATE
const data = await this._post(this.createUrl, { name });
const id = (data && data.id) ? data.id : ('temp-' + Math.random().toString(36).slice(2));
const opt = document.createElement('option');
opt.value = id; opt.textContent = name;
this.$refs.list.appendChild(opt);
this._sortOptions();
}
this.query = '';
this.cancelEdit();
this._maybeRefresh();
},
async removeSelected() {
const opts = Array.from(this.$refs.list.selectedOptions);
if (!opts.length) return;
if (!confirm(`Delete ${opts.length} item(s)?`)) return;
const ids = opts.map(o => o.remove());
this.query = '';
this.cancelEdit();
this._maybeRefresh();
},
_sortOptions() {
const list = this.$refs.list;
const sorted = Array.from(list.options).sort((a, b) => a.text.localeCompare(b.text));
list.innerHTML = ''; sorted.forEach(o => list.appendChild(o));
},
_maybeRefresh() { if (this.refreshUrl) this.$dispatch('combobox:refresh'); },
async _post(url, payload) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const msg = await res.text().catch(() => 'Error');
}
}
}
}

View file

@ -55,7 +55,7 @@
id: '{{ id }}', id: '{{ id }}',
createUrl: {{ create_url|tojson if create_url else 'null' }}, createUrl: {{ create_url|tojson if create_url else 'null' }},
editUrl: {{ edit_url|tojson if edit_url else 'null' }}, editUrl: {{ edit_url|tojson if edit_url else 'null' }},
deleteUrl: {{ dekete_url|tojson if delete_url else 'null' }}, deleteUrl: {{ delete_url|tojson if delete_url else 'null' }},
refreshUrl: {{ refresh_url|tojson if refresh_url else 'null' }}, refreshUrl: {{ refresh_url|tojson if refresh_url else 'null' }},
})" })"
hx-preserve hx-preserve
@ -101,91 +101,4 @@
hx-swap="innerHTML"></div> hx-swap="innerHTML"></div>
{% endif %} {% endif %}
</div> </div>
<script>
function ComboBox(cfg) {
return {
id: cfg.id,
createUrl: cfg.createUrl,
editUrl: cfg.editUrl,
deleteUrl: cfg.deleteUrl,
refreshUrl: cfg.refreshUrl,
query: '',
isEditing: false,
editingOption: null,
get hasSelection() { return this.$refs.list?.selectedOptions.length > 0 },
onListChange() {
const sel = this.$refs.list.selectedOptions;
if (sel.length === 1) {
this.query = sel[0].textContent.trim();
this.isEditing = true;
this.editingOption = sel[0];
} else {
this.cancelEdit();
}
},
cancelEdit() { this.isEditing = false; this.editingOption = null; },
async submitAddOrEdit() {
const name = (this.query || '').trim();
if (!name) return;
if (this.isEditing && this.editingOption && this.editUrl) {
// EDIT
const id = this.editingOption.value;
await this._post(this.editUrl, { id, name });
this.editingOption.textContent = name;
} else if (this.createUrl) {
// CREATE
const data = await this._post(this.createUrl, { name });
const id = (data && data.id) ? data.id : ('temp-' + Math.random().toString(36).slice(2));
const opt = document.createElement('option');
opt.value = id; opt.textContent = name;
this.$refs.list.appendChild(opt);
this._sortOptions();
}
this.query = '';
this.cancelEdit();
this._maybeRefresh();
},
async removeSelected() {
const opts = Array.from(this.$refs.list.selectedOptions);
if (!opts.length) return;
if (!confirm(`Delete ${opts.length} item(s)?`)) return;
const ids = opts.map(o => o.remove());
this.query = '';
this.cancelEdit();
this._maybeRefresh();
},
_sortOptions() {
const list = this.$refs.list;
const sorted = Array.from(list.options).sort((a,b)=>a.text.localeCompare(b.text));
list.innerHTML = ''; sorted.forEach(o => list.appendChild(o));
},
_maybeRefresh() { if (this.refreshUrl) this.$dispatch('combobox:refresh'); },
async _post(url, payload) {
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload)
});
if (!res.ok) {
const msg = await res.text().catch(()=> 'Error');
}
}
}
}
</script>
{% endmacro %} {% endmacro %}

56
inventory/ui/blueprint.py Normal file
View file

@ -0,0 +1,56 @@
from flask import Blueprint, request, render_template, jsonify, abort
from sqlalchemy.exc import IntegrityError
from .defaults import (
default_query, default_create, default_update, default_delete, default_serialize
)
from .. import db
bp = Blueprint("ui", __name__, url_prefix="/ui")
def _normalize(s: str) -> str:
return s.replace("_", "").replace("-", "").lower()
def get_model_class(model_name: str):
"""Resolve a model class by name across SA/Flask-SA versions."""
target = _normalize(model_name)
# SA 2.x / Flask-SQLAlchemy 3.x path
registry = getattr(db.Model, "registry", None)
if registry and getattr(registry, "mappers", None):
for mapper in registry.mappers:
cls = mapper.class_
# match on class name w/ and w/o underscores
if _normalize(cls.__name__) == target or cls.__name__.lower() == model_name.lower():
return cls
# Legacy Flask-SQLAlchemy 2.x path (if someone runs old stack)
decl = getattr(db.Model, "_decl_class_registry", None)
if decl:
for cls in decl.values():
if isinstance(cls, type) and (
_normalize(cls.__name__) == target or cls.__name__.lower() == model_name.lower()
):
return cls
abort(404, f"Unknown resource '{model_name}'")
def call(Model, name, *args, **kwargs):
fn = getattr(Model, name, None)
return fn(*args, **kwargs) if callable(fn) else None
@bp.get("/<model_name>/list")
def list_items(model_name):
Model = get_model_class(model_name)
text = (request.args.get("q") or "").strip() or None
limit = min(int(request.args.get("limit", 100)), 500)
offset = int(request.args.get("offset", 0))
view = (request.args.get("view") or "option").strip()
rows = call(Model, "ui_query", db.session, text=text, limit=limit, offset=offset) \
or default_query(db.session, Model, text=text, limit=limit, offset=offset)
data = [ (call(Model, "ui_serialize", r, view=view) or default_serialize(Model, r, view=view))
for r in rows ]
return jsonify({"items": data})

97
inventory/ui/defaults.py Normal file
View file

@ -0,0 +1,97 @@
from sqlalchemy import select, or_
from sqlalchemy.inspection import inspect
PREFERRED_LABELS = ("identifier", "name", "first_name", "last_name", "description")
def _mapped_column(Model, attr):
"""Return the mapped column attr on the class (InstrumentedAttribute) or None"""
mapper = inspect(Model)
if attr in mapper.columns.keys():
return getattr(Model, attr)
for prop in mapper.column_attrs:
if prop.key == attr:
return getattr(Model, prop.key)
return None
def infer_label_attr(Model):
explicit = getattr(Model, 'ui_label_attr', None)
if explicit:
if _mapped_column(Model, explicit) is not None:
return explicit
raise RuntimeError(f"ui_label_attr '{explicit}' on {Model.__name__} is not a mapped column")
for a in PREFERRED_LABELS:
if _mapped_column(Model, a) is not None:
return a
raise RuntimeError(f"No label-like mapped column on {Model.__name__} (tried {PREFERRED_LABELS})")
def default_query(session, Model, *, text=None, limit=100, offset=0, filters=None, order=None):
label_name = infer_label_attr(Model)
label_col = _mapped_column(Model, label_name) # guaranteed not None now
stmt = select(Model)
# Eager loads if class defines them (expects loader options like selectinload(...))
for opt in getattr(Model, "ui_eagerload", ()) or ():
stmt = stmt.options(opt)
# Text search across mapped columns only
if text:
cols = getattr(Model, "ui_search_cols", None) or (label_name,)
mapped = [ _mapped_column(Model, c) for c in cols ]
mapped = [ c for c in mapped if c is not None ]
if mapped:
stmt = stmt.where(or_(*[ c.ilike(f"%{text}%") for c in mapped ]))
# Filters (exact-match) across mapped columns only
if filters:
for k, v in filters.items():
if v is None:
continue
col = _mapped_column(Model, k)
if col is not None:
stmt = stmt.where(col == v)
# Order by mapped columns (fallback to label)
order_cols = order or getattr(Model, "ui_order_cols", None) or (label_name,)
for c in order_cols:
col = _mapped_column(Model, c)
if col is not None:
stmt = stmt.order_by(col)
stmt = stmt.limit(limit).offset(offset)
return session.execute(stmt).scalars().all()
def default_create(session, Model, payload):
label = infer_label_attr(Model)
obj = Model(**{label: payload.get(label) or payload.get("name")})
session.add(obj)
session.commit()
return obj
def default_update(session, Model, id_, payload):
obj = session.get(Model, id_)
if not obj:
return None
label = infer_label_attr(Model)
if (nv := payload.get(label) or payload.get("name")):
setattr(obj, label, nv)
session.commit()
return obj
def default_delete(session, Model, ids):
count = 0
for i in ids:
obj = session.get(Model, i)
if obj:
session.delete(obj); count += 1
session.commit()
return count
def default_serialize(Model, obj, *, view='option'):
label = infer_label_attr(Model)
data = {'id': obj.id, 'name': getattr(obj, label)}
for attr in getattr(Model, 'ui_extra_attrs', ()):
if hasattr(obj, attr):
data[attr] = getattr(obj, attr)
return data