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 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
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
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