Compare commits
No commits in common. "2845d340daef034421e55ac27b5e6e6867453a03" and "c20d085ab54c0a4dd8e7854125eaa1205733893a" have entirely different histories.
2845d340da
...
c20d085ab5
5 changed files with 20 additions and 334 deletions
|
|
@ -60,8 +60,11 @@ def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, r
|
|||
DELETE /api/<model>/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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,66 +2,7 @@
|
|||
{% from 'components/combobox.html' import combobox %}
|
||||
|
||||
{% block styleincludes %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/combobox.css') }}">
|
||||
{% 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);
|
||||
}
|
||||
}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/combobox.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
|
|
@ -69,12 +10,12 @@
|
|||
<div class="container">
|
||||
<ul class="nav nav-pills nav-fill">
|
||||
<li class="nav-item">
|
||||
<button type="button" class="nav-link active" id="device-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#device-tab-pane">Devices</button>
|
||||
<button type="button" class="nav-link active" id="device-tab"
|
||||
data-bs-toggle="tab" data-bs-target="#device-tab-pane">Devices</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button type="button" class="nav-link" id="location-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#location-tab-pane">Locations</button>
|
||||
<button type="button" class="nav-link" id="location-tab"
|
||||
data-bs-toggle="tab" data-bs-target="#location-tab-pane">Locations</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
|
|
@ -88,44 +29,14 @@
|
|||
</div>
|
||||
<div class="col">
|
||||
<label for="devicetype" class="form-label">Device Types</label>
|
||||
{# { combobox('devicetype', 'devicetype', 'Enter the description of a device type.', device_types, 'id',
|
||||
'description') } #}
|
||||
{# Going to specialize the combobox widget here. #}
|
||||
<div class="combobox">
|
||||
<div class="d-flex">
|
||||
<input type="text" class="form-control border-bottom-0 rounded-bottom-0 rounded-end-0"
|
||||
placeholder="Enter the description of a device type." id="input-devicetype"
|
||||
oninput="enableDTAddButton()">
|
||||
<button type="button"
|
||||
class="btn btn-primary border-bottom-0 rounded-bottom-0 rounded-start-0 rounded-end-0 disabled"
|
||||
id="add-devicetype" onclick="addDTItem()" disabled>Add</button>
|
||||
<button type="button"
|
||||
class="btn btn-info border-bottom-0 rounded-bottom-0 rounded-start-0 rounded-end-0 d-none"
|
||||
id="edit-devicetype" onclick="editDTItem()">Edit</button>
|
||||
<button type="button" class="btn btn-danger border-bottom-0 rounded-bottom-0 rounded-start-0 disabled"
|
||||
id="remove-devicetype" onclick="deleteDTItem()" disabled>Remove</button>
|
||||
</div>
|
||||
<div class="border h-100 ps-3 pe-0 overflow-auto" id="device-type-list">
|
||||
{% for t in device_types %}
|
||||
<div id="devicetype-option-{{ t['id'] }}" data-inv-id="{{ t['id'] }}"
|
||||
class="d-flex justify-content-between align-items-center user-select-none dt-option">
|
||||
<span class="align-middle dt-label">{{ t['description'] }}</span>
|
||||
<input type="number"
|
||||
class="form-control form-control-sm dt-target"
|
||||
id="devicetype-target-{{ t['id'] }}" name="devicetype-target-{{ t['id'] }}"
|
||||
value="{{ t['target'] if t['target'] else 0 }}" min="0" max="999">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{{ combobox('devicetype', 'devicetype', 'Enter the description of a device type.', device_types, 'id', 'description') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col">
|
||||
<label for="status" class="form-label">
|
||||
Conditions
|
||||
<a href="{{ url_for('entry.entry_new', model='status') }}"
|
||||
class="link-success link-underline-opacity-0"><small>[+]</small></a>
|
||||
<a href="{{ url_for('entry.entry_new', model='status') }}" class="link-success link-underline-opacity-0"><small>[+]</small></a>
|
||||
</label>
|
||||
{{ statuses | safe }}
|
||||
</div>
|
||||
|
|
@ -147,8 +58,7 @@
|
|||
<div class="row mt-3">
|
||||
<label for="rooms" class="form-label">
|
||||
Rooms
|
||||
<a href="{{ url_for('entry.entry_new', model='room') }}"
|
||||
class="link-success link-underline-opacity-0"><small>[+]</small></a>
|
||||
<a href="{{ url_for('entry.entry_new', model='room') }}" class="link-success link-underline-opacity-0"><small>[+]</small></a>
|
||||
</label>
|
||||
<div class="col">
|
||||
{{ rooms | safe }}
|
||||
|
|
@ -162,230 +72,5 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block scriptincludes %}
|
||||
<script src="{{ url_for('static', filename='js/components/combobox.js') }}"></script>
|
||||
{% 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');
|
||||
}
|
||||
}
|
||||
<script src="{{ url_for('static', filename='js/components/combobox.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue