Complete and total rework ahead.

This commit is contained in:
Conrad Nelson 2025-09-03 16:33:52 -05:00
parent 559fd56f33
commit e420110fb3
95 changed files with 394 additions and 6351 deletions

View file

@ -1,8 +0,0 @@
from .mixins import CrudMixin
from .dsl import QuerySpec
from .eager import default_eager_policy
from .service import CrudService
from .serialize import serialize
from .blueprint import make_blueprint
__all__ = ["CrudMixin", "QuerySpec", "default_eager_policy", "CrudService", "serialize", "make_blueprint"]

31
crudkit/api/flask_api.py Normal file
View file

@ -0,0 +1,31 @@
from flask import Blueprint, jsonify, request
def generate_crud_blueprint(model, service):
bp = Blueprint(model.__name__.lower(), __name__)
@bp.get('/')
def list_items():
items = service.list(request.args)
return jsonify([item.as_dict() for item in items])
@bp.get('/<int:id>')
def get_item(id):
item = service.get(id)
return jsonify(item.as_dict())
@bp.post('/')
def create_item():
obj = service.create(request.json)
return jsonify(obj.as_dict())
@bp.patch('/<int:id>')
def update_item(id):
obj = service.update(id, request.json)
return jsonify(obj.as_dict())
@bp.delete('/<int:id>')
def delete_item(id):
service.delete(id)
return '', 204
return bp

View file

@ -1,81 +0,0 @@
from flask import Blueprint, request, jsonify, abort
from sqlalchemy.orm import scoped_session
from .dsl import QuerySpec
from .service import CrudService
from .eager import default_eager_policy
from .serialize import serialize
def make_blueprint(db_session_factory, registry):
bp = Blueprint("crud", __name__)
def session(): return scoped_session(db_session_factory)()
@bp.get("/<model>/list")
def list_items(model):
Model = registry.get(model) or abort(404)
spec = QuerySpec(
filters=_parse_filters(request.args),
order_by=request.args.getlist("sort"),
page=request.args.get("page", type=int),
per_page=request.args.get("per_page", type=int),
expand=request.args.getlist("expand"),
fields=request.args.get("fields", type=lambda s: [x.strip() for x in s.split(",")] if s else None),
)
s = session(); svc = CrudService(s, default_eager_policy)
rows, total = svc.list(Model, spec)
data = [serialize(r, fields=spec.fields, expand=spec.expand) for r in rows]
return jsonify({"data": data, "total": total})
@bp.post("/<model>")
def create_item(model):
Model = registry.get(model) or abort(404)
payload = request.get_json() or {}
s = session(); svc = CrudService(s, default_eager_policy)
obj = svc.create(Model, payload)
s.commit()
return jsonify(serialize(obj)), 201
@bp.get("/<model>/<int:id>")
def read_item(model, id):
Model = registry.get(model) or abort(404)
spec = QuerySpec(expand=request.args.getlist("expand"),
fields=request.args.get("fields", type=lambda s: s.split(",")))
s = session(); svc = CrudService(s, default_eager_policy)
obj = svc.get(Model, id, spec) or abort(404)
return jsonify(serialize(obj, fields=spec.fields, expand=spec.expand))
@bp.patch("/<model>/<int:id>")
def update_item(model, id):
Model = registry.get(model) or abort(404)
s = session(); svc = CrudService(s, default_eager_policy)
obj = svc.get(Model, id, QuerySpec()) or abort(404)
payload = request.get_json() or {}
svc.update(obj, payload)
s.commit()
return jsonify(serialize(obj))
@bp.delete("/<model>/<int:id>")
def delete_item(model, id):
Model = registry.get(model) or abort(404)
s = session(); svc = CrudService(s, default_eager_policy)
obj = svc.get(Model, id, QuerySpec()) or abort(404)
svc.soft_delete(obj)
s.commit()
return jsonify({"status": "deleted"})
@bp.post("/<model>/<int:id>/undelete")
def undelete_item(model, id):
Model = registry.get(model) or abort(404)
s = session(); svc = CrudService(s, default_eager_policy)
obj = svc.get(Model, id, QuerySpec()) or abort(404)
svc.undelete(obj)
s.commit()
return jsonify({"status": "restored"})
return bp
def _parse_filters(args):
out = {}
for k, v in args.items():
if k in {"page", "per_page", "sort", "expand", "fields"}:
continue
out[k] = v
return out

26
crudkit/core/base.py Normal file
View file

@ -0,0 +1,26 @@
from sqlalchemy import Column, Integer, DateTime, Boolean, String, JSON, func
from sqlalchemy.orm import declarative_mixin, declarative_base
Base = declarative_base()
@declarative_mixin
class CRUDMixin:
id = Column(Integer, primary_key=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
def as_dict(self):
return {c.name: getattr(self, c.name) for c in self.__table__.columns}
class Version(Base):
__tablename__ = "versions"
id = Column(Integer, primary_key=True)
model_name = Column(String, nullable=False)
object_id = Column(Integer, nullable=False)
change_type = Column(String, nullable=False)
data = Column(JSON, nullable=True)
timestamp = Column(DateTime, default=func.now())
actor = Column(String, nullable=True)
metadata = Column(JSON, nullable=True)

0
crudkit/core/metadata.py Normal file
View file

104
crudkit/core/service.py Normal file
View file

@ -0,0 +1,104 @@
from typing import Type, TypeVar, Generic
from sqlalchemy.orm import Session
from crudkit.core.base import Version
from crudkit.core.spec import CRUDSpec
T = TypeVar("T")
def _is_truthy(val):
return str(val).lower() in ('1', 'true', 'yes', 'on')
class CRUDService(Generic[T]):
def __init__(self, model: Type[T], session: Session):
self.model = model
self.session = session
self.supports_soft_delete = hasattr(model, 'is_deleted')
def get(self, id: int, include_deleted: bool = False) -> T | None:
obj = self.session.get(self.model, id)
if obj is None:
return None
if self.supports_soft_delete and not include_deleted and obj.is_deleted:
return None
return obj
def list(self, params=None) -> list[T]:
query = self.session.query(self.model)
if params:
if self.supports_soft_delete:
include_deleted = False
include_deleted = _is_truthy(params.get('include_deleted'))
if not include_deleted:
query = query.filter(self.model.is_deleted == False)
spec = CRUDSpec(self.model, params)
filters = spec.parse_filters()
order_by = spec.parse_sort()
limit, offset = spec.parse_pagination()
for parent, relationship_attr, alias in spec.get_join_paths():
query = query.join(alias, relationship_attr.of_type(alias), isouter=True)
for eager in spec.get_eager_loads():
query = query.options(eager)
if filters:
query = query.filter(*filters)
if order_by:
query = query.order_by(*order_by)
query = query.offset(offset).limit(limit)
return query.all()
def create(self, data: dict, actor=None) -> T:
obj = self.model(**data)
self.session.add(obj)
self.session.commit()
self._log_version("create", obj, actor)
return obj
def update(self, id: int, data: dict, actor=None) -> T:
obj = self.get(id)
if not obj:
raise ValueError(f"{self.model.__name__} with ID {id} not found.")
valid_fields = {c.name for c in self.model.__table__.columns}
for k, v in data.items():
if k in valid_fields:
setattr(obj, k, v)
self.session.commit()
self._log_version("update", obj, actor)
return obj
def delete(self, id: int, hard: bool = False, actor = False):
obj = self.session.get(self.model, id)
if not obj:
return None
if hard or not self.supports_soft_delete:
self.session.delete(obj)
else:
obj.is_deleted = True
self.session.commit()
self._log_version("delete", obj, actor)
return obj
def _log_version(self, change_type: str, obj: T, actor=None, metadata: dict = {}):
try:
data = obj.as_dict()
except Exception:
data = {"error": "Failed to serialize object."}
version = Version(
model_name=self.model.__name__,
object_id=obj.id,
change_type=change_type,
data=data,
actor=str(actor) if actor else None,
metadata=metadata
)
self.session.add(version)
self.session.commit()

110
crudkit/core/spec.py Normal file
View file

@ -0,0 +1,110 @@
from typing import List, Tuple, Set, Dict
from sqlalchemy import asc, desc
from sqlalchemy.orm import joinedload, aliased
from sqlalchemy.orm.attributes import InstrumentedAttribute
OPERATORS = {
'eq': lambda col, val: col == val,
'lt': lambda col, val: col < val,
'lte': lambda col, val: col <= val,
'gt': lambda col, val: col > val,
'gte': lambda col, val: col >= val,
'ne': lambda col, val: col != val,
'icontains': lambda col, val: col.ilike(f"%{val}%"),
}
class CRUDSpec:
def __init__(self, model, params):
self.model = model
self.params = params
self.eager_paths: Set[Tuple[str, ...]] = set()
self.join_paths: List[Tuple[object, InstrumentedAttribute, object]] = []
self.alias_map: Dict[Tuple[str, ...], object] = {}
def _resolve_column(self, path: str):
current_model = self.model
current_alias = self.model
parts = path.split('.')
join_path = []
for i, attr in enumerate(parts):
if not hasattr(current_model, attr):
return None, None
attr_obj = getattr(current_model, attr)
if isinstance(attr_obj, InstrumentedAttribute):
if hasattr(attr_obj.property, 'direction'):
join_path.append(attr)
path_key = tuple(join_path)
alias = self.alias_map.get(path_key)
if not alias:
alias = aliased(attr_obj.property.mapper.class_)
self.alias_map[path_key] = alias
self.join_paths.append((current_alias, attr_obj, alias))
current_model = attr_obj.property.mapper.class_
current_alias = alias
else:
return getattr(current_alias, attr), tuple(join_path) if join_path else None
return None, None
def parse_filters(self):
filters = []
for key, value in self.params.items():
if key in ('sort', 'limit', 'offset'):
continue
if '__' in key:
path_op = key.rsplit('__', 1)
if len(path_op) != 2:
continue
path, op = path_op
else:
path, op = key, 'eq'
col, join_path = self._resolve_column(path)
if col and op in OPERATORS:
filters.append(OPERATORS[op](col, value))
if join_path:
self.eager_paths.add(join_path)
return filters
def parse_sort(self):
sort_args = self.params.get('sort', '')
result = []
for part in sort_args.split(','):
part = part.strip()
if not part:
continue
if part.startswith('-'):
field = part[1:]
order = desc
else:
field = part
order = asc
col, join_path = self._resolve_column(field)
if col:
result.append(order(col))
if join_path:
self.eager_paths.add(join_path)
return result
def parse_pagination(self):
limit = int(self.params.get('limit', 100))
offset = int(self.params.get('offset', 0))
return limit, offset
def get_eager_loads(self):
loads = []
for path in self.eager_paths:
current = self.model
loader = None
for attr in path:
attr_obj = getattr(current, attr)
if loader is None:
loader = joinedload(attr_obj)
else:
loader = loader.joinedload(attr_obj)
current = attr_obj.property.mapper.class_
if loader:
loads.append(loader)
return loads
def get_join_paths(self):
return self.join_paths

View file

@ -1,117 +0,0 @@
from dataclasses import dataclass, field
from typing import List, Dict, Any, Optional
from sqlalchemy import asc, desc, select, false
from sqlalchemy.inspection import inspect
@dataclass
class QuerySpec:
filters: Dict[str, Any] = field(default_factory=dict)
order_by: List[str] = field(default_factory=list)
page: Optional[int] = None
per_page: Optional[int] = None
expand: List[str] = field(default_factory=list)
fields: Optional[List[str]] = None
FILTER_OPS = {
"__eq": lambda c, v: c == v,
"__ne": lambda c, v: c != v,
"__lt": lambda c, v: c < v,
"__lte": lambda c, v: c <= v,
"__gt": lambda c, v: c > v,
"__gte": lambda c, v: c >= v,
"__ilike": lambda c, v: c.ilike(v),
"__in": lambda c, v: c.in_(v),
"__isnull": lambda c, v: (c.is_(None) if v else c.is_not(None))
}
def _split_filter_key(raw_key: str):
for op in sorted(FILTER_OPS.keys(), key=len, reverse=True):
if raw_key.endswith(op):
return raw_key[: -len(op)], op
return raw_key, None
def _ensure_wildcards(op_key, value):
if op_key == "__ilike" and isinstance(value, str) and "%" not in value and "_" not in value:
return f"%{value}%"
return value
def _related_predicate(Model, path_parts, op_key, value):
"""
Build EXISTS subqueries for dotted filters:
- scalar rels -> attr.has(inner_predicate)
- collection -> attr.any(inner_predicate)
"""
head, *rest = path_parts
# class-bound relationship attribute (InstrumentedAttribute)
attr = getattr(Model, head, None)
if attr is None:
return None
# relationship metadata if you need uselist + target model
rel = inspect(Model).relationships.get(head)
if rel is None:
return None
Target = rel.mapper.class_
if not rest:
# filtering directly on a relationship without a leaf column isn't supported
return None
if len(rest) == 1:
# final hop is a column on the related model
leaf = rest[0]
col = getattr(Target, leaf, None)
if col is None:
return None
pred = FILTER_OPS[op_key](col, value) if op_key else (col == value)
else:
# recurse deeper: owner.room.area.name__ilike=...
pred = _related_predicate(Target, rest, op_key, value)
if pred is None:
return None
# wrap at this hop using the *attribute*, not the RelationshipProperty
return attr.any(pred) if rel.uselist else attr.has(pred)
def build_query(Model, spec: QuerySpec, eager_policy=None):
stmt = select(Model)
# filter out soft-deleted rows
deleted_attr = getattr(Model, "deleted", None)
if deleted_attr is not None:
stmt = stmt.where(deleted_attr == false())
else:
is_deleted_attr = getattr(Model, "is_deleted", None)
if is_deleted_attr is not None:
stmt = stmt.where(is_deleted_attr == false())
# filters
for raw_key, val in spec.filters.items():
path, op_key = _split_filter_key(raw_key)
val = _ensure_wildcards(op_key, val)
if "." in path:
pred = _related_predicate(Model, path.split("."), op_key, val)
if pred is not None:
stmt = stmt.where(pred)
continue
col = getattr(Model, path, None)
if col is None:
continue
stmt = stmt.where(FILTER_OPS[op_key](col, val) if op_key else (col == val))
# order_by
for key in spec.order_by:
desc_ = key.startswith("-")
col = getattr(Model, key[1:] if desc_ else key)
stmt = stmt.order_by(desc(col) if desc_ else asc(col))
# eager loading
if eager_policy:
opts = eager_policy(Model, spec.expand)
if opts:
stmt = stmt.options(*opts)
return stmt

View file

@ -1,42 +0,0 @@
from typing import List
from sqlalchemy.inspection import inspect
from sqlalchemy.orm import Load, joinedload, selectinload
def default_eager_policy(Model, expand: List[str]) -> List[Load]:
"""
Heuristic:
- many-to-one / one-to-one: joinedload
- one-to-many / many-to-many: selectinload
Accepts dotted paths like "author.publisher".
"""
if not expand:
return []
opts: List[Load] = []
for path in expand:
parts = path.split(".")
current_model = Model
current_inspect = inspect(current_model)
# first hop
rel = current_inspect.relationships.get(parts[0])
if not rel:
continue # silently skip bad names
attr = getattr(current_model, parts[0])
loader: Load = selectinload(attr) if rel.uselist else joinedload(attr)
current_model = rel.mapper.class_
# nested hops, if any
for name in parts[1:]:
current_inspect = inspect(current_model)
rel = current_inspect.relationships.get(name)
if not rel:
break
attr = getattr(current_model, name)
loader = loader.selectinload(attr) if rel.uselist else loader.joinedload(attr)
current_model = rel.mapper.class_
opts.append(loader)
return opts

View file

@ -1,3 +0,0 @@
from .ui_fragments import make_fragments_blueprint
__all__ = ["make_fragments_blueprint"]

View file

@ -1,93 +0,0 @@
{% macro options(items, value_attr="id", label_path="name", getp=None) -%}
{%- for obj in items -%}
<option value="{{ getp(obj, value_attr) }}">{{ getp(obj, label_path) }}</option>
{%- endfor -%}
{% endmacro %}
{% macro lis(items, label_path="name", sublabel_path=None, getp=None) -%}
{%- for obj in items -%}
<li data-id="{{ obj.id }}">
<div class="li-main">{{ getp(obj, label_path) }}</div>
{%- if sublabel_path %}
<div class="li-sub">{{ getp(obj, sublabel_path) }}</div>
{%- endif %}
</li>
{%- else -%}
<li class="empty"><em>No results.</em></li>
{%- endfor -%}
{% endmacro %}
{% macro rows(items, fields, getp=None) -%}
{%- for obj in items -%}
<tr id="row-{{ obj.id }}">
{%- for f in fields -%}
<td data-field="{{ f }}">{{ getp(obj, f) }}</td>
{%- endfor -%}
</tr>
{%- else -%}
<tr>
<td colspan="{{ fields|length }}"><em>No results.</em></td>
</tr>
{%- endfor -%}
{%- endmacro %}
{% macro pager(model, page, pages, per_page, sort, filters) -%}
<nav class="pager">
{%- if page > 1 -%}
<a hx-get="/{{ model }}/frag/rows?page={{ page-1 }}&per_page={{ per_page }}{% if sort %}&sort={{ sort }}{% endif %}{% for k,v in filters.items() %}&{{k}}={{v}}{% endfor %}"
hx-target="#rows" hx-push-url="true">Prev</a>
{%- endif -%}
<span>Page {{ page }} / {{ pages }}</span>
{%- if page < pages -%} <a
hx-get="/{{ model }}/frag/rows?page={{ page+1 }}&per_page={{ per_page }}{% if sort %}&sort={{ sort }}{% endif %}{% for k,v in filters.items() %}&{{k}}={{v}}{% endfor %}"
hx-target="#rows" hx-push-url="true">Next</a>
{%- endif -%}
</nav>
{%- endmacro %}
{% macro form(schema, action, method="POST", obj_id=None, hx=False, csrf_token=None) -%}
<form action="{{ action }}" method="post" {%- if hx %} hx-{{ "patch" if obj_id else "post" }}="{{ action }}"
hx-target="closest dialog, #modal-body, body" hx-swap="innerHTML" hx-disabled-elt="button[type=submit]" {%- endif
-%}>
{%- if csrf_token %}<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">{% endif -%}
{%- if obj_id %}<input type="hidden" name="id" value="{{ obj_id }}">{% endif -%}
<input type="hidden" name="fields_csv" value="{{ request.args.get('fields_csv','id,name') }}">
{%- for f in schema -%}
<div class="field" data-name="{{ f.name }}">
{% set fid = 'f-' ~ f.name ~ '-' ~ (obj_id or 'new') %}
<label for="{{ fid }}">{{ f.label or f.name|replace('_',' ')|title }}</label>
{%- if f.type == "textarea" -%}
<textarea id="{{ fid }}" name="{{ f.name }}" {%- if f.required %} required{% endif %}{% if f.maxlength %}
maxlength="{{ f.maxlength }}" {% endif %}>{{ f.value or "" }}</textarea>
{%- elif f.type == "select" -%}
<select id="{{ fid }}" name="{{ f.name }}" {% if f.required %}required{% endif %}>
<option value="">{{ f.placeholder or ("Choose " ~ (f.label or f.name|replace('_',' ')|title)) }}</option>
{% if f.multiple %}
{% set selected = (f.value or [])|list %}
{% for val, lbl in f.choices %}
<option value="{{ val }}" {{ 'selected' if val in selected else '' }}>{{ lbl }}</option>
{% endfor %}
{% else %}
{% for val, lbl in f.choices %}
<option value="{{ val }}" {{ 'selected' if (f.value|string)==(val|string) else '' }}>{{ lbl }}</option>
{% endfor %}
{% endif %}
</select>
{%- elif f.type == "checkbox" -%}
<input type="hidden" name="{{ f.name }}" value="0">
<input id="{{ fid }}" type="checkbox" name="{{ f.name }}" value="1" {{ "checked" if f.value else "" }}>
{%- else -%}
<input id="{{ fid }}" type="{{ f.type }}" name="{{ f.name }}"
value="{{ f.value if f.value is not none else '' }}" {%- if f.required %} required{% endif %} {%- if
f.maxlength %} maxlength="{{ f.maxlength }}" {% endif %}>
{%- endif -%}
{%- if f.help %}<div class="help">{{ f.help }}</div>{% endif -%}
</div>
{%- endfor -%}
<div class="actions">
<button type="submit">Save</button>
</div>
</form>
{%- endmacro %}

View file

@ -1,3 +0,0 @@
{% import "_macros.html" as ui %}
{% set action = url_for('frags.save', model=model) %}
{{ ui.form(schema, action, method="POST", obj_id=obj.id if obj else None, hx=true) }}

View file

@ -1,2 +0,0 @@
{% import "_macros.html" as ui %}
{{ ui.lis(items, label_path=label_path, sublabel_path=sublabel_path, getp=getp) }}

View file

@ -1,3 +0,0 @@
{# Renders only <option>...</option> rows #}
{% import "_macros.html" as ui %}
{{ ui.options(items, value_attr=value_attr, label_path=label_path, getp=getp) }}

View file

@ -1,2 +0,0 @@
{% import "_macros.html" as ui %}
{{ ui.rows([obj], fields, getp=getp) }}

View file

@ -1,3 +0,0 @@
{% import "_macros.html" as ui %}
{{ ui.rows(items, fields, getp=getp) }}
{{ ui.pager(model, page, pages, per_page, sort, filters) }}

View file

@ -1,137 +0,0 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import select
from sqlalchemy.inspection import inspect
from sqlalchemy.orm import Mapper, RelationshipProperty
from sqlalchemy.sql.schema import Column
from sqlalchemy.sql.sqltypes import (
String, Text, Unicode, UnicodeText,
Integer, BigInteger, SmallInteger, Float, Numeric, Boolean,
Date, DateTime, Time, JSON, Enum
)
CANDIDATE_LABELS = ("name", "title", "label", "display_name")
def _guess_label_attr(model_cls) -> str:
for cand in CANDIDATE_LABELS:
if hasattr(model_cls, cand):
return cand
return "id"
def _pretty(label: str) -> str:
return label.replace("_", " ").title()
def _column_input_type(col: Column) -> str:
t = col.type
if isinstance(t, (String, Unicode)):
return "text"
if isinstance(t, (Text, UnicodeText, JSON)):
return "textarea"
if isinstance(t, (Integer, SmallInteger, BigInteger)):
return "number"
if isinstance(t, (Float, Numeric)):
return "number"
if isinstance(t, Boolean):
return "checkbox"
if isinstance(t, Date):
return "date"
if isinstance(t, DateTime):
return "datetime-local"
if isinstance(t, Time):
return "time"
if isinstance(t, Enum):
return "select"
return "text"
def _enum_choices(col: Column) -> Optional[List[Tuple[str, str]]]:
t = col.type
if isinstance(t, Enum):
if t.enum_class:
return [(e.name, e.value) for e in t.enum_class]
if t.enums:
return [(v, v) for v in t.enums]
return None
def build_form_schema(model_cls, session, obj=None, *, include=None, exclude=None, fk_limit=200):
mapper: Mapper = inspect(model_cls)
include = set(include or [])
exclude = set(exclude or {"id", "created_at", "updated_at", "deleted", "version"})
fields = []
fields: List[Dict[str, Any]] = []
fk_map = {}
for rel in mapper.relationships:
for lc in rel.local_columns:
fk_map[lc.key] = rel
for attr in mapper.column_attrs:
col = attr.columns[0]
name = col.key
if include and name not in include:
continue
if name in exclude:
continue
field = {
"name": name,
"type": _column_input_type(col),
"required": not col.nullable,
"value": getattr(obj, name, None) if obj is not None else None,
"placeholder": "",
"help": "",
# default label from column name
"label": _pretty(name),
}
enum_choices = _enum_choices(col)
if enum_choices:
field["type"] = "select"
field["choices"] = enum_choices
if name in fk_map:
rel = fk_map[name]
target = rel.mapper.class_
label_attr = _guess_label_attr(target)
rows = session.execute(select(target).limit(fk_limit)).scalars().all()
field["type"] = "select"
field["choices"] = [(getattr(r, "id"), getattr(r, label_attr)) for r in rows]
field["rel"] = {"target": target.__name__, "label_attr": label_attr}
field["label"] = _pretty(rel.key)
if getattr(col.type, "length", None):
field["maxlength"] = col.type.length
fields.append(field)
for rel in mapper.relationships:
if not rel.uselist or rel.secondary is None:
continue # only true many-to-many
if include and f"{rel.key}_ids" not in include:
continue
target = rel.mapper.class_
label_attr = _guess_label_attr(target)
choices = session.execute(select(target).limit(fk_limit)).scalars().all()
current = []
if obj is not None:
current = [getattr(x, "id") for x in getattr(obj, rel.key, []) or []]
fields.append({
"name": f"{rel.key}_ids", # e.g. "tags_ids"
"label": rel.key.replace("_"," ").title(),
"type": "select",
"multiple": True,
"required": False,
"choices": [(getattr(r,"id"), getattr(r,label_attr)) for r in choices],
"value": current, # list of selected IDs
"placeholder": f"Choose {rel.key.replace('_',' ').title()}",
"help": "",
})
if include:
order = list(include)
fields.sort(key=lambda f: order.index(f["name"]) if f["name"] in include else 10**9)
return fields

View file

@ -1,233 +0,0 @@
from __future__ import annotations
from typing import Any, Dict, List, Tuple
from math import ceil
from flask import Blueprint, request, render_template, abort, make_response
from sqlalchemy import select
from sqlalchemy.orm import scoped_session
from sqlalchemy.inspection import inspect
from sqlalchemy.sql.sqltypes import Integer, Boolean, Date, DateTime, Float, Numeric
from ..dsl import QuerySpec
from ..service import CrudService
from ..eager import default_eager_policy
from .type_map import build_form_schema
def make_fragments_blueprint(db_session_factory, registry: Dict[str, Any], *, name="frags"):
"""
HTML fragments for HTMX/Alpine. No base pages. Pure partials:
GET /<model>/frag/options -> <option>...</option>
GET /<model>/frag/lis -> <li>...</li>
GET /<model>/frag/rows -> <tr>...</tr> + pager markup if wanted
GET /<model>/frag/form -> <form>...</form> (auto-generated)
"""
bp = Blueprint(name, __name__, template_folder="templates/crudkit")
def session(): return scoped_session(db_session_factory)()
def _parse_filters(args):
reserved = {"page", "per_page", "sort", "expand", "fields", "value", "label", "label_tpl", "fields_csv", "li_label", "li_sublabel"}
out = {}
for k, v in args.items():
if k not in reserved and v != "":
out[k] = v
return out
def _paths_from_csv(csv: str) -> List[str]:
return [p.strip() for p in csv.split(",") if p.strip()]
def _collect_expand_from_paths(paths: List[str]) -> List[str]:
rels = set()
for p in paths:
bits = p.split(".")
if len(bits) > 1:
rels.add(bits[0])
return list(rels)
def _getp(obj, path: str):
cur = obj
for part in path.split("."):
cur = getattr(cur, part, None) if cur is not None else None
return cur
def _extract_m2m_lists(Model, req_form) -> dict[str, list[int]]:
"""Return {'tags': [1,2]} for any <rel>_ids fields; caller removes keys from main form."""
mapper = inspect(Model)
out = {}
for rel in mapper.relationships:
if not rel.uselist or rel.secondary is None:
continue
key = f"{rel.key}_ids"
ids = req_form.getlist(key)
if ids is None:
continue
out[rel.key] = [int(i) for i in ids if i]
return out
@bp.get("/<model>/frag/options")
def options(model):
Model = registry.get(model) or abort(404)
value_attr = request.args.get("value", default="id")
label_path = request.args.get("label", default="name")
filters = _parse_filters(request.args)
expand = _collect_expand_from_paths([label_path])
spec = QuerySpec(filters=filters, order_by=[], page=None, per_page=None, expand=expand)
s = session(); svc = CrudService(s, default_eager_policy)
items, _ = svc.list(Model, spec)
return render_template("options.html", items=items, value_attr=value_attr, label_path=label_path, getp=_getp)
@bp.get("/<model>/frag/lis")
def lis(model):
Model = registry.get(model) or abort(404)
label_path = request.args.get("li_label", default="name")
sublabel_path = request.args.get("li_sublabel")
filters = _parse_filters(request.args)
sort = request.args.get("sort")
page = request.args.get("page", type=int)
per_page = request.args.get("per_page", type=int)
expand = _collect_expand_from_paths([p for p in (label_path, sublabel_path) if p])
spec = QuerySpec(filters=filters, order_by=[sort] if sort else [], page=page, per_page=per_page, expand=expand)
s = session(); svc = CrudService(s, default_eager_policy)
rows, total = svc.list(Model, spec)
pages = (ceil(total / per_page) if page and per_page else 1)
return render_template("lis.html", items=rows, label_path=label_path, sublabel_path=sublabel_path, page=page or 1, per_page=per_page or 1, total=total, model=model, sort=sort, filters=filters, getp=_getp)
@bp.get("/<model>/frag/rows")
def rows(model):
Model = registry.get(model) or abort(404)
fields_csv = request.args.get("fields_csv") or "id,name"
fields = _paths_from_csv(fields_csv)
filters = _parse_filters(request.args)
sort = request.args.get("sort")
page = request.args.get("page", type=int) or 1
per_page = request.args.get("per_page", type=int) or 20
expand = _collect_expand_from_paths(fields)
spec = QuerySpec(filters=filters, order_by=[sort] if sort else [], page=page, per_page=per_page, expand=expand)
s = session(); svc = CrudService(s, default_eager_policy)
rows, total = svc.list(Model, spec)
pages = max(1, ceil(total / per_page))
return render_template("rows.html", items=rows, fields=fields, page=page, pages=pages, per_page=per_page, total=total, model=model, sort=sort, filters=filters, getp=_getp)
@bp.get("/<model>/frag/form")
def form(model):
Model = registry.get(model) or abort(404)
id = request.args.get("id", type=int)
include_csv = request.args.get("include")
include = [s.strip() for s in include_csv.split(",")] if include_csv else None
s = session(); svc = CrudService(s, default_eager_policy)
obj = svc.get(Model, id) if id else None
schema = build_form_schema(Model, s, obj=obj, include=include)
hx = request.args.get("hx", type=int) == 1
return render_template("form.html", model=model, obj=obj, schema=schema, hx=hx)
def coerce_form_types(Model, data: dict) -> dict:
"""Turn HTML string inputs into the Python types your columns expect."""
mapper = inspect(Model)
for attr in mapper.column_attrs:
col = attr.columns[0]
name = col.key
if name not in data:
continue
v = data[name]
if v == "":
data[name] = None
continue
t = col.type
try:
if isinstance(t, Boolean):
data[name] = v in ("1", "true", "on", "yes", True)
elif isinstance(t, Integer):
data[name] = int(v)
elif isinstance(t, (Float, Numeric)):
data[name] = float(v)
elif isinstance(t, DateTime):
from datetime import datetime
data[name] = datetime.fromisoformat(v)
elif isinstance(t, Date):
from datetime import date
data[name] = date.fromisoformat(v)
except Exception:
# Leave as string; your validator can complain later.
pass
return data
@bp.post("/<model>/frag/save")
def save(model):
Model = registry.get(model) or abort(404)
s = session(); svc = CrudService(s, default_eager_policy)
# grab the raw form and fields to re-render
raw = request.form
form = raw.to_dict(flat=True)
fields_csv = form.pop("fields_csv", "id,name")
# many-to-many lists first
m2m = _extract_m2m_lists(Model, raw)
for rel_name in list(m2m.keys()):
form.pop(f"{rel_name}_ids", None)
# coerce primitives for regular columns
form = coerce_form_types(Model, form)
id_val = form.pop("id", None)
if id_val:
obj = svc.get(Model, int(id_val)) or abort(404)
svc.update(obj, form)
else:
obj = svc.create(Model, form)
# apply many-to-many selections
mapper = inspect(Model)
for rel_name, id_list in m2m.items():
rel = mapper.relationships[rel_name]
target = rel.mapper.class_
selected = []
if id_list:
selected = s.execute(select(target).where(target.id.in_(id_list))).scalars().all()
coll = getattr(obj, rel_name)
coll.clear()
coll.extend(selected)
s.commit()
rows_html = render_template(
"crudkit/row.html",
obj=obj,
fields=[p.strip() for p in fields_csv.split(",") if p.strip()],
getp=_getp,
)
resp = make_response(rows_html)
if id_val:
resp.headers["HX-Trigger"] = '{"toast":{"level":"success","message":"Updated"}}'
resp.headers["HX-Retarget"] = f"#row-{obj.id}"
resp.headers["HX-Reswap"] = "outerHTML"
else:
resp.headers["HX-Trigger"] = '{"toast":{"level":"success","message":"Created"}}'
resp.headers["HX-Retarget"] = "#rows"
resp.headers["HX-Reswap"] = "beforeend"
return resp
@bp.get("/_debug/<model>/schema")
def debug_model(model):
Model = registry[model]
from sqlalchemy.inspection import inspect
m = inspect(Model)
return {
"columns": [c.key for c in m.columns],
"relationships": [
{
"key": r.key,
"target": r.mapper.class_.__name__,
"uselist": r.uselist,
"local_cols": [c.key for c in r.local_columns],
} for r in m.relationships
],
}
return bp

View file

@ -1,23 +0,0 @@
import datetime as dt
from sqlalchemy import Column, Integer, DateTime, Boolean
from sqlalchemy.orm import declared_attr
from sqlalchemy.ext.hybrid import hybrid_property
class CrudMixin:
id = Column(Integer, primary_key=True)
created_at = Column(DateTime, default=dt.datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=dt.datetime.utcnow, onupdate=dt.datetime.utcnow, nullable=False)
deleted = Column("deleted", Boolean, default=False, nullable=False)
version = Column(Integer, default=1, nullable=False)
@hybrid_property
def is_deleted(self):
return self.deleted
def mark_deleted(self):
self.deleted = True
self.version += 1
@declared_attr
def __mapper_args__(cls):
return {"version_id_col": cls.version}

View file

@ -1,22 +0,0 @@
def serialize(obj, *, fields=None, expand=None):
expand = set(expand or [])
fields = set(fields or [])
out = {}
# base columns
for col in obj.__table__.columns:
name = col.key
if fields and name not in fields:
continue
out[name] = getattr(obj, name)
# expansions
for rel in obj.__mapper__.relationships:
if rel.key not in expand:
continue
val = getattr(obj, rel.key)
if val is None:
out[rel.key] = None
elif rel.uselist:
out[rel.key] = [serialize(child) for child in val]
else:
out[rel.key] = serialize(val)
return out

View file

@ -1,52 +0,0 @@
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .dsl import QuerySpec, build_query
from .eager import default_eager_policy
class CrudService:
def __init__(self, session: Session, eager_policy=default_eager_policy):
self.s = session
self.eager_policy = eager_policy
def create(self, Model, data, *, before=None, after=None):
if before: data = before(data) or data
obj = Model(**data)
self.s.add(obj)
self.s.flush()
if after: after(obj)
return obj
def get(self, Model, id, spec: QuerySpec | None = None):
spec = spec or QuerySpec()
stmt = build_query(Model, spec, self.eager_policy).where(Model.id == id)
return self.s.execute(stmt).scalars().first()
def list(self, Model, spec: QuerySpec):
stmt = build_query(Model, spec, self.eager_policy)
count_stmt = stmt.with_only_columns(func.count()).order_by(None)
total = self.s.execute(count_stmt).scalar_one()
if spec.page and spec.per_page:
stmt = stmt.limit(spec.per_page).offset((spec.page - 1) * spec.per_page)
rows = self.s.execute(stmt).scalars().all()
return rows, total
def update(self, obj, data, *, before=None, after=None):
if obj.is_deleted: raise ValueError("Cannot update a deleted record")
if before: data = before(obj, data) or data
for k, v in data.items(): setattr(obj, k, v)
obj.version += 1
if after: after(obj)
return obj
def soft_delete(self, obj, *, cascade=False, guard=None):
if guard and not guard(obj): raise ValueError("Delete blocked by guard")
# optionsl FK hygiene checks go here
obj.mark_deleted()
return obj
def undelete(self, obj):
obj.deleted = False
obj.version += 1
return obj

0
crudkit/ui/__init__.py Normal file
View file

81
crudkit/ui/fragments.py Normal file
View file

@ -0,0 +1,81 @@
from jinja2 import Environment, FileSystemLoader, ChoiceLoader
from sqlalchemy.orm import class_mapper, RelationshipProperty
from flask import current_app
import os
def get_env():
app_loader = current_app.jinja_loader
default_path = os.path.join(os.path.dirname(__file__), 'templates')
fallback_loader = FileSystemLoader(default_path)
env = Environment(loader=ChoiceLoader([
app_loader,
fallback_loader
]))
return env
def get_crudkit_template(env, name):
try:
return env.get_template(f'crudkit/{name}')
except Exception:
return env.get_template(name)
def render_field(field, value):
env = get_env()
template = get_crudkit_template(env, 'field.html')
return template.render(
field_name=field['name'],
field_label=field.get('label', field['name']),
value=value,
field_type=field.get('type', 'text'),
options=field.get('options', None)
)
def render_table(objects):
env = get_env()
template = get_crudkit_template(env, 'table.html')
return template.render(objects=objects)
def render_form(model_cls, values, session=None):
env = get_env()
template = get_crudkit_template(env, 'form.html')
fields = []
fk_fields = set()
mapper = class_mapper(model_cls)
for prop in mapper.iterate_properties:
# FK Relationship fields (many-to-one)
if isinstance(prop, RelationshipProperty) and prop.direction.name == 'MANYTOONE':
if session is None:
continue
related_model = prop.mapper.class_
options = session.query(related_model).all()
fields.append({
'name': f"{prop.key}_id",
'label': prop.key,
'type': 'select',
'options': [
{'value': getattr(obj, 'id'), 'label': str(obj)}
for obj in options
]
})
fk_fields.add(f"{prop.key}_id")
# Now add basic columns — excluding FKs already covered
for col in model_cls.__table__.columns:
if col.name in fk_fields:
continue
if col.name in ('id', 'created_at', 'updated_at'):
continue
if col.default or col.server_default or col.onupdate:
continue
fields.append({
'name': col.name,
'label': col.name,
'type': 'text',
})
return template.render(fields=fields, values=values, render_field=render_field)

View file

@ -0,0 +1,16 @@
<label for="{{ field_name }}">{{ field_label }}</label>
{% if field_type == 'select' %}
<select name="{{ field_name }}" {%- if not options %}disabled{% endif %}>
{% if options %}
<option value="">-- Select --</option>
{% for opt in options %}
<option value="{{ opt.value }}" {% if opt.value|string == value|string %}selected{% endif %}>{{ opt.label }}</option>
{% endfor %}
{% else %}
<option value="">-- No selection available --</option>
{% endif %}
</select>
{% else %}
<input type="text" name="{{ field_name }}" value="{{ value }}">
{% endif %}

View file

@ -0,0 +1,6 @@
<form method="POST">
{% for field in fields %}
{{ render_field(field, values.get(field.name, '')) }}
{% endfor %}
<button type="submit">Create</button>
</form>

View file

@ -0,0 +1,12 @@
<table>
{% if objects %}
<tr>
{% for field in objects[0].__table__.columns %}<th>{{ field.name }}</th>{% endfor %}
</tr>
{% for obj in objects %}
<tr>{% for field in obj.__table__.columns %}<td>{{ obj[field.name] }}</td>{% endfor %}</tr>
{% endfor %}
{% else %}
<tr><th>No data.</th></tr>
{% endif %}
</table>

View file

@ -1,57 +0,0 @@
from flask import Flask, current_app
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.engine.url import make_url
from sqlalchemy.orm import sessionmaker
import logging
import os
db = SQLAlchemy()
logger = logging.getLogger('sqlalchemy.engine')
logger.setLevel(logging.INFO)
if not logger.handlers:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s'))
logger.addHandler(handler)
def is_in_memory_sqlite():
uri = current_app.config.get("SQLALCHEMY_DATABASE_URI")
if not uri:
return False
url = make_url(uri)
return url.get_backend_name() == "sqlite" and url.database == ":memory:"
def create_app():
from config import Config
app = Flask(__name__)
app.secret_key = os.getenv('SECRET_KEY', 'dev-secret-key-unsafe')
app.config.from_object(Config)
db.init_app(app)
with app.app_context():
from . import models
if is_in_memory_sqlite():
db.create_all()
# ✅ db.engine is only safe to touch inside an app context
SessionLocal = sessionmaker(bind=db.engine, expire_on_commit=False)
from .models import registry
from .routes import main
from .routes.images import image_bp
from .ui.blueprint import bp as ui_bp
from crudkit.blueprint import make_blueprint as make_json_bp
from crudkit.html import make_fragments_blueprint as make_html_bp
app.register_blueprint(main)
app.register_blueprint(image_bp)
app.register_blueprint(ui_bp)
app.register_blueprint(make_json_bp(SessionLocal, registry), url_prefix="/api")
app.register_blueprint(make_html_bp(SessionLocal, registry), url_prefix="/ui")
from .routes.helpers import generate_breadcrumbs
@app.context_processor
def inject_breadcrumbs():
return {'breadcrumbs': generate_breadcrumbs()}
return app

View file

@ -1,6 +0,0 @@
from . import create_app
app = create_app()
if __name__ == "__main__":
app.run()

View file

@ -1,66 +0,0 @@
import os
import urllib.parse
from dotenv import load_dotenv
load_dotenv()
def quote(value: str) -> str:
return urllib.parse.quote_plus(value or '')
class Config:
SQLALCHEMY_TRACK_MODIFICATIONS = False
DEBUG = os.getenv('DEBUG', 'false').strip().lower() in ['true', '1', 'yes']
TESTING = False
DB_BACKEND = os.getenv('DB_BACKEND', 'sqlite').lower()
DB_WINDOWS_AUTH = os.getenv('DB_WINDOWS_AUTH', 'false').strip().lower() in ['true', '1', 'yes']
DB_USER = os.getenv('DB_USER', '')
DB_PASSWORD = os.getenv('DB_PASSWORD', '')
DB_HOST = os.getenv('DB_HOST', 'localhost')
DB_PORT = os.getenv('DB_PORT', '')
DB_NAME = os.getenv('DB_NAME', 'app.db') # default SQLite filename
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
SQLALCHEMY_DATABASE_URI = None # This will definitely be set below
if DB_BACKEND == 'mssql':
driver = os.getenv('DB_DRIVER', 'ODBC Driver 17 for SQL Server')
quoted_driver = quote(driver)
if DB_WINDOWS_AUTH:
SQLALCHEMY_DATABASE_URI = (
f"mssql+pyodbc://@{DB_HOST}/{DB_NAME}?driver={quoted_driver}&Trusted_Connection=yes"
)
else:
SQLALCHEMY_DATABASE_URI = (
f"mssql+pyodbc://{quote(DB_USER)}:{quote(DB_PASSWORD)}@{DB_HOST}:{DB_PORT or '1433'}/{DB_NAME}"
f"?driver={quoted_driver}"
)
elif DB_BACKEND == 'postgres':
SQLALCHEMY_DATABASE_URI = (
f"postgresql://{quote(DB_USER)}:{quote(DB_PASSWORD)}@{DB_HOST}:{DB_PORT or '5432'}/{DB_NAME}"
)
elif DB_BACKEND in ['mariadb', 'mysql']:
SQLALCHEMY_DATABASE_URI = (
f"mysql+pymysql://{quote(DB_USER)}:{quote(DB_PASSWORD)}@{DB_HOST}:{DB_PORT or '3306'}/{DB_NAME}"
)
elif DB_BACKEND == 'sqlite':
if DB_NAME == ':memory:':
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
else:
full_path = os.path.join(BASE_DIR, DB_NAME)
SQLALCHEMY_DATABASE_URI = f"sqlite:///{full_path}"
else:
raise ValueError(
f"Unsupported DB_BACKEND: {DB_BACKEND}. "
"Supported backends: mssql, postgres, mariadb, mysql, sqlite."
)
# Optional: confirm config during development
print(f"Using database URI: {SQLALCHEMY_DATABASE_URI}")

View file

@ -1,68 +0,0 @@
# inventory/models/__init__.py
from inventory import db # your single SQLAlchemy() instance
# Import *modules* so all model classes are defined & registered
from . import image
from . import room_functions
from . import rooms
from . import areas
from . import brands
from . import items
from . import inventory
from . import work_log
from . import work_note
from . import users
from . import image_links
# If you want convenient symbols, export them AFTER modules are imported
Image = image.Image
ImageAttachable = image.ImageAttachable
RoomFunction = room_functions.RoomFunction
Room = rooms.Room
Area = areas.Area
Brand = brands.Brand
Item = items.Item
Inventory = inventory.Inventory
WorkLog = work_log.WorkLog
WorkNote = work_note.WorkNote
worklog_images = image_links.worklog_images
User = users.User
# Now its safe to configure mappers and set global eagerloads
from sqlalchemy.orm import configure_mappers, joinedload, selectinload
configure_mappers()
User.ui_eagerload = (
joinedload(User.supervisor),
joinedload(User.location).joinedload(Room.room_function),
)
Room.ui_eagerload = (
joinedload(Room.area),
joinedload(Room.room_function),
selectinload(Room.inventory),
selectinload(Room.users)
)
registry = {
"area": Area,
"brand": Brand,
"image": Image,
"inventory": Inventory,
"item": Item,
"room_function": RoomFunction,
"room": Room,
"user": User,
"work_log": WorkLog,
"work_note": WorkNote
}
__all__ = [
"db",
"Image", "ImageAttachable",
"RoomFunction", "Room",
"Area", "Brand", "Item", "Inventory",
"WorkLog", "WorkNote", "worklog_images",
"User", "registry"
]

8
inventory/models/area.py Normal file
View file

@ -0,0 +1,8 @@
from sqlalchemy import Column, Unicode
from crudkit.core.base import Base, CRUDMixin
class Area(Base, CRUDMixin):
__tablename__ = "area"
name = Column(Unicode(255), nullable=True)

View file

@ -1,23 +0,0 @@
from typing import List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .rooms import Room
from crudkit import CrudMixin
from sqlalchemy import Identity, Integer, Unicode
from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db
class Area(db.Model, CrudMixin):
__tablename__ = 'area'
id: Mapped[int] = mapped_column(Integer, Identity(start=1, increment=1), primary_key=True)
name: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True)
rooms: Mapped[List['Room']] = relationship('Room', back_populates='area')
def __init__(self, name: Optional[str] = None):
self.name = name
def __repr__(self):
return f"<Area(id={self.id}, name={repr(self.name)})>"

View file

@ -1,27 +0,0 @@
from typing import List, TYPE_CHECKING
if TYPE_CHECKING:
from .inventory import Inventory
from crudkit import CrudMixin
from sqlalchemy import Identity, Integer, Unicode
from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db
class Brand(db.Model, CrudMixin):
__tablename__ = 'brand'
id: Mapped[int] = mapped_column(Integer, Identity(start=1, increment=1), primary_key=True)
name: Mapped[str] = mapped_column(Unicode(255), nullable=False)
inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='brand')
def __init__(self, name: str):
self.name = name
def __repr__(self):
return f"<Brand(id={self.id}, name={repr(self.name)})>"
@property
def identifier(self) -> str:
return self.name if self.name else f"ID: {self.id}"

