Per-type board rows with shared categories, a network device IP field, and two dark-mode dialogs
This commit is contained in:
@@ -96,6 +96,22 @@ Use `PrintersList.vue` as the reference for new list pages. It has NO scoped sty
|
|||||||
|
|
||||||
Dark mode is automatic via `@media (prefers-color-scheme: dark)`. Using CSS variables ensures colors adapt automatically - no extra work needed per page.
|
Dark mode is automatic via `@media (prefers-color-scheme: dark)`. Using CSS variables ensures colors adapt automatically - no extra work needed per page.
|
||||||
|
|
||||||
|
## Data Entry: Page or Modal
|
||||||
|
|
||||||
|
Both patterns are in use on purpose. Pick by what the record IS, not by how big the form feels today.
|
||||||
|
|
||||||
|
**Routed form page** (`views/<Thing>Form.vue` + a route) when the record has its own detail page and its own URL: machines, PCs, printers, network devices, measuring tools, USB devices, applications, knowledge-base articles, notifications, printed items. These forms are long, often carry an image upload, a map-position picker or relationship editing, and someone will want to link straight to one.
|
||||||
|
|
||||||
|
**Modal over its list** when the record is reference data that only exists inside the list it belongs to: every `*TypesList`, plus locations, vendors, models, VLANs, subnets, support teams, custom fields, API tokens, printer drivers, dashboard defaults. The form is a handful of fields, and keeping the list visible behind the dialog is the point.
|
||||||
|
|
||||||
|
Rule of thumb: **a thing with a detail page gets a form page; a lookup row gets a modal.**
|
||||||
|
|
||||||
|
Modal rules (from the overlay-close fix, commit d8fe0a4):
|
||||||
|
|
||||||
|
- A modal holding typed input must NOT close on overlay click or Escape. Losing a part-filled form to a stray click is not an acceptable failure.
|
||||||
|
- A confirmation dialog MAY close on overlay click (`@click.self`), since it holds nothing to lose.
|
||||||
|
- The panel background is `var(--bg-card-solid)`, never `var(--bg-card)`: the card variable is translucent in dark mode, which leaves a dialog see-through over the overlay. The shared `.modal` in `style.css` already does this; hand-rolled `.modal-panel` rules must too.
|
||||||
|
|
||||||
## Component Organization
|
## Component Organization
|
||||||
|
|
||||||
- **Global styles**: `src/assets/style.css`
|
- **Global styles**: `src/assets/style.css`
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -192,7 +192,9 @@ onMounted(load)
|
|||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
.modal-panel {
|
.modal-panel {
|
||||||
background: var(--bg-card);
|
/* Solid: var(--bg-card) is translucent in dark mode, which made this
|
||||||
|
dialog see-through. */
|
||||||
|
background: var(--bg-card-solid);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
|||||||
@@ -24,9 +24,13 @@ def _upsert_primary_ip(asset, ip):
|
|||||||
"""Create/update/clear the asset's primary IP Communication from an
|
"""Create/update/clear the asset's primary IP Communication from an
|
||||||
ipaddress string. No-op if the IP CommunicationType is not seeded."""
|
ipaddress string. No-op if the IP CommunicationType is not seeded."""
|
||||||
ip = (ip or '').strip()
|
ip = (ip or '').strip()
|
||||||
if not ip:
|
|
||||||
return
|
|
||||||
comm = Communication.query.filter_by(assetid=asset.assetid, isprimary=True).first()
|
comm = Communication.query.filter_by(assetid=asset.assetid, isprimary=True).first()
|
||||||
|
if not ip:
|
||||||
|
# Clearing the field clears the row's IP - it used to return early, so
|
||||||
|
# an address could be typed but never taken back out.
|
||||||
|
if comm:
|
||||||
|
comm.ipaddress = None
|
||||||
|
return
|
||||||
if comm:
|
if comm:
|
||||||
comm.ipaddress = ip
|
comm.ipaddress = ip
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -112,6 +112,16 @@
|
|||||||
placeholder="e.g., sw-bldg1-floor2"
|
placeholder="e.g., sw-bldg1-floor2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ipaddress">IP Address</label>
|
||||||
|
<input
|
||||||
|
id="ipaddress"
|
||||||
|
v-model="form.ipaddress"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="e.g., 192.168.1.10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="networkdevicetypeid">Device Type</label>
|
<label for="networkdevicetypeid">Device Type</label>
|
||||||
<select id="networkdevicetypeid" v-model="form.networkdevicetypeid" class="form-control">
|
<select id="networkdevicetypeid" v-model="form.networkdevicetypeid" class="form-control">
|
||||||
@@ -318,6 +328,7 @@ const form = ref({
|
|||||||
locationid: '',
|
locationid: '',
|
||||||
businessunitid: '',
|
businessunitid: '',
|
||||||
hostname: '',
|
hostname: '',
|
||||||
|
ipaddress: '',
|
||||||
networkdevicetypeid: '',
|
networkdevicetypeid: '',
|
||||||
vendorid: '',
|
vendorid: '',
|
||||||
modelnumberid: '',
|
modelnumberid: '',
|
||||||
@@ -419,6 +430,9 @@ async function loadDevice() {
|
|||||||
form.value.mapx = data.mapx
|
form.value.mapx = data.mapx
|
||||||
form.value.mapy = data.mapy
|
form.value.mapy = data.mapy
|
||||||
form.value.notes = data.notes || ''
|
form.value.notes = data.notes || ''
|
||||||
|
// The IP lives in a Communication row, not on the extension table; the API
|
||||||
|
// flattens it onto the response as ipaddress.
|
||||||
|
form.value.ipaddress = data.ipaddress || ''
|
||||||
|
|
||||||
// Network device specific
|
// Network device specific
|
||||||
if (data.networkdevice) {
|
if (data.networkdevice) {
|
||||||
@@ -453,6 +467,7 @@ async function submitForm() {
|
|||||||
locationid: form.value.locationid || null,
|
locationid: form.value.locationid || null,
|
||||||
businessunitid: form.value.businessunitid || null,
|
businessunitid: form.value.businessunitid || null,
|
||||||
hostname: form.value.hostname || null,
|
hostname: form.value.hostname || null,
|
||||||
|
ipaddress: form.value.ipaddress || null,
|
||||||
networkdevicetypeid: form.value.networkdevicetypeid || null,
|
networkdevicetypeid: form.value.networkdevicetypeid || null,
|
||||||
vendorid: form.value.vendorid || null,
|
vendorid: form.value.vendorid || null,
|
||||||
modelnumberid: form.value.modelnumberid || null,
|
modelnumberid: form.value.modelnumberid || null,
|
||||||
|
|||||||
@@ -157,6 +157,13 @@ def _apply_display_fields(t, data):
|
|||||||
if ds not in _DISPLAY_STYLES:
|
if ds not in _DISPLAY_STYLES:
|
||||||
return "displaystyle must be one of: %s" % ", ".join(_DISPLAY_STYLES)
|
return "displaystyle must be one of: %s" % ", ".join(_DISPLAY_STYLES)
|
||||||
t.displaystyle = ds
|
t.displaystyle = ds
|
||||||
|
if 'boardcategory' in data:
|
||||||
|
category = (data.get('boardcategory') or '').strip()
|
||||||
|
if len(category) > 50:
|
||||||
|
return "boardcategory must be 50 characters or fewer"
|
||||||
|
# Blank stores as NULL: "no category" is the absence of one, not the
|
||||||
|
# empty-string category that every uncategorised type would share.
|
||||||
|
t.boardcategory = category or None
|
||||||
if 'gracewindowminutes' in data:
|
if 'gracewindowminutes' in data:
|
||||||
raw = data.get('gracewindowminutes')
|
raw = data.get('gracewindowminutes')
|
||||||
raw = 0 if raw in (None, '') else raw
|
raw = 0 if raw in (None, '') else raw
|
||||||
@@ -178,11 +185,11 @@ def _config_version():
|
|||||||
reach pages that are already open."""
|
reach pages that are already open."""
|
||||||
types = NotificationType.query.order_by(NotificationType.notificationtypeid).all()
|
types = NotificationType.query.order_by(NotificationType.notificationtypeid).all()
|
||||||
parts = [
|
parts = [
|
||||||
"%s|%s|%s|%d|%d|%s|%s|%s|%d|%d" % (
|
"%s|%s|%s|%d|%d|%s|%s|%s|%d|%d|%s" % (
|
||||||
t.notificationtypeid, t.typecolor, t.displaystyle,
|
t.notificationtypeid, t.typecolor, t.displaystyle,
|
||||||
int(bool(t.splitperemployee)), int(bool(t.showemployeephoto)),
|
int(bool(t.splitperemployee)), int(bool(t.showemployeephoto)),
|
||||||
t.expirymode, t.expirydays, t.expiryhour, int(bool(t.isactive)),
|
t.expirymode, t.expirydays, t.expiryhour, int(bool(t.isactive)),
|
||||||
int(t.gracewindowminutes or 0),
|
int(t.gracewindowminutes or 0), t.boardcategory or '',
|
||||||
)
|
)
|
||||||
for t in types
|
for t in types
|
||||||
]
|
]
|
||||||
@@ -779,8 +786,11 @@ def get_shopfloor_notifications():
|
|||||||
'resolved': is_resolved,
|
'resolved': is_resolved,
|
||||||
'typename': ntype.typename if ntype else None,
|
'typename': ntype.typename if ntype else None,
|
||||||
'typecolor': ntype.typecolor if ntype else None,
|
'typecolor': ntype.typecolor if ntype else None,
|
||||||
# Per-type display behavior the dashboard groups/renders by.
|
# Per-type display behavior the dashboard groups/renders by. A
|
||||||
|
# boardcategory puts several types in one row under that name;
|
||||||
|
# blank gives the type a row of its own.
|
||||||
'displaystyle': (ntype.displaystyle or 'standard') if ntype else 'standard',
|
'displaystyle': (ntype.displaystyle or 'standard') if ntype else 'standard',
|
||||||
|
'boardcategory': (ntype.boardcategory or '') if ntype else '',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Employee info (photo only when the type wants it)
|
# Employee info (photo only when the type wants it)
|
||||||
|
|||||||
@@ -32,7 +32,10 @@
|
|||||||
<span class="swatch" :style="{ backgroundColor: swatchColor(t.typecolor) }"></span>
|
<span class="swatch" :style="{ backgroundColor: swatchColor(t.typecolor) }"></span>
|
||||||
<span class="mono">{{ t.typecolor }}</span>
|
<span class="mono">{{ t.typecolor }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td><span class="badge">{{ t.displaystyle || 'standard' }}</span></td>
|
<td>
|
||||||
|
<span class="badge">{{ t.displaystyle || 'standard' }}</span>
|
||||||
|
<span v-if="t.boardcategory" class="badge badge-secondary">{{ t.boardcategory }}</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span v-if="t.splitperemployee" class="badge badge-success">split</span>
|
<span v-if="t.splitperemployee" class="badge badge-success">split</span>
|
||||||
<span v-if="t.showemployeephoto" class="badge badge-success">photo</span>
|
<span v-if="t.showemployeephoto" class="badge badge-success">photo</span>
|
||||||
@@ -87,6 +90,17 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Board category (optional)</span>
|
||||||
|
<input v-model="form.boardcategory" type="text" maxlength="50"
|
||||||
|
placeholder="its own row" />
|
||||||
|
<small class="muted">
|
||||||
|
Types sharing a category share one row on the shopfloor board,
|
||||||
|
under the category name. Blank gives this type a row of its own.
|
||||||
|
Only types with the same display style can share a row.
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Keep showing after end (minutes)</span>
|
<span>Keep showing after end (minutes)</span>
|
||||||
<input v-model.number="form.gracewindowminutes" type="number"
|
<input v-model.number="form.gracewindowminutes" type="number"
|
||||||
@@ -215,6 +229,7 @@ function openNew() {
|
|||||||
expirydays: null,
|
expirydays: null,
|
||||||
expiryhour: null,
|
expiryhour: null,
|
||||||
gracewindowminutes: 0,
|
gracewindowminutes: 0,
|
||||||
|
boardcategory: '',
|
||||||
isactive: true
|
isactive: true
|
||||||
}
|
}
|
||||||
editing.value = true
|
editing.value = true
|
||||||
@@ -234,6 +249,7 @@ function openEdit(t) {
|
|||||||
expirydays: t.expirydays ?? null,
|
expirydays: t.expirydays ?? null,
|
||||||
expiryhour: t.expiryhour ?? null,
|
expiryhour: t.expiryhour ?? null,
|
||||||
gracewindowminutes: t.gracewindowminutes ?? 0,
|
gracewindowminutes: t.gracewindowminutes ?? 0,
|
||||||
|
boardcategory: t.boardcategory || '',
|
||||||
isactive: t.isactive !== false
|
isactive: t.isactive !== false
|
||||||
}
|
}
|
||||||
editing.value = true
|
editing.value = true
|
||||||
@@ -300,7 +316,10 @@ onMounted(load)
|
|||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
.modal-panel {
|
.modal-panel {
|
||||||
background: var(--bg-card);
|
/* Solid, not var(--bg-card): the card variable is translucent in dark mode
|
||||||
|
(rgba(0,0,61,0.4)) so cards glass over the page, which left this dialog
|
||||||
|
see-through with the table legible behind it. */
|
||||||
|
background: var(--bg-card-solid);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Add notificationtypes.boardcategory (shared heading on the shopfloor board).
|
||||||
|
|
||||||
|
The board grouped cards by display style alone, so every type set to grid landed
|
||||||
|
inside the Recertification row and every carousel type inside Recognition's,
|
||||||
|
under a heading that named someone else's type. Each type now gets a row of its
|
||||||
|
own; this column is the opt-in for the other direction - several types that
|
||||||
|
belong together (Change, Awareness, Incident) share one row under a category
|
||||||
|
name instead of taking three.
|
||||||
|
|
||||||
|
Idempotent; downgrade drops the column.
|
||||||
|
|
||||||
|
Revision ID: notifications0004category
|
||||||
|
Revises: notifications0003grace
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = 'notifications0004category'
|
||||||
|
down_revision = 'notifications0003grace'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_TABLE = 'notificationtypes'
|
||||||
|
_COLUMN = 'boardcategory'
|
||||||
|
|
||||||
|
|
||||||
|
def _column_names(insp, table):
|
||||||
|
return {c['name'] for c in insp.get_columns(table)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
if _TABLE not in insp.get_table_names():
|
||||||
|
return
|
||||||
|
if _COLUMN not in _column_names(insp, _TABLE):
|
||||||
|
op.add_column(_TABLE, sa.Column(_COLUMN, sa.String(50), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
if _TABLE not in insp.get_table_names():
|
||||||
|
return
|
||||||
|
if _COLUMN in _column_names(insp, _TABLE):
|
||||||
|
op.drop_column(_TABLE, _COLUMN)
|
||||||
@@ -83,6 +83,12 @@ class NotificationType(db.Model):
|
|||||||
gracewindowminutes = db.Column(db.Integer, nullable=False,
|
gracewindowminutes = db.Column(db.Integer, nullable=False,
|
||||||
server_default='0', default=0)
|
server_default='0', default=0)
|
||||||
|
|
||||||
|
# Optional shared heading on the shopfloor board. Blank (the default) gives
|
||||||
|
# the type a row of its own under its own name; types sharing a category
|
||||||
|
# share one row under that category name, provided they also share a
|
||||||
|
# displaystyle - a banner and a tile row cannot be the same row.
|
||||||
|
boardcategory = db.Column(db.String(50), nullable=True)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<NotificationType {self.typename}>"
|
return f"<NotificationType {self.typename}>"
|
||||||
|
|
||||||
@@ -100,7 +106,8 @@ class NotificationType(db.Model):
|
|||||||
'splitperemployee': bool(self.splitperemployee),
|
'splitperemployee': bool(self.splitperemployee),
|
||||||
'showemployeephoto': bool(self.showemployeephoto),
|
'showemployeephoto': bool(self.showemployeephoto),
|
||||||
'displaystyle': self.displaystyle or 'standard',
|
'displaystyle': self.displaystyle or 'standard',
|
||||||
'gracewindowminutes': int(self.gracewindowminutes or 0)
|
'gracewindowminutes': int(self.gracewindowminutes or 0),
|
||||||
|
'boardcategory': self.boardcategory or ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -64,8 +64,9 @@ EXPECTED_HEAD_REVISION['network'] = 'network0002model'
|
|||||||
# printedparts is post-cutover: its 0001 really creates its tables; 0004 adds
|
# printedparts is post-cutover: its 0001 really creates its tables; 0004 adds
|
||||||
# the per-transaction revision column.
|
# the per-transaction revision column.
|
||||||
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
|
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
|
||||||
# notifications indexes businessunitid, then adds the per-type grace window.
|
# notifications indexes businessunitid, then adds the per-type grace window and
|
||||||
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0003grace'
|
# the shared board category.
|
||||||
|
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0004category'
|
||||||
|
|
||||||
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
||||||
# core chain never owned.
|
# core chain never owned.
|
||||||
|
|||||||
117
tests/test_plugins/test_board_category.py
Normal file
117
tests/test_plugins/test_board_category.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""Tests for notificationtypes.boardcategory (shared row on the shopfloor board).
|
||||||
|
|
||||||
|
The board grouped cards by display style alone, so every type set to grid landed
|
||||||
|
in the Recertification row and every carousel type in Recognition's, under a
|
||||||
|
heading naming someone else's type. Each type now gets a row of its own, and
|
||||||
|
this column is the opt-in for the other direction: Change, Awareness and
|
||||||
|
Incident share one row under a category name.
|
||||||
|
|
||||||
|
The grouping itself is frontend (ShopfloorDashboard.vue); what is pinned here is
|
||||||
|
the contract it groups on - the feed must carry boardcategory per card, and the
|
||||||
|
type API must round-trip it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from plugins.notifications.models import Notification, NotificationType
|
||||||
|
|
||||||
|
|
||||||
|
def _make_type(db, typename, displaystyle='grid', boardcategory=None):
|
||||||
|
t = NotificationType(typename=typename, typecolor='#17a2b8', isactive=True,
|
||||||
|
displaystyle=displaystyle, boardcategory=boardcategory)
|
||||||
|
db.session.add(t)
|
||||||
|
db.session.commit()
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def _make_note(db, ntype, text):
|
||||||
|
n = Notification(notificationtypeid=ntype.notificationtypeid,
|
||||||
|
notification=text, businessunitid=None,
|
||||||
|
isactive=True, isshopfloor=True)
|
||||||
|
db.session.add(n)
|
||||||
|
db.session.commit()
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def _current(client):
|
||||||
|
resp = client.get('/api/notifications/shopfloor')
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
return resp.get_json()['data']['current']
|
||||||
|
|
||||||
|
|
||||||
|
def test_feed_carries_the_category_per_card(client, db):
|
||||||
|
"""The dashboard groups on this field, so it has to reach the card."""
|
||||||
|
ntype = _make_type(db, 'Awareness', boardcategory='Alerts')
|
||||||
|
_make_note(db, ntype, 'watch your step')
|
||||||
|
|
||||||
|
card = _current(client)[0]
|
||||||
|
assert card['boardcategory'] == 'Alerts'
|
||||||
|
assert card['typename'] == 'Awareness'
|
||||||
|
assert card['displaystyle'] == 'grid'
|
||||||
|
|
||||||
|
|
||||||
|
def test_uncategorised_type_reports_an_empty_category(client, db):
|
||||||
|
"""Blank, not null: the dashboard falls back to the type name for a row."""
|
||||||
|
ntype = _make_type(db, 'Recertification')
|
||||||
|
_make_note(db, ntype, 'forklift cert due')
|
||||||
|
|
||||||
|
assert _current(client)[0]['boardcategory'] == ''
|
||||||
|
|
||||||
|
|
||||||
|
def test_types_sharing_a_category_keep_their_own_names(client, db):
|
||||||
|
"""Grouping is the dashboard's job; the feed must not flatten the types."""
|
||||||
|
change = _make_type(db, 'Change', boardcategory='Alerts')
|
||||||
|
incident = _make_type(db, 'Incident', boardcategory='Alerts')
|
||||||
|
_make_note(db, change, 'line 3 retooling')
|
||||||
|
_make_note(db, incident, 'press 12 down')
|
||||||
|
|
||||||
|
cards = _current(client)
|
||||||
|
assert {c['typename'] for c in cards} == {'Change', 'Incident'}
|
||||||
|
assert {c['boardcategory'] for c in cards} == {'Alerts'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_round_trips_the_category(client, db, auth_headers):
|
||||||
|
create = client.post('/api/notifications/types',
|
||||||
|
json={'typename': 'Awareness', 'displaystyle': 'grid',
|
||||||
|
'boardcategory': 'Alerts'},
|
||||||
|
headers=auth_headers)
|
||||||
|
assert create.status_code in (200, 201), create.get_json()
|
||||||
|
typeid = create.get_json()['data']['notificationtypeid']
|
||||||
|
assert create.get_json()['data']['boardcategory'] == 'Alerts'
|
||||||
|
|
||||||
|
listed = client.get('/api/notifications/types', headers=auth_headers)
|
||||||
|
row = next(t for t in listed.get_json()['data']
|
||||||
|
if t['notificationtypeid'] == typeid)
|
||||||
|
assert row['boardcategory'] == 'Alerts'
|
||||||
|
|
||||||
|
|
||||||
|
def test_blank_category_stores_as_null_not_an_empty_group(client, db, auth_headers):
|
||||||
|
"""Every uncategorised type sharing the empty string would be one big row."""
|
||||||
|
create = client.post('/api/notifications/types',
|
||||||
|
json={'typename': 'Solo', 'boardcategory': ' '},
|
||||||
|
headers=auth_headers)
|
||||||
|
assert create.status_code in (200, 201), create.get_json()
|
||||||
|
typeid = create.get_json()['data']['notificationtypeid']
|
||||||
|
|
||||||
|
stored = db.session.get(NotificationType, typeid)
|
||||||
|
assert stored.boardcategory is None
|
||||||
|
assert create.get_json()['data']['boardcategory'] == ''
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_rejects_an_overlong_category(client, db, auth_headers):
|
||||||
|
resp = client.post('/api/notifications/types',
|
||||||
|
json={'typename': 'Bad', 'boardcategory': 'x' * 51},
|
||||||
|
headers=auth_headers)
|
||||||
|
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert 'boardcategory' in resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_change_moves_the_config_version(client, db, auth_headers):
|
||||||
|
"""Open kiosks reload on a layout change; a re-grouped board is one."""
|
||||||
|
ntype = _make_type(db, 'Awareness')
|
||||||
|
before = client.get('/api/notifications/shopfloor').get_json()['data']['configversion']
|
||||||
|
|
||||||
|
client.put(f'/api/notifications/types/{ntype.notificationtypeid}',
|
||||||
|
json={'boardcategory': 'Alerts'}, headers=auth_headers)
|
||||||
|
|
||||||
|
after = client.get('/api/notifications/shopfloor').get_json()['data']['configversion']
|
||||||
|
assert before != after
|
||||||
@@ -57,6 +57,39 @@ def test_update_network_device_ipaddress_upserts(client, db, auth_headers, netwo
|
|||||||
assert len(comms) == 1 and comms[0].ipaddress == '10.0.0.9'
|
assert len(comms) == 1 and comms[0].ipaddress == '10.0.0.9'
|
||||||
|
|
||||||
|
|
||||||
|
def test_clearing_the_ip_takes_it_back_out(client, db, auth_headers, network_setup):
|
||||||
|
"""Blanking the form field must clear the stored IP.
|
||||||
|
|
||||||
|
The upsert helper returned early on a blank value, so an address could be
|
||||||
|
typed and never removed - invisible until the form grew an IP field.
|
||||||
|
"""
|
||||||
|
from shopdb.core.models import Communication
|
||||||
|
created = client.post('/api/network', json={
|
||||||
|
'assetnumber': 'SW-09', 'name': 'Switch', 'ipaddress': '10.0.0.5',
|
||||||
|
}, headers=auth_headers).get_json()['data']
|
||||||
|
devid = created['networkdevice']['networkdeviceid']
|
||||||
|
|
||||||
|
cleared = client.put(f'/api/network/{devid}', json={'ipaddress': ''},
|
||||||
|
headers=auth_headers)
|
||||||
|
assert cleared.status_code == 200, cleared.get_json()
|
||||||
|
assert not cleared.get_json()['data']['ipaddress']
|
||||||
|
|
||||||
|
comms = Communication.query.filter_by(assetid=created['assetid']).all()
|
||||||
|
assert len(comms) == 1 and comms[0].ipaddress is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_omitting_ipaddress_leaves_it_alone(client, db, auth_headers, network_setup):
|
||||||
|
"""A PUT that never mentions the field must not wipe the stored IP."""
|
||||||
|
created = client.post('/api/network', json={
|
||||||
|
'assetnumber': 'SW-10', 'name': 'Switch', 'ipaddress': '10.0.0.7',
|
||||||
|
}, headers=auth_headers).get_json()['data']
|
||||||
|
devid = created['networkdevice']['networkdeviceid']
|
||||||
|
|
||||||
|
updated = client.put(f'/api/network/{devid}', json={'hostname': 'sw-10'},
|
||||||
|
headers=auth_headers)
|
||||||
|
assert updated.get_json()['data']['ipaddress'] == '10.0.0.7'
|
||||||
|
|
||||||
|
|
||||||
def test_seed_reference_data_creates_ip_commtype(app, db):
|
def test_seed_reference_data_creates_ip_commtype(app, db):
|
||||||
"""The reference-data seed now creates the IP communication type the asset
|
"""The reference-data seed now creates the IP communication type the asset
|
||||||
import depends on."""
|
import depends on."""
|
||||||
|
|||||||
Reference in New Issue
Block a user