diff --git a/models/areas.py b/models/areas.py index e1572a4..41be69f 100644 --- a/models/areas.py +++ b/models/areas.py @@ -7,9 +7,11 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from . import db from ..temp import is_temp_id +from ..utils.validation import ValidatableMixin -class Area(db.Model): +class Area(ValidatableMixin, db.Model): __tablename__ = 'Areas' + VALIDATION_LABEL = "Area" id: Mapped[int] = mapped_column("ID", Integer, Identity(start=1, increment=1), primary_key=True) name: Mapped[Optional[str]] = mapped_column("Area", Unicode(255), nullable=True) @@ -96,3 +98,26 @@ class Area(db.Model): **{str(temp): real for temp, real in temp_id_map.items()} # "temp-1" → 5 } return id_map + + @classmethod + def validate_state(cls, submitted_items: list[dict]) -> list[str]: + errors = [] + + for index, item in enumerate(submitted_items): + if not isinstance(item, dict): + errors.append(f"Area entry #{index + 1} is not a valid object.") + continue + + name = item.get('name') + if not name or not str(name).strip(): + errors.append(f"Area entry #{index + 1} is missing a name.") + + raw_id = item.get('id') + if raw_id is not None: + try: + _ = int(raw_id) + except (ValueError, TypeError): + if not is_temp_id(raw_id): + errors.append(f"Area entry #{index + 1} has invalid ID: {raw_id}") + + return errors diff --git a/models/brands.py b/models/brands.py index e972413..7147e42 100644 --- a/models/brands.py +++ b/models/brands.py @@ -7,9 +7,11 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from . import db from ..temp import is_temp_id +from ..utils.validation import ValidatableMixin -class Brand(db.Model): +class Brand(ValidatableMixin, db.Model): __tablename__ = 'Brands' + VALIDATION_LABEL = 'Brand' id: Mapped[int] = mapped_column("ID", Integer, Identity(start=1, increment=1), primary_key=True) name: Mapped[Optional[str]] = mapped_column("Brand", Unicode(255), nullable=True) @@ -89,4 +91,25 @@ class Brand(db.Model): } return id_map + @classmethod + def validate_state(cls, submitted_items: list[dict]) -> list[str]: + errors = [] + for index, item in enumerate(submitted_items): + if not isinstance(item, dict): + errors.append(f"Area entry #{index + 1} is not a valid object.") + continue + + name = item.get('name') + if not name or not str(name).strip(): + errors.append(f"Area entry #{index + 1} is missing a name.") + + raw_id = item.get('id') + if raw_id is not None: + try: + _ = int(raw_id) + except (ValueError, TypeError): + if not is_temp_id(raw_id): + errors.append(f"Area entry #{index + 1} has invalid ID: {raw_id}") + + return errors diff --git a/models/items.py b/models/items.py index 4a47bff..5607d14 100644 --- a/models/items.py +++ b/models/items.py @@ -7,9 +7,11 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from . import db from ..temp import is_temp_id +from ..utils.validation import ValidatableMixin -class Item(db.Model): +class Item(ValidatableMixin, db.Model): __tablename__ = 'Items' + VALIDATION_LABEL = 'Item' id: Mapped[int] = mapped_column("ID", Integer, Identity(start=1, increment=1), primary_key=True) description: Mapped[Optional[str]] = mapped_column("Description", Unicode(255), nullable=True) @@ -89,3 +91,26 @@ class Item(db.Model): **{str(temp): real for temp, real in temp_id_map.items()} } return id_map + + @classmethod + def validate_state(cls, submitted_items: list[dict]) -> list[str]: + errors = [] + + for index, item in enumerate(submitted_items): + if not isinstance(item, dict): + errors.append(f"Area entry #{index + 1} is not a valid object.") + continue + + name = item.get('name') + if not name or not str(name).strip(): + errors.append(f"Area entry #{index + 1} is missing a name.") + + raw_id = item.get('id') + if raw_id is not None: + try: + _ = int(raw_id) + except (ValueError, TypeError): + if not is_temp_id(raw_id): + errors.append(f"Area entry #{index + 1} has invalid ID: {raw_id}") + + return errors \ No newline at end of file diff --git a/models/room_functions.py b/models/room_functions.py index 6a65b1f..b8a4caa 100644 --- a/models/room_functions.py +++ b/models/room_functions.py @@ -7,9 +7,11 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from . import db from ..temp import is_temp_id +from ..utils.validation import ValidatableMixin -class RoomFunction(db.Model): +class RoomFunction(ValidatableMixin, db.Model): __tablename__ = 'Room Functions' + VALIDATION_LABEL = "Function" id: Mapped[int] = mapped_column("ID", Integer, Identity(start=1, increment=1), primary_key=True) description: Mapped[Optional[str]] = mapped_column("Function", Unicode(255), nullable=True) diff --git a/models/rooms.py b/models/rooms.py index acc8fd3..a14b0f0 100644 --- a/models/rooms.py +++ b/models/rooms.py @@ -10,8 +10,11 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from . import db -class Room(db.Model): +from ..utils.validation import ValidatableMixin + +class Room(ValidatableMixin, db.Model): __tablename__ = 'Rooms' + VALIDATION_LABEL = "Room" id: Mapped[int] = mapped_column("ID", Integer, Identity(start=1, increment=1), primary_key=True) name: Mapped[Optional[str]] = mapped_column("Room", Unicode(255), nullable=True) @@ -141,4 +144,38 @@ class Room(db.Model): room = existing_by_id[existing_id] db.session.delete(room) print(f"🗑️ Removing room: {room.name}") - + + @classmethod + def validate_state(cls, submitted_items: list[dict]) -> list[str]: + errors = [] + + for index, item in enumerate(submitted_items): + label = f"Room #{index + 1}" + + if not isinstance(item, dict): + errors.append(f"{label} is not a valid object.") + continue + + name = item.get("name") + if not name or not str(name).strip(): + errors.append(f"{label} is missing a name.") + + raw_id = item.get("id") + if raw_id is not None: + try: + _ = int(raw_id) + except (ValueError, TypeError): + if not str(raw_id).startswith("room-"): + errors.append(f"{label} has an invalid ID: {raw_id}") + + # These fields are FK IDs, so we're just checking for valid formats here. + for fk_field, fk_label in [("section_id", "Section"), ("function_id", "Function")]: + fk_val = item.get(fk_field) + if fk_val is not None: + try: + _ = int(fk_val) + except (ValueError, TypeError): + if not isinstance(fk_val, str) or not fk_val.startswith("temp-"): + errors.append(f"{label} has invalid {fk_label} ID: {fk_val}") + + return errors diff --git a/routes.py b/routes.py index a6a8469..f9ae3a2 100644 --- a/routes.py +++ b/routes.py @@ -4,7 +4,7 @@ from .models import Brand, Item, Inventory, RoomFunction, User, WorkLog, Room, A from sqlalchemy import or_, delete from sqlalchemy.orm import aliased from . import db -from .utils import eager_load_user_relationships, eager_load_inventory_relationships, eager_load_room_relationships, eager_load_worklog_relationships, chunk_list, add_named_entities +from .utils.load import eager_load_user_relationships, eager_load_inventory_relationships, eager_load_room_relationships, eager_load_worklog_relationships, chunk_list, add_named_entities import pandas as pd import traceback import json @@ -522,3 +522,50 @@ def settings(): endpoint='settings' ) +@main.route('/api/settings', methods=['POST']) +def api_settings(): + try: + payload = request.get_json() + if not payload: + return {'error': 'No JSON payload'}, 400 + + for label, entries in [ + ('brands', payload.get('brands', [])), + ('types', payload.get('types', [])), + ('sections', payload.get('sections', [])), + ('functions', payload.get('functions', [])), + ('rooms', payload.get('rooms', [])), + ]: + if not isinstance(entries, list): + return {'error': f"{label} must be a list"}, 400 + for entry in entries: + if not isinstance(entry, dict): + return {'error': f'Each {label} entry must be a dict'}, 400 + + errors = [] + errors += Brand.validate_state(payload.get("brands", [])) + errors += Item.validate_state(payload.get("types", [])) + errors += Area.validate_state(payload.get("sections", [])) + errors += RoomFunction.validate_state(payload.get("functions", [])) + errors += Room.validate_state(payload.get("rooms", [])) + + if errors: + return {'status': 'error', 'errors': errors}, 400 + + with db.session.begin(): + brand_map = Brand.sync_from_state(payload.get('brands', [])) + type_map = Item.sync_from_state(payload.get('types', [])) + section_map = Area.sync_from_state(payload.get('sections', [])) + function_map = RoomFunction.sync_from_state(payload.get('functions', [])) + + Room.sync_from_state( + submitted_rooms=payload.get('rooms', []), + section_map=section_map, + function_map=function_map + ) + + return {'status': 'ok'}, 200 + + except Exception as e: + traceback.print_exc() + return {'status': 'error', 'message': str(e)}, 500 \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils.py b/utils/load.py similarity index 95% rename from utils.py rename to utils/load.py index 1e56c86..8a5c8f4 100644 --- a/utils.py +++ b/utils/load.py @@ -1,6 +1,6 @@ from sqlalchemy.orm import joinedload, selectinload -from .models import User, Room, Inventory, WorkLog -from . import db +from ..models import User, Room, Inventory, WorkLog +from .. import db def eager_load_user_relationships(query): return query.options( diff --git a/utils/validation.py b/utils/validation.py new file mode 100644 index 0000000..c68e3f9 --- /dev/null +++ b/utils/validation.py @@ -0,0 +1,28 @@ +from ..temp import is_temp_id + +class ValidatableMixin: + VALIDATION_LABEL = "entry" + + @classmethod + def validate_state(cls, submitted_items: list[dict]) -> list[str]: + errors = [] + label = cls.VALIDATION_LABEL or cls.__name__ + + for index, item in enumerate(submitted_items): + if not isinstance(item, dict): + errors.append(f"{label.capitalize()} #{index + 1} is not a valid object.") + continue + + name = item.get("name") + if not name or not str(name).strip(): + errors.append(f"{label.capitalize()} #{index + 1} is missing a name.") + + raw_id = item.get('id') + if raw_id is not None: + try: + _ = int(raw_id) + except (ValueError, TypeError): + if not is_temp_id(raw_id): + errors.append(f"{label.capitalize()} #{index + 1} has invalid ID: {raw_id}") + + return errors \ No newline at end of file