From e420110fb3733a71dc9de80d5e782e3827ab0c93 Mon Sep 17 00:00:00 2001 From: Conrad Nelson Date: Wed, 3 Sep 2025 16:33:52 -0500 Subject: [PATCH] Complete and total rework ahead. --- crudkit/__init__.py | 8 - {inventory/utils => crudkit/api}/__init__.py | 0 crudkit/api/flask_api.py | 31 ++ crudkit/blueprint.py | 81 ---- .../.gitkeep => crudkit/core/__init__.py | 0 crudkit/core/base.py | 26 + crudkit/core/metadata.py | 0 crudkit/core/service.py | 104 ++++ crudkit/core/spec.py | 110 +++++ crudkit/dsl.py | 117 ----- crudkit/eager.py | 42 -- crudkit/html/__init__.py | 3 - crudkit/html/templates/crudkit/_macros.html | 93 ---- crudkit/html/templates/crudkit/form.html | 3 - crudkit/html/templates/crudkit/lis.html | 2 - crudkit/html/templates/crudkit/options.html | 3 - crudkit/html/templates/crudkit/row.html | 2 - crudkit/html/templates/crudkit/rows.html | 3 - crudkit/html/type_map.py | 137 ------ crudkit/html/ui_fragments.py | 233 --------- crudkit/mixins.py | 23 - crudkit/serialize.py | 22 - crudkit/service.py | 52 -- crudkit/ui/__init__.py | 0 crudkit/ui/fragments.py | 81 ++++ crudkit/ui/templates/field.html | 16 + crudkit/ui/templates/form.html | 6 + crudkit/ui/templates/table.html | 12 + inventory/__init__.py | 57 --- inventory/app.py | 6 - inventory/config.py | 66 --- inventory/models/__init__.py | 68 --- inventory/models/area.py | 8 + inventory/models/areas.py | 23 - inventory/models/brands.py | 27 -- inventory/models/image.py | 38 -- inventory/models/image_links.py | 7 - inventory/models/inventory.py | 151 ------ inventory/models/items.py | 27 -- inventory/models/room_functions.py | 23 - inventory/models/rooms.py | 54 --- inventory/models/users.py | 80 --- inventory/models/work_log.py | 109 ----- inventory/models/work_note.py | 33 -- inventory/routes/__init__.py | 155 ------ inventory/routes/helpers.py | 162 ------- inventory/routes/hooks.py | 28 -- inventory/routes/images.py | 90 ---- inventory/routes/index.py | 141 ------ inventory/routes/inventory.py | 271 ----------- inventory/routes/search.py | 72 --- inventory/routes/settings.py | 15 - inventory/routes/user.py | 189 -------- inventory/routes/worklog.py | 195 -------- inventory/static/css/combobox.css | 20 - inventory/static/css/dropdown.css | 4 - inventory/static/js/combobox.js | 109 ----- inventory/static/js/csv.js | 33 -- inventory/static/js/dropdown.js | 123 ----- inventory/static/js/editor.js | 75 --- inventory/static/js/image.js | 52 -- inventory/static/js/label.js | 29 -- inventory/static/js/table.js | 115 ----- inventory/static/js/toast.js | 70 --- inventory/templates/coffee.html | 120 ----- inventory/templates/error.html | 9 - .../fragments/_breadcrumb_fragment.html | 18 - .../templates/fragments/_button_fragment.html | 16 - .../fragments/_combobox_fragment.html | 49 -- .../fragments/_dropdown_fragment.html | 57 --- .../templates/fragments/_editor_fragment.html | 45 -- .../templates/fragments/_icon_fragment.html | 6 - .../templates/fragments/_image_fragment.html | 54 --- .../templates/fragments/_label_fragment.html | 8 - .../templates/fragments/_link_fragment.html | 46 -- .../templates/fragments/_list_fragment.html | 10 - .../templates/fragments/_option_fragment.html | 7 - .../fragments/_table_data_fragment.html | 8 - .../templates/fragments/_table_fragment.html | 96 ---- .../fragments/_toolbar_fragment.html | 9 - inventory/templates/index.html | 75 --- inventory/templates/inventory.html | 297 ------------ inventory/templates/inventory_index.html | 44 -- inventory/templates/layout.html | 105 ---- inventory/templates/playground.html | 19 - inventory/templates/search.html | 62 --- inventory/templates/settings.html | 257 ---------- inventory/templates/table.html | 37 -- inventory/templates/user.html | 181 ------- inventory/templates/user_org.html | 64 --- inventory/templates/worklog.html | 252 ---------- inventory/ui/blueprint.py | 455 ------------------ inventory/ui/defaults.py | 356 -------------- inventory/utils/load.py | 45 -- inventory/wsgi.py | 3 - 95 files changed, 394 insertions(+), 6351 deletions(-) rename {inventory/utils => crudkit/api}/__init__.py (100%) create mode 100644 crudkit/api/flask_api.py delete mode 100644 crudkit/blueprint.py rename inventory/static/uploads/.gitkeep => crudkit/core/__init__.py (100%) create mode 100644 crudkit/core/base.py create mode 100644 crudkit/core/metadata.py create mode 100644 crudkit/core/service.py create mode 100644 crudkit/core/spec.py delete mode 100644 crudkit/dsl.py delete mode 100644 crudkit/eager.py delete mode 100644 crudkit/html/__init__.py delete mode 100644 crudkit/html/templates/crudkit/_macros.html delete mode 100644 crudkit/html/templates/crudkit/form.html delete mode 100644 crudkit/html/templates/crudkit/lis.html delete mode 100644 crudkit/html/templates/crudkit/options.html delete mode 100644 crudkit/html/templates/crudkit/row.html delete mode 100644 crudkit/html/templates/crudkit/rows.html delete mode 100644 crudkit/html/type_map.py delete mode 100644 crudkit/html/ui_fragments.py delete mode 100644 crudkit/mixins.py delete mode 100644 crudkit/serialize.py delete mode 100644 crudkit/service.py create mode 100644 crudkit/ui/__init__.py create mode 100644 crudkit/ui/fragments.py create mode 100644 crudkit/ui/templates/field.html create mode 100644 crudkit/ui/templates/form.html create mode 100644 crudkit/ui/templates/table.html delete mode 100644 inventory/__init__.py delete mode 100644 inventory/app.py delete mode 100644 inventory/config.py create mode 100644 inventory/models/area.py delete mode 100644 inventory/models/areas.py delete mode 100644 inventory/models/brands.py delete mode 100644 inventory/models/image.py delete mode 100644 inventory/models/image_links.py delete mode 100644 inventory/models/inventory.py delete mode 100644 inventory/models/items.py delete mode 100644 inventory/models/room_functions.py delete mode 100644 inventory/models/rooms.py delete mode 100644 inventory/models/users.py delete mode 100644 inventory/models/work_log.py delete mode 100644 inventory/models/work_note.py delete mode 100644 inventory/routes/__init__.py delete mode 100644 inventory/routes/helpers.py delete mode 100644 inventory/routes/hooks.py delete mode 100644 inventory/routes/images.py delete mode 100644 inventory/routes/index.py delete mode 100644 inventory/routes/inventory.py delete mode 100644 inventory/routes/search.py delete mode 100644 inventory/routes/settings.py delete mode 100644 inventory/routes/user.py delete mode 100644 inventory/routes/worklog.py delete mode 100644 inventory/static/css/combobox.css delete mode 100644 inventory/static/css/dropdown.css delete mode 100644 inventory/static/js/combobox.js delete mode 100644 inventory/static/js/csv.js delete mode 100644 inventory/static/js/dropdown.js delete mode 100644 inventory/static/js/editor.js delete mode 100644 inventory/static/js/image.js delete mode 100644 inventory/static/js/label.js delete mode 100644 inventory/static/js/table.js delete mode 100644 inventory/static/js/toast.js delete mode 100644 inventory/templates/coffee.html delete mode 100644 inventory/templates/error.html delete mode 100644 inventory/templates/fragments/_breadcrumb_fragment.html delete mode 100644 inventory/templates/fragments/_button_fragment.html delete mode 100644 inventory/templates/fragments/_combobox_fragment.html delete mode 100644 inventory/templates/fragments/_dropdown_fragment.html delete mode 100644 inventory/templates/fragments/_editor_fragment.html delete mode 100644 inventory/templates/fragments/_icon_fragment.html delete mode 100644 inventory/templates/fragments/_image_fragment.html delete mode 100644 inventory/templates/fragments/_label_fragment.html delete mode 100644 inventory/templates/fragments/_link_fragment.html delete mode 100644 inventory/templates/fragments/_list_fragment.html delete mode 100644 inventory/templates/fragments/_option_fragment.html delete mode 100644 inventory/templates/fragments/_table_data_fragment.html delete mode 100644 inventory/templates/fragments/_table_fragment.html delete mode 100644 inventory/templates/fragments/_toolbar_fragment.html delete mode 100644 inventory/templates/index.html delete mode 100644 inventory/templates/inventory.html delete mode 100644 inventory/templates/inventory_index.html delete mode 100644 inventory/templates/layout.html delete mode 100644 inventory/templates/playground.html delete mode 100644 inventory/templates/search.html delete mode 100644 inventory/templates/settings.html delete mode 100644 inventory/templates/table.html delete mode 100644 inventory/templates/user.html delete mode 100644 inventory/templates/user_org.html delete mode 100644 inventory/templates/worklog.html delete mode 100644 inventory/ui/blueprint.py delete mode 100644 inventory/ui/defaults.py delete mode 100644 inventory/utils/load.py delete mode 100644 inventory/wsgi.py diff --git a/crudkit/__init__.py b/crudkit/__init__.py index cf333de..e69de29 100644 --- a/crudkit/__init__.py +++ b/crudkit/__init__.py @@ -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"] \ No newline at end of file diff --git a/inventory/utils/__init__.py b/crudkit/api/__init__.py similarity index 100% rename from inventory/utils/__init__.py rename to crudkit/api/__init__.py diff --git a/crudkit/api/flask_api.py b/crudkit/api/flask_api.py new file mode 100644 index 0000000..ddb77a9 --- /dev/null +++ b/crudkit/api/flask_api.py @@ -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('/') + 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('/') + def update_item(id): + obj = service.update(id, request.json) + return jsonify(obj.as_dict()) + + @bp.delete('/') + def delete_item(id): + service.delete(id) + return '', 204 + + return bp diff --git a/crudkit/blueprint.py b/crudkit/blueprint.py deleted file mode 100644 index cae482e..0000000 --- a/crudkit/blueprint.py +++ /dev/null @@ -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("//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("/") - 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("//") - 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("//") - 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("//") - 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("///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 \ No newline at end of file diff --git a/inventory/static/uploads/.gitkeep b/crudkit/core/__init__.py similarity index 100% rename from inventory/static/uploads/.gitkeep rename to crudkit/core/__init__.py diff --git a/crudkit/core/base.py b/crudkit/core/base.py new file mode 100644 index 0000000..e74fef0 --- /dev/null +++ b/crudkit/core/base.py @@ -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) diff --git a/crudkit/core/metadata.py b/crudkit/core/metadata.py new file mode 100644 index 0000000..e69de29 diff --git a/crudkit/core/service.py b/crudkit/core/service.py new file mode 100644 index 0000000..c235213 --- /dev/null +++ b/crudkit/core/service.py @@ -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() diff --git a/crudkit/core/spec.py b/crudkit/core/spec.py new file mode 100644 index 0000000..5840071 --- /dev/null +++ b/crudkit/core/spec.py @@ -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 diff --git a/crudkit/dsl.py b/crudkit/dsl.py deleted file mode 100644 index a9ee931..0000000 --- a/crudkit/dsl.py +++ /dev/null @@ -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 diff --git a/crudkit/eager.py b/crudkit/eager.py deleted file mode 100644 index f32efc2..0000000 --- a/crudkit/eager.py +++ /dev/null @@ -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 diff --git a/crudkit/html/__init__.py b/crudkit/html/__init__.py deleted file mode 100644 index a94f018..0000000 --- a/crudkit/html/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .ui_fragments import make_fragments_blueprint - -__all__ = ["make_fragments_blueprint"] diff --git a/crudkit/html/templates/crudkit/_macros.html b/crudkit/html/templates/crudkit/_macros.html deleted file mode 100644 index f713e81..0000000 --- a/crudkit/html/templates/crudkit/_macros.html +++ /dev/null @@ -1,93 +0,0 @@ -{% macro options(items, value_attr="id", label_path="name", getp=None) -%} -{%- for obj in items -%} - -{%- endfor -%} -{% endmacro %} - -{% macro lis(items, label_path="name", sublabel_path=None, getp=None) -%} -{%- for obj in items -%} -
  • -
    {{ getp(obj, label_path) }}
    - {%- if sublabel_path %} -
    {{ getp(obj, sublabel_path) }}
    - {%- endif %} -
  • -{%- else -%} -
  • No results.
  • -{%- endfor -%} -{% endmacro %} - -{% macro rows(items, fields, getp=None) -%} -{%- for obj in items -%} - - {%- for f in fields -%} - {{ getp(obj, f) }} - {%- endfor -%} - -{%- else -%} - - No results. - -{%- endfor -%} -{%- endmacro %} - -{% macro pager(model, page, pages, per_page, sort, filters) -%} - -{%- endmacro %} - -{% macro form(schema, action, method="POST", obj_id=None, hx=False, csrf_token=None) -%} -
    - {%- if csrf_token %}{% endif -%} - {%- if obj_id %}{% endif -%} - - - {%- for f in schema -%} -
    - {% set fid = 'f-' ~ f.name ~ '-' ~ (obj_id or 'new') %} - - {%- if f.type == "textarea" -%} - - {%- elif f.type == "select" -%} - - {%- elif f.type == "checkbox" -%} - - - {%- else -%} - - {%- endif -%} - {%- if f.help %}
    {{ f.help }}
    {% endif -%} -
    - {%- endfor -%} - -
    - -
    -
    -{%- endmacro %} \ No newline at end of file diff --git a/crudkit/html/templates/crudkit/form.html b/crudkit/html/templates/crudkit/form.html deleted file mode 100644 index 5f3bfb3..0000000 --- a/crudkit/html/templates/crudkit/form.html +++ /dev/null @@ -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) }} \ No newline at end of file diff --git a/crudkit/html/templates/crudkit/lis.html b/crudkit/html/templates/crudkit/lis.html deleted file mode 100644 index e9b1813..0000000 --- a/crudkit/html/templates/crudkit/lis.html +++ /dev/null @@ -1,2 +0,0 @@ -{% import "_macros.html" as ui %} -{{ ui.lis(items, label_path=label_path, sublabel_path=sublabel_path, getp=getp) }} diff --git a/crudkit/html/templates/crudkit/options.html b/crudkit/html/templates/crudkit/options.html deleted file mode 100644 index 34d6a2b..0000000 --- a/crudkit/html/templates/crudkit/options.html +++ /dev/null @@ -1,3 +0,0 @@ -{# Renders only rows #} -{% import "_macros.html" as ui %} -{{ ui.options(items, value_attr=value_attr, label_path=label_path, getp=getp) }} diff --git a/crudkit/html/templates/crudkit/row.html b/crudkit/html/templates/crudkit/row.html deleted file mode 100644 index a3ac629..0000000 --- a/crudkit/html/templates/crudkit/row.html +++ /dev/null @@ -1,2 +0,0 @@ -{% import "_macros.html" as ui %} -{{ ui.rows([obj], fields, getp=getp) }} \ No newline at end of file diff --git a/crudkit/html/templates/crudkit/rows.html b/crudkit/html/templates/crudkit/rows.html deleted file mode 100644 index 7fafa3a..0000000 --- a/crudkit/html/templates/crudkit/rows.html +++ /dev/null @@ -1,3 +0,0 @@ -{% import "_macros.html" as ui %} -{{ ui.rows(items, fields, getp=getp) }} -{{ ui.pager(model, page, pages, per_page, sort, filters) }} diff --git a/crudkit/html/type_map.py b/crudkit/html/type_map.py deleted file mode 100644 index 5e582c2..0000000 --- a/crudkit/html/type_map.py +++ /dev/null @@ -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 diff --git a/crudkit/html/ui_fragments.py b/crudkit/html/ui_fragments.py deleted file mode 100644 index 525c6e5..0000000 --- a/crudkit/html/ui_fragments.py +++ /dev/null @@ -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 //frag/options -> - GET //frag/lis ->
  • ...
  • - GET //frag/rows -> ... + pager markup if wanted - GET //frag/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 _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("//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("//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("//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("//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("//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//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 - diff --git a/crudkit/mixins.py b/crudkit/mixins.py deleted file mode 100644 index 38a0e71..0000000 --- a/crudkit/mixins.py +++ /dev/null @@ -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} diff --git a/crudkit/serialize.py b/crudkit/serialize.py deleted file mode 100644 index 3ba6116..0000000 --- a/crudkit/serialize.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/crudkit/service.py b/crudkit/service.py deleted file mode 100644 index 1950d06..0000000 --- a/crudkit/service.py +++ /dev/null @@ -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 diff --git a/crudkit/ui/__init__.py b/crudkit/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crudkit/ui/fragments.py b/crudkit/ui/fragments.py new file mode 100644 index 0000000..d30e24b --- /dev/null +++ b/crudkit/ui/fragments.py @@ -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) + diff --git a/crudkit/ui/templates/field.html b/crudkit/ui/templates/field.html new file mode 100644 index 0000000..28fcf7e --- /dev/null +++ b/crudkit/ui/templates/field.html @@ -0,0 +1,16 @@ + + +{% if field_type == 'select' %} + +{% else %} + +{% endif %} diff --git a/crudkit/ui/templates/form.html b/crudkit/ui/templates/form.html new file mode 100644 index 0000000..6109e25 --- /dev/null +++ b/crudkit/ui/templates/form.html @@ -0,0 +1,6 @@ +
    + {% for field in fields %} + {{ render_field(field, values.get(field.name, '')) }} + {% endfor %} + +
    diff --git a/crudkit/ui/templates/table.html b/crudkit/ui/templates/table.html new file mode 100644 index 0000000..b4abd80 --- /dev/null +++ b/crudkit/ui/templates/table.html @@ -0,0 +1,12 @@ + + {% if objects %} + + {% for field in objects[0].__table__.columns %}{% endfor %} + + {% for obj in objects %} + {% for field in obj.__table__.columns %}{% endfor %} + {% endfor %} + {% else %} + + {% endif %} +
    {{ field.name }}
    {{ obj[field.name] }}
    No data.
    \ No newline at end of file diff --git a/inventory/__init__.py b/inventory/__init__.py deleted file mode 100644 index acd5102..0000000 --- a/inventory/__init__.py +++ /dev/null @@ -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 diff --git a/inventory/app.py b/inventory/app.py deleted file mode 100644 index b39e7cb..0000000 --- a/inventory/app.py +++ /dev/null @@ -1,6 +0,0 @@ -from . import create_app - -app = create_app() - -if __name__ == "__main__": - app.run() \ No newline at end of file diff --git a/inventory/config.py b/inventory/config.py deleted file mode 100644 index 6d74fd6..0000000 --- a/inventory/config.py +++ /dev/null @@ -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}") diff --git a/inventory/models/__init__.py b/inventory/models/__init__.py index 83d2e78..e69de29 100644 --- a/inventory/models/__init__.py +++ b/inventory/models/__init__.py @@ -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 it’s 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" -] diff --git a/inventory/models/area.py b/inventory/models/area.py new file mode 100644 index 0000000..a6e8ddd --- /dev/null +++ b/inventory/models/area.py @@ -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) \ No newline at end of file diff --git a/inventory/models/areas.py b/inventory/models/areas.py deleted file mode 100644 index 3a986c5..0000000 --- a/inventory/models/areas.py +++ /dev/null @@ -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"" diff --git a/inventory/models/brands.py b/inventory/models/brands.py deleted file mode 100644 index e4ba1ac..0000000 --- a/inventory/models/brands.py +++ /dev/null @@ -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"" - - @property - def identifier(self) -> str: - return self.name if self.name else f"ID: {self.id}" diff --git a/inventory/models/image.py b/inventory/models/image.py deleted file mode 100644 index 5714064..0000000 --- a/inventory/models/image.py +++ /dev/null @@ -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"" - -class ImageAttachable: - def attach_image(self, image: 'Image') -> None: - raise NotImplementedError("This model doesn't know how to attach images.") diff --git a/inventory/models/image_links.py b/inventory/models/image_links.py deleted file mode 100644 index 04c4d46..0000000 --- a/inventory/models/image_links.py +++ /dev/null @@ -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), -) \ No newline at end of file diff --git a/inventory/models/inventory.py b/inventory/models/inventory.py deleted file mode 100644 index 5ea2154..0000000 --- a/inventory/models/inventory.py +++ /dev/null @@ -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"" - - @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)) - ) diff --git a/inventory/models/items.py b/inventory/models/items.py deleted file mode 100644 index b22baa1..0000000 --- a/inventory/models/items.py +++ /dev/null @@ -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"" - - @property - def identifier(self): - return self.description if self.description else f"Item {self.id}" diff --git a/inventory/models/room_functions.py b/inventory/models/room_functions.py deleted file mode 100644 index c218f80..0000000 --- a/inventory/models/room_functions.py +++ /dev/null @@ -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"" diff --git a/inventory/models/rooms.py b/inventory/models/rooms.py deleted file mode 100644 index 0f8df32..0000000 --- a/inventory/models/rooms.py +++ /dev/null @@ -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"" - - @property - def identifier(self): - name = self.name or "" - func = self.room_function.description if self.room_function else "" - return f"{name} - {func}".strip(" -") diff --git a/inventory/models/users.py b/inventory/models/users.py deleted file mode 100644 index 325d9df..0000000 --- a/inventory/models/users.py +++ /dev/null @@ -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"" - - 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") - ) diff --git a/inventory/models/work_log.py b/inventory/models/work_log.py deleted file mode 100644 index c365cb3..0000000 --- a/inventory/models/work_log.py +++ /dev/null @@ -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"" - - 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 - ) diff --git a/inventory/models/work_note.py b/inventory/models/work_note.py deleted file mode 100644 index d954569..0000000 --- a/inventory/models/work_note.py +++ /dev/null @@ -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"" - - def serialize(self) -> dict: - return { - 'id': self.id, - 'work_log_id': self.work_log_id, - 'timestamp': self.timestamp.isoformat(), - 'content': self.content - } \ No newline at end of file diff --git a/inventory/routes/__init__.py b/inventory/routes/__init__.py deleted file mode 100644 index da18176..0000000 --- a/inventory/routes/__init__.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/inventory/routes/helpers.py b/inventory/routes/helpers.py deleted file mode 100644 index 7dbedb2..0000000 --- a/inventory/routes/helpers.py +++ /dev/null @@ -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 = ''' - -''' -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 diff --git a/inventory/routes/hooks.py b/inventory/routes/hooks.py deleted file mode 100644 index 41fbf9f..0000000 --- a/inventory/routes/hooks.py +++ /dev/null @@ -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 diff --git a/inventory/routes/images.py b/inventory/routes/images.py deleted file mode 100644 index adabbe3..0000000 --- a/inventory/routes/images.py +++ /dev/null @@ -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/", 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/", 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}) diff --git a/inventory/routes/index.py b/inventory/routes/index.py deleted file mode 100644 index 797b3c9..0000000 --- a/inventory/routes/index.py +++ /dev/null @@ -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 - ) \ No newline at end of file diff --git a/inventory/routes/inventory.py b/inventory/routes/inventory.py deleted file mode 100644 index 40b80f5..0000000 --- a/inventory/routes/inventory.py +++ /dev/null @@ -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/", 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/", 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/", 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' - ) \ No newline at end of file diff --git a/inventory/routes/search.py b/inventory/routes/search.py deleted file mode 100644 index 60331a8..0000000 --- a/inventory/routes/search.py +++ /dev/null @@ -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) diff --git a/inventory/routes/settings.py b/inventory/routes/settings.py deleted file mode 100644 index d257da4..0000000 --- a/inventory/routes/settings.py +++ /dev/null @@ -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 - ) diff --git a/inventory/routes/user.py b/inventory/routes/user.py deleted file mode 100644 index 0c4cb3f..0000000 --- a/inventory/routes/user.py +++ /dev/null @@ -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/") -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//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/", 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) diff --git a/inventory/routes/worklog.py b/inventory/routes/worklog.py deleted file mode 100644 index 6e9d91f..0000000 --- a/inventory/routes/worklog.py +++ /dev/null @@ -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/") -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/", 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/", 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) - # }) \ No newline at end of file diff --git a/inventory/static/css/combobox.css b/inventory/static/css/combobox.css deleted file mode 100644 index 237a70d..0000000 --- a/inventory/static/css/combobox.css +++ /dev/null @@ -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; /* Bootstrap’s 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; -} diff --git a/inventory/static/css/dropdown.css b/inventory/static/css/dropdown.css deleted file mode 100644 index 72868e3..0000000 --- a/inventory/static/css/dropdown.css +++ /dev/null @@ -1,4 +0,0 @@ -.dropdown-search-input:focus { - outline: none !important; - box-shadow: none !important; -} diff --git a/inventory/static/js/combobox.js b/inventory/static/js/combobox.js deleted file mode 100644 index 1fc160a..0000000 --- a/inventory/static/js/combobox.js +++ /dev/null @@ -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; - } - } - } -} diff --git a/inventory/static/js/csv.js b/inventory/static/js/csv.js deleted file mode 100644 index 3b22e12..0000000 --- a/inventory/static/js/csv.js +++ /dev/null @@ -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' }); - } -} \ No newline at end of file diff --git a/inventory/static/js/dropdown.js b/inventory/static/js/dropdown.js deleted file mode 100644 index e4f3441..0000000 --- a/inventory/static/js/dropdown.js +++ /dev/null @@ -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'); - } - }); - } - }; -} diff --git a/inventory/static/js/editor.js b/inventory/static/js/editor.js deleted file mode 100644 index aec8204..0000000 --- a/inventory/static/js/editor.js +++ /dev/null @@ -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++}`; -} diff --git a/inventory/static/js/image.js b/inventory/static/js/image.js deleted file mode 100644 index 57653cc..0000000 --- a/inventory/static/js/image.js +++ /dev/null @@ -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 - } -})(); \ No newline at end of file diff --git a/inventory/static/js/label.js b/inventory/static/js/label.js deleted file mode 100644 index be3649d..0000000 --- a/inventory/static/js/label.js +++ /dev/null @@ -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; - } - } - }; -} \ No newline at end of file diff --git a/inventory/static/js/table.js b/inventory/static/js/table.js deleted file mode 100644 index d4a3380..0000000 --- a/inventory/static/js/table.js +++ /dev/null @@ -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 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
      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))); - }, - }; -} diff --git a/inventory/static/js/toast.js b/inventory/static/js/toast.js deleted file mode 100644 index c6797d6..0000000 --- a/inventory/static/js/toast.js +++ /dev/null @@ -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 = ` - - `; - - 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 - }; -})(); \ No newline at end of file diff --git a/inventory/templates/coffee.html b/inventory/templates/coffee.html deleted file mode 100644 index fd0dd1b..0000000 --- a/inventory/templates/coffee.html +++ /dev/null @@ -1,120 +0,0 @@ -{% extends 'layout.html' %} - -{% block style %} -.light { - transition: background-color .25s; -} -{% endblock %} - -{% block precontent %} - {{ toolbars.render_toolbar( - id = 'score', - left = 'Loading...' | safe, - right= ('Score: ' + score|string + '') | safe - ) }} -{% endblock %} - -{% block content %} -
      - {% for x in range(level + 3) %} -
      - {% for y in range(level + 3) %} -
      -
      - -
      -
      - {% endfor %} -
      - {% endfor %} -
      -{% 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 %} \ No newline at end of file diff --git a/inventory/templates/error.html b/inventory/templates/error.html deleted file mode 100644 index 605c4ed..0000000 --- a/inventory/templates/error.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends 'layout.html' %} - -{% block title %}{{ title }}{% endblock %} - -{% block content %} -
      - {{ message }} -
      -{% endblock %} \ No newline at end of file diff --git a/inventory/templates/fragments/_breadcrumb_fragment.html b/inventory/templates/fragments/_breadcrumb_fragment.html deleted file mode 100644 index 31c5e73..0000000 --- a/inventory/templates/fragments/_breadcrumb_fragment.html +++ /dev/null @@ -1,18 +0,0 @@ -{% import "fragments/_icon_fragment.html" as icons %} - -{% macro render_breadcrumb(breadcrumbs=[]) %} - -{% endmacro %} diff --git a/inventory/templates/fragments/_button_fragment.html b/inventory/templates/fragments/_button_fragment.html deleted file mode 100644 index f85bda0..0000000 --- a/inventory/templates/fragments/_button_fragment.html +++ /dev/null @@ -1,16 +0,0 @@ -{% import "fragments/_icon_fragment.html" as icons %} - -{% macro render_button(id, icon, style='primary', logic = None, label = None, enabled = True) %} - - - {% if logic %} - - {% endif %} -{% endmacro %} \ No newline at end of file diff --git a/inventory/templates/fragments/_combobox_fragment.html b/inventory/templates/fragments/_combobox_fragment.html deleted file mode 100644 index eee8ad1..0000000 --- a/inventory/templates/fragments/_combobox_fragment.html +++ /dev/null @@ -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 %} - -{% endif %} -
      -
      - - - - - -
      - - - - {% if refresh_url %} - {% set url = refresh_url ~ ('&' if '?' in refresh_url else '?') ~ 'view=option&limit=0&per_page=0' %} -
      -
      - {% endif %} -
      -{% endmacro %}} \ No newline at end of file diff --git a/inventory/templates/fragments/_dropdown_fragment.html b/inventory/templates/fragments/_dropdown_fragment.html deleted file mode 100644 index 30d3b95..0000000 --- a/inventory/templates/fragments/_dropdown_fragment.html +++ /dev/null @@ -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) %} - - -{% endmacro %} diff --git a/inventory/templates/fragments/_editor_fragment.html b/inventory/templates/fragments/_editor_fragment.html deleted file mode 100644 index 418423e..0000000 --- a/inventory/templates/fragments/_editor_fragment.html +++ /dev/null @@ -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) %} - -
      -
      - -
      -
      -
      - -
      -
      -
      -
      -{% endmacro %} \ No newline at end of file diff --git a/inventory/templates/fragments/_icon_fragment.html b/inventory/templates/fragments/_icon_fragment.html deleted file mode 100644 index ee14e58..0000000 --- a/inventory/templates/fragments/_icon_fragment.html +++ /dev/null @@ -1,6 +0,0 @@ - -{% macro render_icon(icon, size=24, extra_class='') %} - - - -{% endmacro %} diff --git a/inventory/templates/fragments/_image_fragment.html b/inventory/templates/fragments/_image_fragment.html deleted file mode 100644 index 2fd72f6..0000000 --- a/inventory/templates/fragments/_image_fragment.html +++ /dev/null @@ -1,54 +0,0 @@ -{% import "fragments/_icon_fragment.html" as icons %} - -{% macro render_image(id, image=None, enabled=True) %} - -
      - {% if image %} - Image of ID {{ id }} - - {% else %} - {% if enabled %} - - {{ icons.render_icon('image', 256) }} - -
      - - - - -
      - {% endif %} - {% endif %} -
      -{% endmacro %} \ No newline at end of file diff --git a/inventory/templates/fragments/_label_fragment.html b/inventory/templates/fragments/_label_fragment.html deleted file mode 100644 index 699969e..0000000 --- a/inventory/templates/fragments/_label_fragment.html +++ /dev/null @@ -1,8 +0,0 @@ -{% macro render_label(id, text=none, refresh_url=none, field_name=none, record_id=none) %} -{{ text if text else '' }} -{% endmacro %} \ No newline at end of file diff --git a/inventory/templates/fragments/_link_fragment.html b/inventory/templates/fragments/_link_fragment.html deleted file mode 100644 index 2fbfa7d..0000000 --- a/inventory/templates/fragments/_link_fragment.html +++ /dev/null @@ -1,46 +0,0 @@ -{% import "fragments/_icon_fragment.html" as icons %} - -{% macro category_link(endpoint, label, icon_html=none, arguments={}) %} - - - -{% endmacro %} - -{% macro navigation_link(endpoint, label, icon_html=none, arguments={}, active=false) %} - - -{% if not active %} -{% set active = request.endpoint == 'main.' + endpoint %} -{% endif %} - - -{% endmacro %} - -{% macro entry_link(endpoint, id) %} - - - - {{ icons.render_icon('link', 12) }} - -{% endmacro %} - -{% macro export_link(id, endpoint, ids) %} - - -{{ icons.render_icon('box-arrow-up', 12) }} -{% endmacro %} \ No newline at end of file diff --git a/inventory/templates/fragments/_list_fragment.html b/inventory/templates/fragments/_list_fragment.html deleted file mode 100644 index c1add41..0000000 --- a/inventory/templates/fragments/_list_fragment.html +++ /dev/null @@ -1,10 +0,0 @@ - -{% for it in options %} -
    • - {{ it.name }} -
    • -{% endfor %} diff --git a/inventory/templates/fragments/_option_fragment.html b/inventory/templates/fragments/_option_fragment.html deleted file mode 100644 index bc25b57..0000000 --- a/inventory/templates/fragments/_option_fragment.html +++ /dev/null @@ -1,7 +0,0 @@ -{# templates/fragments/_option_fragment.html #} -{% for it in options %} - -{% endfor %} diff --git a/inventory/templates/fragments/_table_data_fragment.html b/inventory/templates/fragments/_table_data_fragment.html deleted file mode 100644 index e631f76..0000000 --- a/inventory/templates/fragments/_table_data_fragment.html +++ /dev/null @@ -1,8 +0,0 @@ - -{% for r in rows %} - - {% for key, val in r.items() if not key == 'id' %} - {{ val if val else '-' }} - {% endfor %} - -{% endfor %} \ No newline at end of file diff --git a/inventory/templates/fragments/_table_fragment.html b/inventory/templates/fragments/_table_fragment.html deleted file mode 100644 index ca3d8d4..0000000 --- a/inventory/templates/fragments/_table_fragment.html +++ /dev/null @@ -1,96 +0,0 @@ -{% macro render_table(headers, rows, id, entry_route=None, title=None, per_page=15) %} - - -{% if rows %} -{% if title %} - -{% endif %} -
      - - - - {% for h in headers %} - - {% endfor %} - - - - {% for row in rows %} - - {% for cell in row.cells %} - - {% endfor %} - - {% endfor %} - -
      {{ h }}
      - {% if cell.type == 'bool' %} - {{ cell.html | safe }} - {% elif cell.url %} - {{ cell.text }} - {% else %} - {{ cell.text or '-' }} - {% endif %} -
      -
      - - -{% else %} -
      No data.
      -{% endif %} -{% endmacro %} - -{% macro dynamic_table(id, headers=none, fields=none, entry_route=None, title=None, per_page=15, offset=0, -refresh_url=none) %} - - -{% if rows or refresh_url %} -{% if title %} - -{% endif %} -
      - - - - - {% for h in headers %} - - {% endfor %} - - - -
      - -
      {{ h }}
      -
      -{% else %} -
      No data.
      -{% endif %} -{% endmacro %} \ No newline at end of file diff --git a/inventory/templates/fragments/_toolbar_fragment.html b/inventory/templates/fragments/_toolbar_fragment.html deleted file mode 100644 index c65c085..0000000 --- a/inventory/templates/fragments/_toolbar_fragment.html +++ /dev/null @@ -1,9 +0,0 @@ -{% macro render_toolbar(id, left=None, center=None, right=None) %} - -{% endmacro %} \ No newline at end of file diff --git a/inventory/templates/index.html b/inventory/templates/index.html deleted file mode 100644 index 4e0fc6f..0000000 --- a/inventory/templates/index.html +++ /dev/null @@ -1,75 +0,0 @@ - -{% extends "layout.html" %} - -{% block title %}{{ title }}{% endblock %} - -{% block content %} -
      -

      Welcome to Inventory Manager

      -

      Find out about all of your assets.

      -
      - {% if active_worklog_rows %} -
      -
      -
      -
      Active Worklogs
      -
      - 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 - ) }} -
      - {{ tables.render_table( - headers = active_worklog_headers, - rows = active_worklog_rows, - id = 'Active Worklog', - entry_route = 'worklog_item', - per_page = 10 - )}} -
      -
      -
      - {% endif %} - {% if (datasets['summary'][0]['values'] | sum) > 0 %} -
      -
      -
      -
      Inventory Summary
      -
      -
      -
      -
      - {% endif %} -
      -
      - {% if (datasets['summary'][0]['values'] | sum) > 0 %} -
      -
      -
      -
      Work Summary
      -
      -
      -
      -
      - {% endif %} -
      -
      -{% 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 %} \ No newline at end of file diff --git a/inventory/templates/inventory.html b/inventory/templates/inventory.html deleted file mode 100644 index 4327504..0000000 --- a/inventory/templates/inventory.html +++ /dev/null @@ -1,297 +0,0 @@ - -{% 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 %} -
      - {% 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 %} -
      - {% endset %} - {{ toolbars.render_toolbar( - id='inventory', - left=breadcrumb_macro.render_breadcrumb(breadcrumbs=breadcrumbs), - right=buttonBar - ) }} - {% if item.condition in ["Removed", "Disposed"] %} -
      - This item is not available and cannot be edited. -
      - {% endif %} -{% endblock %} - -{% block content %} - -
      -
      -
      -
      -
      - - -
      -
      - - -
      -
      -
      -
      - - -
      -
      - - -
      -
      - - -
      -
      -
      -
      - {{ 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' - ) }} -
      -
      - - -
      -
      - {{ 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' - ) }} -
      -
      -
      -
      - {{ 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' - ) }} -
      -
      - {{ 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' - ) }} -
      -
      - - -
      -
      -
      - - -
      -
      -
      -
      - {% if item.image or item.condition not in ["Removed", "Disposed"] %} -
      - {{ images.render_image(item.id, item.image, enabled = item.condition not in ["Removed", "Disposed"]) }} -
      - {% endif %} -
      -
      -
      - {{ 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 - ) }} -
      - {% if worklog %} -
      -
      -
      - 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} - ) }} -
      -
      -
      -
      - {% 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 %} -
      - {% endif %} -
      -
      -{% endblock %} diff --git a/inventory/templates/inventory_index.html b/inventory/templates/inventory_index.html deleted file mode 100644 index 45d5829..0000000 --- a/inventory/templates/inventory_index.html +++ /dev/null @@ -1,44 +0,0 @@ - -{% extends "layout.html" %} - -{% block title %}{{ title }}{% endblock %} - -{% block precontent %} - {{ toolbars.render_toolbar('index', left=breadcrumb_macro.render_breadcrumb(breadcrumbs=breadcrumbs)) }} -{% endblock %} - -{% block content %} -
      - {% if not category %} -
      -
      -

      Browse

      -
      -
      -
      - {{ 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'}) }} -
      -
      -
      -

      Reports

      -
      -
      -
      - {{ links.category_link(endpoint = 'inventory_available', label = 'Available', icon_html = icons.render_icon('box-seam', 32)) }} -
      - {% else %} -
      - {% for line in listing %} -
      - {% for id, name in line %} - {{ links.category_link(endpoint = 'list_inventory', label = name, arguments = {'filter_by': category, 'id': id}) }} - {% endfor %} -
      - {% endfor %} -
      - {% endif %} -
      -{% endblock %} \ No newline at end of file diff --git a/inventory/templates/layout.html b/inventory/templates/layout.html deleted file mode 100644 index eb3d108..0000000 --- a/inventory/templates/layout.html +++ /dev/null @@ -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 %} - - - - - - - - - Inventory Manager{% if title %} - {% endif %}{% block title %}{% endblock %} - - - - - - - - - - - - {% block precontent %} - {% endblock %} -
      - {% block content %}{% endblock %} -
      - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/inventory/templates/playground.html b/inventory/templates/playground.html deleted file mode 100644 index aa343a9..0000000 --- a/inventory/templates/playground.html +++ /dev/null @@ -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 %} \ No newline at end of file diff --git a/inventory/templates/search.html b/inventory/templates/search.html deleted file mode 100644 index e00836f..0000000 --- a/inventory/templates/search.html +++ /dev/null @@ -1,62 +0,0 @@ - -{% 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'] %} -
      There are no results for "{{ query }}".
      - {% endif %} -{% endblock %} - -{% block content %} -
      - {% if results['inventory']['rows'] %} -
      - {{ tables.render_table( - headers = results['inventory']['headers'], - rows = results['inventory']['rows'], - entry_route = 'inventory_item', - title='Inventory Results', - id='search-inventory' - )}} -
      - {% endif %} - {% if results['users']['rows'] %} -
      - {{ tables.render_table( - headers = results['users']['headers'], - rows = results['users']['rows'], - entry_route = 'user_item', - title='User Results', - id='search-users' - )}} -
      - {% endif %} - {% if results['worklog']['rows'] %} -
      - {{ tables.render_table( - headers = results['worklog']['headers'], - rows = results['worklog']['rows'], - entry_route = 'worklog_item', - title='Worklog Results', - id='search-worklog' - )}} -
      - {% endif %} -
      -{% 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 %} \ No newline at end of file diff --git a/inventory/templates/settings.html b/inventory/templates/settings.html deleted file mode 100644 index c469c3f..0000000 --- a/inventory/templates/settings.html +++ /dev/null @@ -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 %} -
      - -
      -
      -
      -
      - {{ 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') - ) }} -
      -
      - {{ 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') - ) }} -
      -
      -
      -
      -
      -
      - {{ 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') - ) }} -
      -
      - {{ 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') - ) }} -
      -
      -
      -
      - {{ 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') - ) }} -
      -
      -
      -
      -
      - {% for chunk in image_list %} -
      - {% for image in chunk %} -
      - {{ images.render_image( - id=image.id, - image=image - ) }} -
      - {% endfor %} -
      - {% endfor %} -
      -
      -
      - -
      -{% 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 = ''; - modalFunctions.innerHTML = ''; - - 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 %} \ No newline at end of file diff --git a/inventory/templates/table.html b/inventory/templates/table.html deleted file mode 100644 index 683fcf7..0000000 --- a/inventory/templates/table.html +++ /dev/null @@ -1,37 +0,0 @@ - -{% 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 %} -
      - {{ 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') }} -
      - {% 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 %} diff --git a/inventory/templates/user.html b/inventory/templates/user.html deleted file mode 100644 index 1a54231..0000000 --- a/inventory/templates/user.html +++ /dev/null @@ -1,181 +0,0 @@ - -{% 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 %} -
      - {% 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' - ) }} -
      - {% endset %} - {{ toolbars.render_toolbar( - id = 'newUser', - left = breadcrumb_macro.render_breadcrumb(breadcrumbs=breadcrumbs), - right = iconBar - ) }} -{% if not user.active %} -
      This user is inactive. You will not be able to make any changes to this record.
      -{% endif %} -{% endblock %} - -{% block content %} - -
      -
      -
      -
      - - -
      - -
      - - -
      - -
      - - -
      -
      - -
      -
      - {{ 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' - ) }} -
      - -
      - {{ 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' - ) }} -
      -
      -
      -
      - - -
      -
      - - -
      -
      -
      -
      - {% if inventory_rows %} -
      - {% 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 %} -
      - {{ tables.render_table(headers=inventory_headers, rows=inventory_rows, id='assets', entry_route='inventory_item', title=inventory_title, per_page=8) }} -
      -
      - {% 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 %} -
      -
      - {{ tables.render_table(headers=worklog_headers, rows=worklog_rows, id='worklog', entry_route='worklog_item', title=worklog_title, per_page=8) }} -
      -
      - {% endif %} -
      -
      -{% endblock %} diff --git a/inventory/templates/user_org.html b/inventory/templates/user_org.html deleted file mode 100644 index 11b85a7..0000000 --- a/inventory/templates/user_org.html +++ /dev/null @@ -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 %} -
      -
      -
      -
      - {{ layer.user.first_name }} {{ layer.user.last_name }}
      {{ links.entry_link('user_item', layer.user.id) }} -
      -
      - {% if layer.user.title %} - ({{ layer.user.title }}) - {% endif %} -
      -
      -
      -
      - {% endif %} - - {% if layer.subordinates %} -
      -
      - {% for subordinate in layer.subordinates %} -
      -
      -
      - {% if subordinate == user %} - {{ subordinate.first_name }} {{ subordinate.last_name }} - {% else %} - - {{ subordinate.first_name }} {{ subordinate.last_name }} - - {% endif %} -
      -
      - {% if subordinate.title %} - ({{ subordinate.title }})
      - {% endif %} - {{ links.entry_link('user_item', subordinate.id) }} -
      -
      -
      - {% endfor %} -
      -
      - {% endif %} -{% endfor %} -{% endblock %} - -{% block script %} - document.querySelectorAll('.highlighted-card').forEach(card => { - card.scrollIntoView({ - behavior: "smooth", - block: "center", - inline: "center" - }); - }); -{% endblock %} diff --git a/inventory/templates/worklog.html b/inventory/templates/worklog.html deleted file mode 100644 index 5f0d669..0000000 --- a/inventory/templates/worklog.html +++ /dev/null @@ -1,252 +0,0 @@ - -{% 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 %} -
      - {% 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 %} -
      - {% endset %} - {{ toolbars.render_toolbar( - id='newWorklog', - left=breadcrumb_macro.render_breadcrumb(breadcrumbs=breadcrumbs), - right=iconBar - ) }} - {% if log.complete %} -
      - This work item is complete. You cannot make any further changes. -
      - {% endif %} -{% endblock %} - -{% block content %} - -
      -
      -
      - - -
      -
      - - -
      -
      -
      -
      - {{ 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' - ) }} -
      -
      - {{ 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' - ) }} -
      -
      -
      -
      - - -
      -
      -
      -
      - - -
      -
      -
      -
      - - -
      -
      -
      -
      -
      -
      -
      - -
      -
      - {% 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 - ) }} -
      -
      - {% 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 %} - -
      -
      -{% endblock %} diff --git a/inventory/ui/blueprint.py b/inventory/ui/blueprint.py deleted file mode 100644 index 683ff47..0000000 --- a/inventory/ui/blueprint.py +++ /dev/null @@ -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 doesn’t 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("//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("//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("//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("//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("//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("/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}) \ No newline at end of file diff --git a/inventory/ui/defaults.py b/inventory/ui/defaults.py deleted file mode 100644 index ab6be3e..0000000 --- a/inventory/ui/defaults.py +++ /dev/null @@ -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 diff --git a/inventory/utils/load.py b/inventory/utils/load.py deleted file mode 100644 index 3b40097..0000000 --- a/inventory/utils/load.py +++ /dev/null @@ -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 diff --git a/inventory/wsgi.py b/inventory/wsgi.py deleted file mode 100644 index 4047fd7..0000000 --- a/inventory/wsgi.py +++ /dev/null @@ -1,3 +0,0 @@ -from inventory import create_app - -app = create_app()