Adding some default CRUD behaviors.
This commit is contained in:
parent
c8190be21c
commit
b2231f8ef9
6 changed files with 243 additions and 90 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -23,13 +23,13 @@ class Area(ValidatableMixin, db.Model):
|
|||
|
||||
def __repr__(self):
|
||||
return f"<Area(id={self.id}, name={repr(self.name)})>"
|
||||
|
||||
|
||||
def serialize(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name
|
||||
}
|
||||
|
||||
|
||||
@classmethod
|
||||
def sync_from_state(cls, submitted_items: list[dict]) -> dict[str, int]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"></div>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
|
|
|
|||
56
inventory/ui/blueprint.py
Normal file
56
inventory/ui/blueprint.py
Normal 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
97
inventory/ui/defaults.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue