diff --git a/models/areas.py b/models/areas.py index 9576068..d59d23d 100644 --- a/models/areas.py +++ b/models/areas.py @@ -6,6 +6,7 @@ from sqlalchemy import Identity, Integer, Unicode from sqlalchemy.orm import Mapped, mapped_column, relationship from . import db +from ..temp import is_temp_id class Area(db.Model): __tablename__ = 'Areas' @@ -28,31 +29,54 @@ class Area(db.Model): } @classmethod - def sync_from_state(cls, submitted_items: list[dict]): + def sync_from_state(cls, submitted_items: list[dict]) -> dict[str, int]: """ - Syncs the Area table with the provided list of dictionaries. - Adds new areas and removes areas that are not in the list. + Syncs the Area table (aka 'sections') with the submitted list. + Supports add, update, and delete. + Also returns a mapping of temp IDs to real IDs for resolution. """ - submitted = { - str(item.get("name", "")).strip() - for item in submitted_items - if isinstance(item, dict) and str(item.get("name", "")).strip() - } - + submitted_clean = [] + seen_ids = set() + temp_id_map = {} + + for item in submitted_items: + if not isinstance(item, dict): + continue + name = str(item.get("name", "")).strip() + if not name: + continue + submitted_clean.append({"id": item.get("id"), "name": name}) + if isinstance(item.get("id"), int) and item["id"] >= 0: + seen_ids.add(item["id"]) + existing_query = db.session.query(cls) - existing = {area.name: area for area in existing_query.all()} + existing_by_id = {area.id: area for area in existing_query.all()} + existing_ids = set(existing_by_id.keys()) + + print(f"Existing area IDs: {existing_ids}") + print(f"Submitted area IDs: {seen_ids}") + + for entry in submitted_clean: + submitted_id = entry.get("id") + submitted_name = entry["name"] + + if is_temp_id(submitted_id): + new_area = cls(name=submitted_name) + db.session.add(new_area) + db.session.flush() # Get the real ID + temp_id_map[submitted_id] = new_area.id + print(f"➕ Adding area: {submitted_name}") + elif submitted_id in existing_by_id: + area = existing_by_id[submitted_id] + if area.name != submitted_name: + print(f"✏️ Updating area {area.id}: '{area.name}' → '{submitted_name}'") + area.name = submitted_name + + for existing_id in existing_ids - seen_ids: + area = existing_by_id[existing_id] + db.session.delete(area) + print(f"🗑️ Removing area: {area.name}") + + return temp_id_map - existing_names = set(existing.keys()) - print(f"Existing areas: {existing_names}") - print(f"Submitted areas: {submitted}") - print(f"Areas to add: {submitted - existing_names}") - print(f"Areas to remove: {existing_names - submitted}") - - for name in submitted - existing_names: - db.session.add(cls(name=name)) - print(f"➕ Adding area: {name}") - - for name in existing_names - submitted: - db.session.delete(existing[name]) - print(f"🗑️ Removing area: {name}") diff --git a/models/brands.py b/models/brands.py index 82f913c..62344d9 100644 --- a/models/brands.py +++ b/models/brands.py @@ -6,6 +6,7 @@ from sqlalchemy import Identity, Integer, Unicode from sqlalchemy.orm import Mapped, mapped_column, relationship from . import db +from ..temp import is_temp_id class Brand(db.Model): __tablename__ = 'Brands' @@ -28,31 +29,47 @@ class Brand(db.Model): } @classmethod - def sync_from_state(cls, submitted_items: list[dict]): - """ - Syncs the Brand table with the provided list of dictionaries. - Adds new brands and removes brands that are not in the list. - """ - submitted = { - str(item.get("name", "")).strip() - for item in submitted_items - if isinstance(item, dict) and str(item.get("name", "")).strip() - } + def sync_from_state(cls, submitted_items: list[dict]) -> dict[str, int]: + submitted_clean = [] + seen_ids = set() + temp_id_map = {} - existing_query = db.session.query(cls) - existing = {brand.name: brand for brand in existing_query.all()} + for item in submitted_items: + if not isinstance(item, dict): + continue + name = str(item.get("name", "")).strip() + if not name: + continue + submitted_clean.append({"id": item.get("id"), "name": name}) + if isinstance(item.get("id"), int) and item["id"] >= 0: + seen_ids.add(item["id"]) - existing_names = set(existing.keys()) + existing_by_id = {b.id: b for b in db.session.query(cls).all()} + existing_ids = set(existing_by_id.keys()) - print(f"Existing brands: {existing_names}") - print(f"Submitted brands: {submitted}") - print(f"Brands to add: {submitted - existing_names}") - print(f"Brands to remove: {existing_names - submitted}") + print(f"Existing brand IDs: {existing_ids}") + print(f"Submitted brand IDs: {seen_ids}") - for name in submitted - existing_names: - db.session.add(cls(name=name)) - print(f"➕ Adding brand: {name}") + for entry in submitted_clean: + submitted_id = entry.get("id") + name = entry["name"] - for name in existing_names - submitted: - db.session.delete(existing[name]) - print(f"🗑️ Removing brand: {name}") + if is_temp_id(submitted_id): + obj = cls(name=name) + db.session.add(obj) + db.session.flush() + temp_id_map[submitted_id] = obj.id + print(f"➕ Adding brand: {name}") + elif submitted_id in existing_by_id: + obj = existing_by_id[submitted_id] + if obj.name != name: + print(f"✏️ Updating brand {obj.id}: '{obj.name}' → '{name}'") + obj.name = name + + for id_to_remove in existing_ids - seen_ids: + db.session.delete(existing_by_id[id_to_remove]) + print(f"🗑️ Removing brand ID {id_to_remove}") + + return temp_id_map + + diff --git a/models/items.py b/models/items.py index 95aac57..5775b58 100644 --- a/models/items.py +++ b/models/items.py @@ -6,6 +6,7 @@ from sqlalchemy import Identity, Integer, Unicode from sqlalchemy.orm import Mapped, mapped_column, relationship from . import db +from ..temp import is_temp_id class Item(db.Model): __tablename__ = 'Items' @@ -31,31 +32,46 @@ class Item(db.Model): } @classmethod - def sync_from_state(cls, submitted_items: list[dict]): - """ - Syncs the Items table with the provided list of dictionaries. - Adds new items and removes items that are not in the list. - """ - submitted = { - str(item.get("name", "")).strip() - for item in submitted_items - if isinstance(item, dict) and str(item.get("name", "")).strip() - } + def sync_from_state(cls, submitted_items: list[dict]) -> dict[str, int]: + submitted_clean = [] + seen_ids = set() + temp_id_map = {} - existing_query = db.session.query(cls) - existing = {item.description: item for item in existing_query.all()} + for item in submitted_items: + if not isinstance(item, dict): + continue + description = str(item.get("name", "")).strip() + if not description: + continue + submitted_clean.append({"id": item.get("id"), "description": description}) + if isinstance(item.get("id"), int) and item["id"] >= 0: + seen_ids.add(item["id"]) - existing_descriptions = set(existing.keys()) + existing_by_id = {t.id: t for t in db.session.query(cls).all()} + existing_ids = set(existing_by_id.keys()) - print(f"Existing items: {existing_descriptions}") - print(f"Submitted items: {submitted}") - print(f"items to add: {submitted - existing_descriptions}") - print(f"items to remove: {existing_descriptions - submitted}") + print(f"Existing item IDs: {existing_ids}") + print(f"Submitted item IDs: {seen_ids}") - for description in submitted - existing_descriptions: - db.session.add(cls(description=description)) - print(f"➕ Adding item: {description}") + for entry in submitted_clean: + submitted_id = entry.get("id") + description = entry["description"] - for description in existing_descriptions - submitted: - db.session.delete(existing[description]) - print(f"🗑️ Removing item: {description}") \ No newline at end of file + if is_temp_id(submitted_id): + obj = cls(description=description) + db.session.add(obj) + db.session.flush() + temp_id_map[submitted_id] = obj.id + print(f"➕ Adding type: {description}") + elif submitted_id in existing_by_id: + obj = existing_by_id[submitted_id] + if obj.description != description: + print(f"✏️ Updating type {obj.id}: '{obj.description}' → '{description}'") + obj.description = description + + for id_to_remove in existing_ids - seen_ids: + db.session.delete(existing_by_id[id_to_remove]) + print(f"🗑️ Removing type ID {id_to_remove}") + + return temp_id_map + diff --git a/models/room_functions.py b/models/room_functions.py index 455fbbe..1d69ac2 100644 --- a/models/room_functions.py +++ b/models/room_functions.py @@ -6,6 +6,7 @@ from sqlalchemy import Identity, Integer, Unicode from sqlalchemy.orm import Mapped, mapped_column, relationship from . import db +from ..temp import is_temp_id class RoomFunction(db.Model): __tablename__ = 'Room Functions' @@ -28,31 +29,46 @@ class RoomFunction(db.Model): } @classmethod - def sync_from_state(cls, submitted_items: list[dict]): - """ - Syncs the functions table with the provided list of dictionaries. - Adds new functions and removes functions that are not in the list. - """ - submitted = { - str(item.get("name", "")).strip() - for item in submitted_items - if isinstance(item, dict) and str(item.get("name", "")).strip() - } + def sync_from_state(cls, submitted_items: list[dict]) -> dict[str, int]: + submitted_clean = [] + seen_ids = set() + temp_id_map = {} - existing_query = db.session.query(cls) - existing = {item.description: item for item in existing_query.all()} + for item in submitted_items: + if not isinstance(item, dict): + continue + description = str(item.get("name", "")).strip() + if not description: + continue + submitted_clean.append({"id": item.get("id"), "description": description}) + if isinstance(item.get("id"), int) and item["id"] >= 0: + seen_ids.add(item["id"]) - existing_descriptions = set(existing.keys()) + existing_by_id = {f.id: f for f in db.session.query(cls).all()} + existing_ids = set(existing_by_id.keys()) - print(f"Existing functions: {existing_descriptions}") - print(f"Submitted functions: {submitted}") - print(f"Functions to add: {submitted - existing_descriptions}") - print(f"Functions to remove: {existing_descriptions - submitted}") + print(f"Existing function IDs: {existing_ids}") + print(f"Submitted function IDs: {seen_ids}") - for description in submitted - existing_descriptions: - db.session.add(cls(description=description)) - print(f"➕ Adding item: {description}") + for entry in submitted_clean: + submitted_id = entry.get("id") + description = entry["description"] - for description in existing_descriptions - submitted: - db.session.delete(existing[description]) - print(f"🗑️ Removing item: {description}") \ No newline at end of file + if is_temp_id(submitted_id): + obj = cls(description=description) + db.session.add(obj) + db.session.flush() + temp_id_map[submitted_id] = obj.id + print(f"➕ Adding function: {description}") + elif submitted_id in existing_by_id: + obj = existing_by_id[submitted_id] + if obj.description != description: + print(f"✏️ Updating function {obj.id}: '{obj.description}' → '{description}'") + obj.description = description + + for id_to_remove in existing_ids - seen_ids: + db.session.delete(existing_by_id[id_to_remove]) + print(f"🗑️ Removing function ID {id_to_remove}") + + return temp_id_map + diff --git a/models/rooms.py b/models/rooms.py index 02dc3fc..afbafd8 100644 --- a/models/rooms.py +++ b/models/rooms.py @@ -46,59 +46,116 @@ class Room(db.Model): } @classmethod - def sync_from_state(cls, submitted_rooms: list[dict], section_map: dict, function_map: dict, section_fallbacks: list, function_fallbacks: list): + def sync_from_state( + cls, + submitted_rooms: list[dict], + section_map: dict, + function_map: dict, + section_fallbacks: list, + function_fallbacks: list + ): """ Syncs the Rooms table with the submitted room list. Resolves foreign keys using section_map and function_map. + Supports add, update, and delete. """ - def resolve_id(raw_id, fallback_list, id_map, label): - try: - resolved = int(raw_id) - if resolved >= 0: - if resolved in id_map.values(): - return resolved - raise ValueError(f"ID {resolved} not found in {label}.") - except Exception: - pass + def resolve_fk(raw_id, fallback_list, id_map, label): + if not raw_id: + return None + + # 🛠 Handle SQLAlchemy model objects and dicts both + name_entry = next( + (getattr(item, "name", None) or item.get("name") + for item in fallback_list + if str(getattr(item, "id", None) or item.get("id")) == str(raw_id)), + None + ) + + if name_entry is None: + raise ValueError(f"Unable to resolve {label} ID: {raw_id}") + + resolved_id = id_map.get(name_entry) + if resolved_id is None: + raise ValueError(f"{label.capitalize()} '{name_entry}' not found in ID map.") + return resolved_id + + submitted_clean = [] + seen_ids = set() + + for room in submitted_rooms: + if not isinstance(room, dict): + continue + name = str(room.get("name", "")).strip() + if not name: + continue + + rid = room.get("id") + section_id = room.get("section_id") + function_id = room.get("function_id") + + submitted_clean.append({ + "id": rid, + "name": name, + "section_id": section_id, + "function_id": function_id + }) + + if rid and not str(rid).startswith("room-"): + try: + seen_ids.add(int(rid)) + except ValueError: + pass # It's an invalid non-temp string - index = abs(resolved + 1) - try: - entry = fallback_list[index] - key = entry.get("name") if isinstance(entry, dict) else str(entry).strip() - final_id = id_map.get(key) - if final_id is None: - raise ValueError(f"ID for {key} not found in {label}.") - return final_id - except Exception as e: - raise ValueError(f"Failed to resolve {label} ID: {e}") from e - existing_query = db.session.query(cls) - existing = {room.name: room for room in existing_query.all()} + existing_by_id = {room.id: room for room in existing_query.all()} + existing_ids = set(existing_by_id.keys()) - submitted = { - str(room.get("name", "")).strip(): room - for room in submitted_rooms - if isinstance(room, dict) and str(room.get("name", "")).strip() - } + print(f"Existing room IDs: {existing_ids}") + print(f"Submitted room IDs: {seen_ids}") - existing_names = set(existing.keys()) - submitted_names = set(submitted.keys()) + for entry in submitted_clean: + rid = entry.get("id") + name = entry["name"] - print(f"Existing rooms: {existing_names}") - print(f"Submitted rooms: {submitted_names}") - print(f"Rooms to add: {submitted_names - existing_names}") - print(f"Rooms to remove: {existing_names - submitted_names}") + resolved_section_id = resolve_fk(entry.get("section_id"), section_fallbacks, section_map, "section") + resolved_function_id = resolve_fk(entry.get("function_id"), function_fallbacks, function_map, "function") + + if not rid or str(rid).startswith("room-"): + new_room = cls(name=name, area_id=resolved_section_id, function_id=resolved_function_id) + db.session.add(new_room) + print(f"➕ Adding room: {new_room}") + else: + try: + rid_int = int(rid) + except ValueError: + print(f"⚠️ Invalid room ID format: {rid}") + continue + + room = existing_by_id.get(rid_int) + if not room: + print(f"⚠️ No matching room in DB for ID: {rid_int}") + continue + + updated = False + if room.name != name: + print(f"✏️ Updating room name {room.id}: '{room.name}' → '{name}'") + room.name = name + updated = True + if room.area_id != resolved_section_id: + print(f"✏️ Updating room area {room.id}: {room.area_id} → {resolved_section_id}") + room.area_id = resolved_section_id + updated = True + if room.function_id != resolved_function_id: + print(f"✏️ Updating room function {room.id}: {room.function_id} → {resolved_function_id}") + room.function_id = resolved_function_id + updated = True + if not updated: + print(f"✅ No changes to room {room.id}") + + for existing_id in existing_ids - seen_ids: + room = existing_by_id[existing_id] + db.session.delete(room) + print(f"🗑️ Removing room: {room.name}") - for name in submitted_names - existing_names: - room_data = submitted[name] - area_id = resolve_id(room_data.get("section_id", -1), section_fallbacks, section_map, "section") if room_data.get("section_id") is not None else None - function_id = resolve_id(room_data.get("function_id", -1), function_fallbacks, function_map, "function") if room_data.get("function_id") is not None else None - new_room = cls(name=name, area_id=area_id, function_id=function_id) - db.session.add(new_room) - print(f"➕ Adding room: {new_room}") - for name in existing_names - submitted_names: - room_to_remove = existing[name] - db.session.delete(room_to_remove) - print(f"🗑️ Removing room: {room_to_remove}") diff --git a/routes.py b/routes.py index ba4bf43..6345525 100644 --- a/routes.py +++ b/routes.py @@ -412,21 +412,30 @@ def settings(): try: with db.session.begin(): - Brand.sync_from_state(state.get("brands", [])) - Item.sync_from_state(state.get("types", [])) - Area.sync_from_state(state.get("sections", [])) - RoomFunction.sync_from_state(state.get("functions", [])) + # Sync each table and grab temp ID maps + brand_map = Brand.sync_from_state(state.get("brands", [])) + type_map = Item.sync_from_state(state.get("types", [])) + section_map = Area.sync_from_state(state.get("sections", [])) + function_map = RoomFunction.sync_from_state(state.get("functions", [])) - # Refresh maps after inserts - section_map = {a.name: a.id for a in db.session.query(Area).all()} - function_map = {f.description: f.id for f in db.session.query(RoomFunction).all()} + # Fix up room foreign keys based on real IDs + submitted_rooms = [] + for room in state.get("rooms", []): + room = dict(room) # shallow copy + sid = room.get("section_id") + fid = room.get("function_id") + if sid in section_map: + room["section_id"] = section_map[sid] + if fid in function_map: + room["function_id"] = function_map[fid] + submitted_rooms.append(room) Room.sync_from_state( - submitted_rooms=state.get("rooms", []), + submitted_rooms=submitted_rooms, section_map=section_map, function_map=function_map, - section_fallbacks=state.get("sections", []), - function_fallbacks=state.get("functions", []) + section_fallbacks=[{"id": v, "name": k} for k, v in section_map.items()], + function_fallbacks=[{"id": v, "name": k} for k, v in function_map.items()] ) print("✅ COMMIT executed.") @@ -454,3 +463,4 @@ def settings(): functions=[f.serialize() for f in functions], rooms=[r.serialize() for r in rooms], ) + diff --git a/static/js/widget.js b/static/js/widget.js index cdbffbe..ff719e2 100644 --- a/static/js/widget.js +++ b/static/js/widget.js @@ -1,10 +1,14 @@ const ComboBoxWidget = (() => { - let tempIdCounter = -1; + let tempIdCounter = 1; + + function createTempId(prefix = "temp") { + return `${prefix}-${tempIdCounter++}`; + } function createOption(text, value = null) { const option = document.createElement('option'); option.textContent = text; - option.value = value ?? (tempIdCounter--); + option.value = value ?? createTempId(); return option; } @@ -144,6 +148,7 @@ const ComboBoxWidget = (() => { initComboBox, createOption, sortOptions, - handleComboAdd + handleComboAdd, + createTempId }; })(); diff --git a/temp.py b/temp.py new file mode 100644 index 0000000..64394ab --- /dev/null +++ b/temp.py @@ -0,0 +1,6 @@ +def is_temp_id(val): + return ( + val is None or + (isinstance(val, int) and val < 0) or + (isinstance(val, str) and val.startswith("temp-")) + ) diff --git a/templates/settings.html b/templates/settings.html index 7c1d1fc..4b45a60 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -59,7 +59,19 @@ submit_button=True {% set room_editor %} const roomEditor = new bootstrap.Modal(document.getElementById('roomEditor')); const roomNameInput = document.getElementById('roomName'); - roomNameInput.value = document.getElementById('room-input').value; + const input = document.getElementById('room-input'); + const name = input.value.trim(); + const existingOption = Array.from(document.getElementById('room-list').options) + .find(opt => opt.textContent.trim() === name); + + roomNameInput.value = name; + document.getElementById('roomId').value = existingOption?.value ?? ''; // this will be the ID or temp ID + + if (existingOption?.dataset.sectionId) + document.getElementById('roomSection').value = existingOption.dataset.sectionId; + + if (existingOption?.dataset.functionId) + document.getElementById('roomFunction').value = existingOption.dataset.functionId; roomEditor.show(); @@ -67,11 +79,12 @@ submit_button=True {% endset %}
{{ combos.render_combobox( - id='room', - options=rooms, - label='Rooms', - placeholder='Add a new room', - onAdd=room_editor + id='room', + options=rooms, + label='Rooms', + placeholder='Add a new room', + onAdd=room_editor, + onEdit=room_editor ) }}
@@ -89,6 +102,7 @@ submit_button=True
+
@@ -134,16 +148,20 @@ submit_button=True function buildFormState() { function extractOptions(id) { const select = document.getElementById(`${id}-list`); - return Array.from(select.options).map(opt => ({ name: opt.textContent.trim(), id: parseInt(opt.value) || undefined })); + return Array.from(select.options).map(opt => ({ + name: opt.textContent.trim(), + id: opt.value || undefined + })); } const roomOptions = Array.from(document.getElementById('room-list').options); const rooms = roomOptions.map(opt => { const data = opt.dataset; return { + id: opt.value || undefined, name: opt.textContent.trim(), - section_id: data.sectionId ? parseInt(data.sectionId) : null, - function_id: data.functionId ? parseInt(data.functionId) : null + section_id: data.sectionId ?? null, + function_id: data.functionId ?? null, }; }); @@ -192,44 +210,48 @@ submit_button=True const name = document.getElementById('roomName').value.trim(); const sectionVal = document.getElementById('roomSection').value; const funcVal = document.getElementById('roomFunction').value; - - const section = sectionVal !== "" ? parseInt(sectionVal) : null; - const func = funcVal !== "" ? parseInt(funcVal) : null; - + let idRaw = document.getElementById('roomId').value; if (!name) { alert('Please enter a room name.'); return; } - - // Avoid duplicate visible names + const roomList = document.getElementById('room-list'); - const exists = Array.from(roomList.options).some(opt => opt.textContent.trim() === name); - if (exists) { - alert(`Room "${name}" already exists.`); - return; + let existingOption = Array.from(roomList.options).find(opt => opt.value === idRaw); + + // If it's a brand new ID, generate one (string-based!) + if (!idRaw) { + idRaw = ComboBoxWidget.createTempId("room"); } - - // Add to select box visibly - const option = ComboBoxWidget.createOption(name); - - if (section !== null) { - option.dataset.sectionId = section; + + if (!existingOption) { + existingOption = ComboBoxWidget.createOption(name, idRaw); + roomList.appendChild(existingOption); } - if (func !== null) { - option.dataset.functionId = func; - } - - roomList.appendChild(option); + + existingOption.textContent = name; + existingOption.value = idRaw; + existingOption.dataset.sectionId = sectionVal; + existingOption.dataset.functionId = funcVal; + ComboBoxWidget.sortOptions(roomList); - - // Track in state object - formState.rooms.push({ + + // Update formState.rooms + const index = formState.rooms.findIndex(r => r.id === idRaw); + const payload = { + id: idRaw, name, - section_id: section, - function_id: func - }); - + section_id: sectionVal !== "" ? sectionVal : null, + function_id: funcVal !== "" ? funcVal : null + }; + + if (index >= 0) { + formState.rooms[index] = payload; + } else { + formState.rooms.push(payload); + } + bootstrap.Modal.getInstance(modal).hide(); });