View file

@ -1,38 +0,0 @@
from typing import Optional, List, TYPE_CHECKING
if TYPE_CHECKING:
from .inventory import Inventory
from .users import User
from .work_log import WorkLog
import datetime
from sqlalchemy import Integer, Unicode, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db
from .image_links import worklog_images
from crudkit import CrudMixin
class Image(db.Model, CrudMixin):
__tablename__ = 'images'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
filename: Mapped[str] = mapped_column(Unicode(512))
caption: Mapped[str] = mapped_column(Unicode(255), default="")
timestamp: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.now(), server_default=func.now())
inventory: Mapped[Optional['Inventory']] = relationship('Inventory', back_populates='image')
user: Mapped[Optional['User']] = relationship('User', back_populates='image')
worklogs: Mapped[List['WorkLog']] = relationship('WorkLog', secondary=worklog_images, back_populates='images')
def __init__(self, filename: str, caption: Optional[str] = None):
self.filename = filename
self.caption = caption or ""
def __repr__(self):
return f"<Image(id={self.id}, filename={self.filename})>"
class ImageAttachable:
def attach_image(self, image: 'Image') -> None:
raise NotImplementedError("This model doesn't know how to attach images.")

View file

@ -1,7 +0,0 @@
from .. import db
worklog_images = db.Table(
'worklog_images',
db.Column('worklog_id', db.Integer, db.ForeignKey('work_log.id'), primary_key=True),
db.Column('image_id', db.Integer, db.ForeignKey('images.id', ondelete='CASCADE'), primary_key=True),
)

View file

@ -1,151 +0,0 @@
from typing import Any, List, Optional, TYPE_CHECKING
from .image import Image
if TYPE_CHECKING:
from .brands import Brand
from .items import Item
from .work_log import WorkLog
from .rooms import Room
from .image import Image
from .users import User
from crudkit import CrudMixin
from sqlalchemy import Boolean, ForeignKey, Identity, Index, Integer, Unicode, DateTime, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
import datetime
from . import db
from .brands import Brand
from .image import ImageAttachable
from .users import User
class Inventory(db.Model, ImageAttachable, CrudMixin):
__tablename__ = 'inventory'
__table_args__ = (
Index('Inventory$Barcode', 'barcode'),
)
id: Mapped[int] = mapped_column(Integer, Identity(start=1, increment=1), primary_key=True)
timestamp: Mapped[datetime.datetime] = mapped_column(DateTime)
condition: Mapped[str] = mapped_column(Unicode(255))
type_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("item.id"), nullable=True, index=True)
name: Mapped[Optional[str]] = mapped_column(Unicode(255))
serial: Mapped[Optional[str]] = mapped_column(Unicode(255))
model: Mapped[Optional[str]] = mapped_column(Unicode(255))
notes: Mapped[Optional[str]] = mapped_column(Unicode(255))
owner_id = mapped_column(Integer, ForeignKey('users.id'), nullable=True, index=True)
brand_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("brand.id"), nullable=True, index=True)
location_id: Mapped[Optional[str]] = mapped_column(ForeignKey("rooms.id"), nullable=True, index=True)
barcode: Mapped[Optional[str]] = mapped_column(Unicode(255))
shared: Mapped[Optional[bool]] = mapped_column(Boolean, server_default=text('((0))'))
image_id: Mapped[Optional[int]] = mapped_column(ForeignKey('images.id', ondelete='SET NULL'), nullable=True, index=True)
location: Mapped[Optional['Room']] = relationship('Room', back_populates='inventory')
owner = relationship('User', back_populates='inventory')
brand: Mapped[Optional['Brand']] = relationship('Brand', back_populates='inventory')
# item: Mapped['Item'] = relationship('Item', back_populates='inventory')
work_logs: Mapped[List['WorkLog']] = relationship('WorkLog', back_populates='work_item')
image: Mapped[Optional['Image']] = relationship('Image', back_populates='inventory', passive_deletes=True)
device_type: Mapped[Optional['Item']] = relationship('Item', back_populates='inventory')
def __init__(self, timestamp: datetime.datetime, condition: str, type_id: Optional[int] = None,
name: Optional[str] = None, serial: Optional[str] = None,
model: Optional[str] = None, notes: Optional[str] = None, owner_id: Optional[int] = None,
brand_id: Optional[int] = None, location_id: Optional[str] = None, barcode: Optional[str] = None,
shared: bool = False):
self.timestamp = timestamp
self.condition = condition
self.type_id = type_id
self.name = name
self.serial = serial
self.model = model
self.notes = notes
self.owner_id = owner_id
self.brand_id = brand_id
self.location_id = location_id
self.barcode = barcode
self.shared = shared
def __repr__(self):
parts = [f"id={self.id}"]
if self.name:
parts.append(f"name={repr(self.name)}")
if self.device_type:
parts.append(f"item={repr(self.device_type.description)}")
if self.notes:
parts.append(f"notes={repr(self.notes)}")
if self.owner:
parts.append(f"owner={repr(self.owner.identifier)}")
if self.location:
parts.append(f"location={repr(self.location.identifier)}")
return f"<Inventory({', '.join(parts)})>"
@property
def identifier(self) -> str:
if self.name:
return f"Name: {self.name}"
elif self.barcode:
return f"Bar: {self.barcode}"
elif self.serial:
return f"Serial: {self.serial}"
else:
return f"ID: {self.id}"
def serialize(self) -> dict[str, Any]:
return {
'id': self.id,
'timestamp': self.timestamp.isoformat() if self.timestamp else None,
'condition': self.condition,
'type_id': self.type_id,
'name': self.name,
'serial': self.serial,
'model': self.model,
'notes': self.notes,
'owner_id': self.owner_id,
'brand_id': self.brand_id,
'location_id': self.location_id,
'barcode': self.barcode,
'shared': self.shared
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Inventory":
timestamp_str = data.get("timestamp")
return cls(
timestamp = datetime.datetime.fromisoformat(str(timestamp_str)) if timestamp_str else datetime.datetime.now(),
condition=data.get("condition", "Unverified"),
type_id=data["type_id"],
name=data.get("name"),
serial=data.get("serial"),
model=data.get("model"),
notes=data.get("notes"),
owner_id=data.get("owner_id"),
brand_id=data.get("brand_id"),
location_id=data.get("location_id"),
barcode=data.get("barcode"),
shared=bool(data.get("shared", False))
)
def attach_image(self, image: Image) -> None:
self.image = image
@staticmethod
def ui_search(stmt, text: str):
t = f"%{text}%"
return stmt.where(
Inventory.name.ilike(t) |
Inventory.serial.ilike(t) |
Inventory.model.ilike(t) |
Inventory.notes.ilike(t) |
Inventory.barcode.ilike(t) |
Inventory.owner.has(User.first_name.ilike(t)) |
Inventory.owner.has(User.last_name.ilike(t)) |
Inventory.brand.has(Brand.name.ilike(t))
)

View file

@ -1,27 +0,0 @@
from typing import List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .inventory import Inventory
from crudkit import CrudMixin
from sqlalchemy import Identity, Integer, Unicode
from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db
class Item(db.Model, CrudMixin):
__tablename__ = 'item'
id: Mapped[int] = mapped_column(Integer, Identity(start=1, increment=1), primary_key=True)
description: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True)
inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='device_type')
def __init__(self, description: Optional[str] = None):
self.description = description
def __repr__(self):
return f"<Item(id={self.id}, description={repr(self.description)})>"
@property
def identifier(self):
return self.description if self.description else f"Item {self.id}"

