428 lines
20 KiB
Python
428 lines
20 KiB
Python
from datetime import datetime
|
|
from flask import Blueprint, render_template, abort, request, jsonify, current_app
|
|
from sqlalchemy.inspection import inspect
|
|
from typing import Any, Dict, List, Tuple, Callable, Optional
|
|
|
|
import crudkit
|
|
from crudkit.ui.fragments import render_form, render_table, register_template_globals
|
|
from crudkit.core import normalize_payload
|
|
|
|
bp_entry = Blueprint("entry", __name__)
|
|
|
|
ENTRY_WHITELIST = ["inventory", "user", "worklog", "room"]
|
|
|
|
def _fields_for_model(model: str):
|
|
fields: list[str] = []
|
|
fields_spec = []
|
|
layout = []
|
|
|
|
if model == "inventory":
|
|
fields = [
|
|
"label",
|
|
"name",
|
|
"serial",
|
|
"barcode",
|
|
"model",
|
|
"condition",
|
|
"notes",
|
|
"owner.id",
|
|
]
|
|
fields_spec = [
|
|
{"name": "label", "type": "display", "label": "", "row": "label",
|
|
"attrs": {"class": "display-6 mb-3"}, "wrap": {"class": "col"}},
|
|
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
|
|
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
|
|
{"name": "name", "row": "names", "label": "Name", "wrap": {"class": "col-3"},
|
|
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}},
|
|
{"name": "serial", "row": "names", "label": "Serial #", "wrap": {"class": "col"},
|
|
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}},
|
|
{"name": "barcode", "row": "names", "label": "Barcode #", "wrap": {"class": "col"},
|
|
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}},
|
|
{"name": "brand", "label_spec": "{name}", "row": "device", "wrap": {"class": "col"},
|
|
"attrs": {"class": "form-control"}, "label": "Brand", "label_attrs": {"class": "form-label"}},
|
|
{"name": "model", "row": "device", "wrap": {"class": "col"},
|
|
"attrs": {"class": "form-control"}, "label": "Model #", "label_attrs": {"class": "form-label"}},
|
|
{"name": "device_type", "label_spec": "{description}", "row": "device", "wrap": {"class": "col"},
|
|
"attrs": {"class": "form-control"}, "label": "Device Type", "label_attrs": {"class": "form-label"}},
|
|
{"name": "owner", "row": "status", "label": "Contact", "wrap": {"class": "col"},
|
|
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"},
|
|
"label_spec": "{label}", "link": {"endpoint": "entry.entry", "params": {"model": "user", "id": "{owner.id}"}}},
|
|
{"name": "location", "row": "status", "label": "Location", "wrap": {"class": "col"},
|
|
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"},
|
|
"label_spec": "{name} - {room_function.description}"},
|
|
{"name": "condition", "label": "Condition", "row": "status", "wrap": {"class": "col"},
|
|
"type": "select", "options": [
|
|
{"label": "Deployed", "value": "Deployed"},
|
|
{"label": "Working", "value": "Working"},
|
|
{"label": "Unverified", "value": "Unverified"},
|
|
{"label": "Partially Inoperable", "value": "Partially Inoperable"},
|
|
{"label": "Inoperable", "value": "Inoperable"},
|
|
{"label": "Removed", "value": "Removed"},
|
|
{"label": "Disposed", "value": "Disposed"},
|
|
],
|
|
"label_attrs": {"class": "form-label"}, "attrs": {"class": "form-control"}},
|
|
{"name": "image", "label": "", "row": "image", "type": "template", "label_spec": "{filename}",
|
|
"template": "image_display.html", "attrs": {"class": "img-fluid img-thumbnail h-auto"},
|
|
"wrap": {"class": "h-100 w-100"}},
|
|
{"name": "notes", "type": "template", "label": "Notes", "row": "notes", "wrap": {"class": "col"},
|
|
"template": "inventory_note.html"},
|
|
{"name": "work_logs", "type": "template", "template_ctx": {}, "row": "notes", "wrap": {"class": "col"},
|
|
"template": "inventory_logs.html"},
|
|
]
|
|
layout = [
|
|
{"name": "label", "order": 5, "attrs": {"class": "row align-items-center"}},
|
|
{"name": "kitchen_sink", "order": 6, "attrs": {"class": "row"}},
|
|
{"name": "everything", "order": 10, "attrs": {"class": "col"}, "parent": "kitchen_sink"},
|
|
{"name": "names", "order": 20, "attrs": {"class": "row"}, "parent": "everything"},
|
|
{"name": "device", "order": 30, "attrs": {"class": "row mt-2"}, "parent": "everything"},
|
|
{"name": "status", "order": 40, "attrs": {"class": "row mt-2"}, "parent": "everything"},
|
|
{"name": "image", "order": 50, "attrs": {"class": "col-4"}, "parent": "kitchen_sink"},
|
|
{"name": "notes", "order": 55, "attrs": {"class": "row mt-2"}},
|
|
]
|
|
|
|
elif model.lower() == 'user':
|
|
fields = [
|
|
"label",
|
|
"first_name",
|
|
"last_name",
|
|
"title",
|
|
"active",
|
|
"staff",
|
|
"supervisor.id"
|
|
]
|
|
fields_spec = [
|
|
{"name": "label", "row": "label", "label": "", "type": "display",
|
|
"attrs": {"class": "display-6 mb-3"}, "wrap": {"class": "col"}, "label_spec": "{label} ({title})"},
|
|
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
|
|
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
|
|
{"name": "last_name", "label": "Last Name", "label_attrs": {"class": "form-label"},
|
|
"attrs": {"placeholder": "Doe", "class": "form-control"}, "row": "name", "wrap": {"class": "col-3"}},
|
|
{"name": "first_name", "label": "First Name", "label_attrs": {"class": "form-label"},
|
|
"attrs": {"placeholder": "John", "class": "form-control"}, "row": "name", "wrap": {"class": "col-3"}},
|
|
{"name": "title", "label": "Title", "label_attrs": {"class": "form-label"},
|
|
"attrs": {"placeholder": "President of the Universe", "class": "form-control"},
|
|
"row": "name", "wrap": {"class": "col-3"}},
|
|
{"name": "supervisor", "label": "Supervisor", "label_attrs": {"class": "form-label"},
|
|
"label_spec": "{label}", "row": "details", "wrap": {"class": "col-3"},
|
|
"attrs": {"class": "form-control"}, "link": {"endpoint": "entry.entry", "params": {"id": "{supervisor.id}", "model": "user"}}},
|
|
{"name": "location", "label": "Room", "label_attrs": {"class": "form-label"},
|
|
"label_spec": "{name} - {room_function.description}",
|
|
"row": "details", "wrap": {"class": "col-3"}, "attrs": {"class": "form-control"}},
|
|
{"name": "active", "label": "Active", "label_attrs": {"class": "form-check-label"},
|
|
"row": "checkboxes", "attrs": {"class": "form-check-input"}, "wrap": {"class": "form-check"}},
|
|
{"name": "staff", "label": "Staff Member", "label_attrs": {"class": "form-check-label"},
|
|
"row": "checkboxes", "attrs": {"class": "form-check-input"}, "wrap": {"class": "form-check"}},
|
|
]
|
|
layout = [
|
|
{"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}},
|
|
{"name": "name", "order": 10, "attrs": {"class": "row"}},
|
|
{"name": "details", "order": 20, "attrs": {"class": "row mt-2"}},
|
|
{"name": "checkboxes", "order": 30, "parent": "details",
|
|
"attrs": {"class": "col d-flex flex-column justify-content-end"}},
|
|
]
|
|
|
|
elif model == "worklog":
|
|
# tell the service to eager-load precisely what the template needs
|
|
fields = [
|
|
"id",
|
|
"contact.label",
|
|
"work_item.label",
|
|
"start_time",
|
|
"end_time",
|
|
"complete",
|
|
"updates.id",
|
|
"updates.content",
|
|
"updates.timestamp",
|
|
"updates.is_deleted",
|
|
]
|
|
fields_spec = [
|
|
{"name": "id", "label": "", "type": "display", "label_spec": "Work Item #{id}",
|
|
"attrs": {"class": "display-6 mb-3"}, "row": "label", "wrap": {"class": "col"}},
|
|
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
|
|
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
|
|
{"name": "contact", "row": "ownership", "wrap": {"class": "col"}, "label": "Contact",
|
|
"label_spec": "{label}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"},
|
|
"link": {"endpoint": "entry.entry", "params": {"id": "{contact.id}", "model": "user"}}},
|
|
{"name": "work_item", "row": "ownership", "wrap": {"class": "col"}, "label": "Work Item",
|
|
"label_spec": "{label}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"},
|
|
"link": {"endpoint": "entry.entry", "params": {"id": "{work_item.id}", "model": "inventory"}}},
|
|
{"name": "start_time", "type": "datetime", "attrs": {"class": "form-control"}, "row": "timestamps",
|
|
"wrap": {"class": "col"}, "label_attrs": {"class": "form-label"}, "label": "Start"},
|
|
{"name": "end_time", "type": "datetime", "attrs": {"class": "form-control"}, "row": "timestamps",
|
|
"wrap": {"class": "col"}, "label_attrs": {"class": "form-label"}, "label": "End"},
|
|
{"name": "complete", "label": "Complete", "label_attrs": {"class": "form-check-label"},
|
|
"attrs": {"class": "form-check-input"}, "row": "timestamps", "wrap": {"class": "col form-check"}},
|
|
{"name": "updates", "label": "Updates", "row": "updates", "label_attrs": {"class": "form-label"},
|
|
"type": "template", "template": "update_list.html"},
|
|
]
|
|
layout = [
|
|
{"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}},
|
|
{"name": "ownership", "order": 10, "attrs": {"class": "row mb-2"}},
|
|
{"name": "timestamps", "order": 20, "attrs": {"class": "row d-flex align-items-center"}},
|
|
{"name": "updates", "order": 30, "attrs": {"class": "row"}},
|
|
]
|
|
elif model == "room":
|
|
fields = [
|
|
"label",
|
|
"name"
|
|
]
|
|
fields_spec = [
|
|
{"name": "label", "label": "", "type": "display", "attrs": {"class": "display-6 mb-3"},
|
|
"row": "label", "wrap": {"class": "col"}},
|
|
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
|
|
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
|
|
{"name": "name", "label": "Name", "row": "name", "attrs": {"class": "form-control"},
|
|
"label_attrs": {"class": "form-label"}, "wrap": {"class": "col mb-3"}},
|
|
{"name": "area", "label": "Area", "row": "details", "attrs": {"class": "form-control"},
|
|
"label_attrs": {"class": "form-label"}, "wrap": {"class": "col"}, "label_spec": "{name}"},
|
|
{"name": "room_function", "label": "Description", "label_spec": "{description}",
|
|
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}, "row": "details",
|
|
"wrap": {"class": "col"}},
|
|
]
|
|
layout = [
|
|
{"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}},
|
|
{"name": "name", "order": 10, "attrs": {"class": "row"}},
|
|
{"name": "details", "order": 20, "attrs": {"class": "row"}},
|
|
]
|
|
|
|
return (fields, fields_spec, layout)
|
|
|
|
def _apply_worklog_updates(worklog, updates, delete_ids):
|
|
note_cls = type(worklog).updates.property.mapper.class_
|
|
note_svc = crudkit.crud.get_service(note_cls)
|
|
sess = note_svc.session
|
|
|
|
existing = {u.id: u for u in worklog.updates if not getattr(u, "is_deleted", False)}
|
|
|
|
for item in updates:
|
|
uid = item.get("id")
|
|
content = (item.get("content") or "").strip()
|
|
if not content and not uid:
|
|
continue
|
|
if uid:
|
|
# per-note version log preserved, but commit deferred.
|
|
note_svc.update(uid, {"content": content}, actor="bulk_child_update", commit=False)
|
|
else:
|
|
note_svc.create({"work_log_id": worklog.id, "content": content}, actor="bulk_child_create", commit=False)
|
|
|
|
for uid in delete_ids:
|
|
if uid in existing:
|
|
note_svc.delete(uid, actor="bulk_child_delete", commit=False)
|
|
|
|
def init_entry_routes(app):
|
|
# Make helpers available in all templates
|
|
register_template_globals(app)
|
|
|
|
@bp_entry.get("/entry/<model>/<int:id>")
|
|
def entry(model: str, id: int):
|
|
cls = crudkit.crud.get_model(model)
|
|
if cls is None or model not in ENTRY_WHITELIST:
|
|
abort(404)
|
|
|
|
fields, fields_spec, layout = _fields_for_model(model)
|
|
|
|
svc = crudkit.crud.get_service(cls)
|
|
params = {"fields": fields} if fields else {}
|
|
obj = svc.get(id, params)
|
|
if obj is None:
|
|
abort(404)
|
|
|
|
ScopedSession = current_app.extensions["crudkit"]["Session"]
|
|
|
|
if model == "inventory":
|
|
log_cls = crudkit.crud.get_model("worklog")
|
|
log_svc = crudkit.crud.get_service(log_cls)
|
|
logs = log_svc.list({
|
|
"work_item_id__eq": id,
|
|
"fields": "id,contact.label,complete,created_at,updated_at",
|
|
})
|
|
fields_spec[13]["template_ctx"]["table"] = render_table(logs, [
|
|
{"field": "id", "label": "ID"},
|
|
{"field": "contact.label", "label": "Contact",
|
|
"link": {"endpoint": "entry.entry", "params": {"id": "{contact.id}", "model": "user"}}},
|
|
{"field": "created_at", "label": "Created", "format": "datetime"},
|
|
{"field": "updated_at", "label": "Updated", "format": "datetime"},
|
|
{"field": "complete", "format": "yesno"},
|
|
], opts={"object_class": "worklog"})
|
|
|
|
form = render_form(
|
|
cls,
|
|
obj.as_dict(),
|
|
session=ScopedSession,
|
|
instance=obj,
|
|
fields_spec=fields_spec,
|
|
layout=layout,
|
|
submit_attrs={"class": "d-none", "disabled": True}
|
|
)
|
|
|
|
return render_template("entry.html", form=form)
|
|
|
|
@bp_entry.get("/entry/<model>/new")
|
|
def entry_new(model: str):
|
|
cls = crudkit.crud.get_model(model)
|
|
if cls is None or model not in ENTRY_WHITELIST:
|
|
abort(404)
|
|
|
|
fields, fields_spec, layout = _fields_for_model(model)
|
|
|
|
if model == "worklog":
|
|
for f in fields_spec:
|
|
if f.get("name") == "id" and f.get("type") == "display":
|
|
f["label_spec"] = "New Work Item"
|
|
break
|
|
elif model == "inventory":
|
|
for f in fields_spec:
|
|
if f.get("name") == "label" and f.get("type") == "display":
|
|
f["label"] = ""
|
|
f["label_spec"] = "New Inventory Item"
|
|
break
|
|
elif model == "user":
|
|
for f in fields_spec:
|
|
if f.get("name") == "label" and f.get("type") == "display":
|
|
f["label"] = ""
|
|
f["label_spec"] = "New User"
|
|
break
|
|
|
|
obj = cls()
|
|
ScopedSession = current_app.extensions["crudkit"]["Session"]
|
|
|
|
form = render_form(
|
|
cls,
|
|
obj.as_dict() if hasattr(obj, "as_dict") else {},
|
|
session=ScopedSession,
|
|
instance=obj,
|
|
fields_spec=fields_spec,
|
|
layout=layout,
|
|
submit_attrs={"class": "d-none", "disabled": True},
|
|
)
|
|
return render_template("entry.html", form=form)
|
|
|
|
@bp_entry.post("/entry/<model>")
|
|
def create_entry(model: str):
|
|
try:
|
|
if model not in ENTRY_WHITELIST:
|
|
raise TypeError("Invalid model.")
|
|
cls = crudkit.crud.get_model(model)
|
|
svc = crudkit.crud.get_service(cls)
|
|
sess = svc.session
|
|
|
|
payload = normalize_payload(request.get_json(force=True) or {}, cls)
|
|
|
|
# Child mutations and friendly-to-FK mapping
|
|
updates = payload.pop("updates", []) or []
|
|
payload.pop("delete_update_ids", None) # irrelevant on create
|
|
|
|
if model == "inventory":
|
|
if "device_type_id" in payload and "type_id" not in payload:
|
|
payload["type_id"] = payload.pop("device_type_id")
|
|
for k in ("brand_id", "type_id", "owner_id", "location_id", "image_id"):
|
|
if payload.get(k) == "":
|
|
payload[k] = None
|
|
|
|
if model == "worklog":
|
|
if "contact" in payload and "contact_id" not in payload:
|
|
payload["contact_id"] = payload.pop("contact")
|
|
if "work_item" in payload and "work_item_id" not in payload:
|
|
payload["work_item_id"] = payload.pop("work_item")
|
|
|
|
# Parent first, no commit yet
|
|
obj = svc.create(payload, actor="create_entry", commit=False)
|
|
|
|
# Ensure PK is available for children and relationship auto-FK works
|
|
sess.flush()
|
|
|
|
# Children
|
|
if model == "worklog" and updates:
|
|
note_mapper = type(obj).updates.property.mapper
|
|
note_cls = note_mapper.class_
|
|
for item in updates:
|
|
content = (item.get("content") or "").strip()
|
|
if content:
|
|
obj.updates.append(note_cls(content=content))
|
|
|
|
sess.commit()
|
|
return {"status": "success", "id": obj.id}
|
|
except Exception as e:
|
|
try:
|
|
crudkit.crud.get_service(crudkit.crud.get_model(model)).session.rollback()
|
|
except Exception:
|
|
pass
|
|
return {"status": "failure", "error": str(e)}, 400
|
|
|
|
@bp_entry.post("/entry/<model>/<int:id>")
|
|
def update_entry(model, id):
|
|
try:
|
|
if model not in ENTRY_WHITELIST:
|
|
raise TypeError("Invalid model.")
|
|
cls = crudkit.crud.get_model(model)
|
|
payload = normalize_payload(request.get_json(), cls)
|
|
|
|
updates = payload.pop("updates", None) or []
|
|
delete_ids = payload.pop("delete_update_ids", None) or []
|
|
|
|
params = {}
|
|
if model == "inventory":
|
|
if "device_type_id" in payload:
|
|
payload["type_id"] = payload.pop("device_type_id")
|
|
for k in ("brand_id", "type_id", "owner_id", "location_id", "image_id"):
|
|
if payload.get(k) == "":
|
|
payload[k] = None
|
|
params = {
|
|
"fields": [
|
|
"barcode",
|
|
"name",
|
|
"serial",
|
|
"condition",
|
|
"model",
|
|
"notes",
|
|
"shared",
|
|
"timestamp",
|
|
"brand_id",
|
|
"type_id",
|
|
"image_id",
|
|
"location_id",
|
|
"owner_id",
|
|
]
|
|
}
|
|
elif model == "user":
|
|
params = {
|
|
"fields": [
|
|
"last_name",
|
|
"first_name",
|
|
"title",
|
|
"supervisor_id",
|
|
"location_id",
|
|
"active",
|
|
"staff",
|
|
]
|
|
}
|
|
elif model == "worklog":
|
|
params = {
|
|
"fields": [
|
|
"contact_id",
|
|
"work_item_id",
|
|
"start_time",
|
|
"end_time",
|
|
"complete",
|
|
"updates",
|
|
]
|
|
}
|
|
else:
|
|
raise TypeError("Invalid model.")
|
|
|
|
service = crudkit.crud.get_service(cls)
|
|
sess = service.session
|
|
|
|
obj = service.update(id, data=payload, actor="update_entry", commit=False)
|
|
|
|
if model == "worklog" and (updates or delete_ids):
|
|
_apply_worklog_updates(obj, updates, delete_ids)
|
|
|
|
sess.commit()
|
|
|
|
return {"status": "success", "payload": payload}
|
|
except Exception as e:
|
|
print(e)
|
|
return {"status": "failure", "error": str(e)}
|
|
|
|
app.register_blueprint(bp_entry)
|