diff --git a/crudkit/api/flask_api.py b/crudkit/api/flask_api.py index 0f31264..39e7c49 100644 --- a/crudkit/api/flask_api.py +++ b/crudkit/api/flask_api.py @@ -60,8 +60,11 @@ def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, r DELETE /api//delete?id=123[&hard=1] """ model_name = model.__name__.lower() + # bikeshed if you want pluralization; this is the least-annoying default + collection = (base_prefix or model_name).lower() + plural = collection if collection.endswith('s') else f"{collection}s" - bp = Blueprint(model_name, __name__, url_prefix=f"/api/{model_name}") + bp = Blueprint(plural, __name__, url_prefix=f"/api/{plural}") @bp.errorhandler(Exception) def _handle_any(e: Exception): @@ -102,7 +105,7 @@ def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, r obj = service.create(payload) resp = jsonify(obj.as_dict()) resp.status_code = 201 - resp.headers["Location"] = url_for(f"{bp.name}.rest_get", obj_id=obj.id, _external=False) + resp.headers["Location"] = url_for(f"{plural}.rest_get", obj_id=obj.id, _external=False) return resp except Exception as e: return _json_error(e) diff --git a/inventory/models/device_type.py b/inventory/models/device_type.py index 9b7ff91..58def72 100644 --- a/inventory/models/device_type.py +++ b/inventory/models/device_type.py @@ -1,6 +1,6 @@ from typing import List, Optional -from sqlalchemy import Boolean, Integer, Unicode +from sqlalchemy import Boolean, Unicode from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql import expression as sql @@ -10,7 +10,6 @@ class DeviceType(Base, CRUDMixin): __tablename__ = 'item' description: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True) - target: Mapped[int] = mapped_column(Integer, nullable=True, default=0) inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='device_type') diff --git a/inventory/routes/search.py b/inventory/routes/search.py index 7a605ae..249f262 100644 --- a/inventory/routes/search.py +++ b/inventory/routes/search.py @@ -32,7 +32,7 @@ def init_search_routes(app): {"field": "location.label", "label": "Location"}, ] inventory_results = inventory_service.list({ - 'notes|label|model|owner.label__icontains': q, + 'notes|label|owner.label__icontains': q, 'fields': [ "label", "name", diff --git a/inventory/routes/settings.py b/inventory/routes/settings.py index 1128422..ccd2044 100644 --- a/inventory/routes/settings.py +++ b/inventory/routes/settings.py @@ -23,7 +23,7 @@ def init_settings_routes(app): status_service = crudkit.crud.get_service(status_model) brands = brand_service.list({"sort": "name", "limit": 0}) - device_types = device_type_service.list({"sort": "description", "limit": 0, "fields": ["description", "target"]}) + device_types = device_type_service.list({"sort": "description", "limit": 0}) areas = area_service.list({"sort": "name", "limit": 0}) functions = function_service.list({"sort": "description", "limit": 0}) rooms = room_service.list({ @@ -53,7 +53,6 @@ def init_settings_routes(app): ], }) statuses = render_table(statuses, opts={"object_class": 'status'}) - print([t.as_dict() for t in device_types]) return render_template("settings.html", brands=brands, device_types=device_types, areas=areas, functions=functions, rooms=rooms, statuses=statuses) diff --git a/inventory/templates/settings.html b/inventory/templates/settings.html index 7b12ffa..e1a6227 100644 --- a/inventory/templates/settings.html +++ b/inventory/templates/settings.html @@ -2,66 +2,7 @@ {% from 'components/combobox.html' import combobox %} {% block styleincludes %} - -{% endblock %} - -{% block style %} - .dt-target { - width: 6ch; - -moz-appearance: textfield; - appearance: textfield; - } - - /* keep the row highlight, but keep the input looking normal */ - .dt-option.selected .dt-target { - background-color: var(--bs-body-bg); - color: var(--bs-body-color); - } - - /* nuke the blue focus ring/border inside selected rows */ - .dt-option .dt-target:focus { - border-color: var(--bs-border-color); - box-shadow: none; - outline: 0; - } - - .dt-target::-webkit-outer-spin-button, - .dt-target::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; - } - - .dt-option { - cursor: default; - } - - /* Selection styling for the row */ - @supports (background-color: AccentColor) { - .dt-option.selected { background-color: AccentColor; } - .dt-option.selected .dt-label { color: AccentColorText; } - - /* Hard force the input to be opaque and not inherit weirdness */ - .dt-option .dt-target, - .dt-option .dt-target:focus { - background-color: Field !important; /* system input bg */ - color: FieldText !important; /* system input text */ - box-shadow: none; /* not the halo issue, but be thorough */ - border-color: var(--bs-border-color); /* keep Bootstrap-ish border */ - } - } - - @supports not (background-color: AccentColor) { - .dt-option.selected { background-color: var(--bs-list-group-active-bg, #0d6efd); } - .dt-option.selected .dt-label { color: var(--bs-list-group-active-color, #fff); } - - .dt-option .dt-target, - .dt-option .dt-target:focus { - background-color: var(--bs-body-bg) !important; - color: var(--bs-body-color) !important; - box-shadow: none; - border-color: var(--bs-border-color); - } - } + {% endblock %} {% block main %} @@ -69,12 +10,12 @@
@@ -84,48 +25,18 @@
- {{ combobox('brand', 'brand', 'Enter the name of a brand.', brands, 'id', 'name') }} + {{ combobox('brand', 'brand', 'Enter the name of a brand.', brands, 'id', 'name') }}
- {# { combobox('devicetype', 'devicetype', 'Enter the description of a device type.', device_types, 'id', - 'description') } #} - {# Going to specialize the combobox widget here. #} -
-
- - - - -
-
- {% for t in device_types %} -
- {{ t['description'] }} - -
- {% endfor %} -
-
+ {{ combobox('devicetype', 'devicetype', 'Enter the description of a device type.', device_types, 'id', 'description') }}
{{ statuses | safe }}
@@ -136,19 +47,18 @@
- {{ combobox('area', 'area', 'Enter the name of an area.', areas, 'id', 'name') }} + {{ combobox('area', 'area', 'Enter the name of an area.', areas, 'id', 'name') }}
- {{ combobox('roomfunction', 'roomfunction', 'Enter a room description.', functions, 'id', 'description') }} + {{ combobox('roomfunction', 'roomfunction', 'Enter a room description.', functions, 'id', 'description') }}
{{ rooms | safe }} @@ -162,230 +72,5 @@ {% endblock %} {% block scriptincludes %} - -{% endblock %} - -{% block script %} -const brands = document.getElementById('brand'); -const dtlist = document.getElementById('device-type-list'); -const dtinput = document.getElementById('input-devicetype'); - -const height = getComputedStyle(brands).height; -dtlist.style.height = height; -dtlist.style.maxHeight = height; -dtlist.style.minHeight = height; - -document.addEventListener('click', (ev) => { - const addButton = document.getElementById('add-devicetype'); - const editButton = document.getElementById('edit-devicetype'); - const deleteButton = document.getElementById('remove-devicetype'); - - if (!ev.target.closest('#device-type-list')) return; - - // Do not toggle selection when interacting with the input itself - if (ev.target.closest('.dt-target')) return; - - const node = ev.target.closest('.dt-option'); - if (!node) return; - - // clear others - document.querySelectorAll('.dt-option') - .forEach(n => { n.classList.remove('selected', 'active'); n.removeAttribute('aria-selected'); }); - - // select this one - node.classList.add('selected', 'active'); - node.setAttribute('aria-selected', 'true'); - - // set the visible input to the label, not the whole row - const label = node.querySelector('.dt-label'); - dtinput.value = (label ? label.textContent : node.textContent).replace(/\s+/g, ' ').trim(); - - addButton.classList.add('d-none'); - editButton.classList.remove('d-none'); - deleteButton.classList.remove('disabled'); - deleteButton.disabled = false; -}); - -window.enableDTAddButton = function enableDTAddButton() { - const addButton = document.getElementById('add-devicetype'); - if (addButton.classList.contains('d-none')) return; - - addButton.disabled = dtinput.value === ''; - if (addButton.disabled) { - addButton.classList.add('disabled'); - } else { - addButton.classList.remove('disabled'); - } -}; - -window.addDTItem = async function addDTItem() { - const input = document.getElementById('input-devicetype'); - const list = document.getElementById('device-type-list'); - const addButton = document.getElementById('add-devicetype'); - const editButton = document.getElementById('edit-devicetype'); - - const value = (input.value || '').trim(); - if (!value) { - toastMessage('Type a device type first.', 'warning'); - return; - } - - addButton.disabled = true; - addButton.classList.add('disabled'); - - let res, data; - try { - const res = await fetch('{{ url_for("crudkit.devicetype.rest_create") }}', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }, - credentials: 'same-origin', - body: JSON.stringify({ description: value, target: 0 }) - }); - - const ct = res.headers.get('Content-Type') || ''; - if (ct.includes('application/json')) { - data = await res.json(); - } - - if (res.status !== 201) { - const msg = data?.error || `Create failed (${res.status})`; - toastMessage(msg, 'danger'); - return; - } - } catch (err) { - toastMessage('Network error creating device type.', 'danger'); - return; - } finally { - addButton.disabled = false; - addButton.classList.remove('disabled'); - } - - const id = data?.id ?? data?.obj?.id; - const description = String(data?.description ?? value); - - const row = document.createElement('div'); - row.id = `devicetype-option-${id}`; - row.dataset.invId = id; - row.className = 'd-flex justify-content-between align-items-center user-select-none dt-option'; - - const label = document.createElement('span'); - label.className = 'align-middle dt-label'; - label.textContent = description; - - const qty = document.createElement('input'); - qty.type = 'number'; - qty.min = '0'; - qty.max = '999'; - qty.value = '0'; - qty.id = `devicetype-target-${id}`; - qty.name = `devicetype-target-${id}`; - qty.className = 'form-control form-control-sm dt-target'; - - row.append(label, qty); - list.appendChild(row); - - list.querySelectorAll('.dt-option').forEach(n => { - n.classList.remove('selected', 'active'); - n.removeAttribute('aria-selected'); - }); - - input.value = ''; - - row.scrollIntoView({ block: 'nearest' }); - - toastMessage(`Created new device type: ${description}`, 'success'); -}; - -window.editDTItem = async function editDTItem() { - const input = document.getElementById('input-devicetype'); - const addButton = document.getElementById('add-devicetype'); - const editButton = document.getElementById('edit-devicetype'); - const option = document.querySelector('.dt-option.selected'); - - const value = (input.value || option.dataset.invId).trim(); - if (!value) { - toastMessage('Type a device type first.', 'warning'); - return; - } - - let res, data; - try { - const res = await fetch(`{{ url_for('crudkit.devicetype.rest_list') }}${option.dataset.invId}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }, - credentials: 'same-origin', - body: JSON.stringify({ description: value }) - }); - - const ct = res.headers.get('Content-Type') || ''; - if (ct.includes('application/json')) { - data = await res.json(); - } - - if (res.status !== 200) { - const msg = data?.error || `Create failed (${res.status})`; - toastMessage(msg, 'danger'); - return; - } - } catch (err) { - toastMessage('Network error creating device type.', 'danger'); - return; - } finally { - editButton.disabled = true; - editButton.classList.add('disabled', 'd-none'); - addButton.classList.remove('d-none'); - input.value = ''; - } - - option.querySelector('.dt-label').textContent = value; - - toastMessage(`Updated device type: ${value}`, 'success'); -} - -window.deleteDTItem = async function deleteDTItem() { - const input = document.getElementById('input-devicetype'); - const addButton = document.getElementById('add-devicetype'); - const editButton = document.getElementById('edit-devicetype'); - const option = document.querySelector('.dt-option.selected'); - const deleteButton = document.getElementById('remove-devicetype'); - const value = (input.value || '').trim(); - - let res, data; - try { - const res = await fetch(`{{ url_for('crudkit.devicetype.rest_list') }}${option.dataset.invId}`, { - method: 'DELETE', - headers: { 'Accept': 'application/json' } - }); - - if (res.ok) { - option.remove(); - toastMessage(`Deleted ${value} successfully.`, 'success'); - editButton.disabled = true; - editButton.classList.add('disabled', 'd-none'); - deleteButton.disabled = true; - deleteButton.classList.add('disabled'); - addButton.classList.remove('d-none'); - input.value = ''; - return; - } - - let msg = 'Delete failed.'; - try { - const err = await res.json(); - msg = err?.error || msg; - } catch { - const txt = await res.text(); - if (txt) msg = txt; - } - toastMessage(msg, 'danger'); - } catch (e) { - toastMessage(`Delete failed: ${e?.message || e}`, 'danger'); - } -} + {% endblock %}