View file

@ -1,23 +0,0 @@
from typing import List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .rooms import Room
from crudkit import CrudMixin
from sqlalchemy import Identity, Integer, Unicode
from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db
class RoomFunction(db.Model, CrudMixin):
__tablename__ = 'room_function'
id: Mapped[int] = mapped_column(Integer, Identity(start=1, increment=1), primary_key=True)
description: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True)
rooms: Mapped[List['Room']] = relationship('Room', back_populates='room_function')
def __init__(self, description: Optional[str] = None):
self.description = description
def __repr__(self):
return f"<RoomFunction(id={self.id}, description={repr(self.description)})>"

View file

@ -1,54 +0,0 @@
from typing import Optional, TYPE_CHECKING, List
if TYPE_CHECKING:
from .areas import Area
from .room_functions import RoomFunction
from .inventory import Inventory
from .users import User
from crudkit import CrudMixin
from sqlalchemy import ForeignKey, Identity, Integer, Unicode
from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db
class Room(db.Model, CrudMixin):
__tablename__ = 'rooms'
id: Mapped[int] = mapped_column(Integer, Identity(start=1, increment=1), primary_key=True)
name: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True)
area_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("area.id"), nullable=True, index=True)
function_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("room_function.id"), nullable=True, index=True)
area: Mapped[Optional['Area']] = relationship('Area', back_populates='rooms')
room_function: Mapped[Optional['RoomFunction']] = relationship('RoomFunction', back_populates='rooms')
inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='location')
users: Mapped[List['User']] = relationship('User', back_populates='location')
ui_eagerload = tuple()
ui_extra_attrs = ('area_id', 'function_id')
@classmethod
def ui_update(cls, session, id_, payload):
print(payload)
obj = session.get(cls, id_)
if not obj:
return None
obj.name = payload.get("name", obj.name)
obj.area_id = payload.get("area_id", obj.area_id)
obj.function_id = payload.get("function_id", obj.function_id)
session.commit()
return obj
def __init__(self, name: Optional[str] = None, area_id: Optional[int] = None, function_id: Optional[int] = None):
self.name = name
self.area_id = area_id
self.function_id = function_id
def __repr__(self):
return f"<Room(id={self.id}, room={repr(self.name)}, area_id={self.area_id}, function_id={self.function_id})>"
@property
def identifier(self):
name = self.name or ""
func = self.room_function.description if self.room_function else ""
return f"{name} - {func}".strip(" -")

View file

