Implement sync_from_state methods for Area, Brand, Item, RoomFunction, and Room models; enhance entity management and foreign key resolution in settings

This commit is contained in:
Yaro Kasear 2025-06-25 09:31:05 -05:00
parent 8a5c5db9e0
commit 7833c4828b
9 changed files with 359 additions and 186 deletions

View file

@ -6,6 +6,7 @@ from sqlalchemy import Identity, Integer, Unicode
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db from . import db
from ..temp import is_temp_id
class Area(db.Model): class Area(db.Model):
__tablename__ = 'Areas' __tablename__ = 'Areas'
@ -28,31 +29,54 @@ class Area(db.Model):
} }
@classmethod @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. Syncs the Area table (aka 'sections') with the submitted list.
Adds new areas and removes areas that are not in the list. Supports add, update, and delete.
Also returns a mapping of temp IDs to real IDs for resolution.
""" """
submitted = { submitted_clean = []
str(item.get("name", "")).strip() seen_ids = set()
for item in submitted_items temp_id_map = {}
if isinstance(item, dict) and str(item.get("name", "")).strip()
} 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_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}")

View file

@ -6,6 +6,7 @@ from sqlalchemy import Identity, Integer, Unicode
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db from . import db
from ..temp import is_temp_id
class Brand(db.Model): class Brand(db.Model):
__tablename__ = 'Brands' __tablename__ = 'Brands'
@ -28,31 +29,47 @@ class Brand(db.Model):
} }
@classmethod @classmethod
def sync_from_state(cls, submitted_items: list[dict]): def sync_from_state(cls, submitted_items: list[dict]) -> dict[str, int]:
""" submitted_clean = []
Syncs the Brand table with the provided list of dictionaries. seen_ids = set()
Adds new brands and removes brands that are not in the list. temp_id_map = {}
"""
submitted = {
str(item.get("name", "")).strip()
for item in submitted_items
if isinstance(item, dict) and str(item.get("name", "")).strip()
}
existing_query = db.session.query(cls) for item in submitted_items:
existing = {brand.name: brand for brand in existing_query.all()} 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"Existing brand IDs: {existing_ids}")
print(f"Submitted brands: {submitted}") print(f"Submitted brand IDs: {seen_ids}")
print(f"Brands to add: {submitted - existing_names}")
print(f"Brands to remove: {existing_names - submitted}")
for name in submitted - existing_names: for entry in submitted_clean:
db.session.add(cls(name=name)) submitted_id = entry.get("id")
print(f" Adding brand: {name}") name = entry["name"]
for name in existing_names - submitted: if is_temp_id(submitted_id):
db.session.delete(existing[name]) obj = cls(name=name)
print(f"🗑️ Removing brand: {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

View file

@ -6,6 +6,7 @@ from sqlalchemy import Identity, Integer, Unicode
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db from . import db
from ..temp import is_temp_id
class Item(db.Model): class Item(db.Model):
__tablename__ = 'Items' __tablename__ = 'Items'
@ -31,31 +32,46 @@ class Item(db.Model):
} }
@classmethod @classmethod
def sync_from_state(cls, submitted_items: list[dict]): def sync_from_state(cls, submitted_items: list[dict]) -> dict[str, int]:
""" submitted_clean = []
Syncs the Items table with the provided list of dictionaries. seen_ids = set()
Adds new items and removes items that are not in the list. temp_id_map = {}
"""
submitted = {
str(item.get("name", "")).strip()
for item in submitted_items
if isinstance(item, dict) and str(item.get("name", "")).strip()
}
existing_query = db.session.query(cls) for item in submitted_items:
existing = {item.description: item for item in existing_query.all()} 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"Existing item IDs: {existing_ids}")
print(f"Submitted items: {submitted}") print(f"Submitted item IDs: {seen_ids}")
print(f"items to add: {submitted - existing_descriptions}")
print(f"items to remove: {existing_descriptions - submitted}")
for description in submitted - existing_descriptions: for entry in submitted_clean:
db.session.add(cls(description=description)) submitted_id = entry.get("id")
print(f" Adding item: {description}") description = entry["description"]
for description in existing_descriptions - submitted: if is_temp_id(submitted_id):
db.session.delete(existing[description]) obj = cls(description=description)
print(f"🗑️ Removing item: {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

View file

@ -6,6 +6,7 @@ from sqlalchemy import Identity, Integer, Unicode
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db from . import db
from ..temp import is_temp_id
class RoomFunction(db.Model): class RoomFunction(db.Model):
__tablename__ = 'Room Functions' __tablename__ = 'Room Functions'
@ -28,31 +29,46 @@ class RoomFunction(db.Model):
} }
@classmethod @classmethod
def sync_from_state(cls, submitted_items: list[dict]): def sync_from_state(cls, submitted_items: list[dict]) -> dict[str, int]:
""" submitted_clean = []
Syncs the functions table with the provided list of dictionaries. seen_ids = set()
Adds new functions and removes functions that are not in the list. temp_id_map = {}
"""
submitted = {
str(item.get("name", "")).strip()
for item in submitted_items
if isinstance(item, dict) and str(item.get("name", "")).strip()
}
existing_query = db.session.query(cls) for item in submitted_items:
existing = {item.description: item for item in existing_query.all()} 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"Existing function IDs: {existing_ids}")
print(f"Submitted functions: {submitted}") print(f"Submitted function IDs: {seen_ids}")
print(f"Functions to add: {submitted - existing_descriptions}")
print(f"Functions to remove: {existing_descriptions - submitted}")
for description in submitted - existing_descriptions: for entry in submitted_clean:
db.session.add(cls(description=description)) submitted_id = entry.get("id")
print(f" Adding item: {description}") description = entry["description"]
for description in existing_descriptions - submitted: if is_temp_id(submitted_id):
db.session.delete(existing[description]) obj = cls(description=description)
print(f"🗑️ Removing item: {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

View file

@ -46,59 +46,116 @@ class Room(db.Model):
} }
@classmethod @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. Syncs the Rooms table with the submitted room list.
Resolves foreign keys using section_map and function_map. Resolves foreign keys using section_map and function_map.
Supports add, update, and delete.
""" """
def resolve_id(raw_id, fallback_list, id_map, label): def resolve_fk(raw_id, fallback_list, id_map, label):
try: if not raw_id:
resolved = int(raw_id) return None
if resolved >= 0:
if resolved in id_map.values(): # 🛠 Handle SQLAlchemy model objects and dicts both
return resolved name_entry = next(
raise ValueError(f"ID {resolved} not found in {label}.") (getattr(item, "name", None) or item.get("name")
except Exception: for item in fallback_list
pass 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_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 = { print(f"Existing room IDs: {existing_ids}")
str(room.get("name", "")).strip(): room print(f"Submitted room IDs: {seen_ids}")
for room in submitted_rooms
if isinstance(room, dict) and str(room.get("name", "")).strip()
}
existing_names = set(existing.keys()) for entry in submitted_clean:
submitted_names = set(submitted.keys()) rid = entry.get("id")
name = entry["name"]
print(f"Existing rooms: {existing_names}") resolved_section_id = resolve_fk(entry.get("section_id"), section_fallbacks, section_map, "section")
print(f"Submitted rooms: {submitted_names}") resolved_function_id = resolve_fk(entry.get("function_id"), function_fallbacks, function_map, "function")
print(f"Rooms to add: {submitted_names - existing_names}")
print(f"Rooms to remove: {existing_names - submitted_names}") 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}")

View file

@ -412,21 +412,30 @@ def settings():
try: try:
with db.session.begin(): with db.session.begin():
Brand.sync_from_state(state.get("brands", [])) # Sync each table and grab temp ID maps
Item.sync_from_state(state.get("types", [])) brand_map = Brand.sync_from_state(state.get("brands", []))
Area.sync_from_state(state.get("sections", [])) type_map = Item.sync_from_state(state.get("types", []))
RoomFunction.sync_from_state(state.get("functions", [])) section_map = Area.sync_from_state(state.get("sections", []))
function_map = RoomFunction.sync_from_state(state.get("functions", []))
# Refresh maps after inserts # Fix up room foreign keys based on real IDs
section_map = {a.name: a.id for a in db.session.query(Area).all()} submitted_rooms = []
function_map = {f.description: f.id for f in db.session.query(RoomFunction).all()} 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( Room.sync_from_state(
submitted_rooms=state.get("rooms", []), submitted_rooms=submitted_rooms,
section_map=section_map, section_map=section_map,
function_map=function_map, function_map=function_map,
section_fallbacks=state.get("sections", []), section_fallbacks=[{"id": v, "name": k} for k, v in section_map.items()],
function_fallbacks=state.get("functions", []) function_fallbacks=[{"id": v, "name": k} for k, v in function_map.items()]
) )
print("✅ COMMIT executed.") print("✅ COMMIT executed.")
@ -454,3 +463,4 @@ def settings():
functions=[f.serialize() for f in functions], functions=[f.serialize() for f in functions],
rooms=[r.serialize() for r in rooms], rooms=[r.serialize() for r in rooms],
) )

View file

@ -1,10 +1,14 @@
const ComboBoxWidget = (() => { const ComboBoxWidget = (() => {
let tempIdCounter = -1; let tempIdCounter = 1;
function createTempId(prefix = "temp") {
return `${prefix}-${tempIdCounter++}`;
}
function createOption(text, value = null) { function createOption(text, value = null) {
const option = document.createElement('option'); const option = document.createElement('option');
option.textContent = text; option.textContent = text;
option.value = value ?? (tempIdCounter--); option.value = value ?? createTempId();
return option; return option;
} }
@ -144,6 +148,7 @@ const ComboBoxWidget = (() => {
initComboBox, initComboBox,
createOption, createOption,
sortOptions, sortOptions,
handleComboAdd handleComboAdd,
createTempId
}; };
})(); })();

6
temp.py Normal file
View file

@ -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-"))
)

View file

@ -59,7 +59,19 @@ submit_button=True
{% set room_editor %} {% set room_editor %}
const roomEditor = new bootstrap.Modal(document.getElementById('roomEditor')); const roomEditor = new bootstrap.Modal(document.getElementById('roomEditor'));
const roomNameInput = document.getElementById('roomName'); 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(); roomEditor.show();
@ -67,11 +79,12 @@ submit_button=True
{% endset %} {% endset %}
<div class="col"> <div class="col">
{{ combos.render_combobox( {{ combos.render_combobox(
id='room', id='room',
options=rooms, options=rooms,
label='Rooms', label='Rooms',
placeholder='Add a new room', placeholder='Add a new room',
onAdd=room_editor onAdd=room_editor,
onEdit=room_editor
) }} ) }}
</div> </div>
</div> </div>
@ -89,6 +102,7 @@ submit_button=True
<div class="col"> <div class="col">
<label for="roomName" class="form-label">Room Name</label> <label for="roomName" class="form-label">Room Name</label>
<input type="text" class="form-input" id="roomName" placeholder="Enter room name"> <input type="text" class="form-input" id="roomName" placeholder="Enter room name">
<input type="hidden" id="roomId">
</div> </div>
</div> </div>
<div class="row"> <div class="row">
@ -134,16 +148,20 @@ submit_button=True
function buildFormState() { function buildFormState() {
function extractOptions(id) { function extractOptions(id) {
const select = document.getElementById(`${id}-list`); 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 roomOptions = Array.from(document.getElementById('room-list').options);
const rooms = roomOptions.map(opt => { const rooms = roomOptions.map(opt => {
const data = opt.dataset; const data = opt.dataset;
return { return {
id: opt.value || undefined,
name: opt.textContent.trim(), name: opt.textContent.trim(),
section_id: data.sectionId ? parseInt(data.sectionId) : null, section_id: data.sectionId ?? null,
function_id: data.functionId ? parseInt(data.functionId) : null function_id: data.functionId ?? null,
}; };
}); });
@ -192,44 +210,48 @@ submit_button=True
const name = document.getElementById('roomName').value.trim(); const name = document.getElementById('roomName').value.trim();
const sectionVal = document.getElementById('roomSection').value; const sectionVal = document.getElementById('roomSection').value;
const funcVal = document.getElementById('roomFunction').value; const funcVal = document.getElementById('roomFunction').value;
let idRaw = document.getElementById('roomId').value;
const section = sectionVal !== "" ? parseInt(sectionVal) : null;
const func = funcVal !== "" ? parseInt(funcVal) : null;
if (!name) { if (!name) {
alert('Please enter a room name.'); alert('Please enter a room name.');
return; return;
} }
// Avoid duplicate visible names
const roomList = document.getElementById('room-list'); const roomList = document.getElementById('room-list');
const exists = Array.from(roomList.options).some(opt => opt.textContent.trim() === name); let existingOption = Array.from(roomList.options).find(opt => opt.value === idRaw);
if (exists) {
alert(`Room "${name}" already exists.`); // If it's a brand new ID, generate one (string-based!)
return; if (!idRaw) {
idRaw = ComboBoxWidget.createTempId("room");
} }
// Add to select box visibly if (!existingOption) {
const option = ComboBoxWidget.createOption(name); existingOption = ComboBoxWidget.createOption(name, idRaw);
roomList.appendChild(existingOption);
if (section !== null) {
option.dataset.sectionId = section;
} }
if (func !== null) {
option.dataset.functionId = func; existingOption.textContent = name;
} existingOption.value = idRaw;
existingOption.dataset.sectionId = sectionVal;
roomList.appendChild(option); existingOption.dataset.functionId = funcVal;
ComboBoxWidget.sortOptions(roomList); ComboBoxWidget.sortOptions(roomList);
// Track in state object // Update formState.rooms
formState.rooms.push({ const index = formState.rooms.findIndex(r => r.id === idRaw);
const payload = {
id: idRaw,
name, name,
section_id: section, section_id: sectionVal !== "" ? sectionVal : null,
function_id: func function_id: funcVal !== "" ? funcVal : null
}); };
if (index >= 0) {
formState.rooms[index] = payload;
} else {
formState.rooms.push(payload);
}
bootstrap.Modal.getInstance(modal).hide(); bootstrap.Modal.getInstance(modal).hide();
}); });