From b2231f8ef9adcc6d2067541a722afe67ce1cd299 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Wed, 13 Aug 2025 10:53:22 -0500 Subject: [PATCH] Adding some default CRUD behaviors. --- inventory/__init__.py | 2 + inventory/models/areas.py | 4 +- inventory/static/js/combobox.js | 85 ++++++++++++++++ .../fragments/_combobox_fragment.html | 89 +---------------- inventory/ui/blueprint.py | 56 +++++++++++ inventory/ui/defaults.py | 97 +++++++++++++++++++ 6 files changed, 243 insertions(+), 90 deletions(-) create mode 100644 inventory/ui/blueprint.py create mode 100644 inventory/ui/defaults.py diff --git a/inventory/__init__.py b/inventory/__init__.py index 7634e87..cb125ab 100644 --- a/inventory/__init__.py +++ b/inventory/__init__.py @@ -35,8 +35,10 @@ def create_app(): from .routes import main from .routes.images import image_bp + from .ui.blueprint import bp as ui_bp app.register_blueprint(main) app.register_blueprint(image_bp) + app.register_blueprint(ui_bp) from .routes.helpers import generate_breadcrumbs @app.context_processor diff --git a/inventory/models/areas.py b/inventory/models/areas.py index 199dda4..dbf86b0 100644 --- a/inventory/models/areas.py +++ b/inventory/models/areas.py @@ -23,13 +23,13 @@ class Area(ValidatableMixin, db.Model): def __repr__(self): return f"" - + def serialize(self): return { 'id': self.id, 'name': self.name } - + @classmethod def sync_from_state(cls, submitted_items: list[dict]) -> dict[str, int]: """ diff --git a/inventory/static/js/combobox.js b/inventory/static/js/combobox.js index 814e796..f825b8b 100644 --- a/inventory/static/js/combobox.js +++ b/inventory/static/js/combobox.js @@ -127,3 +127,88 @@ const ComboBoxWidget = (() => { 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'); + } + } + } +} \ No newline at end of file diff --git a/inventory/templates/fragments/_combobox_fragment.html b/inventory/templates/fragments/_combobox_fragment.html index e13c21e..cc812ad 100644 --- a/inventory/templates/fragments/_combobox_fragment.html +++ b/inventory/templates/fragments/_combobox_fragment.html @@ -55,7 +55,7 @@ id: '{{ id }}', createUrl: {{ create_url|tojson if create_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' }}, })" hx-preserve @@ -101,91 +101,4 @@ hx-swap="innerHTML"> {% endif %} - - {% endmacro %} diff --git a/inventory/ui/blueprint.py b/inventory/ui/blueprint.py new file mode 100644 index 0000000..160f9a5 --- /dev/null +++ b/inventory/ui/blueprint.py @@ -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("//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}) diff --git a/inventory/ui/defaults.py b/inventory/ui/defaults.py new file mode 100644 index 0000000..16d8d16 --- /dev/null +++ b/inventory/ui/defaults.py @@ -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