@ -1,80 +0,0 @@
from typing import Any, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .inventory import Inventory
from .rooms import Room
from .work_log import WorkLog
from .image import Image
from crudkit import CrudMixin
from sqlalchemy import Boolean, ForeignKey, Identity, Integer, Unicode, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db
from .image import ImageAttachable
class User(db.Model, ImageAttachable, CrudMixin):
__tablename__ = 'users'
id: Mapped[int] = mapped_column(Integer, Identity(start=1, increment=1), primary_key=True)
staff: Mapped[Optional[bool]] = mapped_column(Boolean, server_default=text('((0))'))
active: Mapped[Optional[bool]] = mapped_column(Boolean, server_default=text('((0))'))
last_name: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True)
first_name: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True)
title: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True, default=None)
location_id: Mapped[Optional[int]] = mapped_column(ForeignKey("rooms.id"), nullable=True, index=True)
supervisor_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id"), nullable=True, index=True)
image_id: Mapped[Optional[int]] = mapped_column(ForeignKey('images.id', ondelete='SET NULL'), nullable=True, index=True)
supervisor: Mapped[Optional['User']] = relationship('User', remote_side='User.id', back_populates='subordinates')
subordinates: Mapped[List['User']] = relationship('User', back_populates='supervisor')
work_logs: Mapped[List['WorkLog']] = relationship('WorkLog', back_populates='contact')
location: Mapped[Optional['Room']] = relationship('Room', back_populates='users')
inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='owner')
image: Mapped[Optional['Image']] = relationship('Image', back_populates='user', passive_deletes=True)
ui_eagerload = tuple()
ui_order_cols = ('first_name', 'last_name',)
@property
def identifier(self) -> str:
return f"{self.first_name or ''} {self.last_name or ''}{', ' + (''.join(word[0].upper() for word in self.title.split())) if self.title else ''}".strip()
def __init__(self, first_name: Optional[str] = None, last_name: Optional[str] = None,
title: Optional[str] = None,location_id: Optional[int] = None,
supervisor_id: Optional[int] = None, staff: Optional[bool] = False,
active: Optional[bool] = False):
self.first_name = first_name
self.last_name = last_name
self.title = title
self.location_id = location_id
self.supervisor_id = supervisor_id
self.staff = staff
self.active = active
def __repr__(self):
return f"<User(id={self.id}, first_name={repr(self.first_name)}, last_name={repr(self.last_name)}, " \
f"location={repr(self.location)}, staff={self.staff}, active={self.active})>"
def serialize(self):
return {
'id': self.id,
'first_name': self.first_name,
'last_name': self.last_name,
'title': self.title,
'location_id': self.location_id,
'supervisor_id': self.supervisor_id,
'staff': self.staff,
'active': self.active
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "User":
return cls(
staff=bool(data.get("staff", False)),
active=bool(data.get("active", False)),
last_name=data.get("last_name"),
first_name=data.get("first_name"),
title=data.get("title"),
location_id=data.get("location_id"),
supervisor_id=data.get("supervisor_id")
)

View file

@ -1,109 +0,0 @@
from typing import Optional, Any, List, TYPE_CHECKING
if TYPE_CHECKING:
from .inventory import Inventory
from .image import Image
from .users import User
from .work_note import WorkNote
from crudkit import CrudMixin
from sqlalchemy import Boolean, Identity, Integer, ForeignKey, Unicode, DateTime, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
import datetime
from . import db
from .image import ImageAttachable
from .image_links import worklog_images
from .work_note import WorkNote
class WorkLog(db.Model, ImageAttachable, CrudMixin):
__tablename__ = 'work_log'
id: Mapped[int] = mapped_column(Integer, Identity(start=1, increment=1), primary_key=True)
start_time: Mapped[Optional[datetime.datetime]] = mapped_column(DateTime)
end_time: Mapped[Optional[datetime.datetime]] = mapped_column(DateTime)
notes: Mapped[Optional[str]] = mapped_column(Unicode())
complete: Mapped[Optional[bool]] = mapped_column(Boolean, server_default=text('((0))'))
followup: Mapped[Optional[bool]] = mapped_column(Boolean, server_default=text('((0))'))
contact_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id"), nullable=True, index=True)
analysis: Mapped[Optional[bool]] = mapped_column(Boolean, server_default=text('((0))'))
work_item_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("inventory.id"), nullable=True, index=True)
work_item: Mapped[Optional['Inventory']] = relationship('Inventory', back_populates='work_logs')
contact: Mapped[Optional['User']] = relationship('User', back_populates='work_logs')
updates: Mapped[List['WorkNote']] = relationship(
'WorkNote',
back_populates='work_log',
cascade='all, delete-orphan',
order_by='WorkNote.timestamp'
)
images: Mapped[List['Image']] = relationship('Image', secondary=worklog_images, back_populates='worklogs', passive_deletes=True)
def __init__(
self,
start_time: Optional[datetime.datetime] = None,
end_time: Optional[datetime.datetime] = None,
notes: Optional[str] = None,
complete: Optional[bool] = False,
followup: Optional[bool] = False,
contact_id: Optional[int] = None,
analysis: Optional[bool] = False,
work_item_id: Optional[int] = None,
updates: Optional[List[WorkNote]] = None
) -> None:
self.start_time = start_time
self.end_time = end_time
self.notes = notes
self.complete = complete
self.followup = followup
self.contact_id = contact_id
self.analysis = analysis
self.work_item_id = work_item_id
self.updates = updates or []
def __repr__(self):
return f"<WorkLog(id={self.id}, start_time={self.start_time}, end_time={self.end_time}, " \
f"notes={repr(self.notes)}, complete={self.complete}, followup={self.followup}, " \
f"contact_id={self.contact_id}, analysis={self.analysis}, work_item_id={self.work_item_id})>"
def serialize(self):
return {
'id': self.id,
'start_time': self.start_time.isoformat() if self.start_time else None,
'end_time': self.end_time.isoformat() if self.end_time else None,
'notes': self.notes,
'updates': [note.serialize() for note in self.updates or []],
'complete': self.complete,
'followup': self.followup,
'contact_id': self.contact_id,
'analysis': self.analysis,
'work_item_id': self.work_item_id
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "WorkLog":
start_time_str = data.get("start_time")
end_time_str = data.get("end_time")
updates_raw = data.get("updates", [])
updates: list[WorkNote] = []
for u in updates_raw:
if isinstance(u, dict):
content = u.get("content", "").strip()
else:
content = str(u).strip()
if content:
updates.append(WorkNote(content=content))
return cls(
start_time=datetime.datetime.fromisoformat(str(start_time_str)) if start_time_str else datetime.datetime.now(),
end_time=datetime.datetime.fromisoformat(str(end_time_str)) if end_time_str else None,
notes=data.get("notes"), # Soon to be removed and sent to a farm upstate
complete=bool(data.get("complete", False)),
followup=bool(data.get("followup", False)),
analysis=bool(data.get("analysis", False)),
contact_id=data.get("contact_id"),
work_item_id=data.get("work_item_id"),
updates=updates
)

View file

@ -1,33 +0,0 @@
import datetime
from crudkit import CrudMixin
from sqlalchemy import ForeignKey, DateTime, UnicodeText, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db
class WorkNote(db.Model, CrudMixin):
__tablename__ = 'work_note'
id: Mapped[int] = mapped_column(primary_key=True)
work_log_id: Mapped[int] = mapped_column(ForeignKey('work_log.id', ondelete='CASCADE'), nullable=False, index=True)
timestamp: Mapped[datetime.datetime] = mapped_column(DateTime, default=func.now(), server_default=func.now())
content: Mapped[str] = mapped_column(UnicodeText, nullable=False)
work_log = relationship('WorkLog', back_populates='updates')
def __init__(self, content: str, timestamp: datetime.datetime | None = None) -> None:
self.content = content
self.timestamp = timestamp or datetime.datetime.now()
def __repr__(self) -> str:
preview = self.content[:30].replace("\n", " ") + "..." if len(self.content) > 30 else self.content
return f"<WorkNote(id={self.id}), log_id={self.work_log_id}, ts={self.timestamp}, content={preview!r}>"
def serialize(self) -> dict:
return {
'id': self.id,
'work_log_id': self.work_log_id,
'timestamp': self.timestamp.isoformat(),
'content': self.content
}

View file

@ -1,155 +0,0 @@
import logging
from flask import Blueprint, g
from sqlalchemy.engine import ScalarResult
from sqlalchemy.orm import joinedload
from sqlalchemy.sql import Select
from typing import Iterable, Any, cast
main = Blueprint('main', __name__)
log = logging.getLogger(__name__)
from . import inventory, user, worklog, settings, index, search, hooks
from .. import db
from ..ui.blueprint import get_model_class, call
from ..ui.defaults import default_query
def _eager_from_fields(Model, fields: Iterable[str]):
rels = {f.split(".", 1)[0] for f in fields if "." in f}
opts = []
for r in rels:
rel_attr = getattr(Model, r, None)
if getattr(rel_attr, "property", None) is not None:
opts.append(joinedload(rel_attr))
return opts
def _cell_cache():
if not hasattr(g, '_cell_cache'):
g._cell_cache = {}
return g._cell_cache
def _tmpl_cache(name: str):
if not hasattr(g, "_tmpl_caches"):
g._tmpl_caches = {}
return g._tmpl_caches.setdefault(name, {})
def _project_row(obj: Any, fields: Iterable[str]) -> dict[str, Any]:
out = {"id": obj.id}
Model = type(obj)
allow = getattr(Model, "ui_value_allow", None)
for f in fields:
if allow and f not in allow:
out[f] = None
continue
if "." in f:
rel, attr = f.split(".", 1)
relobj = getattr(obj, rel, None)
out[f] = getattr(relobj, attr, None) if relobj else None
else:
out[f] = getattr(obj, f, None)
return out
@main.app_template_global()
def cell(model_name: str, id_: int, field: str, default: str = ""):
from ..ui.blueprint import get_model_class, call
from ..ui.defaults import default_value
key = (model_name, int(id_), field)
cache = _cell_cache()
if key in cache:
return cache[key]
try:
Model = get_model_class(model_name)
val = call(Model, 'ui_value', db.session, id_=id_, field=field)
if val is None:
val = default_value(db.session, Model, id_=id_, field=field)
except Exception as e:
log.warning(f"cell() error for {model_name} {id_} {field}: {e}")
val = default
if val is None:
val = default
cache[key] = val
return val
@main.app_template_global()
def cells(model_name: str, id_: int, *fields: str):
from ..ui.blueprint import get_model_class, call
from ..ui.defaults import default_values
fields = [f for f in fields if f]
key = (model_name, int(id_), tuple(fields))
cache = _cell_cache()
if key in cache:
return cache[key]
try:
Model = get_model_class(model_name)
data = call(Model, 'ui_values', db.session, id_=int(id_), fields=fields)
if data is None:
data = default_values(db.session, Model, id_=int(id_), fields=fields)
except Exception:
data = {f: None for f in fields}
cache[key] = data
return data
@main.app_template_global()
def row(model_name: str, id_: int, *fields: str):
"""
One row, many fields. Returns a dict like {'id': 1, 'a':..., 'rel.b': ...}
"""
fields = [f for f in fields if f]
key = (model_name, int(id_), tuple(fields))
cache = _tmpl_cache("row")
if key in cache:
return cache[key]
Model = get_model_class(model_name)
obj = db.session.get(Model, int(id_))
data = _project_row(obj, fields) if obj else {"id": int(id_), **{f: None for f in fields}}
cache[key] = data
return data
@main.app_template_global()
def table(model_name: str, fields: Iterable[str], *,
q: str = None, sort: str = None, direction: str = "asc",
limit: int = 100, offset: int = 0):
"""
Many rows, many fields mirrors /list behavior, but returns only the requested columns.
Uses ui_query(Model, session, **qkwargs) if present else default_query. Cached per request.
"""
fields = [f.strip() for f in (fields or []) if f and f.strip()]
key = (model_name, tuple(fields), q or "", sort or "", direction or "asc", int(limit), int(offset))
cache = _tmpl_cache("table")
if key in cache:
return cache[key]
Model = get_model_class(model_name)
qkwargs = dict(text=(q or None), limit=int(limit), offset=int(offset),
sort=(sort or None), direction=(direction or "asc").lower())
extra_opts = _eager_from_fields(Model, fields)
if extra_opts:
if isinstance(rows_any, Select):
rows_any = rows_any.options(*extra_opts)
elif rows_any is None:
original = getattr(Model, 'ui_eagerload', ())
def dyn_opts():
base = original() if callable(original) else original
return tuple(base) + tuple(extra_opts)
setattr(Model, 'ui_eagerload', dyn_opts)
rows_any: Any = call(Model, "ui_query", db.session, **qkwargs)
if rows_any is None:
objs = default_query(db.session, Model, **qkwargs)
elif isinstance(rows_any, list):
objs = rows_any
elif isinstance(rows_any, Select):
objs = list(cast(ScalarResult[Any], db.session.execute(rows_any).scalars()))
else:
scalars = getattr(rows_any, "scalars", None)
if callable(scalars):
objs = list(cast(ScalarResult[Any], scalars()))
else:
objs = list(rows_any)
data = [_project_row(o, fields) for o in objs]
cache[key] = data
return data

View file

@ -1,162 +0,0 @@
import base64
import csv
import hashlib
import io
import os
from flask import url_for, jsonify, request
from flask import current_app as app
from ..models import User, Inventory, WorkLog
from ..models.image import ImageAttachable
ROUTE_BREADCRUMBS = {
'main.user': {
'trail': [('Users', 'main.list_users')],
'model': User,
'arg': 'id',
'label_attr': 'identifier',
'url_func': lambda i: url_for('main.user', id=i.id)
},
'main.inventory_item': {
'trail': [('Inventory', 'main.list_inventory')],
'model': Inventory,
'arg': 'id',
'label_attr': 'identifier',
'url_func': lambda i: url_for('main.inventory_item', id=i.id)
},
'main.worklog': {
'trail': [('Work Log', 'main.list_worklog')],
'model': WorkLog,
'arg': 'id',
'label_attr': 'identifier',
'url_func': lambda i: url_for('main.worklog', id=i.id)
}
}
inventory_headers = {
"Date Entered": lambda i: {"field": "timestamp", "text": i.timestamp.strftime("%Y-%m-%d") if i.timestamp else None},
"Identifier": lambda i: {"field": "identifier", "text": i.identifier},
"Name": lambda i: {"field": "name", "text": i.name},
"Serial Number": lambda i: {"field": "serial", "text": i.serial},
"Bar Code": lambda i: {"field": "barcode", "text": i.barcode},
"Brand": lambda i: {"field": "brand.name", "text": i.brand.name} if i.brand else {"text": None},
"Model": lambda i: {"field": "model", "text": i.model},
"Item Type": lambda i: {"field": "device_type.description", "text": i.device_type.description} if i.device_type else {"text": None},
"Shared?": lambda i: {"field": "shared", "text": i.shared, "type": "bool", "html": checked_box if i.shared else unchecked_box},
"Owner": lambda i: {"field": "owner.identifier", "text": i.owner.identifier, "url": url_for("main.user_item", id=i.owner.id)} if i.owner else {"text": None},
"Location": lambda i: {"field": "location.identifier", "text": i.location.identifier} if i.location else {"Text": None},
"Condition": lambda i: {"field": "condition", "text": i.condition}
}
checked_box = '''
<i class="bi bi-check2"></i>
'''
unchecked_box = ''
ACTIVE_STATUSES = [
"Working",
"Deployed",
"Partially Inoperable",
"Unverified"
]
INACTIVE_STATUSES = [
"Inoperable",
"Removed",
"Disposed"
]
FILTER_MAP = {
'user': Inventory.owner_id,
'location': Inventory.location_id,
'type': Inventory.type_id,
}
user_headers = {
"Last Name": lambda i: {"field": "last_name","text": i.last_name},
"First Name": lambda i: {"field": "first_name","text": i.first_name},
"Title": lambda i: {"field": "title","text": i.title},
"Supervisor": lambda i: {"field": "supervisor,identifier","text": i.supervisor.identifier, "url": url_for("main.user_item", id=i.supervisor.id)} if i.supervisor else {"text": None},
"Location": lambda i: {"field": "location,identifier","text": i.location.identifier} if i.location else {"text": None},
"Staff?": lambda i: {"field": "staff","text": i.staff, "type": "bool", "html": checked_box if i.staff else unchecked_box},
"Active?": lambda i: {"field": "active","text": i.active, "type": "bool", "html": checked_box if i.active else unchecked_box}
}
worklog_headers = {
"Contact": lambda i: {"text": i.contact.identifier, "url": url_for("main.user_item", id=i.contact.id)} if i.contact else {"Text": None},
"Work Item": lambda i: {"text": i.work_item.identifier, "url": url_for('main.inventory_item',id=i.work_item.id)} if i.work_item else {"text": None},
"Start Time": lambda i: {"text": i.start_time.strftime("%Y-%m-%d")},
"End Time": lambda i: {"text": i.end_time.strftime("%Y-%m-%d")} if i.end_time else {"text": None},
"Complete?": lambda i: {"text": i.complete, "type": "bool", "html": checked_box if i.complete else unchecked_box},
"Follow Up?": lambda i: {"text": i.followup, "type": "bool", "html": checked_box if i.followup else unchecked_box, "highlight": i.followup},
"Quick Analysis?": lambda i: {"text": i.analysis, "type": "bool", "html": checked_box if i.analysis else unchecked_box},
}
def link(text, endpoint, **values):
return {"text": text, "url": url_for(endpoint, **values)}
def generate_hashed_filename(file) -> str:
content = file.read()
file.seek(0) # Reset after reading
hash = hashlib.sha256(content).hexdigest()
ext = os.path.splitext(file.filename)[1]
return f"{hash}_{file.filename}"
def get_image_attachable_class_by_name(name: str):
for cls in ImageAttachable.__subclasses__():
if getattr(cls, '__tablename__', None) == name:
return cls
return None
def make_csv(export_func, columns, rows):
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(columns)
for row in rows:
writer.writerow([export_func(row, col) for col in columns])
csv_string = output.getvalue()
output.close()
return jsonify({
"success": True,
"csv": base64.b64encode(csv_string.encode()).decode(),
"count": len(rows)
})
def generate_breadcrumbs():
crumbs = []
endpoint = request.endpoint
view_args = request.view_args or {}
if endpoint in ROUTE_BREADCRUMBS:
print(endpoint, view_args)
config = ROUTE_BREADCRUMBS[endpoint]
for label, ep in config.get('trail', []):
crumbs.append({'label': label, 'url': url_for(ep)})
obj_id = view_args.get(config['arg'])
if obj_id:
obj = config['model'].query.get(obj_id)
if obj:
crumbs.append({
'label': getattr(obj, config['label_attr'], str(obj)),
'url': config['url_func'](obj)
})
else:
# fallback to ugly slashes
path = request.path.strip('/').split('/')
accumulated = ''
for segment in path:
accumulated += '/' + segment
crumbs.append({'label': segment.title(), 'url': accumulated})
return crumbs

View file

@ -1,28 +0,0 @@
from bs4 import BeautifulSoup
from flask import current_app as app
from . import main
@main.after_request
def prettify_or_minify_html_response(response):
if response.content_type.startswith("text/html"):
try:
html = response.get_data(as_text=True)
soup = BeautifulSoup(html, 'html5lib')
if app.debug:
pretty_html = soup.prettify()
response.set_data(pretty_html.encode("utf-8")) # type: ignore
#else:
# # Minify by stripping extra whitespace between tags and inside text
# minified_html = re.sub(r">\s+<", "><", str(soup)) # collapse whitespace between tags
# minified_html = re.sub(r"\s{2,}", " ", minified_html) # collapse multi-spaces to one
# minified_html = re.sub(r"\n+", "", minified_html) # remove newlines
# response.set_data(minified_html.encode("utf-8")) # type: ignore
response.headers['Content-Type'] = 'text/html; charset=utf-8'
except Exception as e:
print(f"⚠️ Prettifying/Minifying failed: {e}")
return response

View file

@ -1,90 +0,0 @@
import os
import posixpath
from flask import Blueprint, current_app, request, jsonify
from .helpers import generate_hashed_filename, get_image_attachable_class_by_name
from .. import db
from ..models import Image
image_bp = Blueprint("image_api", __name__)
def save_image(file, model: str) -> str:
assert current_app.static_folder
filename = generate_hashed_filename(file)
rel_path = posixpath.join("uploads", "images", model, filename)
abs_path = os.path.join(current_app.static_folder, rel_path)
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
file.save(abs_path)
return rel_path
@image_bp.route("/api/images", methods=["POST"])
def upload_image():
file = request.files.get("file")
model = request.form.get("target_model")
model_id = request.form.get("model_id")
caption = request.form.get("caption", "")
if not file or not model or not model_id:
return jsonify({"success": False, "error": "Missing file, model, or model_id"}), 400
ModelClass = get_image_attachable_class_by_name(model)
if not ModelClass:
return jsonify({"success": False, "error": f"Model '{model}' does not support image attachments."}), 400
try:
model_id = int(model_id)
except ValueError:
return jsonify({"success": False, "error": "model_id must be an integer"}), 400
# Save file
rel_path = save_image(file, model)
# Create Image row
image = Image(filename=rel_path, caption=caption)
db.session.add(image)
# Attach image to model
target = db.session.get(ModelClass, model_id)
if not target:
return jsonify({"success": False, "error": f"No {model} found with ID {model_id}"}), 404
target.attach_image(image)
db.session.commit()
return jsonify({"success": True, "id": image.id}), 201
@image_bp.route("/api/images/<int:image_id>", methods=["GET"])
def get_image(image_id: int):
image = db.session.get(Image, image_id)
if not image:
return jsonify({"success": False, "error": f"No image found with ID {image_id}"}), 404
return jsonify({
"success": True,
"id": image.id,
"filename": image.filename,
"caption": image.caption,
"timestamp": image.timestamp.isoformat() if image.timestamp else None,
"url": f"/static/{image.filename}"
})
@image_bp.route("/api/images/<int:image_id>", methods=["DELETE"])
def delete_image(image_id):
image = db.session.get(Image, image_id)
if not image:
return jsonify({"success": False, "error": "Image not found"})
rel_path = posixpath.normpath(str(image.filename))
static_dir = str(current_app.static_folder) # appease the gods
abs_path = os.path.join(static_dir, rel_path)
if os.path.exists(abs_path):
os.remove(abs_path)
db.session.delete(image)
db.session.commit()
return jsonify({"success": True})

View file

@ -1,141 +0,0 @@
from flask import render_template, request
import pandas as pd
import random
from . import main
from .helpers import worklog_headers
from .. import db
from ..models import WorkLog, Inventory
from ..utils.load import eager_load_worklog_relationships, eager_load_inventory_relationships
def generate_solvable_matrix(level, seed_clicks=None):
size = level + 3
matrix = [[True for _ in range(size)] for _ in range(size)]
presses = []
def press(x, y):
# record the press (once)
presses.append((x, y))
# apply its effect
for dx, dy in [(0,0),(-1,0),(1,0),(0,-1),(0,1)]:
nx, ny = x + dx, y + dy
if 0 <= nx < size and 0 <= ny < size:
matrix[nx][ny] = not matrix[nx][ny]
num_clicks = seed_clicks if seed_clicks is not None else random.randint(size, size * 2)
for _ in range(num_clicks):
x = random.randint(0, size - 1)
y = random.randint(0, size - 1)
press(x, y)
return matrix, presses # return the PRESS LIST as the “solution”
@main.route("/12648243")
def coffee():
level = request.args.get('level', 0, int)
score = request.args.get('score', 0, int)
matrix, clicked = generate_solvable_matrix(level)
return render_template("coffee.html", matrix=matrix, level=level, clicked=clicked, score=score)
@main.route("/playground")
def playground():
return render_template("playground.html")
@main.route("/")
def index():
worklog_query = eager_load_worklog_relationships(
db.session.query(WorkLog)
).all()
active_worklogs = [log for log in worklog_query if not log.complete]
active_count = len(active_worklogs)
active_worklog_headers = {
k: v for k, v in worklog_headers.items()
if k not in ['End Time', 'Quick Analysis?', 'Complete?', 'Follow Up?']
}
inventory_query = eager_load_inventory_relationships(
db.session.query(Inventory)
)
results = inventory_query.all()
data = [{
'id': item.id,
'condition': item.condition
} for item in results]
df = pd.DataFrame(data)
# Count items per condition
expected_conditions = [
'Deployed','Inoperable', 'Partially Inoperable',
'Unverified', 'Working'
]
if 'condition' in df.columns:
pivot = df['condition'].value_counts().reindex(expected_conditions, fill_value=0)
else:
pivot = pd.Series([0] * len(expected_conditions), index=expected_conditions)
# Convert pandas/numpy int64s to plain old Python ints
pivot = pivot.astype(int)
labels = list(pivot.index)
data = [int(x) for x in pivot.values]
datasets = {}
datasets['summary'] = [{
'type': 'pie',
'labels': labels,
'values': data,
'name': 'Inventory Conditions'
}]
users = set([log.contact for log in worklog_query if log.contact])
work_summary = {}
for user in sorted(users, key=lambda u: u.identifier):
work_summary[user.identifier] = {}
work_summary[user.identifier]['active_count'] = len([log for log in worklog_query if log.contact == user and not log.complete])
work_summary[user.identifier]['complete_count'] = len([log for log in worklog_query if log.contact == user and log.complete])
datasets['work_summary'] = [{
'type': 'bar',
'x': list(work_summary.keys()),
'y': [work_summary[user]['active_count'] for user in work_summary],
'name': 'Active Worklogs',
'marker': {'color': 'red'}
}, {
'type': 'bar',
'x': list(work_summary.keys()),
'y': [work_summary[user]['complete_count'] for user in work_summary],
'name': 'Completed Worklogs',
'marker': {'color': 'green'}
}]
active_worklog_rows = []
for log in active_worklogs:
# Create a dictionary of {column name: cell dict}
cells_by_key = {k: fn(log) for k, fn in worklog_headers.items()}
# Use original, full header set for logic
highlight = cells_by_key.get("Follow Up?", {}).get("highlight", False)
# Use only filtered headers — and in exact order
cells = [cells_by_key[k] for k in active_worklog_headers]
active_worklog_rows.append({
"id": log.id,
"cells": cells,
"highlight": highlight
})
return render_template(
"index.html",
active_count=active_count,
active_worklog_headers=active_worklog_headers,
active_worklog_rows=active_worklog_rows,
labels=labels,
datasets=datasets
)

View file

@ -1,271 +0,0 @@
import datetime
from flask import request, render_template, jsonify
from . import main
from .helpers import FILTER_MAP, inventory_headers, worklog_headers, make_csv
from .. import db
from ..models import Inventory, User, Room, Item, RoomFunction, Brand, WorkLog
from ..utils.load import eager_load_inventory_relationships, eager_load_user_relationships, eager_load_worklog_relationships, eager_load_room_relationships, chunk_list
@main.route("/inventory")
def list_inventory():
filter_by = request.args.get('filter_by', type=str)
id = request.args.get('id', type=int)
filter_name = None
query = db.session.query(Inventory)
query = eager_load_inventory_relationships(query)
# query = query.order_by(Inventory.name, Inventory.barcode, Inventory.serial)
if filter_by and id:
column = FILTER_MAP.get(filter_by)
if column is not None:
filter_name = None
if filter_by == 'user':
if not (user := db.session.query(User).filter(User.id == id).first()):
return "Invalid User ID", 400
filter_name = user.identifier
elif filter_by == 'location':
if not (room := db.session.query(Room).filter(Room.id == id).first()):
return "Invalid Location ID", 400
filter_name = room.identifier
else:
if not (item := db.session.query(Item).filter(Item.id == id).first()):
return "Invalid Type ID", 400
filter_name = item.description
query = query.filter(column == id)
else:
return "Invalid filter_by parameter", 400
inventory = query.all()
inventory = sorted(inventory, key=lambda i: i.identifier)
rows=[{"id": item.id, "cells": [row_fn(item) for row_fn in inventory_headers.values()]} for item in inventory]
fields = [d['field'] for d in rows[0]['cells']]
return render_template(
'table.html',
title=f"Inventory Listing ({filter_name})" if filter_by else "Inventory Listing",
header=inventory_headers,
fields=fields,
rows=rows,
entry_route = 'inventory_item',
csv_route = 'inventory',
model_name = 'inventory'
)
@main.route("/inventory/index")
def inventory_index():
category = request.args.get('category')
listing = None
if category == 'user':
users = db.session.query(User.id, User.first_name, User.last_name).order_by(User.first_name, User.last_name).all()
listing = chunk_list([(user.id, f"{user.first_name or ''} {user.last_name or ''}".strip()) for user in users], 12)
elif category == 'location':
rooms = (
db.session.query(Room.id, Room.name, RoomFunction.description)
.join(RoomFunction, Room.function_id == RoomFunction.id)
.order_by(Room.name, RoomFunction.description)
.all()
)
listing = chunk_list([(room.id, f"{room.name or ''} - {room.description or ''}".strip()) for room in rooms], 12)
elif category == 'type':
types = db.session.query(Item.id, Item.description).order_by(Item.description).all()
listing = chunk_list(types, 12)
elif category:
return f"Dude, why {category}?"
return render_template(
'inventory_index.html',
title=f"Inventory ({category.capitalize()} Index)" if category else "Inventory",
category=category,
listing=listing
)
@main.route("/inventory_item/<id>", methods=['GET', 'POST'])
def inventory_item(id):
try:
id = int(id)
except ValueError:
return render_template('error.html', title="Bad ID", message="ID must be an integer", endpoint='inventory_item', endpoint_args={'id': -1})
inventory_query = db.session.query(Inventory)
item = eager_load_inventory_relationships(inventory_query).filter(Inventory.id == id).first()
brands = db.session.query(Brand).order_by(Brand.name).all()
users = eager_load_user_relationships(db.session.query(User).filter(User.active == True).order_by(User.first_name, User.last_name)).all()
rooms = eager_load_room_relationships(db.session.query(Room).order_by(Room.name)).all()
worklog_query = db.session.query(WorkLog).filter(WorkLog.work_item_id == id)
worklog = eager_load_worklog_relationships(worklog_query).all()
notes = [note for log in worklog for note in log.updates]
types = db.session.query(Item).order_by(Item.description).all()
filtered_worklog_headers = {k: v for k, v in worklog_headers.items() if k not in ['Work Item', 'Contact', 'Follow Up?', 'Quick Analysis?']}
if item:
title = f"Inventory Record - {item.identifier}"
else:
title = "Inventory Record - Not Found"
return render_template('error.html',
title=title,
message=f'Inventory item with id {id} not found!',
endpoint='inventory_item',
endpoint_args={'id': -1})
return render_template("inventory.html", title=title, item=item,
brands=brands, users=users, rooms=rooms,
worklog=worklog,
worklog_headers=filtered_worklog_headers,
worklog_rows=[{"id": log.id, "cells": [fn(log) for fn in filtered_worklog_headers.values()]} for log in worklog],
types=types,
notes=notes
)
@main.route("/inventory_item/new", methods=["GET"])
def new_inventory_item():
brands = db.session.query(Brand).order_by(Brand.name).all()
users = eager_load_user_relationships(db.session.query(User).filter(User.active == True).order_by(User.first_name, User.last_name)).all()
rooms = eager_load_room_relationships(db.session.query(Room).order_by(Room.name)).all()
types = db.session.query(Item).order_by(Item.description).all()
item = Inventory(
timestamp=datetime.datetime.now(),
condition="Unverified",
type_id=None,
)
return render_template(
"inventory.html",
item=item,
brands=brands,
users=users,
rooms=rooms,
types=types,
worklog=[],
worklog_headers={},
worklog_rows=[]
)
@main.route("/api/inventory", methods=["POST"])
def create_inventory_item():
try:
data = request.get_json(force=True)
new_item = Inventory.from_dict(data)
db.session.add(new_item)
db.session.commit()
return jsonify({"success": True, "id": new_item.id}), 201
except Exception as e:
db.session.rollback()
return jsonify({"success": False, "error": str(e)}), 400
@main.route("/api/inventory/<int:id>", methods=["PUT"])
def update_inventory_item(id):
try:
data = request.get_json(force=True)
item = db.session.query(Inventory).get(id)
if not item:
return jsonify({"success": False, "error": f"Inventory item with ID {id} not found."}), 404
item.timestamp = datetime.datetime.fromisoformat(data.get("timestamp")) if data.get("timestamp") else item.timestamp
item.condition = data.get("condition", item.condition)
item.type_id = data.get("type_id", item.type_id)
item.name = data.get("name", item.name)
item.serial = data.get("serial", item.serial)
item.model = data.get("model", item.model)
item.notes = data.get("notes", item.notes)
item.owner_id = data.get("owner_id", item.owner_id)
item.brand_id = data.get("brand_id", item.brand_id)
item.location_id = data.get("location_id", item.location_id)
item.barcode = data.get("barcode", item.barcode)
item.shared = bool(data.get("shared", item.shared))
db.session.commit()
return jsonify({"success": True, "id": item.id}), 200
except Exception as e:
db.session.rollback()
return jsonify({"success": False, "error": str(e)}), 400
@main.route("/api/inventory/<int:id>", methods=["DELETE"])
def delete_inventory_item(id):
try:
item = db.session.query(Inventory).get(id)
if not item:
return jsonify({"success": False, "error": f"Item with ID {id} not found"}), 404
db.session.delete(item)
db.session.commit()
return jsonify({"success": True}), 200
except Exception as e:
db.session.rollback()
return jsonify({"success": False, "error": str(e)}), 400
@main.route("/api/inventory/export", methods=["POST"])
def get_inventory_csv():
def export_value(item, col):
try:
match col:
case "brand":
return item.brand.name
case "location":
return item.location.identifier
case "owner":
return item.owner.identifier
case "type":
return item.device_type.description
case _:
return getattr(item, col, "")
except Exception:
return ""
data = request.get_json()
ids = data.get('ids', [])
if not ids:
return jsonify({"success": False, "error": "No IDs provided"}), 400
rows = eager_load_inventory_relationships(db.session.query(Inventory).filter(Inventory.id.in_(ids))).all()
columns = [
"id",
"timestamp",
"condition",
"type",
"name",
"serial",
"model",
"notes",
"owner",
"brand",
"location",
"barcode",
"shared"
]
return make_csv(export_value, columns, rows)
@main.route("/inventory_available")
def inventory_available():
query = eager_load_inventory_relationships(db.session.query(Inventory).filter(Inventory.condition == "Working"))
inventory = query.all()
inventory = sorted(inventory, key=lambda i: i.identifier)
return render_template(
"table.html",
title = "Available Inventory",
header=inventory_headers,
rows=[{"id": item.id, "cells": [row_fn(item) for row_fn in inventory_headers.values()]} for item in inventory],
entry_route = 'inventory_item'
)

View file

@ -1,72 +0,0 @@
from flask import request, redirect, url_for, render_template
from sqlalchemy import or_
from sqlalchemy.orm import aliased
from . import main
from .helpers import inventory_headers, user_headers, worklog_headers
from .. import db
from ..models import Inventory, User, WorkLog
from ..utils.load import eager_load_inventory_relationships, eager_load_user_relationships, eager_load_worklog_relationships
@main.route("/search")
def search():
query = request.args.get('q', '').strip()
if not query:
return redirect(url_for('main.index'))
InventoryAlias = aliased(Inventory)
UserAlias = aliased(User)
inventory_query = eager_load_inventory_relationships(db.session.query(Inventory).join(UserAlias, Inventory.owner)).filter(
or_(
Inventory.name.ilike(f"%{query}%"),
Inventory.serial.ilike(f"%{query}%"),
Inventory.barcode.ilike(f"%{query}%"),
Inventory.notes.ilike(f"%{query}%"),
UserAlias.first_name.ilike(f"%{query}%"),
UserAlias.last_name.ilike(f"%{query}%"),
UserAlias.title.ilike(f"%{query}%")
))
inventory_results = inventory_query.all()
user_query = eager_load_user_relationships(db.session.query(User).outerjoin(UserAlias, User.supervisor)).filter(
or_(
User.first_name.ilike(f"%{query}%"),
User.last_name.ilike(f"%{query}%"),
User.title.ilike(f"%{query}%"),
UserAlias.first_name.ilike(f"%{query}%"),
UserAlias.last_name.ilike(f"%{query}%"),
UserAlias.title.ilike(f"%{query}%")
))
user_results = user_query.all()
worklog_query = eager_load_worklog_relationships(db.session.query(WorkLog).join(UserAlias, WorkLog.contact).join(InventoryAlias, WorkLog.work_item)).filter(
or_(
WorkLog.notes.ilike(f"%{query}%"),
UserAlias.first_name.ilike(f"%{query}%"),
UserAlias.last_name.ilike(f"%{query}%"),
UserAlias.title.ilike(f"%{query}%"),
InventoryAlias.name.ilike(f"%{query}%"),
InventoryAlias.serial.ilike(f"%{query}%"),
InventoryAlias.barcode.ilike(f"%{query}%")
))
worklog_results = worklog_query.all()
results = {
'inventory': {
'results': inventory_query,
'headers': inventory_headers,
'rows': [{"id": item.id, "cells": [fn(item) for fn in inventory_headers.values()]} for item in inventory_results]
},
'users': {
'results': user_query,
'headers': user_headers,
'rows': [{"id": user.id, "cells": [fn(user) for fn in user_headers.values()]} for user in user_results]
},
'worklog': {
'results': worklog_query,
'headers': worklog_headers,
'rows': [{"id": log.id, "cells": [fn(log) for fn in worklog_headers.values()]} for log in worklog_results]
}
}
return render_template('search.html', title=f"Database Search ({query})" if query else "Database Search", results=results, query=query)

View file

@ -1,15 +0,0 @@
from flask import render_template
from . import main
from .. import db
from ..models import Image
from ..utils.load import chunk_list
@main.route('/settings')
def settings():
images = chunk_list(db.session.query(Image).order_by(Image.timestamp).all(), 6)
return render_template('settings.html',
title="Settings",
image_list=images
)

View file

@ -1,189 +0,0 @@
import base64
import csv
import io
from flask import render_template, request, jsonify
from . import main
from .helpers import ACTIVE_STATUSES, user_headers, inventory_headers, worklog_headers, make_csv
from .. import db
from ..utils.load import eager_load_user_relationships, eager_load_room_relationships, eager_load_inventory_relationships, eager_load_worklog_relationships
from ..models import User, Room, Inventory, WorkLog
@main.route("/users")
def list_users():
return render_template(
'table.html',
header = user_headers,
model_name = 'user',
title = "Users",
entry_route = 'user_item',
csv_route = 'user',
fields = ['last_name', 'first_name', 'title', 'supervisor.identifier', 'location.identifier', 'staff', 'active'],
)
@main.route("/user/<id>")
def user_item(id):
try:
id = int(id)
except ValueError:
return render_template('error.html', title='Bad ID', message='ID must be an integer.', endpoint='user_item', endpoint_args={'id': -1})
users_query = db.session.query(User).order_by(User.first_name, User.last_name)
users = eager_load_user_relationships(users_query).all()
user = next((u for u in users if u.id == id), None)
rooms_query = db.session.query(Room)
rooms = eager_load_room_relationships(rooms_query).all()
inventory_query = (
eager_load_inventory_relationships(db.session.query(Inventory))
.filter(Inventory.owner_id == id) # type: ignore
.filter(Inventory.condition.in_(ACTIVE_STATUSES))
)
inventory = inventory_query.all()
filtered_inventory_headers = {k: v for k, v in inventory_headers.items() if k not in ['Date Entered', 'Name', 'Serial Number',
'Bar Code', 'Condition', 'Owner', 'Notes',
'Brand', 'Model', 'Shared?', 'Location']}
worklog_query = eager_load_worklog_relationships(db.session.query(WorkLog)).filter(WorkLog.contact_id == id)
worklog = worklog_query.order_by(WorkLog.start_time.desc()).all()
filtered_worklog_headers = {k: v for k, v in worklog_headers.items() if k not in ['Contact', 'Follow Up?', 'Quick Analysis?']}
if user:
title = f"User Record - {user.identifier}" if user.active else f"User Record - {user.identifier} (Inactive)"
else:
title = f"User Record - User Not Found"
return render_template(
'error.html',
title=title,
message=f"User with id {id} not found!"
)
return render_template(
"user.html",
title=title,
user=user, users=users, rooms=rooms, assets=inventory,
inventory_headers=filtered_inventory_headers,
inventory_rows=[{"id": item.id, "cells": [fn(item) for fn in filtered_inventory_headers.values()]} for item in inventory],
worklog=worklog,
worklog_headers=filtered_worklog_headers,
worklog_rows=[{"id": log.id, "cells": [fn(log) for fn in filtered_worklog_headers.values()]} for log in worklog]
)
@main.route("/user/<id>/org")
def user_org(id):
user = eager_load_user_relationships(db.session.query(User).filter(User.id == id).order_by(User.first_name, User.last_name)).first()
if not user:
return render_template('error.html', title='User Not Found', message=f'User with ID {id} not found.')
current_user = user
org_chart = []
while current_user:
subordinates = (
eager_load_user_relationships(
db.session.query(User).filter(User.supervisor_id == current_user.id).order_by(User.first_name, User.last_name)
).all()
)
org_chart.insert(0, {
"user": current_user,
"subordinates": [subordinate for subordinate in subordinates if subordinate.active and subordinate.staff]
})
current_user = current_user.supervisor
return render_template(
"user_org.html",
user=user,
org_chart=org_chart
)
@main.route("/user/new", methods=["GET"])
def new_user():
rooms = eager_load_room_relationships(db.session.query(Room)).all()
users = eager_load_user_relationships(db.session.query(User)).all()
user = User(
active=True
)
return render_template(
"user.html",
title="New User",
user=user,
users=users,
rooms=rooms
)
@main.route("/api/user", methods=["POST"])
def create_user():
try:
data = request.get_json(force=True)
new_user = User.from_dict(data)
db.session.add(new_user)
db.session.commit()
return jsonify({"success": True, "id": new_user.id}), 201
except Exception as e:
db.session.rollback()
return jsonify({"success": False, "error": str(e)}), 400
@main.route("/api/user/<int:id>", methods=["PUT"])
def update_user(id):
try:
data = request.get_json(force=True)
user = db.session.query(User).get(id)
if not user:
return jsonify({"success": False, "error": f"User with ID {id} not found."}), 404
user.staff = bool(data.get("staff", user.staff))
user.active = bool(data.get("active", user.active))
user.last_name = data.get("last_name", user.last_name)
user.first_name = data.get("first_name", user.first_name)
user.title = data.get("title", user.title)
user.location_id = data.get("location_id", user.location_id)
user.supervisor_id = data.get("supervisor_id", user.supervisor_id)
db.session.commit()
return jsonify({"success": True, "id": user.id}), 200
except Exception as e:
db.session.rollback()
return jsonify({"success": False, "error": str(e)}), 400
@main.route("/api/user/export", methods=["POST"])
def get_user_csv():
def export_value(user, col):
try:
match col:
case "location":
return user.location.identifier
case "supervisor":
return user.supervisor.identifier
case _:
return getattr(user, col, "")
except Exception:
return ""
data = request.get_json()
ids = data.get('ids', [])
if not ids:
return jsonify({"success": False, "error": "No IDs provided"}), 400
rows = eager_load_user_relationships(db.session.query(User).filter(User.id.in_(ids))).all()
columns = [
"id",
"staff",
"active",
"last_name",
"first_name",
"title",
"location",
"supervisor"
]
return make_csv(export_value, columns, rows)

View file

@ -1,195 +0,0 @@
import base64
import csv
import datetime
import io
from flask import request, render_template, jsonify
from . import main
from .helpers import worklog_headers, make_csv
from .. import db
from ..models import WorkLog, User, Inventory, WorkNote
from ..utils.load import eager_load_worklog_relationships, eager_load_user_relationships, eager_load_inventory_relationships
@main.route("/worklog")
def list_worklog():
query = eager_load_worklog_relationships(db.session.query(WorkLog))
return render_template(
'table.html',
header=worklog_headers,
model_name='worklog',
title="Work Log",
fields = ['contact.identifier', 'work_item.identifier', 'start_time', 'end_time', 'complete', 'followup', 'analysis'],
entry_route='worklog_item',
csv_route='worklog'
)
@main.route("/worklog/<id>")
def worklog_item(id):
try:
id = int(id)
except ValueError:
return render_template('error.html', title='Bad ID', message='ID must be an integer.', endpoint='worklog_item', endpoint_args={'id': -1})
log = eager_load_worklog_relationships(db.session.query(WorkLog)).get(id)
user_query = db.session.query(User).order_by(User.first_name)
users = eager_load_user_relationships(user_query).all()
item_query = db.session.query(Inventory)
items = eager_load_inventory_relationships(item_query).all()
items = sorted(items, key=lambda i: i.identifier)
if log:
title = f'Work Log - Entry #{id}'
else:
title = "Work Log - Entry Not Found"
return render_template(
'error.html',
title=title,
message=f"The work log with ID {id} is not found!"
)
return render_template(
"worklog.html",
title=title,
log=log,
users=users,
items=items
)
@main.route("/worklog_item/new", methods=["GET"])
def new_worklog():
items = eager_load_inventory_relationships(db.session.query(Inventory)).all()
users = eager_load_user_relationships(db.session.query(User).order_by(User.first_name)).all()
items = sorted(items, key=lambda i: i.identifier)
log = WorkLog(
start_time=datetime.datetime.now(),
followup=True
)
return render_template(
"worklog.html",
title="New Entry",
log=log,
users=users,
items=items
)
@main.route("/api/worklog", methods=["POST"])
def create_worklog():
try:
data = request.get_json(force=True)
new_worklog = WorkLog.from_dict(data)
db.session.add(new_worklog)
db.session.commit()
return jsonify({"success": True, "id": new_worklog.id}), 201
except Exception as e:
db.session.rollback()
return jsonify({"success": False, "error": str(e)}), 400
@main.route("/api/worklog/<int:id>", methods=["PUT"])
def update_worklog(id):
try:
data = request.get_json(force=True)
log = eager_load_worklog_relationships(db.session.query(WorkLog)).get(id)
if not log:
return jsonify({"success": False, "error": f"Work Log with ID {id} not found."}), 404
log.start_time = datetime.datetime.fromisoformat(data.get("start_time")) if data.get("start_time") else log.start_time
log.end_time = datetime.datetime.fromisoformat(data.get("end_time")) if data.get("end_time") else log.end_time
log.complete = bool(data.get("complete", log.complete))
log.followup = bool(data.get("followup", log.followup))
log.analysis = bool(data.get("analysis", log.analysis))
log.contact_id = data.get("contact_id", log.contact_id)
log.work_item_id = data.get("work_item_id", log.work_item_id)
existing = {str(note.id): note for note in log.updates}
incoming = data.get("updates", [])
new_updates = []
for note_data in incoming:
if isinstance(note_data, dict):
if "id" in note_data and str(note_data["id"]) in existing:
note = existing[str(note_data["id"])]
note.content = note_data.get("content", note.content)
new_updates.append(note)
elif "content" in note_data:
new_updates.append(WorkNote(content=note_data["content"]))
log.updates[:] = new_updates # This replaces in-place
db.session.commit()
return jsonify({"success": True, "id": log.id}), 200
except Exception as e:
db.session.rollback()
return jsonify({"success": False, "error": str(e)}), 400
@main.route("/api/worklog/<int:id>", methods=["DELETE"])
def delete_worklog(id):
try:
log = db.session.query(WorkLog).get(id)
if not log:
return jsonify({"success": False, "errpr": f"Item with ID {id} not found!"}), 404
db.session.delete(log)
db.session.commit()
return jsonify({"success": True}), 200
except Exception as e:
db.session.rollback()
return jsonify({"success": False, "error": str(e)}), 400
@main.route("/api/worklog/export", methods=["POST"])
def get_worklog_csv():
def export_value(log, col):
try:
match col:
case "contact":
return log.contact.identifier
case "work_item":
return log.work_item.identifier
case "latest_update":
if log.updates:
return log.updates[-1].content
return ""
case _:
return getattr(log, col, "")
except Exception:
return ""
data = request.get_json()
ids = data.get('ids', [])
if not ids:
return jsonify({"success": False, "error": "No IDs provided"}), 400
rows = eager_load_worklog_relationships(db.session.query(WorkLog).filter(WorkLog.id.in_(ids))).all()
columns = [
"id",
"start_time",
"end_time",
"complete",
"followup",
"contact",
"work_item",
"analysis",
"latest_update"
]
return make_csv(export_value, columns, rows)
# return jsonify({
# "success": True,
# "csv": base64.b64encode(csv_string.encode()).decode(),
# "count": len(rows)
# })

View file

@ -1,20 +0,0 @@
.combo-box-widget .form-control:focus,
.combo-box-widget .form-select:focus,
.combo-box-widget .btn:focus {
box-shadow: none !important;
outline: none !important;
border-color: #ced4da !important; /* Bootstraps default neutral border */
background-color: inherit; /* Or explicitly #fff if needed */
color: inherit;
}
.combo-box-widget .btn-primary:focus,
.combo-box-widget .btn-danger:focus {
background-color: inherit; /* Keep button from darkening */
color: inherit;
}
.combo-box-widget:focus-within {
box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.25);
border-radius: 0.375rem;
}

View file

@ -1,4 +0,0 @@
.dropdown-search-input:focus {
outline: none !important;
box-shadow: none !important;
}

View file

@ -1,109 +0,0 @@
function ComboBox(cfg) {
return {
id: cfg.id,
createUrl: cfg.createUrl,
editUrl: cfg.editUrl,
deleteUrl: cfg.deleteUrl,
refreshUrl: cfg.refreshUrl,
query: '',
isEditing: false,
editingOption: null,
selectedIds: [],
get hasSelection() { return this.selectedIds.length > 0 },
onListChange() {
const sel = Array.from(this.$refs.list.selectedOptions);
this.selectedIds = sel.map(o => o.value);
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) {
const id = this.editingOption.value;
const ok = await this._post(this.editUrl, { id, name });
if (ok) this.editingOption.textContent = name;
this.$dispatch('combobox:item-edited', { id, name, ...this.editingOption.dataset });
} else if (this.createUrl) {
const data = await this._post(this.createUrl, { name }, true);
const id = (data && data.id) ? data.id : ('temp-' + Math.random().toString(36).slice(2));
const opt = document.createElement('option');
opt.value = id; opt.textContent = data?.name || name;
this.$refs.list.appendChild(opt);
this._sortOptions();
this.$dispatch('combobox:item-created', { id, name: data?.name || name });
}
this.query = '';
this.cancelEdit();
this._maybeRefresh();
},
async removeSelected() {
const ids = [...this.selectedIds];
if (!ids.length) return;
if (!confirm(`Delete ${ids.length} item(s)?`)) return;
let ok = true;
if (this.deleteUrl) ok = !!(await this._post(this.deleteUrl, { ids }));
if (!ok) return;
// Remove matching options from DOM
const all = Array.from(this.$refs.list.options);
all.forEach(o => { if (ids.includes(o.value)) o.remove(); });
// Clear selection reactively
this.selectedIds = [];
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, expectJson = false) {
try {
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');
alert(msg);
return false;
}
if (expectJson) {
const ct = res.headers.get('content-type') || '';
if (ct.includes('application/json')) return await res.json();
}
return true;
} catch (e) {
alert('Network error');
return false;
}
}
}
}

View file

@ -1,33 +0,0 @@
async function export_csv(ids, csv_route, filename=`${csv_route}_export.csv`) {
const payload = ids;
try {
const response = await fetch(`/api/${csv_route}/export`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify(payload)
});
const result = await response.json();
if (result.success) {
const decodedCsv = atob(result.csv);
const blob = new Blob([decodedCsv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
} else {
Toast.renderToast({ message: `Export failed: ${result.error}`, type: 'danger' });
}
} catch (err) {
Toast.renderToast({ message: `Export failed: ${err}`, type: 'danger' });
}
}

View file

@ -1,123 +0,0 @@
function DropDown(cfg) {
return {
id: cfg.id,
refreshUrl: cfg.refreshUrl,
selectUrl: cfg.selectUrl,
recordId: cfg.recordId, // NEW
field: cfg.field, // NEW
selectedId: null,
selectedLabel: '',
init() {
const v = this.$refs.hidden?.value || '';
if (v) {
this.selectedId = v;
this.$refs.clear?.classList.remove('d-none');
}
this.$refs.button.addEventListener('shown.bs.dropdown', (e) => this.onShown(e));
},
itemSelect(e) {
const a = e.currentTarget;
const id = a.dataset.invValue || a.getAttribute('data-inv-value');
const label = a.textContent.trim();
const hidden = this.$refs.hidden;
const button = this.$refs.button;
const clear = this.$refs.clear;
this.selectedId = id;
this.selectedLabel = label;
if (hidden) hidden.value = id;
if (button) {
button.textContent = label || '-';
button.dataset.invValue = id;
button.classList.add("rounded-end-0", "border-end-0");
button.classList.remove("rounded-end");
}
clear?.classList.toggle('d-none', !id);
if (this.selectUrl && this.recordId && this.field) {
const payload = { id: this.recordId };
payload[this.field] = id ? parseInt(id) : null;
fetch(this.selectUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).catch(() => { });
}
},
clearSelection() {
const hidden = this.$refs.hidden;
const button = this.$refs.button;
const clear = this.$refs.clear;
this.selectedId = '';
this.selectedLabel = '';
if (hidden) hidden.value = '';
if (button) {
button.textContent = '-';
button.removeAttribute('data-inv-value');
button.classList.remove("rounded-end-0", "border-end-0");
button.classList.add("rounded-end");
}
clear?.classList.add('d-none');
if (this.selectUrl && this.recordId && this.field) {
const payload = { id: this.recordId };
payload[this.field] = null;
fetch(this.selectUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).catch(() => { });
}
this.$dispatch('dropdown:cleared', {});
},
onShown() {
const { menu, search, content } = this.$refs || {};
if (!menu || !search || !content) return;
requestAnimationFrame(() => {
const viewportH = window.innerHeight;
const menuTop = menu.getBoundingClientRect().top;
const capByViewport = viewportH * 0.40;
const spaceBelow = viewportH - menuTop - 12;
const menuCap = Math.max(0, Math.min(capByViewport, spaceBelow));
const inputH = search.offsetHeight || 0;
const contentMax = Math.max(0, menuCap - inputH);
content.style.maxHeight = `${contentMax - 2}px`;
requestAnimationFrame(() => search.focus());
});
},
filterItems() {
const search = this.$refs.search;
const dropdown = this.$refs.dropdown;
const filter = search.value.toLowerCase().trim();
const items = dropdown.querySelectorAll('.dropdown-item');
items.forEach(item => {
const text = item.textContent.toLowerCase();
if (text.includes(filter)) {
item.classList.remove('d-none');
} else {
item.classList.add('d-none');
}
});
}
};
}

View file

@ -1,75 +0,0 @@
function Editor(cfg) {
return {
id: cfg.id,
refreshUrl: cfg.refreshUrl,
updateUrl: cfg.updateUrl,
createUrl: cfg.createUrl,
deleteUrl: cfg.deleteUrl,
fieldName: cfg.fieldName,
recordId: cfg.recordId,
init() {
this.renderViewer();
if (this.refreshUrl) this.refresh();
},
buildRefreshUrl() {
if (!this.refreshUrl) return null;
const u = new URL(this.refreshUrl, window.location.origin);
u.search = new URLSearchParams({ field: this.fieldName, id: this.recordId }).toString();
return u.toString();
},
async refresh() {
const url = this.buildRefreshUrl();
if (!url) return;
const res = await fetch(url, { headers: { 'HX-Request': 'true' } });
const text = await res.text();
if (this.$refs.editor) {
this.$refs.editor.value = text;
this.resizeEditor();
this.renderViewer();
}
},
triggerRefresh() {
this.$refs.container?.dispatchEvent(new CustomEvent('editor:refresh', { bubbles: true }));
},
openEditTab() {
this.$nextTick(() => { this.resizeEditor(); this.renderViewer(); });
},
resizeEditor() {
const ta = this.$refs.editor;
if (!ta) return;
ta.style.height = 'auto';
ta.style.height = `${ta.scrollHeight + 2}px`;
},
renderViewer() {
const ta = this.$refs.editor, viewer = this.$refs.viewer;
if (!viewer || !ta) return;
const raw = ta.value || '';
viewer.innerHTML = (window.marked ? marked.parse(raw) : raw);
}
};
}
let tempIdCounter = 1;
function createEditorWidget(template, id, timestamp, content = '') {
let html = template.innerHTML
.replace(/__ID__/g, id)
.replace(/__TIMESTAMP__/g, timestamp)
.replace(/__CONTENT__/g, content);
const wrapper = document.createElement("div");
wrapper.innerHTML = html;
return wrapper.firstElementChild;
}
function createTempId(prefix = "temp") {
return `${prefix}-${tempIdCounter++}`;
}

View file

@ -1,52 +0,0 @@
const ImageWidget = (() => {
function submitImageUpload(id) {
const form = document.getElementById(`image-upload-form-${id}`);
const formData = new FormData(form);
fetch("/api/images", {
method: "POST",
body: formData
}).then(async response => {
if (!response.ok) {
// Try to parse JSON, fallback to text
const contentType = response.headers.get("Content-Type") || "";
let errorDetails;
if (contentType.includes("application/json")) {
errorDetails = await response.json();
} else {
errorDetails = { error: await response.text() };
}
throw errorDetails;
}
return response.json();
}).then(data => {
Toast.renderToast({ message: `Image uploaded.`, type: "success" });
location.reload();
}).catch(err => {
const msg = typeof err === "object" && err.error ? err.error : err.toString();
Toast.renderToast({ message: `Upload failed: ${msg}`, type: "danger" });
});
}
function deleteImage(inventoryId, imageId) {
if (!confirm("Are you sure you want to delete this image?")) return;
fetch(`/api/images/${imageId}`, {
method: "DELETE"
}).then(response => response.json()).then(data => {
if (data.success) {
Toast.renderToast({ message: "Image deleted.", type: "success" });
location.reload(); // Update view
} else {
Toast.renderToast({ message: `Failed to delete: ${data.error}`, type: "danger" });
}
}).catch(err => {
Toast.renderToast({ message: `Error deleting image: ${err}`, type: "danger" });
});
}
return {
submitImageUpload,
deleteImage
}
})();

View file

@ -1,29 +0,0 @@
function Label(cfg) {
return {
id: cfg.id,
refreshUrl: cfg.refreshUrl,
fieldName: cfg.fieldName,
recordId: cfg.recordId,
init() {
if (this.refreshUrl) this.refresh();
},
buildRefreshUrl() {
if (!this.refreshUrl) return null;
const u = new URL(this.refreshUrl, window.location.origin);
u.search = new URLSearchParams({ field: this.fieldName, id: this.recordId }).toString();
return u.toString();
},
async refresh() {
const url = this.buildRefreshUrl();
if (!url) return;
const res = await fetch(url, { headers: { 'HX-Request': 'true' } });
const text = await res.text();
if (this.$refs.label) {
this.$refs.label.innerHTML = text;
}
}
};
}

View file

@ -1,115 +0,0 @@
function Table(cfg) {
return {
id: cfg.id,
refreshUrl: cfg.refreshUrl,
headers: cfg.headers || [],
fields: cfg.fields || [],
// external API
perPage: cfg.perPage || 10,
offset: cfg.offset || 0,
// derived + server-fed state
page: Math.floor((cfg.offset || 0) / (cfg.perPage || 10)) + 1,
total: 0,
pages: 0,
init() {
if (this.refreshUrl) this.refresh();
},
buildRefreshUrl() {
if (!this.refreshUrl) return null;
const u = new URL(this.refreshUrl, window.location.origin);
// We want server-side pagination with page/per_page
u.searchParams.set('view', 'table');
u.searchParams.set('page', this.page);
u.searchParams.set('per_page', this.perPage);
// Send requested fields in the way your backend expects
// If your route supports &field=... repeaters, do this:
this.fields.forEach(f => u.searchParams.append('field', f));
// If your route only supports "fields=a,b,c", then use:
// if (this.fields.length) u.searchParams.set('fields', this.fields.join(','));
return u.toString();
},
async refresh() {
const url = this.buildRefreshUrl();
if (!url) return;
const res = await fetch(url, { headers: { 'X-Requested-With': 'fetch' } });
const html = await res.text();
// Dump the server-rendered <tr> rows into the tbody
if (this.$refs.body) this.$refs.body.innerHTML = html;
// Read pagination metadata from headers
const toInt = (v, d=0) => {
const n = parseInt(v ?? '', 10);
return Number.isFinite(n) ? n : d;
};
const total = toInt(res.headers.get('X-Total'));
const pages = toInt(res.headers.get('X-Pages'));
const page = toInt(res.headers.get('X-Page'), this.page);
const per = toInt(res.headers.get('X-Per-Page'), this.perPage);
// Update local state
this.total = total;
this.pages = pages;
this.page = page;
this.perPage = per;
this.offset = (this.page - 1) * this.perPage;
// Update pager UI (if you put <ul x-ref="pagination"> in your caption)
this.buildPager();
// Caption numbers are bound via x-text so they auto-update.
},
buildPager() {
const ul = this.$refs.pagination;
if (!ul) return;
ul.innerHTML = '';
const mk = (label, page, disabled=false, active=false) => {
const li = document.createElement('li');
li.className = `page-item${disabled ? ' disabled' : ''}${active ? ' active' : ''}`;
const a = document.createElement('a');
a.className = 'page-link';
a.href = '#';
a.textContent = label;
a.onclick = (e) => {
e.preventDefault();
if (disabled || active) return;
this.page = page;
this.refresh();
};
li.appendChild(a);
return li;
};
// Prev
ul.appendChild(mk('«', Math.max(1, this.page - 1), this.page <= 1));
// Windowed page buttons
const maxButtons = 7;
let start = Math.max(1, this.page - Math.floor(maxButtons/2));
let end = Math.min(this.pages || 1, start + maxButtons - 1);
start = Math.max(1, Math.min(start, Math.max(1, end - maxButtons + 1)));
if (start > 1) ul.appendChild(mk('1', 1));
if (start > 2) ul.appendChild(mk('…', this.page, true));
for (let p = start; p <= end; p++) {
ul.appendChild(mk(String(p), p, false, p === this.page));
}
if (end < (this.pages || 1) - 1) ul.appendChild(mk('…', this.page, true));
if (end < (this.pages || 1)) ul.appendChild(mk(String(this.pages), this.pages));
// Next
ul.appendChild(mk('»', Math.min(this.pages || 1, this.page + 1), this.page >= (this.pages || 1)));
},
};
}

View file

@ -1,70 +0,0 @@
document.addEventListener("DOMContentLoaded", () => {
const toastData = localStorage.getItem("toastMessage");
if (toastData) {
const { message, type } = JSON.parse(toastData);
Toast.renderToast({ message, type });
localStorage.removeItem("toastMessage");
}
});
const Toast = (() => {
const ToastConfig = {
containerId: 'toast-container',
positionClasses: 'toast-container position-fixed bottom-0 end-0 p-3',
defaultType: 'info',
defaultTimeout: 3000
};
function updateToastConfig(overrides = {}) {
Object.assign(ToastConfig, overrides);
}
function renderToast({ message, type = ToastConfig.defaultType, timeout = ToastConfig.defaultTimeout }) {
if (!message) {
console.warn('renderToast was called without a message.');
return;
}
// Auto-create the toast container if it doesn't exist
let container = document.getElementById(ToastConfig.containerId);
if (!container) {
container = document.createElement('div');
container.id = ToastConfig.containerId;
container.className = ToastConfig.positionClasses;
document.body.appendChild(container);
}
const toastId = `toast-${Date.now()}`;
const wrapper = document.createElement('div');
wrapper.innerHTML = `
<div id="${toastId}" class="toast align-items-center text-bg-${type}" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
`;
const toastElement = wrapper.firstElementChild;
container.appendChild(toastElement);
const toast = new bootstrap.Toast(toastElement, { delay: timeout });
toast.show();
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.remove();
// Clean up container if empty
if (container.children.length === 0) {
container.remove();
}
});
}
return {
updateToastConfig,
renderToast
};
})();

View file

@ -1,120 +0,0 @@
{% extends 'layout.html' %}
{% block style %}
.light {
transition: background-color .25s;
}
{% endblock %}
{% block precontent %}
{{ toolbars.render_toolbar(
id = 'score',
left = '<span id="best_score">Loading...</span>' | safe,
right= ('<span id="current_score">Score: ' + score|string + '</span>') | safe
) }}
{% endblock %}
{% block content %}
<div class="container border border-black bg-success-subtle">
{% for x in range(level + 3) %}
<div class="row" style="min-height: {{ 80 / (level + 3) }}vh;">
{% for y in range(level + 3) %}
<div class="col m-0 p-0 align-items-center d-flex justify-content-center outline outline-{% if (x, y) in clicked and false %}danger border-2{% else %}black{% endif %} light" id="light-{{ x }}-{{ y }}">
<div class="form-check">
<input type="checkbox" class="form-check-input d-none" id="checkbox-{{ x }}-{{ y }}"{% if matrix[x][y] %} checked{% endif %}>
</div>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
{% endblock %}
{% block script %}
var score = {{ score }};
var initialScore = score;
const gridSize = {{ level + 3 }};
var clickOrder = {};
var clickCounter = 0;
updateLights();
{{ clicked | tojson }}.forEach(([x, y]) => {
clickCounter++;
const key = `${x}-${y}`;
(clickOrder[key] ??= []).push(clickCounter);
});
let bestScore = Object.values(clickOrder)
.reduce((n, arr) => n + (arr.length & 1), 0);
document.getElementById('best_score').textContent = `Perfect Clicks: ${bestScore}`;
Object.entries(clickOrder).forEach(([key, value]) => {
const light = document.querySelector(`#light-${key}`);
// light.innerHTML += value;
});
function updateLights() {
document.querySelectorAll('.light').forEach(light => {
const [x, y] = light.id.split('-').slice(1).map(Number);
const checkbox = document.querySelector(`#checkbox-${x}-${y}`);
if(checkbox.checked) {
light.classList.add('bg-danger-subtle');
light.classList.remove('bg-light-subtle');
} else {
light.classList.remove('bg-danger-subtle');
light.classList.add('bg-light-subtle');
}
});
}
document.querySelectorAll('.light').forEach(light => {
light.addEventListener('click', function() {
const [x, y] = this.id.split('-').slice(1).map(Number);
const checkbox = document.querySelector(`#checkbox-${x}-${y}`);
++score;
document.getElementById('current_score').textContent = `Score: ${score}`;
// Toggle manually
checkbox.checked = !checkbox.checked;
// Fire a non-bubbling change so it won't climb back to .light
checkbox.dispatchEvent(new Event('change'));
updateLights();
});
});
document.querySelectorAll('.form-check-input').forEach(checkbox => {
checkbox.addEventListener('change', function() {
const [x, y] = this.id.split('-').slice(1).map(Number);
const neighbors = [
[x - 1, y],
[x + 1, y],
[x, y - 1],
[x, y + 1]
];
neighbors.forEach(([nx, ny]) => {
if (nx < 0 || nx >= gridSize || ny < 0 || ny >= gridSize) return; // Skip out of bounds
const neighborCheckbox = document.querySelector(`#checkbox-${nx}-${ny}`);
neighborCheckbox.checked = !neighborCheckbox.checked;
});
// Check if all checkboxes are checked
const allChecked = Array.from(document.querySelectorAll('.form-check-input')).every(cb => cb.checked);
const allUnchecked = Array.from(document.querySelectorAll('.form-check-input')).every(cb => !cb.checked);
if (allChecked && !window.__alreadyNavigated && {{ level }} < 51) {
window.__alreadyNavigated = true;
if ((score - bestScore) == initialScore) {
bestScore *= 2;
}
location.href = `{{ url_for('main.coffee', level=level + 1) | safe }}&score=${Math.max(score - bestScore, 0)}`;
} else if (allUnchecked && !window.__alreadyNavigated && {{ level }} > -2) {
window.__alreadyNavigated = true;
location.href = `{{ url_for('main.coffee', level=level - 1) | safe }}&score=${score + bestScore}`;
}
});
});
{% endblock %}

View file

@ -1,9 +0,0 @@
{% extends 'layout.html' %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="alert alert-danger text-center">
{{ message }}
</div>
{% endblock %}

View file

@ -1,18 +0,0 @@
{% import "fragments/_icon_fragment.html" as icons %}
{% macro render_breadcrumb(breadcrumbs=[]) %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb m-0">
<li class="breadcrumb-item"><a href="{{ url_for('main.index') }}" class="link-success link-underline-opacity-0">{{ icons.render_icon('house', 16) }}</a></li>
{% for crumb in breadcrumbs %}
<li class="breadcrumb-item{% if loop.last %} active{% endif %}" {% if loop.last %}aria-current="page"{% endif %}>
{% if not loop.last %}
<a href="{{ crumb.url }}" class="link-success link-underline-opacity-0">{{ crumb.label }}</a>
{% else %}
{{ crumb.label }}
{% endif %}
</li>
{% endfor %}
</ol>
</nav>
{% endmacro %}

View file

@ -1,16 +0,0 @@
{% import "fragments/_icon_fragment.html" as icons %}
{% macro render_button(id, icon, style='primary', logic = None, label = None, enabled = True) %}
<!-- Button Fragment -->
<button type="button" class="btn btn-{{ style }}" id="{{ id }}Button"{% if not enabled %} disabled{% endif %}>{{ icons.render_icon(icon, 16) }}{{ label if label
else '' }}</button>
{% if logic %}
<script>
document.addEventListener("DOMContentLoaded", () => {
document.getElementById("{{ id }}Button").addEventListener("click", async (e) => {
{{ logic | safe }}
});
});
</script>
{% endif %}
{% endmacro %}

View file

@ -1,49 +0,0 @@
{% import "fragments/_icon_fragment.html" as icons %}
{% macro render_combobox(
id, options, label = none, placeholder = none, data_attributes = none,
create_url = none, edit_url = none, delete_url = none, refresh_url = none
) %}
{% if label %}
<label for="{{ id }}-input" class="form-label">{{ label }}</label>
{% endif %}
<div id="{{ id }}-container" x-data='ComboBox({
id: {{ id|tojson }},
createUrl: {{ create_url|tojson if create_url else "null" }},
editUrl: {{ edit_url|tojson if edit_url else "null" }},
deleteUrl: {{ delete_url|tojson if delete_url else "null" }},
refreshUrl: {{ refresh_url|tojson if refresh_url else "null" }}
})' hx-preserve class="combo-box-widget">
<div class="input-group">
<input type="text" id="{{ id }}-input" x-model.trim="query" @keydown.enter.prevent="submitAddOrEdit()"
@keydown.escape="cancelEdit()" class="form-control rounded-bottom-0">
<button id="{{ id }}-add" :disabled="!query" @click="submitAddOrEdit()"
class="btn btn-primary rounded-bottom-0">
<i class="bi icon-state" :class="isEditing ? 'bi-pencil' : 'bi-plus-lg'"></i>
</button>
<button id="{{ id }}-remove" :disabled="!hasSelection" @click="removeSelected()"
class="btn btn-danger rounded-bottom-0">
{{ icons.render_icon('dash-lg', 16) }}
</button>
</div>
<select id="{{ id }}-list" multiple x-ref="list" @change="onListChange"
class="form-select border-top-0 rounded-top-0" name="{{ id }}" size="10">
{% for option in options if options %}
<option value="{{ option.id }}">{{ option.name }}</option>
{% endfor %}
{% if not options and refresh_url %}
<option disabled>Loading...</option>
{% endif %}
</select>
{% if refresh_url %}
{% set url = refresh_url ~ ('&' if '?' in refresh_url else '?') ~ 'view=option&limit=0&per_page=0' %}
<div id="{{ id }}-htmx-refresh" class="d-none" hx-get="{{ url }}"
hx-trigger="revealed, combobox:refresh from:#{{ id }}-container" hx-target="#{{ id }}-list" hx-swap="innerHTML">
</div>
{% endif %}
</div>
{% endmacro %}}

View file

@ -1,57 +0,0 @@
{% import "fragments/_icon_fragment.html" as icons %}
{% import "fragments/_link_fragment.html" as links %}
{% macro render_dropdown(id, list = none, label = none, current_item = none, entry_link = none, enabled = true, refresh_url = none, select_url = none, record_id = none, field_name = none) %}
<label for="{{ id }}" class="form-label">
{{ label or '' }}
{% if entry_link %}
{{ links.entry_link(entry_link, current_item.id) }}
{% endif %}
</label>
<div class="dropdown" id="{{ id }}-dropdown" x-data='DropDown({
id: {{ id|tojson }},
refreshUrl: {{ refresh_url|tojson if refresh_url else "null" }},
selectUrl: {{ select_url|tojson if select_url else "null" }},
recordId: {{ record_id|tojson if record_id else "null" }},
field: {{ field_name|tojson if field_name else "null" }}
})'
hx-preserve x-init="init()" x-ref="dropdown">
<div class="btn-group w-100">
<button
class="btn btn-outline-dark dropdown-toggle overflow-x-hidden w-100 rounded-end{% if current_item and enabled %}-0 border-end-0{% endif %} dropdown-button"
type="button" data-bs-toggle="dropdown" data-inv-value="{{ current_item.id if current_item else '' }}"
id="{{ id }}Button" {% if not enabled %} disabled{% endif %}
style="border-color: rgb(222, 226, 230);{% if not enabled %} background-color: rgb(233, 236, 239); color: rgb(0, 0, 0);{% endif %}"
x-ref="button">
{{ current_item.identifier if current_item else '-' }}
</button>
<button
class="btn btn-outline-danger rounded-end font-weight-bold border-start-0{% if not current_item or not enabled %} d-none{% endif %}"
type="button" id="{{ id }}ClearButton"
style="z-index: 9999; border-color: rgb(222, 226, 230);{% if not enabled %} background-color: rgb(233, 236, 239); color: rgb(0, 0, 0);{% endif %}"
x-ref="clear"
@click="clearSelection">
{{ icons.render_icon('x-lg', 16) }}
</button>
<input type="hidden" name="{{ id }}" id="{{ id }}" value="{{ current_item.id if current_item else '' }}" x-ref="hidden">
<ul class="dropdown-menu w-100 pt-0" style="max-height: 40vh; z-index: 9999;" id="menu{{ id }}" x-ref="menu">
<input type="text"
class="form-control rounded-bottom-0 border-start-0 border-top-0 border-end-0 dropdown-search-input"
id="search{{ id }}" placeholder="Search..." x-ref="search" @input="filterItems()">
<div class="overflow-auto overflow-x-hidden" style="z-index: 9999;" id="{{ id }}DropdownContent" x-ref="content">
{% if list %}
{% for item in list %}
<li><a class="dropdown-item" data-inv-value="{{ item.id }}">{{
item.identifier }}</a></li>
{% endfor %}
{% endif %}
</div>
</ul>
</div>
{% if refresh_url %}
{% set url = refresh_url ~ ('&' if '?' in refresh_url else '?') ~ 'view=list&limit=0&per_page=0' %}
<div id="{{ id }}-htmx-refresh" class="d-none" hx-get="{{ url }}"
hx-trigger="revealed, combobox:refresh from:#{{ id }}-dropdown" hx-target="#{{ id }}DropdownContent" hx-swap="innerHTML"></div>
{% endif %}
</div>
{% endmacro %}

View file

@ -1,45 +0,0 @@
{% import "fragments/_icon_fragment.html" as icons %}
{% macro render_editor(id, title, mode='edit', content=none, enabled=true, create_url=none, refresh_url=none,
update_url=none, delete_url=none, field_name=none, record_id=none) %}
<!-- Editor Fragment -->
<div class="row mb-3" id="editor-container-{{ id }}" x-data='Editor({
id: "{{ id }}",
createUrl: {{ create_url|tojson if create_url else "null" }},
refreshUrl: {{ refresh_url|tojson if refresh_url else "null" }},
deleteUrl: {{ delete_url|tojson if delete_url else "null" }},
updateUrl: {{ update_url|tojson if update_url else "null" }},
fieldName: {{ field_name|tojson if field_name else "null" }},
recordId: {{ record_id|tojson if record_id else "null" }}
})' x-ref="container" hx-preserve @editor:refresh.window="refresh()">
<div class="col">
<ul class="nav nav-tabs">
<li class="nav-item">
<span class="nav-link text-black">{{ title }}</span>
</li>
{% if enabled %}
<li class="nav-item">
<a class="nav-link{% if mode == 'view' %} active{% endif %}" data-bs-toggle="tab"
data-bs-target="#viewer{{ id }}">{{ icons.render_icon('file-earmark-richtext', 16) }}</a>
</li>
<li class="nav-item">
<a class="nav-link{% if mode == 'edit' %} active{% endif %}" data-bs-toggle="tab"
data-bs-target="#editor{{ id }}" id="editTab{{ id }}" @shown.bs.tab="openEditTab()">
{{ icons.render_icon('pencil', 16) }}
</a>
</li>
{% endif %}
</ul>
<div class="tab-content" id="tabContent{{ id }}">
<div class="tab-pane fade{% if mode == 'view' %} show active border border-top-0{% endif %} p-2 markdown-body viewer"
id="viewer{{ id }}" x-ref="viewer"></div>
<div class="tab-pane fade{% if mode == 'edit' %} show active border border-top-0{% endif %}"
id="editor{{ id }}" @change="renderViewer()">
<textarea x-ref="editor" id="textEditor{{ id }}" name="editor{{ id }}"
class="form-control border-top-0 rounded-top-0{% if not enabled %} disabled{% endif %} editor"
data-note-id="{{ id }}" @input="resizeEditor(); renderViewer()">{{ content if content }}</textarea>
</div>
</div>
</div>
</div>
{% endmacro %}

View file

@ -1,6 +0,0 @@
{% macro render_icon(icon, size=24, extra_class='') %}
<!-- Icon Fragment -->
<i class="bi bi-{{ icon }} {{ extra_class }}" style="font-size: {{ size }}px;"></i>
{% endmacro %}

View file

@ -1,54 +0,0 @@
{% import "fragments/_icon_fragment.html" as icons %}
{% macro render_image(id, image=None, enabled=True) %}
<!-- Image fragment -->
<div class="image-slot text-center">
{% if image %}
<img src="{{ url_for('static', filename=image.filename) }}" alt="Image of ID {{ id }}" class="img-thumbnail w-100"
style="height: auto;" data-bs-toggle="modal" data-bs-target="#imageModal-{{ id }}">
<div class="modal fade" id="imageModal-{{ id }}" tabindex="-1" style="z-index: 9999;">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-body text-center">
<img src="{{ url_for('static', filename=image.filename) }}" alt="Image of ID {{ id }}"
class="img-fluid">
</div>
{% if enabled %}
<div class="modal-footer justify-content-between">
<button class="btn btn-danger" onclick="ImageWidget.deleteImage('{{ id }}', '{{ image.id }}')">
{{ icons.render_icon('trash') }}
</button>
<form method="POST" enctype="multipart/form-data" id="image-upload-form-{{ id }}" class="d-none">
<input type="file" id="image-upload-input-{{ id }}" name="file"
onchange="ImageWidget.submitImageUpload('{{ id }}')">
<input type="hidden" name="target_model" value="inventory">
<input type="hidden" name="model_id" value="{{ id }}">
<input type="hidden" name="caption" value="Uploaded via UI">
</form>
<label class="btn btn-secondary mb0" for="image-upload-input-{{ id }}">
{{ icons.render_icon('upload') }}
</label>
</div>
{% endif %}
</div>
</div>
</div>
{% else %}
{% if enabled %}
<a href="#" class="link-secondary"
onclick="document.getElementById('image-upload-input-{{ id }}').click(); return false;">
{{ icons.render_icon('image', 256) }}
</a>
<form method="POST" enctype="multipart/form-data" id="image-upload-form-{{ id }}" class="d-none">
<input type="file" id="image-upload-input-{{ id }}" name="file"
onchange="ImageWidget.submitImageUpload('{{ id }}')">
<input type="hidden" name="target_model" value="inventory">
<input type="hidden" name="model_id" value="{{ id }}">
<input type="hidden" name="caption" value="Uploaded via UI">
</form>
{% endif %}
{% endif %}
</div>
{% endmacro %}

View file

@ -1,8 +0,0 @@
{% macro render_label(id, text=none, refresh_url=none, field_name=none, record_id=none) %}
<span id="label-{{ id }}" x-data='Label({
id: "{{ id }}",
refreshUrl: {{ refresh_url|tojson if refresh_url else "null" }},
fieldName: {{ field_name|tojson if field_name else "null" }},
recordId: {{ record_id|tojson if record_id else "null" }}
})' x-ref="label" hx-preserve>{{ text if text else '' }}</span>
{% endmacro %}

View file

@ -1,46 +0,0 @@
{% import "fragments/_icon_fragment.html" as icons %}
{% macro category_link(endpoint, label, icon_html=none, arguments={}) %}
<!-- Category Link Fragment -->
<div class="col text-center">
<a href="{{ url_for('main.' + endpoint, **arguments) }}"
class="d-flex flex-column justify-content-center link-success link-underline-opacity-0">
{% if icon_html %}
{{ icon_html | safe }}
{% endif %}
{{ label }}
</a>
</div>
{% endmacro %}
{% macro navigation_link(endpoint, label, icon_html=none, arguments={}, active=false) %}
<!-- Navigation Link Fragment -->
{% if not active %}
{% set active = request.endpoint == 'main.' + endpoint %}
{% endif %}
<li class="nav-item">
<a href="{{ url_for('main.' + endpoint, **arguments) }}" class="nav-link{% if active %} active{% endif %}">
{% if icon_html %}
{{ icon_html | safe }}
{% endif %}
{{ label }}
</a>
</li>
{% endmacro %}
{% macro entry_link(endpoint, id) %}
<!-- Entry Link Fragment -->
<a href="{{ url_for('main.' + endpoint, id=id) }}" class="link-success link-underline-opacity-0">
{{ icons.render_icon('link', 12) }}
</a>
{% endmacro %}
{% macro export_link(id, endpoint, ids) %}
<!-- Export Link Fragment -->
<a class="link-success link-underline-opacity-0" onclick="export_csv({{ ids }}, '{{ endpoint }}', '{{ id }}_export');">{{ icons.render_icon('box-arrow-up', 12) }}</a>
{% endmacro %}

View file

@ -1,10 +0,0 @@
<!-- List Fragment -->
{% for it in options %}
<li>
<a class="dropdown-item" data-inv-value="{{ it.id }}"
{% for k, v in it.items() if k not in ('id','name') and v is not none %}
data-{{ k|e }}="{{ v|e }}"
{% endfor %}
@click.prevent='itemSelect($event)'>{{ it.name }}</a>
</li>
{% endfor %}

View file

@ -1,7 +0,0 @@
{# templates/fragments/_option_fragment.html #}
{% for it in options %}
<option value="{{ it.id }}"
{% for k, v in it.items() if k not in ('id','name') and v is not none %}
data-{{ k|e }}="{{ v|e }}"
{% endfor %}>{{ it.name }}</option>
{% endfor %}

View file

@ -1,8 +0,0 @@
<!-- fragments/_table_data_fragment.html -->
{% for r in rows %}
<tr style="cursor: pointer;" onclick="window.location='{{ url_for('main.' + model_name + '_item', id=r.id) }}'">
{% for key, val in r.items() if not key == 'id' %}
<td class="text-nowrap">{{ val if val else '-' }}</td>
{% endfor %}
</tr>
{% endfor %}

View file

@ -1,96 +0,0 @@
{% macro render_table(headers, rows, id, entry_route=None, title=None, per_page=15) %}
<!-- Table Fragment -->
{% if rows %}
{% if title %}
<label for="datatable-{{ id|default('table')|replace(' ', '-')|lower }}" class="form-label">{{ title }}</label>
{% endif %}
<div class="table-responsive">
<table id="datatable-{{ id|default('table')|replace(' ', '-')|lower }}"
class="table table-bordered table-sm table-hover table-striped table-light m-0{% if title %} caption-top{% endif %}">
<thead class="sticky-top">
<tr>
{% for h in headers %}
<th class="text-nowrap">{{ h }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr {% if entry_route %}onclick="window.location='{{ url_for('main.' + entry_route, id=row.id) }}'"
style="cursor: pointer;" {% endif %}{% if row['highlight'] %} class="table-info" {% endif %}>
{% for cell in row.cells %}
<td class="text-nowrap{% if cell.type=='bool' %} text-center{% endif %}">
{% if cell.type == 'bool' %}
{{ cell.html | safe }}
{% elif cell.url %}
<a class="link-success link-underline-opacity-0" href="{{ cell.url }}">{{ cell.text }}</a>
{% else %}
{{ cell.text or '-' }}
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
document.addEventListener("DOMContentLoaded", function () {
new DataTable('#datatable-{{ id|default('table')|replace(' ', ' - ')|lower }}', {
pageLength: {{ per_page }},
scrollX: true,
scrollY: '60vh',
scrollCollapse: true,
})
})
</script>
{% else %}
<div class="container text-center">No data.</div>
{% endif %}
{% endmacro %}
{% macro dynamic_table(id, headers=none, fields=none, entry_route=None, title=None, per_page=15, offset=0,
refresh_url=none) %}
<!-- Table Fragment -->
{% if rows or refresh_url %}
{% if title %}
<label for="datatable-{{ id|default('table')|replace(' ', '-')|lower }}" class="form-label">{{ title }}</label>
{% endif %}
<div class="table-responsive" id="table-container-{{ id }}" x-data='Table({
id: "{{ id }}",
refreshUrl: {{ refresh_url|tojson if refresh_url else "null" }},
headers: {{ headers|tojson if headers else "[]" }},
perPage: {{ per_page }},
offset: {{ offset if offset else 0 }},
fields: {{ fields|tojson if fields else "[]" }}
})'>
<table id="datatable-{{ id|default('table')|replace(' ', '-')|lower }}"
class="table table-bordered table-sm table-hover table-striped table-light m-0 caption-bottom">
<caption class="p-0">
<nav class="d-flex flex-column align-items-center px-2 py-1">
<!-- This is your pagination control -->
<ul class="pagination mb-0" x-ref="pagination"></ul>
<!-- This is just Alpine text binding -->
<div>
Page <span x-text="page"></span> of <span x-text="pages"></span>
(<span x-text="total"></span> total)
</div>
</nav>
</caption>
<thead class="sticky-top">
<tr>
{% for h in headers %}
<th class="text-nowrap">{{ h }}</th>
{% endfor %}
</tr>
</thead>
<tbody x-ref="body"></tbody>
</table>
</div>
{% else %}
<div class="container text-center">No data.</div>
{% endif %}
{% endmacro %}

View file

@ -1,9 +0,0 @@
{% macro render_toolbar(id, left=None, center=None, right=None) %}
<nav class="navbar navbar-expand bg-light-subtle border-bottom" id="toolbar-{{ id }}">
<div class="d-flex justify-content-between container-fluid">
<div>{{ left if left }}</div>
<div>{{ center if center }}</div>
<div>{{ right if right }}</div>
</div>
</nav>
{% endmacro %}

View file

@ -1,75 +0,0 @@
<!-- templates/index.html -->
{% extends "layout.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="container text-center">
<h1 class="display-4">Welcome to Inventory Manager</h1>
<p class="lead">Find out about all of your assets.</p>
<div class="row">
{% if active_worklog_rows %}
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Active Worklogs</h5>
<h6 class="card-subtitle mb-2 text-body-secondary">
You have {{ active_count }} active worklogs.
{% set ids %}
{ids: [{% for row in active_worklog_rows %}{{ row['id'] }}, {% endfor %}]}
{% endset %}
{{ links.export_link(
'active_worklog',
'worklog',
ids
) }}
</h6>
{{ tables.render_table(
headers = active_worklog_headers,
rows = active_worklog_rows,
id = 'Active Worklog',
entry_route = 'worklog_item',
per_page = 10
)}}
</div>
</div>
</div>
{% endif %}
{% if (datasets['summary'][0]['values'] | sum) > 0 %}
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Inventory Summary</h5>
<div id="summary"></div>
</div>
</div>
</div>
{% endif %}
</div>
<div class="row mt-2">
{% if (datasets['summary'][0]['values'] | sum) > 0 %}
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Work Summary</h5>
<div id="work_summary"></div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block script %}
{% if datasets['summary'] %}
const data = {{ datasets['summary']|tojson }};
const layout = { title: 'Summary' };
Plotly.newPlot('summary', data, layout)
{% endif %}
{% if datasets['work_summary'] %}
const work_data = {{ datasets['work_summary']|tojson }};
const work_layout = { title: 'Work Summary', xaxis: { tickangle: -45 } };
Plotly.newPlot('work_summary', work_data, work_layout);
{% endif %}
{% endblock %}

View file

@ -1,297 +0,0 @@
<!-- templates/inventory.html -->
{% extends "layout.html" %}
{% block title %}{{ title }}{% endblock %}
{% block precontent %}
{% set saveLogic %}
e.preventDefault();
const payload = {
timestamp: document.querySelector("input[name='timestamp']").value,
condition: document.querySelector("select[name='condition']").value,
type_id: parseInt(document.querySelector("input[name='type']").value),
name: document.querySelector("input[name='name']").value || null,
serial: document.querySelector("input[name='serial']").value || null,
model: document.querySelector("input[name='model']").value || null,
notes: document.querySelector("textarea[name='editornotes']").value || null,
owner_id: parseInt(document.querySelector("input[name='owner']").value) || null,
brand_id: parseInt(document.querySelector("input[name='brand']").value) || null,
location_id: parseInt(document.querySelector("input[name='room']").value) || null,
barcode: document.querySelector("input[name='barcode']").value || null,
shared: document.querySelector("input[name='shared']").checked
};
try {
const id = document.querySelector("#inventoryId").value;
const isEdit = id && id !== "None";
const endpoint = isEdit ? `/api/inventory/${id}` : "/api/inventory";
const method = isEdit ? "PUT" : "POST";
const response = await fetch(endpoint, {
method,
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
const result = await response.json();
if (result.success) {
localStorage.setItem("toastMessage", JSON.stringify({
message: isEdit ? "Inventory item updated!" : "Inventory item created!",
type: "success"
}));
window.location.href = `/inventory_item/${result.id}`;
} else {
Toast.renderToast({ message: `Error: ${result.error}`, type: "danger" });
}
} catch (err) {
console.error(err);
Toast.renderToast({ message: `Error: ${err}`, type: "danger" });
}
{% endset %}
{% set deleteLogic %}
const id = document.querySelector("#inventoryId").value;
if (!id || id === "None") {
Toast.renderToast({ message: "No item ID found to delete.", type: "danger" });
return;
}
if (!confirm("Are you sure you want to delete this inventory item? This action cannot be undone.")) {
return;
}
try {
const response = await fetch(`/api/inventory/${id}`, {
method: "DELETE"
});
const result = await response.json();
if (result.success) {
localStorage.setItem("toastMessage", JSON.stringify({
message: "Inventory item deleted.",
type: "success"
}));
window.location.href = "/inventory";
} else {
Toast.renderToast({ message: `Error: ${result.error}`, type: "danger" });
}
} catch (err) {
console.error(err);
Toast.renderToast({ message: `Error: ${err}`, type: "danger" });
}
{% endset %}
{% set buttonBar %}
<div class="btn-group">
{% if item.id != None %}
{{ buttons.render_button(
id='new',
icon='plus-lg',
style='outline-primary rounded-start',
logic="window.location.href = '" + url_for('main.new_inventory_item') + "';"
)}}
{% endif %}
{{ buttons.render_button(
id='save',
icon='floppy',
logic=saveLogic,
style="outline-primary" + (' rounded-end' if not item.id else '')
) }}
{% if item.id != None %}
{{ buttons.render_button(
id='delete',
icon='trash',
logic=deleteLogic,
style="outline-danger rounded-end"
) }}
{% endif %}
</div>
{% endset %}
{{ toolbars.render_toolbar(
id='inventory',
left=breadcrumb_macro.render_breadcrumb(breadcrumbs=breadcrumbs),
right=buttonBar
) }}
{% if item.condition in ["Removed", "Disposed"] %}
<div class="alert alert-danger rounded-0">
This item is not available and cannot be edited.
</div>
{% endif %}
{% endblock %}
{% block content %}
<input type="hidden" id="inventoryId" value="{{ item.id }}">
<div class="container">
<div class="row">
<div class="col">
<div class="row align-items-center">
<div class="col">
<label for="timestamp" class="form-label">Date Entered</label>
<input type="date" class="form-control-plaintext" name="timestamp"
value="{{ item.timestamp.date().isoformat() }}" readonly>
</div>
<div class="col">
<label for="identifier" class="form-label">Identifier</label>
<input type="text" class="form-control-plaintext" value="{{ item.identifier }}" readonly>
</div>
</div>
<div class="row">
<div class="col-4">
<label for="name" class="form-label">Name</label>
<input type="text" class="form-control" name="name" placeholder="-" value="{{ item.name or '' }}" {%
if item.condition in ["Removed", "Disposed" ] %} disabled{% endif %}>
</div>
<div class="col-4">
<label for="serial" class="form-label">Serial Number</label>
<input type="text" class="form-control" name="serial" placeholder="-"
value="{{ item.serial if item.serial else '' }}" {% if item.condition in ["Removed", "Disposed"
] %} disabled{% endif %}>
</div>
<div class="col-4">
<label for="barcode" class="form-label">Bar Code</label>
<input type="text" class="form-control" name="barcode" placeholder="-"
value="{{ item.barcode if item.barcode else '' }}" {% if item.condition in
["Removed", "Disposed" ] %} disabled{% endif %}>
</div>
</div>
<div class="row">
<div class="col-4">
{{ dropdowns.render_dropdown(
id='brand',
label='Brand',
current_item=item.brand,
enabled=item.condition not in ["Removed", "Disposed"],
refresh_url=url_for('ui.list_items', model_name='brand'),
select_url=url_for('ui.update_item', model_name='inventory'),
record_id=item.id,
field_name='brand_id'
) }}
</div>
<div class="col-4">
<label for="model" class="form-label">Model</label>
<input type="text" class="form-control" name="model" placeholder="-"
value="{{ item.model if item.model else '' }}" {% if item.condition in ["Removed", "Disposed" ]
%} disabled{% endif %}>
</div>
<div class="col-4">
{{ dropdowns.render_dropdown(
id='type',
label='Category',
current_item=item.device_type,
enabled=item.condition not in ["Removed", "Disposed"],
refresh_url=url_for('ui.list_items', model_name='item'),
select_url=url_for('ui.update_item', model_name='inventory'),
record_id=item.id,
field_name='type_id'
) }}
</div>
</div>
<div class="row">
<div class="col-4">
{{ dropdowns.render_dropdown(
id='owner',
label='Contact',
current_item=item.owner,
entry_link='user_item',
enabled=item.condition not in ["Removed", "Disposed"],
refresh_url=url_for('ui.list_items', model_name='user'),
select_url=url_for('ui.update_item', model_name='inventory'),
record_id=item.id,
field_name='owner_id'
) }}
</div>
<div class="col-4">
{{ dropdowns.render_dropdown(
id='room',
label='Location',
current_item=item.location,
enabled=item.condition not in ["Removed", "Disposed"],
refresh_url=url_for('ui.list_items', model_name='room'),
select_url=url_for('ui.update_item', model_name='inventory'),
record_id=item.id,
field_name='location_id'
) }}
</div>
<div class="col-2">
<label for="condition" class="form-label">Condition</label>
<select name="condition" id="condition" class="form-select">
<option>-</option>
{% for condition in ["Working", "Deployed", "Partially Inoperable", "Inoperable", "Unverified",
"Removed", "Disposed"] %}
<option value="{{ condition }}" {% if item.condition==condition %} selected{% endif %}>{{
condition }}
</option>
{% endfor %}
</select>
</div>
<div class="col-2 d-flex align-items-center justify-content-center" style="margin-top: 1.9rem;">
<div class="form-check mb-0">
<input type="checkbox" class="form-check-input" id="shared" name="shared" {% if item.shared
%}checked{% endif %}{% if item.condition in ["Removed", "Disposed" ] %} disabled{% endif %}>
<label for="shared" class="form-check-label">Shared?</label>
</div>
</div>
</div>
</div>
{% if item.image or item.condition not in ["Removed", "Disposed"] %}
<div class="col-4">
{{ images.render_image(item.id, item.image, enabled = item.condition not in ["Removed", "Disposed"]) }}
</div>
{% endif %}
</div>
<div class="row">
<div class="col p-3">
{{ editor.render_editor(
id = "notes",
title = "Notes & Comments",
mode = 'view' if item.id else 'edit',
enabled = item.condition not in ["Removed", "Disposed"],
refresh_url = url_for('ui.get_value', model_name='inventory'),
field_name='notes',
record_id=item.id
) }}
</div>
{% if worklog %}
<div class="col" id="worklog">
<div class="row">
<div class="col form-label">
Work Log Entries
{% set id_list = worklog_rows | map(attribute='id') | list %}
{{ links.export_link(
id = (item.identifier | replace('Name: ', '')
| replace('ID:', '')
| replace('Serial: ', '')
| replace('Barcode: ', '')
| lower) + '_worklog',
endpoint = 'worklog',
ids = {'ids': id_list}
) }}
</div>
</div>
<div class="row border">
<div class="col overflow-auto" style="max-height: 300px;">
{% for note in notes %}
{% set title %}
{{ note.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}{{ links.entry_link('worklog_item', note.work_log_id) }}
{% endset %}
{{ editor.render_editor(
id = 'updates' + (note.id | string),
title = title,
mode = 'view',
enabled = false,
refresh_url = url_for('ui.get_value', model_name='work_note'),
field_name='content',
record_id=note.id
) }}
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -1,44 +0,0 @@
<!-- templates/inventory_index.html -->
{% extends "layout.html" %}
{% block title %}{{ title }}{% endblock %}
{% block precontent %}
{{ toolbars.render_toolbar('index', left=breadcrumb_macro.render_breadcrumb(breadcrumbs=breadcrumbs)) }}
{% endblock %}
{% block content %}
<div class="container">
{% if not category %}
<div class="row">
<div class="col">
<h2 class="display-6 text-center mt-5">Browse</h2>
</div>
</div>
<div class="row text-center">
{{ links.category_link(endpoint = 'list_inventory', label = "Full Listing", icon_html = icons.render_icon('table', 32)) }}
{{ links.category_link(endpoint = 'inventory_index', label = "By User", icon_html = icons.render_icon('person', 32), arguments = {'category': 'user'}) }}
{{ links.category_link(endpoint = 'inventory_index', label = 'By Location', icon_html = icons.render_icon('map', 32), arguments = {'category': 'location'}) }}
{{ links.category_link(endpoint = 'inventory_index', label = 'By Type', icon_html = icons.render_icon('motherboard', 32), arguments = {'category': 'type'}) }}
</div>
<div class="row">
<div class="col">
<h2 class="display-6 text-center mt-5">Reports</h2>
</div>
</div>
<div class="row text-center">
{{ links.category_link(endpoint = 'inventory_available', label = 'Available', icon_html = icons.render_icon('box-seam', 32)) }}
</div>
{% else %}
<div class="container">
{% for line in listing %}
<div class="row my-3">
{% for id, name in line %}
{{ links.category_link(endpoint = 'list_inventory', label = name, arguments = {'filter_by': category, 'id': id}) }}
{% endfor %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endblock %}

View file

@ -1,105 +0,0 @@
{% import "fragments/_button_fragment.html" as buttons %}
{% import "fragments/_breadcrumb_fragment.html" as breadcrumb_macro %}
{% import "fragments/_combobox_fragment.html" as combos %}
{% import "fragments/_dropdown_fragment.html" as dropdowns %}
{% import "fragments/_editor_fragment.html" as editor %}
{% import "fragments/_icon_fragment.html" as icons %}
{% import "fragments/_image_fragment.html" as images %}
{% import "fragments/_label_fragment.html" as labels %}
{% import "fragments/_link_fragment.html" as links %}
{% import "fragments/_table_fragment.html" as tables %}
{% import "fragments/_toolbar_fragment.html" as toolbars %}
<!-- templates/layout.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Inventory Manager{% if title %} - {% endif %}{% block title %}{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-4Q6Gf2aSP4eDXB8Miphtr37CMZZQ5oXLH2yaXMJ2w8e2ZtHTl7GptT4jmndRuHDT" crossorigin="anonymous">
<link
href="https://cdn.datatables.net/v/bs5/jq-3.7.0/moment-2.29.4/jszip-3.10.1/dt-2.3.2/b-3.2.4/b-html5-3.2.4/fh-4.0.3/r-3.0.5/datatables.min.css"
rel="stylesheet" integrity="sha384-hSj3bXMT805MYGWJ+03fwhNIuAiFbC0OFMeKgQeB0ndGAdPMSutk5qr9WHSXzHU/"
crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
<link rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.8.1/github-markdown.min.css"
integrity="sha512-BrOPA520KmDMqieeM7XFe6a3u3Sb3F1JBaQnrIAmWg3EYrciJ+Qqe6ZcKCdfPv26rGcgTrJnZ/IdQEct8h3Zhw=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="{{ url_for('static', filename='css/combobox.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/dropdown.css') }}">
<style>
{% block style %}{% endblock %}
</style>
</head>
<body class="bg-tertiary text-primary-emphasis">
<nav class="navbar navbar-expand bg-body-tertiary border-bottom">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('main.index') }}">
Inventory Manager
</a>
<button class="navbar-toggler">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
{{ links.navigation_link(endpoint = 'inventory_index', label = 'Inventory') }}
{{ links.navigation_link(endpoint = 'list_users', label = 'Users') }}
{{ links.navigation_link(endpoint = 'list_worklog', label = 'Worklog') }}
</ul>
<form class="d-flex" method="GET" action="{{ url_for('main.search') }}">
<input type="text" class="form-control me-2" placeholder="Search" name="q" id="search" />
<button class="btn btn-primary" type="submit" id="searchButton" disabled>Search</button>
</form>
<ul class="navbar-nav ms-2">
{{ links.navigation_link(endpoint='settings', label = '', icon_html = icons.render_icon('gear')) }}
</ul>
</div>
</div>
</nav>
{% block precontent %}
{% endblock %}
<main class="container-flex m-5">
{% block content %}{% endblock %}
</main>
<script src="{{ url_for('static', filename='js/combobox.js') }}"></script>
<script src="{{ url_for('static', filename='js/csv.js') }}"></script>
<script src="{{ url_for('static', filename='js/dropdown.js') }}"></script>
<script src="{{ url_for('static', filename='js/editor.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/image.js') }}"></script>
<script src="{{ url_for('static', filename='js/label.js') }}"></script>
<script src="{{ url_for('static', filename='js/table.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/toast.js') }}" defer></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/js/bootstrap.bundle.min.js"
integrity="sha384-j1CDi7MgGQ12Z7Qab0qlWQ/Qqz24Gc6BM0thvEMVjHnfYGF0rmFCozFSxQBxwHKO"
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mark.js/8.11.1/mark.min.js"
integrity="sha512-5CYOlHXGh6QpOFA/TeTylKLWfB3ftPsde7AnmhuitiTX4K5SqCLBeKro6sPS8ilsz1Q4NRx3v8Ko2IBiszzdww=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdn.plot.ly/plotly-3.0.1.min.js" charset="utf-8"></script>
<script
src="https://cdn.datatables.net/v/bs5/jq-3.7.0/moment-2.29.4/dt-2.3.2/b-3.2.4/r-3.0.5/sc-2.4.3/sp-2.3.3/datatables.min.js"
integrity="sha384-zqgMe4cx+N3TuuqXt4kWWDluM5g1CiRwqWBm3vpvY0GcDoXTwU8d17inavaLy3p3"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/lib/marked.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.6/dist/htmx.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script>
const searchInput = document.querySelector('#search');
const searchButton = document.querySelector('#searchButton');
searchInput.addEventListener('input', () => {
searchButton.disabled = searchInput.value.trim() === '';
});
document.addEventListener("DOMContentLoaded", () => {
{% block script %} {% endblock %}
});
</script>
</body>
</html>

View file

@ -1,19 +0,0 @@
{% extends 'layout.html' %}
{% block content %}
{{ combos.render_combobox(
id='combo',
label='Breakfast!',
refresh_url=url_for('ui.list_items', model_name='brand')
) }}
{{ dropdowns.render_dropdown(
id = 'dropdown',
refresh_url=url_for('ui.list_items', model_name='user')
) }}
{% set vals = cells('user', 8, 'first_name', 'last_name') %}
{{ vals['first_name'] }} {{ vals['last_name'] }}
{{ table('inventory', ['name', 'barcode', 'serial'], limit=0, q='BH0298') | tojson }}
{% endblock %}

View file

@ -1,62 +0,0 @@
<!-- templates/search.html -->
{% extends "layout.html" %}
{% block title %}{{ title }}{% endblock %}
{% block precontent %}
{{ toolbars.render_toolbar(
id='search',
left = breadcrumb_macro.render_breadcrumb(breadcrumbs=breadcrumbs)
) }}
{% if not results['inventory']['rows'] and not results['users']['rows'] and not results['worklog']['rows'] %}
<div class="alert alert-danger rounded-0">There are no results for "{{ query }}".</div>
{% endif %}
{% endblock %}
{% block content %}
<div class="container">
{% if results['inventory']['rows'] %}
<div>
{{ tables.render_table(
headers = results['inventory']['headers'],
rows = results['inventory']['rows'],
entry_route = 'inventory_item',
title='Inventory Results',
id='search-inventory'
)}}
</div>
{% endif %}
{% if results['users']['rows'] %}
<div>
{{ tables.render_table(
headers = results['users']['headers'],
rows = results['users']['rows'],
entry_route = 'user_item',
title='User Results',
id='search-users'
)}}
</div>
{% endif %}
{% if results['worklog']['rows'] %}
<div>
{{ tables.render_table(
headers = results['worklog']['headers'],
rows = results['worklog']['rows'],
entry_route = 'worklog_item',
title='Worklog Results',
id='search-worklog'
)}}
</div>
{% endif %}
</div>
{% endblock %}
{% block script %}
{% if query and (results['inventory']['rows'] or results['users']['rows'] or results['worklog']['rows']) %}
const query = "{{ query|e }}";
if (query) {
const instance = new Mark(document.querySelector("main"));
instance.mark(query);
}
{% endif %}
{% endblock %}

View file

@ -1,257 +0,0 @@
{% extends "layout.html" %}
{% block title %}{{ title }}{% endblock %}
{% block precontent %}
{{ toolbars.render_toolbar(
id='settings',
left=breadcrumb_macro.render_breadcrumb(breadcrumbs=breadcrumbs)
) }}
{% endblock %}
{% block content %}
<div class="container">
<ul class="nav nav-tabs">
<li class="nav-item">
<button class="nav-link active" id="inventory-tab" data-bs-toggle="tab" data-bs-target="#inventory-tab-pane"
type="button">Inventory</button>
</li>
<li class="nav-item">
<button class="nav-link" id="location-tab" data-bs-toggle="tab" data-bs-target="#location-tab-pane"
type="button">Location</button>
</li>
<li class="nav-item">
<button class="nav-link" id="photo-tab" data-bs-toggle="tab" data-bs-target="#images-tab-pane"
type="button">Images</button>
</li>
</ul>
<div class="tab-content" id="tabContent">
<div class="tab-pane fade show active border border-top-0 p-3" id="inventory-tab-pane">
<div class="row">
<div class="col">
{{ combos.render_combobox(
id='brand',
label='Brands',
placeholder='Add a new brand',
create_url=url_for('ui.create_item', model_name='brand'),
edit_url=url_for('ui.update_item', model_name='brand'),
refresh_url=url_for('ui.list_items', model_name='brand'),
delete_url=url_for('ui.delete_item', model_name='brand')
) }}
</div>
<div class="col">
{{ combos.render_combobox(
id='type',
label='Inventory Types',
placeholder='Add a new type',
create_url=url_for('ui.create_item', model_name='item'),
edit_url=url_for('ui.update_item', model_name='item'),
refresh_url=url_for('ui.list_items', model_name='item'),
delete_url=url_for('ui.delete_item', model_name='item')
) }}
</div>
</div>
</div>
<div class="tab-pane fade border border-top-0 p-3" id="location-tab-pane">
<div class="row">
<div class="col">
{{ combos.render_combobox(
id='section',
label='Sections',
placeholder='Add a new section',
create_url=url_for('ui.create_item', model_name='area'),
edit_url=url_for('ui.update_item', model_name='area'),
refresh_url=url_for('ui.list_items', model_name='area'),
delete_url=url_for('ui.delete_item', model_name='area')
) }}
</div>
<div class="col">
{{ combos.render_combobox(
id='function',
label='Functions',
placeholder='Add a new function',
create_url=url_for('ui.create_item', model_name='room_function'),
edit_url=url_for('ui.update_item', model_name='room_function'),
refresh_url=url_for('ui.list_items', model_name='room_function'),
delete_url=url_for('ui.delete_item', model_name='room_function')
) }}
</div>
</div>
<div class="row">
<div class="col">
{{ combos.render_combobox(
id='room',
label='Rooms',
placeholder='Add a new room',
data_attributes={'area_id': 'section-id', 'function_id': 'function-id'},
create_url=url_for('ui.create_item', model_name='room'),
edit_url=url_for('ui.update_item', model_name='room'),
refresh_url=url_for('ui.list_items', model_name='room'),
delete_url=url_for('ui.delete_item', model_name='room')
) }}
</div>
</div>
</div>
<div class="tab-pane fade border border-top-0 p-3" id="images-tab-pane">
<div class="container border rounded" style="max-height: 60vh; overflow-y: auto;">
{% for chunk in image_list %}
<div class="row my-3">
{% for image in chunk %}
<div class="col mx-3">
{{ images.render_image(
id=image.id,
image=image
) }}
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
</div>
<div class="modal fade" id="roomEditor" data-bs-backdrop="static" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="roomEditorLabel">Room Editor</h1>
</div>
<div class="modal-body">
<div class="row">
<div class="col">
<label for="roomName" class="form-label">Room Name</label>
<input type="text" class="form-input" id="roomName" placeholder="Enter room name">
<input type="hidden" id="roomId">
</div>
</div>
<div class="row">
<div class="col">
<label for="roomSection" class="form-label">Section</label>
<select id="roomSection" class="form-select">
<option value="">Select a section</option>
{% for section in sections %}
<option value="{{ section.id }}">{{ section.name }}</option>
{% endfor %}
</select>
</div>
<div class="col">
<label class="form-label">Function</label>
<select id="roomFunction" class="form-select">
<option value="">Select a function</option>
{% for function in functions %}
<option value="{{ function.id }}">{{ function.name }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger" data-bs-dismiss="modal" id="roomEditorCancelButton">{{
icons.render_icon('x-lg', 16) }}</button>
{% set editorSaveLogic %}
const modalEl = document.getElementById('roomEditor');
const idRaw = document.getElementById('roomId').value;
const name = document.getElementById('roomName').value.trim();
const sectionId = document.getElementById('roomSection').value || null;
const functionId = document.getElementById('roomFunction').value || null;
if (!name) { alert('Please enter a room name.'); return; }
if (!idRaw) { alert('Missing room ID.'); return; }
(async () => {
const res = await fetch('{{ url_for("ui.update_item", model_name="room") }}', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ id: parseInt(idRaw, 10), name, area_id: sectionId, function_id: functionId })
});
if (!res.ok) {
const txt = await res.text().catch(()=> 'Error'); alert(txt); return;
}
htmx.trigger('#room-container', 'combobox:refresh');
bootstrap.Modal.getInstance(modalEl).hide();
})();
{% endset %}
{{ buttons.render_button(
id='editorSave',
icon='floppy',
logic=editorSaveLogic
) }}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block script %}
const modal = document.getElementById('roomEditor');
const cancelButton = document.getElementById('roomEditorCancelButton');
const form = document.getElementById('settingsForm');
modal.addEventListener('roomEditor:prepare', (event) => {
const { id, name, sectionId, functionId } = event.detail;
document.getElementById('roomId').value = id;
document.getElementById('roomName').value = name;
// Populate dropdowns before assigning selection
const modalSections = document.getElementById('roomSection');
const modalFunctions = document.getElementById('roomFunction');
const pageSections = document.getElementById('section-list');
const pageFunctions = document.getElementById('function-list');
modalSections.innerHTML = '<option value="">Select a section</option>';
modalFunctions.innerHTML = '<option value="">Select a function</option>';
Array.from(pageSections.options).forEach(opt => {
const option = new Option(opt.textContent, opt.value);
if (opt.value === sectionId) {
option.selected = true;
}
modalSections.appendChild(option);
});
Array.from(pageFunctions.options).forEach(opt => {
const option = new Option(opt.textContent, opt.value);
if (opt.value === functionId) {
option.selected = true;
}
modalFunctions.appendChild(option);
});
});
cancelButton.addEventListener('click', () => {
bootstrap.Modal.getInstance(modal).hide();
});
(function () {
const container = document.getElementById('room-container');
if (!container) return;
container.addEventListener('combobox:item-created', (e) => {
if (container.id !== 'room-container') return;
const { id, name } = e.detail || {};
const prep = new CustomEvent('roomEditor:prepare', {
detail: { id, name, sectionId: '', functionId: '' }
});
document.getElementById('roomEditor').dispatchEvent(prep);
const roomEditorModal = new bootstrap.Modal(document.getElementById('roomEditor'));
roomEditorModal.show();
});
container.addEventListener('combobox:item-edited', (e) => {
if (container.id !== 'room-container') return;
const { id, name, area_id, function_id } = e.detail;
console.log(id, name, area_id, function_id)
const prep = new CustomEvent('roomEditor:prepare', {
detail: { id, name, sectionId: area_id, functionId: function_id }
});
document.getElementById('roomEditor').dispatchEvent(prep);
const roomEditorModal = new bootstrap.Modal(document.getElementById('roomEditor'));
roomEditorModal.show();
});
})();
{% endblock %}

View file

@ -1,37 +0,0 @@
<!-- templates/table.html -->
{% extends "layout.html" %}
{% block title %}{{ title }}{% endblock %}
{% block precontent %}
{% set createButtonLogic %}
window.location.href = '/{{ entry_route }}/new';
{% endset %}
{% set exportButtonLogic %}
const payload = {ids: [{% for row in rows %}{{ row['id'] }}, {% endfor %}]}
export_csv(payload, '{{ csv_route }}');
{% endset %}
{% set toolbarButtons %}
<div class="btn-group">
{{ buttons.render_button(id='export', icon='box-arrow-up', style='outline-primary rounded-start', logic=exportButtonLogic) }}
{{ buttons.render_button(id='import', icon='box-arrow-in-down', style='outline-primary', logic='alert("Not implemented yet!")') }}
{{ buttons.render_button(id='create', icon='plus-lg', logic=createButtonLogic, style='outline-primary rounded-end') }}
</div>
{% endset %}
{{ toolbars.render_toolbar(
'table',
left = breadcrumb_macro.render_breadcrumb(breadcrumbs=breadcrumbs),
right = toolbarButtons
) }}
{% endblock %}
{% block content %}
{{ tables.dynamic_table(
id='table',
headers=header.keys()|list if header else [],
entry_route=entry_route,
refresh_url = url_for('ui.list_items', model_name=model_name, view='table'),
offset=offset,
fields=fields
) }}
{% endblock %}

View file

@ -1,181 +0,0 @@
<!-- templates/user.html -->
{% extends "layout.html" %}
{% block title %}{{ title }}{% endblock %}
{% block precontent %}
{% set saveLogic %}
e.preventDefault();
const payload = {
staff: document.querySelector("input[name='staffCheck']").checked,
active: document.querySelector("input[name='activeCheck']").checked,
last_name: document.querySelector("input[name='lastName']").value,
first_name: document.querySelector("input[name='firstName']").value,
title: document.querySelector("input[name='title']").value,
supervisor_id: parseInt(document.querySelector("input[name='supervisor']").value) || null,
location_id: parseInt(document.querySelector("input[name='location']").value) || null
};
try {
const id = document.querySelector("#userId").value;
const isEdit = id && id !== "None";
const endpoint = isEdit ? `/api/user/${id}` : "/api/user";
const method = isEdit ? "PUT" : "POST";
const response = await fetch(endpoint, {
method,
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
const result = await response.json();
if (result.success) {
localStorage.setItem("toastMessage", JSON.stringify({
message: isEdit ? "User updated!" : "User created!",
type: "success"
}));
window.location.href = `/user/${result.id}`;
} else {
Toast.renderToast({ message: `Error: ${result.error}`, type: "danger" });
}
} catch (err) {
console.error(err);
}
{% endset %}
{% set iconBar %}
{% if user.id != None %}
{{ buttons.render_button(
id = 'org_chart',
icon = 'diagram-3',
logic = "window.location.href = '" + url_for('main.user_org', id=user.id) + "';",
style = 'outline-secondary'
) }}
{% endif %}
<div class="btn-group">
{% if user.id != None %}
{{ buttons.render_button(
id = 'new',
icon = 'plus-lg',
style = 'outline-secondary rounded-start',
logic = "window.location.href = '" + url_for('main.new_user') + "';"
)}}
{% endif %}
{{ buttons.render_button(
id = 'save',
icon = 'floppy',
logic = saveLogic,
style = 'outline-primary rounded-end'
) }}
</div>
{% endset %}
{{ toolbars.render_toolbar(
id = 'newUser',
left = breadcrumb_macro.render_breadcrumb(breadcrumbs=breadcrumbs),
right = iconBar
) }}
{% if not user.active %}
<div class="alert alert-danger rounded-0">This user is inactive. You will not be able to make any changes to this record.</div>
{% endif %}
{% endblock %}
{% block content %}
<input type="hidden" id="userId" value="{{ user.id }}">
<div class="container">
<form action="POST">
<div class="row">
<div class="col">
<label for="lastName" class="form-label">Last Name</label>
<input type="text" class="form-control" id="lastName" name="lastName" placeholder="Doe" value="{{ user.last_name if user.last_name else '' }}"{% if not user.active %} disabled readonly{% endif %}>
</div>
<div class="col">
<label for="firstName" class="form-label">First Name</label>
<input type="text" class="form-control" id="firstName" name="firstName" placeholder="John" value="{{ user.first_name if user.first_name else '' }}"{% if not user.active %} disabled readonly{% endif %}>
</div>
<div class="col">
<label for="title" class="form-label">Title</label>
<input type="text" class="form-control" id="title" name="title" placeholder="President" value="{{ user.title if user.title else '' }}"{% if not user.active %} disabled readonly{% endif %}>
</div>
</div>
<div class="row mt-2">
<div class="col-6">
{{ dropdowns.render_dropdown(
id='supervisor',
label='Supervisor',
current_item=user.supervisor if user.supervisor else None,
entry_link='user_item',
enabled=user.active,
refresh_url = url_for('ui.list_items', model_name='user'),
select_url = url_for('ui.update_item', model_name='user'),
record_id = user.id,
field_name = 'supervisor_id'
) }}
</div>
<div class="col-6">
{{ dropdowns.render_dropdown(
id='location',
label='Location',
current_item=user.location if user.location else None,
enabled=user.active,
refresh_url = url_for('ui.list_items', model_name='room'),
select_url = url_for('ui.update_item', model_name='user'),
record_id = user.id,
field_name = 'location_id'
) }}
</div>
</div>
<div class="row mt-4">
<div class="col-6">
<input type="checkbox" class="form-check-input" id="activeCheck" name="activeCheck"{% if user.active %} checked{% endif %}>
<label for="activeCheck" class="form-check-label">Active</label>
</div>
<div class="col-6">
<input type="checkbox" class="form-check-input" id="staffCheck" name="staffCheck"{% if user.staff %} checked{% endif %}{% if not user.active %} disabled readonly{% endif %}>
<label for="staffCheck" class="form-check-label">Staff</label>
</div>
</div>
</form>
<div class="row mt-3">
{% if inventory_rows %}
<div class="col">
{% set id_list = inventory_rows | map(attribute='id') | list %}
{% set inventory_title %}
Assets
{{ links.export_link(
(user.identifier | lower | replace(' ', '_')) + '_user_inventory',
'inventory',
{'ids': id_list}
) }}
{% endset %}
<div class="row">
{{ tables.render_table(headers=inventory_headers, rows=inventory_rows, id='assets', entry_route='inventory_item', title=inventory_title, per_page=8) }}
</div>
</div>
{% endif %}
{% if worklog_rows %}
{% set id_list = worklog_rows | map(attribute='id') | list %}
{% set worklog_title %}
Work Done
{{ links.export_link(
(user.identifier | lower | replace(' ', '_')) + '_user_worklog',
'worklog',
{'ids': id_list}
) }}
{% endset %}
<div class="col">
<div class="row">
{{ tables.render_table(headers=worklog_headers, rows=worklog_rows, id='worklog', entry_route='worklog_item', title=worklog_title, per_page=8) }}
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -1,64 +0,0 @@
{% extends 'layout.html' %}
{% block content %}
{% for layer in org_chart %}
{% set current_index = loop.index0 %}
{% set next_user = org_chart[current_index + 1].user if current_index + 1 < org_chart|length else None %}
{% if loop.first %}
<div class="d-flex mb-5 justify-content-center">
<div class="card border border-primary border-2" style="width: 15rem;">
<div class="card-body">
<h5 class="card-title text-center">
{{ layer.user.first_name }} {{ layer.user.last_name }}<br />{{ links.entry_link('user_item', layer.user.id) }}
</h5>
<div class="card-text text-center">
{% if layer.user.title %}
({{ layer.user.title }})
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
{% if layer.subordinates %}
<div class="mb-5 px-3">
<div class="org-row d-grid gap-3" style="grid-auto-flow: column; overflow-x: auto; max-width: 100%;">
{% for subordinate in layer.subordinates %}
<div class="card {% if next_user and subordinate.id == next_user.id %}border border-primary border-2 highlighted-card{% endif %}" style="min-width: 15rem;">
<div class="card-body">
<h5 class="card-title text-center">
{% if subordinate == user %}
{{ subordinate.first_name }} {{ subordinate.last_name }}
{% else %}
<a class="link-success link-underline-opacity-0"
href="{{ url_for('main.user_org', id=subordinate.id) }}">
{{ subordinate.first_name }} {{ subordinate.last_name }}
</a>
{% endif %}
</h5>
<div class="card-text text-center">
{% if subordinate.title %}
({{ subordinate.title }})<br />
{% endif %}
{{ links.entry_link('user_item', subordinate.id) }}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endfor %}
{% endblock %}
{% block script %}
document.querySelectorAll('.highlighted-card').forEach(card => {
card.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "center"
});
});
{% endblock %}

View file

@ -1,252 +0,0 @@
<!-- templates/worklog.html -->
{% extends "layout.html" %}
{% block title %}{{ title }}{% endblock %}
{% block precontent %}
{% set saveLogic %}
e.preventDefault();
const updateTextareas = Array.from(document.querySelectorAll("textarea[name^='editor']"));
const updates = updateTextareas
.map(el => {
const content = el.value.trim();
if (!content) return null;
const id = el.dataset.noteId;
return id ? { id, content } : { content };
})
.filter(u => u !== null);
const payload = {
start_time: document.querySelector("input[name='start']").value,
end_time: document.querySelector("input[name='end']").value,
complete: document.querySelector("input[name='complete']").checked,
analysis: document.querySelector("input[name='analysis']").checked,
followup: document.querySelector("input[name='followup']").checked,
contact_id: parseInt(document.querySelector("input[name='contact']").value) || null,
work_item_id: parseInt(document.querySelector("input[name='item']").value) || null,
updates: updates
};
try {
const id = document.querySelector("#logId").value;
const isEdit = id && id !== "None";
const endpoint = isEdit ? `/api/worklog/${id}` : "/api/worklog";
const method = isEdit ? "PUT" : "POST";
const response = await fetch(endpoint, {
method,
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
const result = await response.json();
if (result.success) {
localStorage.setItem("toastMessage", JSON.stringify({
message: isEdit ? "Work Log entry updated!" : "Work Log entry created!",
type: "success"
}));
window.location.href = `/worklog/${result.id}`;
} else {
Toast.renderToast({ message: `Error: ${result.error}`, type: "danger" });
}
} catch (err) {
console.error(err)
Toast.renderToast({ message: `Error: ${err}`, type: "danger" });
}
{% endset %}
{% set deleteLogic %}
const id = document.querySelector("#logId").value;
if (!id || id === "None") {
Toast.renderToast({ message: "No item ID found to delete.", type: "danger" });
return;
}
if (!confirm("Are you sure you want to delete this work log entry? This action cannot be undone.")) {
return;
}
try {
const response = await fetch(`/api/worklog/${id}`, {
method: "DELETE"
});
const result = await response.json();
if (result.success) {
localStorage.setItem("toastMessage", JSON.stringify({
message: "Work log entry deleted.",
type: "success"
}));
window.location.href = "/worklog";
} else {
Toast.renderToast({ message: `Error: ${result.error}`, type: "danger" });
}
} catch (err) {
Toast.renderToast({ message: `Error: ${err}`, type: "danger" });
}
{% endset %}
{% set iconBar %}
<div class="btn-group">
{% if log.id != None %}
{{ buttons.render_button(
id='new',
icon='plus-lg',
style='outline-primary rounded-start',
logic="window.location.href = '" + url_for('main.new_worklog') + "';"
) }}
{% endif %}
{{ buttons.render_button(
id='save',
icon='floppy',
logic=saveLogic,
style='outline-primary'
) }}
{% if log.id != None %}
{{ buttons.render_button(
id='delete',
icon='trash',
style='outline-danger rounded-end',
logic=deleteLogic
) }}
{% endif %}
</div>
{% endset %}
{{ toolbars.render_toolbar(
id='newWorklog',
left=breadcrumb_macro.render_breadcrumb(breadcrumbs=breadcrumbs),
right=iconBar
) }}
{% if log.complete %}
<div class="alert alert-success rounded-0">
This work item is complete. You cannot make any further changes.
</div>
{% endif %}
{% endblock %}
{% block content %}
<input type="hidden" id="logId" value="{{ log.id }}">
<div class="container">
<div class="row">
<div class="col-6">
<label for="start" class="form-label">Start Timestamp</label>
<input type="date" class="form-control" name="start" placeholder="-"
value="{{ log.start_time.date().isoformat() if log.start_time }}"{% if log.complete %} disabled{% endif %}>
</div>
<div class="col-6">
<label for="end" class="form-label">End Timestamp</label>
<input type="date" class="form-control" name="end" placeholder="-"
value="{{ log.end_time.date().isoformat() if log.end_time }}"{% if log.complete %} disabled{% endif %}>
</div>
</div>
<div class="row">
<div class="col-4">
{{ dropdowns.render_dropdown(
id='contact',
label='Contact',
current_item=log.contact,
entry_link='user_item',
enabled = not log.complete,
refresh_url=url_for('ui.list_items', model_name='user'),
select_url=url_for('ui.update_item', model_name='worklog'),
record_id=log.id,
field_name='contact_id'
) }}
</div>
<div class="col-4">
{{ dropdowns.render_dropdown(
id='item',
label='Work Item',
current_item=log.work_item,
entry_link='inventory_item',
enabled = not log.complete,
refresh_url=url_for('ui.list_items', model_name='inventory'),
select_url=url_for('ui.update_item', model_name='worklog'),
record_id=log.id,
field_name='work_item_id'
) }}
</div>
<div class="col-4">
<div class="row">
<div class="col">
<input type="checkbox" id="complete" class="form-check-input" name="complete" {% if log.complete %}
checked{% endif %}>
<label for="complete" class="form-check-label">
Complete?
</label>
</div>
</div>
<div class="row">
<div class="col">
<input type="checkbox" id="followup" class="form-check-input" name="followup" {% if log.followup %}
checked{% endif %}{% if log.complete %} disabled{% endif %}>
<label for="followup" class="form-check-label">
Follow Up?
</label>
</div>
</div>
<div class="row">
<div class="col">
<input type="checkbox" id="analysis" class="form-check-input" name="analysis" {% if log.analysis %}
checked{% endif %}{% if log.complete %} disabled{% endif %}>
<label for="analysis" class="form-check-label">
Quick Analysis?
</label>
</div>
</div>
</div>
</div>
<div class="container" id="updates-container">
<div class="row">
<div class="col-11">
<label class="form-label">Updates</label>
</div>
<div class="col">
{% set addUpdateLogic %}
function formatDate(date) {
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} `
+ `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
const template = document.getElementById("editor-template");
const newEditor = createEditorWidget(template, createTempId("new"), formatDate(new Date()));
const updatesContainer = document.getElementById("updates-container");
updatesContainer.appendChild(newEditor);
{% endset %}
{{ buttons.render_button(
id='addUpdate',
icon='plus-lg',
logic=addUpdateLogic
) }}
</div>
</div>
{% for update in log.updates %}
{{ editor.render_editor(
id = update.id,
title = labels.render_label(
id = update.id,
refresh_url = url_for('ui.get_value', model_name='work_note'),
field_name = 'timestamp',
record_id = update.id
),
mode = 'view',
enabled = not log.complete,
refresh_url = url_for('ui.get_value', model_name='work_note'),
field_name = 'content',
record_id = update.id
) }}
{% endfor %}
<template id="editor-template">
{{ editor.render_editor('__ID__', '__TIMESTAMP__', 'edit', '') }}
</template>
</div>
</div>
{% endblock %}

View file

@ -1,455 +0,0 @@
from collections import defaultdict
from flask import Blueprint, request, render_template, jsonify, abort, make_response
from sqlalchemy.engine import ScalarResult
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import class_mapper, load_only, selectinload, joinedload, Load
from sqlalchemy.sql import Select
from typing import Any, List, cast, Iterable, Tuple, Set, Dict
from .defaults import (
default_query, default_create, default_update, default_delete, default_serialize, default_values, default_value, default_select, ensure_order_by, count_for
)
from .. import db
bp = Blueprint("ui", __name__, url_prefix="/ui")
from sqlalchemy.orm import Load
def _option_targets_rel(opt: Load, Model, rel_name: str) -> bool:
"""
Return True if this Load option targets Model.rel_name at its root path.
Works for joinedload/selectinload/subqueryload options.
"""
try:
# opt.path is a PathRegistry; .path is a tuple of (mapper, prop, mapper, prop, ...)
path = tuple(getattr(opt, "path", ()).path) # type: ignore[attr-defined]
except Exception:
return False
if not path:
return False
# We only care about the first hop: (Mapper[Model], RelationshipProperty(rel_name))
if len(path) < 2:
return False
first_mapper, first_prop = path[0], path[1]
try:
is_model = first_mapper.class_ is Model # type: ignore[attr-defined]
is_rel = getattr(first_prop, "key", "") == rel_name
return bool(is_model and is_rel)
except Exception:
return False
def _has_loader_for(stmt: Select, Model, rel_name: str) -> bool:
"""
True if stmt already has any loader option configured for Model.rel_name.
"""
opts = getattr(stmt, "_with_options", ()) # SQLAlchemy stores Load options here
for opt in opts:
if isinstance(opt, Load) and _option_targets_rel(opt, Model, rel_name):
return True
return False
def _strategy_for_rel_attr(rel_attr) -> type[Load] | None:
# rel_attr is an InstrumentedAttribute (Model.foo)
prop = getattr(rel_attr, "property", None)
lazy = getattr(prop, "lazy", None)
if lazy in ("joined", "subquery"):
return joinedload
if lazy == "selectin":
return selectinload
# default if mapper left it None or something exotic like 'raise'
return selectinload
def apply_model_default_eager(stmt: Select, Model, skip_rels: Set[str]) -> Select:
# mapper.relationships yields RelationshipProperty objects
mapper = class_mapper(Model)
for prop in mapper.relationships:
if prop.key in skip_rels:
continue
lazy = getattr(prop, "lazy", None)
if lazy in ("joined", "subquery"):
stmt = stmt.options(joinedload(getattr(Model, prop.key)))
elif lazy == "selectin":
stmt = stmt.options(selectinload(getattr(Model, prop.key)))
# else: leave it alone (noload/raise/dynamic/etc.)
return stmt
def split_fields(Model, fields: Iterable[str]) -> Tuple[Set[str], Dict[str, Set[str]]]:
"""
Split requested fields into base model columns and relation->attr sets.
Example: ["name", "brand.name", "owner.identifier"] =>
base_cols = {"name"}
rel_cols = {"brand": {"name"}, "owner": {"identifier"}}
"""
base_cols: Set[str] = set()
rel_cols: Dict[str, Set[str]] = defaultdict(set)
for f in fields:
f = f.strip()
if not f:
continue
if "." in f:
rel, attr = f.split(".", 1)
rel_cols[rel].add(attr)
else:
base_cols.add(f)
return base_cols, rel_cols
def _load_only_existing(Model, names: Set[str]):
"""
Return a list of mapped column attributes present on Model for load_only(...).
Skips relationships and unmapped/hybrid attributes so SQLA doesnt scream.
"""
cols = []
mapper = class_mapper(Model)
mapped_attr_names = set(mapper.attrs.keys())
for n in names:
if n in mapped_attr_names:
attr = getattr(Model, n)
prop = getattr(attr, "property", None)
if prop is not None and hasattr(prop, "columns"):
cols.append(attr)
return cols
def apply_field_loaders(stmt: Select, Model, fields: Iterable[str]) -> Select:
base_cols, rel_cols = split_fields(Model, fields)
base_only = _load_only_existing(Model, base_cols)
if base_only:
stmt = stmt.options(load_only(*base_only))
for rel_name, attrs in rel_cols.items():
if not hasattr(Model, rel_name):
continue
# If someone already attached a loader for this relation, don't add another
if _has_loader_for(stmt, Model, rel_name):
# still allow trimming columns on the related entity if we can
rel_attr = getattr(Model, rel_name)
try:
target_cls = rel_attr.property.mapper.class_
except Exception:
continue
rel_only = _load_only_existing(target_cls, attrs)
if rel_only:
# attach a Load that only applies load_only to that path,
# without picking a different strategy
# This relies on SQLA merging load_only onto existing Load for the same path.
stmt = stmt.options(
getattr(Load(Model), rel_name).load_only(*rel_only)
)
continue
# Otherwise choose a strategy and add it
rel_attr = getattr(Model, rel_name)
strategy = _strategy_for_rel_attr(rel_attr)
if not strategy:
continue
opt = strategy(rel_attr)
# Trim columns on the related entity if requested
try:
target_cls = rel_attr.property.mapper.class_
except Exception:
continue
rel_only = _load_only_existing(target_cls, attrs)
if rel_only:
opt = opt.options(load_only(*rel_only))
stmt = stmt.options(opt)
return stmt
def _normalize(s: str) -> str:
return s.replace("_", "").replace("-", "").lower()
def get_model_class(model_name: str) -> type:
"""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: type, name: str, *args: Any, **kwargs: Any) -> Any:
fn = getattr(Model, name, None)
return fn(*args, **kwargs) if callable(fn) else None
from flask import request, jsonify, render_template
from sqlalchemy.sql import Select
from sqlalchemy.engine import ScalarResult
from typing import Any, cast
@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
fields_raw = (request.args.get("fields") or "").strip()
fields = [f.strip() for f in fields_raw.split(",") if f.strip()]
fields.extend(request.args.getlist("field"))
# legacy params
limit_param = request.args.get("limit")
if limit_param in (None, "", "0", "-1"):
effective_limit = 0
else:
effective_limit = min(int(limit_param), 500)
offset = int(request.args.get("offset", 0))
# new-school params
page = request.args.get("page", type=int)
per_page = request.args.get("per_page", type=int)
# map legacy limit/offset to page/per_page if new params not provided
if per_page is None:
per_page = effective_limit or 20 # default page size if not unlimited
if page is None:
page = (offset // per_page) + 1 if per_page else 1
# unlimited: treat as "no pagination"
unlimited = (per_page == 0)
view = (request.args.get("view") or "json").strip()
sort = (request.args.get("sort") or "").strip() or None
direction = (request.args.get("dir") or request.args.get("direction") or "asc").lower()
if direction not in ("asc", "desc"):
direction = "asc"
qkwargs: dict[str, Any] = {
"text": text,
"limit": 0 if unlimited else per_page,
"offset": 0 if unlimited else (page - 1) * per_page if per_page else 0,
"sort": sort,
"direction": direction,
}
# compute requested relations once
base_cols, rel_cols = split_fields(Model, fields)
skip_rels = set(rel_cols.keys()) if fields else set()
# 1) per-model override first
rows_any: Any = call(Model, "ui_query", db.session, **qkwargs)
stmt: Select | None = None
total: int
if rows_any is None:
stmt = default_select(Model, text=text, sort=sort, direction=direction, eager=False)
if not fields:
stmt = apply_model_default_eager(stmt, Model, skip_rels=set())
else:
stmt = apply_field_loaders(stmt, Model, fields)
stmt = ensure_order_by(stmt, Model, sort=sort, direction=direction)
elif isinstance(rows_any, Select):
# TRUST ui_query; don't add loaders on top
stmt = ensure_order_by(rows_any, Model, sort=sort, direction=direction)
elif isinstance(rows_any, list):
# materialized list; paginate in python
total = len(rows_any)
if unlimited:
rows = rows_any
else:
start = (page - 1) * per_page
end = start + per_page
rows = rows_any[start:end]
# serialize and return at the bottom like usual
else:
# SQLAlchemy Result-like or generic iterable
scalars = getattr(rows_any, "scalars", None)
if callable(scalars):
all_rows = list(cast(ScalarResult[Any], scalars()))
total = len(all_rows)
rows = all_rows if unlimited else all_rows[(page - 1) * per_page : (page * per_page)]
else:
try:
all_rows = list(rows_any)
total = len(all_rows)
rows = all_rows if unlimited else all_rows[(page - 1) * per_page : (page * per_page)]
except TypeError:
total = 1
rows = [rows_any]
# If we have a real Select, run it once (unlimited) or paginate once.
if stmt is not None:
if unlimited:
rows = list(db.session.execute(stmt).scalars())
total = count_for(db.session, stmt)
else:
pagination = db.paginate(stmt, page=page, per_page=per_page, error_out=False)
rows = pagination.items
total = pagination.total
# Serialize
if fields:
items = []
for r in rows:
row = {"id": r.id}
for f in fields:
if '.' in f:
rel, attr = f.split('.', 1)
rel_obj = getattr(r, rel, None)
row[f] = getattr(rel_obj, attr, None) if rel_obj else None
else:
row[f] = getattr(r, f, None)
items.append(row)
else:
items = [
(call(Model, "ui_serialize", r, view=view) or default_serialize(Model, r, view=view))
for r in rows
]
# Views
want_option = (request.args.get("view") == "option")
want_list = (request.args.get("view") == "list")
want_table = (request.args.get("view") == "table")
if want_option:
return render_template("fragments/_option_fragment.html", options=items)
if want_list:
return render_template("fragments/_list_fragment.html", options=items)
if want_table:
resp = make_response(render_template("fragments/_table_data_fragment.html",
rows=items, model_name=model_name))
resp.headers['X-Total'] = str(total)
resp.headers['X-Page'] = str(page)
resp.headers['X-PAges'] = str((0 if unlimited else ((total + per_page - 1) // per_page)))
resp.headers['X-Per-Page'] = str(per_page)
return resp
return jsonify({
"items": items,
"total": total,
"page": page,
"per_page": per_page,
"pages": (0 if unlimited else ((total + per_page - 1) // per_page))
})
@bp.post("/<model_name>/create")
def create_item(model_name):
Model = get_model_class(model_name)
payload: dict[str, Any] = request.get_json(silent=True) or {}
if not payload:
return jsonify({"error": "Payload required"}), 422
try:
obj = call(Model, 'ui_create', db.session, payload=payload) \
or default_create(db.session, Model, payload)
except IntegrityError:
db.session.rollback()
return jsonify({"error": "Duplicate"}), 409
data = call(Model, 'ui_serialize', obj) or default_serialize(Model, obj)
want_html = (request.args.get('view') == 'option') or ("HX-Request" in request.headers)
if want_html:
return "Yo."
return jsonify(data), 201
@bp.post("/<model_name>/update")
def update_item(model_name):
Model = get_model_class(model_name)
payload: dict[str, Any] = request.get_json(silent=True) or {}
id_raw: Any = payload.get("id")
if isinstance(id_raw, bool): # bool is an int subclass; explicitly ban
return jsonify({"error": "Invalid id"}), 422
try:
id_ = int(id_raw) # will raise on None, '', junk
except (TypeError, ValueError):
return jsonify({"error": "Invalid id"}), 422
obj = call(Model, 'ui_update', db.session, id_=id_, payload=payload) \
or default_update(db.session, Model, id_, payload)
if not obj:
return jsonify({"error": "Not found"}), 404
return ("", 204)
@bp.post("/<model_name>/delete")
def delete_item(model_name):
Model = get_model_class(model_name)
payload: dict[str, Any] = request.get_json(silent=True) or {}
ids_raw = payload.get("ids") or []
if not isinstance(ids_raw, list):
return jsonify({"error": "Invalid ids"}), 422
try:
ids: List[int] = [int(x) for x in ids_raw]
except (TypeError, ValueError):
return jsonify({"error": "Invalid ids"}), 422
try:
deleted = call(Model, 'ui_delete', db.session, ids=ids) \
or default_delete(db.session, Model, ids)
except IntegrityError as e:
db.session.rollback()
return jsonify({"error": "Constraint", "detail": str(e)}), 409
return jsonify({"deleted": deleted}), 200
@bp.get("/<model_name>/value")
def get_value(model_name):
Model = get_model_class(model_name)
field = (request.args.get("field") or "").strip()
if not field:
return jsonify({"error": "field required"}), 422
id_raw = request.args.get("id")
try:
id_ = int(id_raw)
except (TypeError, ValueError):
return jsonify({"error": "Invalid id"}), 422
# per-model override hook: ui_value(session, id_: int, field: str) -> Any
try:
val = call(Model, "ui_value", db.session, id_=id_, field=field)
if val is None:
val = default_value(db.session, Model, id_=id_, field=field)
except ValueError as e:
return jsonify({"error": str(e)}), 400
# If HTMX hit this, keep the response boring and small
if request.headers.get("HX-Request"):
# text/plain keeps htmx happy for innerHTML swaps
return (str(val) if val is not None else ""), 200, {"Content-Type": "text/plain; charset=utf-8"}
return jsonify({"id": id_, "field": field, "value": val})
@bp.get("<model_name>/values")
def get_values(model_name):
Model = get_model_class(model_name)
raw = request.args.get("fields") or ""
parts = [p for p in raw.split(",") if p.strip()]
parts.extend(request.args.getlist("field"))
id_raw = request.args.get("id")
try:
id_ = int(id_raw)
except (TypeError, ValueError):
return jsonify({"error": "Invalid id"}), 422
try:
data = call(Model, "ui_values", db.session, id_=id_, fields=parts) \
or default_values(db.session, Model, id_=id_, fields=parts)
except ValueError as e:
return jsonify({"error": str(e)}), 400
return jsonify({"id": id_, "fields": parts, "values": data})

View file

@ -1,356 +0,0 @@
from sqlalchemy import select, asc as sa_asc, desc as sa_desc, or_, func
from sqlalchemy.inspection import inspect
from sqlalchemy.orm import class_mapper, joinedload, selectinload
from sqlalchemy.sql import Select
from sqlalchemy.sql.sqltypes import String, Unicode, Text
from typing import Any, Optional, cast, Iterable
PREFERRED_LABELS = ("identifier", "name", "first_name", "last_name", "description")
def _columns_for_text_search(Model):
mapper = inspect(Model)
cols = []
for c in mapper.columns:
if isinstance(c.type, (String, Unicode, Text)):
cols.append(getattr(Model, c.key))
return cols
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 count_for(session, stmt: Select) -> int:
# strip ORDER BY for efficiency
subq = stmt.order_by(None).subquery()
count_stmt = select(func.count()).select_from(subq)
return session.execute(count_stmt).scalar_one()
def ensure_order_by(stmt, Model, sort=None, direction="asc"):
try:
has_order = bool(getattr(stmt, '_order_by_clauses', None))
except Exception:
has_order = False
if has_order:
return stmt
cols = []
if sort and hasattr(Model, sort):
col = getattr(Model, sort)
cols.append(col.desc() if direction == "desc" else col.asc())
if not cols:
ui_order_cols = getattr(Model, 'ui_order_cols', ())
for name in ui_order_cols or ():
c = getattr(Model, name, None)
if c is not None:
cols.append(c.asc())
if not cols:
for pk_col in inspect(Model).primary_key:
cols.append(pk_col.asc())
return stmt.order_by(*cols)
def default_select(
Model,
*,
text: Optional[str] = None,
sort: Optional[str] = None,
direction: str = "asc",
eager = False,
skip_rels=frozenset()
) -> Select[Any]:
stmt: Select[Any] = select(Model)
# search
ui_search = getattr(Model, "ui_search", None)
if callable(ui_search) and text:
stmt = cast(Select[Any], ui_search(stmt, text))
elif text:
# optional generic search fallback if you used this in default_query
t = f"%{text}%"
text_cols = _columns_for_text_search(Model) # your existing helper
if text_cols:
stmt = stmt.where(or_(*(col.ilike(t) for col in text_cols)))
# sorting
if sort:
ui_sort = getattr(Model, "ui_sort", None)
if callable(ui_sort):
stmt = cast(Select[Any], ui_sort(stmt, sort, direction))
else:
col = getattr(Model, sort, None)
if col is not None:
stmt = stmt.order_by(sa_desc(col) if direction == "desc" else sa_asc(col))
else:
ui_order_cols = getattr(Model, "ui_order_cols", ())
if ui_order_cols:
order_cols = []
for name in ui_order_cols:
col = getattr(Model, name, None)
if col is not None:
order_cols.append(sa_asc(col))
if order_cols:
stmt = stmt.order_by(*order_cols)
# eagerload defaults
opts_attr = getattr(Model, "ui_eagerload", ())
if callable(opts_attr):
opts = cast(Iterable[Any], opts_attr()) # if you prefer, pass Model in
else:
opts = cast(Iterable[Any], opts_attr)
for opt in opts:
stmt = stmt.options(opt)
if eager:
for prop in class_mapper(Model).relationships:
if prop.key in skip_rels:
continue
lazy = getattr(prop, "lazy", None)
if lazy in ("joined", "subquery"):
stmt = stmt.options(joinedload(getattr(Model, prop.key)))
elif lazy == "selectin":
stmt = stmt.options(selectinload(getattr(Model, prop.key)))
return stmt
def default_query(
session,
Model,
*,
text: Optional[str] = None,
limit: int = 0,
offset: int = 0,
sort: Optional[str] = None,
direction: str = "asc",
) -> list[Any]:
"""
SA 2.x ONLY. Returns list[Model].
Hooks:
- ui_search(stmt: Select, text: str) -> Select
- ui_sort(stmt: Select, sort: str, direction: str) -> Select
- ui_order_cols: tuple[str, ...] # default ordering columns
"""
stmt: Select[Any] = select(Model)
ui_search = getattr(Model, "ui_search", None)
if callable(ui_search) and text:
stmt = cast(Select[Any], ui_search(stmt, text))
elif text:
t = f"%{text}%"
text_cols = _columns_for_text_search(Model)
if text_cols:
stmt = stmt.where(or_(*(col.ilike(t) for col in text_cols)))
if sort:
ui_sort = getattr(Model, "ui_sort", None)
if callable(ui_sort):
stmt = cast(Select[Any], ui_sort(stmt, sort, direction))
else:
col = getattr(Model, sort, None)
if col is not None:
stmt = stmt.order_by(sa_desc(col) if direction == "desc" else sa_asc(col))
else:
order_cols = getattr(Model, "ui_order_cols", ())
if order_cols:
for colname in order_cols:
col = getattr(Model, colname, None)
if col is not None:
stmt = stmt.order_by(sa_asc(col))
if offset:
stmt = stmt.offset(offset)
if limit > 0:
stmt = stmt.limit(limit)
opts_attr = getattr(Model, "ui_eagerload", ())
opts: Iterable[Any]
if callable(opts_attr):
opts = cast(Iterable[Any], opts_attr()) # if you want, pass Model to it: opts_attr(Model)
else:
opts = cast(Iterable[Any], opts_attr)
for opt in opts:
stmt = stmt.options(opt)
return list(session.execute(stmt).scalars().all())
def _resolve_column(Model, path: str):
"""Return (selectable, joins:list[tuple[parent, attr]]) for 'col' or 'rel.col'"""
if '.' not in path:
col = _mapped_column(Model, path)
if col is None:
raise ValueError(f"Column '{path}' is not a mapped column on {Model.__name__}")
return col, []
rel_name, rel_field = path.split('.', 1)
rel_attr = getattr(Model, rel_name, None)
if getattr(rel_attr, 'property', None) is None:
raise ValueError(f"Column '{path}' is not a valid relationship on {Model.__name__}")
Rel = rel_attr.property.mapper.class_
col = _mapped_column(Rel, rel_field)
if col is None:
raise ValueError(f"Column '{path}' is not a mapped column on {Rel.__name__}")
return col, [(Model, rel_name)]
def default_values(session, Model, *, id_: int, fields: Iterable[str]) -> dict[str, Any]:
fields = [f.strip() for f in fields if f.strip()]
if not fields:
raise ValueError("No fields provided for default_values")
mapper = inspect(Model)
pk = mapper.primary_key[0]
selects = []
joins = []
for f in fields:
col, j = _resolve_column(Model, f)
selects.append(col.label(f.replace('.', '_')))
joins.extend(j)
seen = set()
stmt = select(*selects).where(pk == id_)
current = Model
for parent, attr_name in joins:
key = (parent, attr_name)
if key in seen:
continue
seen.add(key)
stmt = stmt.join(getattr(parent, attr_name))
row = session.execute(stmt).one_or_none()
if row is None:
return {}
allow = getattr(Model, "ui_value_allow", None)
if allow:
for f in fields:
if f not in allow:
raise ValueError(f"Field '{f}' not allowed")
data = {}
for f in fields:
key = f.replace('.', '_')
data[f] = getattr(row, key, None)
return data
def default_value(session, Model, *, id_: int, field: str) -> Any:
if '.' not in field:
col = _mapped_column(Model, field)
if col is None:
raise ValueError(f"Field '{field}' is not a mapped column on {Model.__name__}")
pk = inspect(Model).primary_key[0]
return session.scalar(select(col).where(pk == id_))
rel_name, rel_field = field.split('.', 1)
rel_attr = getattr(Model, rel_name, None)
if rel_attr is None or not hasattr(rel_attr, 'property'):
raise ValueError(f"Field '{field}' is not a valid relationship on {Model.__name__}")
Rel = rel_attr.property.mapper.class_
rel_col = _mapped_column(Rel, rel_field)
if rel_col is None:
raise ValueError(f"Field '{field}' is not a mapped column on {Rel.__name__}")
pk = inspect(Model).primary_key[0]
stmt = select(rel_col).join(getattr(Model, rel_name)).where(pk == id_).limit(1)
return session.scalar(stmt)
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
editable = getattr(Model, 'ui_editable_cols', None)
changed = False
for k, v in payload.items():
if k == 'id':
continue
col = _mapped_column(Model, k)
if col is None:
continue
if editable and k not in editable:
continue
if v == '' or v is None:
nv = None
else:
try:
nv = int(v) if col.type.python_type is int else v
except Exception:
nv = v
setattr(obj, k, nv)
changed = True
if changed:
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=None):
# 1. Explicit config wins
label_attr = getattr(Model, 'ui_label_attr', None)
# 2. Otherwise, pick the first PREFERRED_LABELS that exists (can be @property or real column)
if not label_attr:
for candidate in PREFERRED_LABELS:
if hasattr(obj, candidate):
label_attr = candidate
break
# 3. Fallback to str(obj) if literally nothing found
if not label_attr:
name_val = str(obj)
else:
try:
name_val = getattr(obj, label_attr)
except Exception:
name_val = str(obj)
data = {'id': obj.id, 'name': name_val}
# Include extra attrs if defined
for attr in getattr(Model, 'ui_extra_attrs', ()):
if hasattr(obj, attr):
data[attr] = getattr(obj, attr)
return data

View file

@ -1,45 +0,0 @@
from sqlalchemy.orm import joinedload, selectinload
from ..models import User, Room, Inventory, WorkLog
from .. import db
def eager_load_user_relationships(query):
return query.options(
joinedload(User.supervisor),
joinedload(User.location).joinedload(Room.room_function)
)
def eager_load_inventory_relationships(query):
return query.options(
joinedload(Inventory.owner),
joinedload(Inventory.brand),
joinedload(Inventory.device_type),
selectinload(Inventory.location).selectinload(Room.room_function)
)
def eager_load_room_relationships(query):
return query.options(
joinedload(Room.area),
joinedload(Room.room_function),
selectinload(Room.inventory),
selectinload(Room.users)
)
def eager_load_worklog_relationships(query):
return query.options(
joinedload(WorkLog.contact),
joinedload(WorkLog.work_item),
joinedload(WorkLog.updates)
)
def chunk_list(lst, chunk_size):
return [lst[i:i + chunk_size] for i in range(0, len(lst), chunk_size)]
def add_named_entities(items: list[str], model, attr: str, mapper: dict | None = None):
for name in items:
clean = name.strip()
if clean:
new_obj = model(**{attr: clean})
db.session.add(new_obj)
if mapper is not None:
db.session.flush()
mapper[clean] = new_obj.id

View file

@ -1,3 +0,0 @@
from inventory import create_app
app = create_app()