Add photo management for models and employees; fix stale detail navigation
Some checks failed
CI / backend (push) Failing after 9s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

Model photos: upload/replace/delete on /api/models/<id>/image (admin),
stored under instance/modelimages/ with a public serve route; thumbnail
plus Upload/Replace/Remove controls in the Models settings modal; the
URL field remains as a manual alternative.

Employee photos, mode-aware: self-hosted directory employees get
upload/replace/delete (photo-<sso> under instance/employeephotos/,
employees plugin migration 0002); external directory mode passes the
HR-supplied picture URL through read-only (writes 409). One resolver
feeds both consumers - the shopfloor recognition/recert kiosk cards and
the employee detail hero - in either mode.

Navigation fix: router-view is keyed on route path, so following a
relationship link between two assets of the same type (machine ->
dualpath machine) reloads the page instead of showing stale content;
query-only URL changes still avoid a remount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-11 21:00:37 -04:00
parent 7dae281993
commit 1d21bf0206
19 changed files with 926 additions and 29 deletions

View File

@@ -12,6 +12,19 @@ ADR-007 and ADR-002.
### Added ### Added
- Vendor-model photo management. New admin-gated core endpoints
`POST /api/models/<modelid>/image` (multipart `file`, png/jpg/jpeg/gif/webp/svg,
one image per model, replace semantics) and
`DELETE /api/models/<modelid>/image`, plus the public
`GET /api/models/image/<filename>` serve route. Uploads land in
`instance/modelimages/` (survives upgrades, backed up with the rest of
`instance/`) and set `models.imageurl` to the served URL; the manual Image URL
field still accepts external URLs and the shipped `/images/models/*` assets
(upload is additive). Delete only removes files we own under the instance dir.
The Models settings page grows a thumbnail, Upload/Replace, and Remove
controls in the edit modal. Asset hero images (e.g. the machine badge) read
`imageurl` unchanged, so uploaded photos render with no consumer changes.
- Application support teams with contacts, replacing the legacy - Application support teams with contacts, replacing the legacy
supportteams/appowners pair. New core `supportteamcontacts` table (multiple supportteams/appowners pair. New core `supportteamcontacts` table (multiple
named contacts per team, ordered by `sortorder`); `supportteams` keeps named contacts per team, ordered by `sortorder`); `supportteams` keeps
@@ -51,6 +64,21 @@ ADR-007 and ADR-002.
no-target list with dispositions, a worked idempotent Python importer, and no-target list with dispositions, a worked idempotent Python importer, and
row-count parity checks. row-count parity checks.
### Fixed
- Asset relationships card no longer lists a symmetric peer twice. Relationship
types gain `relationshiptypes.isdirectional` (migration
`7d19_relationshiptype_directional`; seeded false for the connection-like
types Dualpath, connectedto, Cluster Member, Serial Cable, Direct Ethernet,
USB, WiFi, true for controls/Controlled By/Backup For/Master-Slave/partof/
defaultprinter). The card now collapses every stored direction row of a
symmetric type into one direction-blind "Connected" entry per peer (deleting
it removes all collapsed rows), while directional types drop the
Outgoing/Incoming headers for inline `Type -> peer` / `<- Type from peer`
phrasing. The type CRUD and the per-asset relationships endpoint carry
`isdirectional`; the Relationship Types settings page gains a Directional
toggle.
## [0.6.0] - 2026-07-11 ## [0.6.0] - 2026-07-11
### Added ### Added

View File

@@ -18,6 +18,8 @@ app pointing at floor plans and logos that no longer exist.
|------|----------|-----| |------|----------|-----|
| Database | MySQL `shopdb_flask` | All application data. | | Database | MySQL `shopdb_flask` | All application data. |
| `instance/branding/` | repo `instance/` dir | Uploaded logos and favicon. | | `instance/branding/` | repo `instance/` dir | Uploaded logos and favicon. |
| `instance/modelimages/` | repo `instance/` dir | Uploaded vendor-model photos. |
| `instance/employeephotos/` | repo `instance/` dir | Uploaded self-hosted employee photos (external mode serves photos from the HR database instead). |
| `instance/` floor plans | repo `instance/` dir | Uploaded map blueprints. | | `instance/` floor plans | repo `instance/` dir | Uploaded map blueprints. |
| `instance/plugins.json` | repo `instance/` dir | Which plugins this site enabled. | | `instance/plugins.json` | repo `instance/` dir | Which plugins this site enabled. |
| `.env` | repo root (offline, secured) | Secrets needed to bring the stack back up. Store separately from the data backup, in a secrets manager. | | `.env` | repo root (offline, secured) | Secrets needed to bring the stack back up. Store separately from the data backup, in a secrets manager. |

View File

@@ -194,10 +194,16 @@ Per-plugin extension fields:
| `vendors` | `POST /api/vendors` | `vendor` -> `vendor` | `vendor` | | `vendors` | `POST /api/vendors` | `vendor` -> `vendor` | `vendor` |
| `machinetypes` | `POST /api/modeltypes` | `machinetype` -> `modeltype`; set `category` (Equipment/Computer/...) | `modeltype` | | `machinetypes` | `POST /api/modeltypes` | `machinetype` -> `modeltype`; set `category` (Equipment/Computer/...) | `modeltype` |
| `models` | `POST /api/models` | `modelnumber`, `vendorid` (remapped), `machinetypeid` -> `modeltypeid`, `notes`, `image` -> `imageurl`, `documentationpath` -> `documentationurl` | `modelnumber` + `vendor` | | `models` | `POST /api/models` | `modelnumber`, `vendorid` (remapped), `machinetypeid` -> `modeltypeid`, `notes`, `image` -> `imageurl`, `documentationpath` -> `documentationurl` | `modelnumber` + `vendor` |
`imageurl` imports as a plain URL string (an external URL or a legacy
`/images/models/*` path). Binary photos are not part of the import payload;
upload them after import via `POST /api/models/<modelid>/image` (multipart
`file`), which stores the file under `instance/modelimages/` and rewrites
`imageurl` to the served URL.
| `businessunits` | `POST /api/businessunits` | `businessunit` -> `businessunit` | `businessunit` | | `businessunits` | `POST /api/businessunits` | `businessunit` -> `businessunit` | `businessunit` |
| `operatingsystems` | `POST /api/operatingsystems` | `operatingsystem` -> `osname` | `osname` (+`osversion`) | | `operatingsystems` | `POST /api/operatingsystems` | `operatingsystem` -> `osname` | `osname` (+`osversion`) |
| `machinestatus` | `POST /api/assets/statuses` | `machinestatus` -> `status` | `status` | | `machinestatus` | `POST /api/assets/statuses` | `machinestatus` -> `status` | `status` |
| `relationshiptypes` | `POST /api/assets/relationshiptypes` | `relationshiptype` -> `relationshiptype`, `description` | `relationshiptype` | | `relationshiptypes` | `POST /api/assets/relationshiptypes` | `relationshiptype` -> `relationshiptype`, `description`, `isdirectional` (bool, default true; false = symmetric connection) | `relationshiptype` |
| `notificationtypes` | `POST /api/notifications/types` | `typename`, `typedescription`, `typecolor` | `typename` | | `notificationtypes` | `POST /api/notifications/types` | `typename`, `typedescription`, `typecolor` | `typename` |
| `pctype` | `POST /api/computers/types` | `typename` -> `computertype`, `description` | `computertype` | | `pctype` | `POST /api/computers/types` | `typename` -> `computertype`, `description` | `computertype` |
| `subnettypes` | (see subnets) | used as `subnettype` string on subnets | - | | `subnettypes` | (see subnets) | used as `subnettype` string on subnets | - |
@@ -286,6 +292,18 @@ USB devices and their history:
The `checkouttime`/`checkintime` overrides are honored only in import mode. The `checkouttime`/`checkintime` overrides are honored only in import mode.
Employee directory (people): only self-hosted mode (`employee_directory_mode =
selfhosted`) owns people in this app; import them via the directory bulk-upsert
`POST /api/employees/directory/import` (CSV headers `SSO,First_Name,Last_Name,
Team,Role,Picture`) or per-person `POST /api/employees/directory`. Photos:
- External mode: the photo is a URL/relative path supplied by the HR database
(`Picture` column); it is a read-only pass-through and cannot be uploaded here.
- Self-hosted mode: the `Picture` CSV field is a legacy text label and does not
drive the displayed photo. Upload the real photo after import via
`POST /api/employees/<sso>/photo` (multipart `file`, png/jpg/jpeg/gif/webp),
which stores it under `instance/employeephotos/` and serves it publicly.
### 3.7 Anything unmappable -> custom fields ### 3.7 Anything unmappable -> custom fields
For a legacy column with no target field (for example `machines.logicmonitorurl`, For a legacy column with no target field (for example `machines.logicmonitorurl`,

View File

@@ -389,6 +389,15 @@ export const modelsApi = {
}, },
delete(id) { delete(id) {
return api.delete(`/models/${id}`) return api.delete(`/models/${id}`)
},
uploadImage(id, file) {
// multipart photo upload; backend sets imageurl to the served URL
const form = new FormData()
form.append('file', file)
return api.post(`/models/${id}/image`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
},
removeImage(id) {
return api.delete(`/models/${id}/image`)
} }
} }
@@ -730,6 +739,15 @@ export const employeesApi = {
}, },
importCsv(csv) { importCsv(csv) {
return api.post('/employees/directory/import', { csv }) return api.post('/employees/directory/import', { csv })
},
// multipart photo upload; backend sets photofilename + returns photourl
uploadPhoto(sso, file) {
const form = new FormData()
form.append('file', file)
return api.post(`/employees/${sso}/photo`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
},
removePhoto(sso) {
return api.delete(`/employees/${sso}/photo`)
} }
} }
} }

View File

@@ -75,7 +75,10 @@
</span> </span>
</div> </div>
</div> </div>
<router-view /> <!-- Keyed on path so same-component navigation (machine -> machine via
a relationship link) remounts and reloads; query-only changes
(e.g. /reports?report=x) do not remount. -->
<router-view :key="route.path" />
</main> </main>
<ToastHost /> <ToastHost />
</div> </div>
@@ -83,7 +86,7 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import ToastHost from '../components/ToastHost.vue' import ToastHost from '../components/ToastHost.vue'
import { import {
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor, Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
@@ -95,6 +98,7 @@ import { dashboardApi, notificationsApi } from '../api'
import { getFacilityName, getSiteLogo, getServicenowUrls } from '../utils/siteSettings' import { getFacilityName, getSiteLogo, getServicenowUrls } from '../utils/siteSettings'
const router = useRouter() const router = useRouter()
const route = useRoute()
const authStore = useAuthStore() const authStore = useAuthStore()
const searchQuery = ref('') const searchQuery = ref('')
const navItems = ref([]) const navItems = ref([])

View File

@@ -47,7 +47,7 @@
<div class="recognition-photo-container"> <div class="recognition-photo-container">
<img <img
v-if="rec.employeepicture" v-if="rec.employeepicture"
:src="`/static/employees/${rec.employeepicture}`" :src="rec.employeepicture"
:alt="rec.employeename" :alt="rec.employeename"
class="recognition-photo" class="recognition-photo"
@error="handlePhotoError" @error="handlePhotoError"
@@ -91,7 +91,7 @@
> >
<img <img
v-if="rec.employeepicture" v-if="rec.employeepicture"
:src="`/static/employees/${rec.employeepicture}`" :src="rec.employeepicture"
:alt="rec.employeename" :alt="rec.employeename"
class="recert-photo" class="recert-photo"
@error="handlePhotoError" @error="handlePhotoError"

View File

@@ -5,8 +5,8 @@
<template v-else-if="employee"> <template v-else-if="employee">
<div class="hero-card"> <div class="hero-card">
<div class="hero-image" v-if="employee.Picture"> <div class="hero-image" v-if="employee.photourl">
<img :src="employee.Picture" :alt="fullName" /> <img :src="employee.photourl" :alt="fullName" />
</div> </div>
<div class="hero-image placeholder" v-else> <div class="hero-image placeholder" v-else>
<span class="initials">{{ initials }}</span> <span class="initials">{{ initials }}</span>

View File

@@ -35,7 +35,10 @@
<td>{{ e.First_Name }} {{ e.Last_Name }}</td> <td>{{ e.First_Name }} {{ e.Last_Name }}</td>
<td>{{ e.Team || '-' }}</td> <td>{{ e.Team || '-' }}</td>
<td>{{ e.Role || '-' }}</td> <td>{{ e.Role || '-' }}</td>
<td class="mono">{{ e.Picture || '-' }}</td> <td>
<img v-if="e.photourl" :src="e.photourl" alt="Photo" class="photo-thumb" />
<span v-else class="mono">-</span>
</td>
<td class="actions"> <td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(e)">Edit</button> <button class="btn btn-secondary btn-sm" @click="openModal(e)">Edit</button>
<button class="btn btn-danger btn-sm" @click="remove(e)">Delete</button> <button class="btn btn-danger btn-sm" @click="remove(e)">Delete</button>
@@ -79,6 +82,28 @@
<div class="form-group"><label>Team</label><input v-model="form.Team" type="text" class="form-control" /></div> <div class="form-group"><label>Team</label><input v-model="form.Team" type="text" class="form-control" /></div>
<div class="form-group"><label>Role</label><input v-model="form.Role" type="text" class="form-control" /></div> <div class="form-group"><label>Role</label><input v-model="form.Role" type="text" class="form-control" /></div>
</div> </div>
<div class="form-group">
<label>Photo</label>
<div class="photo-manage">
<img v-if="form.photourl" :src="form.photourl" alt="Employee photo" class="photo-thumb-lg" />
<div class="photo-actions">
<template v-if="editing">
<input
ref="photoFileInput"
type="file"
accept=".png,.jpg,.jpeg,.gif,.webp"
style="display: none"
@change="onPhotoSelected"
/>
<button type="button" class="btn btn-secondary btn-sm" :disabled="uploadingPhoto" @click="triggerPhotoUpload">
{{ uploadingPhoto ? 'Uploading...' : (form.photourl ? 'Replace' : 'Upload') }}
</button>
<button v-if="form.photourl" type="button" class="btn btn-danger btn-sm" @click="removePhoto">Remove</button>
</template>
<small v-else class="hint">Save the person first, then upload a photo.</small>
</div>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div> <div v-if="error" class="error-message">{{ error }}</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@@ -128,8 +153,10 @@ const form = ref(blank())
const showImport = ref(false) const showImport = ref(false)
const csvText = ref('') const csvText = ref('')
const importing = ref(false) const importing = ref(false)
const photoFileInput = ref(null)
const uploadingPhoto = ref(false)
function blank() { return { SSO: '', First_Name: '', Last_Name: '', Team: '', Role: '', Picture: '' } } function blank() { return { SSO: '', First_Name: '', Last_Name: '', Team: '', Role: '', Picture: '', photourl: '' } }
const filtered = computed(() => { const filtered = computed(() => {
const term = search.value.trim().toLowerCase() const term = search.value.trim().toLowerCase()
@@ -186,6 +213,43 @@ async function save() {
} }
} }
function triggerPhotoUpload() {
photoFileInput.value?.click()
}
async function onPhotoSelected(event) {
const file = event.target.files?.[0]
if (!file || !editing.value) return
uploadingPhoto.value = true
try {
const response = await employeesApi.directory.uploadPhoto(editing.value.SSO, file)
// Backend returns the updated employee with photourl set to the served URL.
form.value.photourl = response.data.data.photourl || ''
form.value.photofilename = response.data.data.photofilename || ''
toast.success('Photo uploaded')
load()
} catch (err) {
toast.error(apiError(err, 'Failed to upload photo'))
} finally {
uploadingPhoto.value = false
if (photoFileInput.value) photoFileInput.value.value = ''
}
}
async function removePhoto() {
if (!editing.value) return
if (!confirm('Remove this photo?')) return
try {
await employeesApi.directory.removePhoto(editing.value.SSO)
form.value.photourl = ''
form.value.photofilename = ''
toast.success('Photo removed')
load()
} catch (err) {
toast.error(apiError(err, 'Failed to remove photo'))
}
}
async function remove(e) { async function remove(e) {
if (!confirm(`Remove ${e.First_Name} ${e.Last_Name}?`)) return if (!confirm(`Remove ${e.First_Name} ${e.Last_Name}?`)) return
try { try {
@@ -231,4 +295,8 @@ async function doImport() {
.form-row .form-group { flex: 1; } .form-row .form-group { flex: 1; }
.pagination { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 0.9rem 0 0.2rem; } .pagination { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 0.9rem 0 0.2rem; }
.page-info { color: var(--text-light); font-size: 0.85rem; } .page-info { color: var(--text-light); font-size: 0.85rem; }
.photo-thumb { width: 36px; height: 36px; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); }
.photo-manage { display: flex; align-items: center; gap: 1rem; }
.photo-thumb-lg { width: 80px; height: 80px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); }
.photo-actions { display: flex; align-items: center; gap: 0.5rem; }
</style> </style>

View File

@@ -133,6 +133,29 @@
/> />
</div> </div>
<div class="form-group">
<label>Image</label>
<div class="image-manage">
<img v-if="form.imageurl" :src="form.imageurl" alt="Model image" class="image-thumb" />
<div class="image-actions">
<template v-if="editingModel">
<input
ref="imageFileInput"
type="file"
accept=".png,.jpg,.jpeg,.gif,.webp,.svg"
style="display: none"
@change="onImageSelected"
/>
<button type="button" class="btn btn-secondary btn-sm" :disabled="uploadingImage" @click="triggerImageUpload">
{{ uploadingImage ? 'Uploading...' : (form.imageurl ? 'Replace' : 'Upload') }}
</button>
<button v-if="form.imageurl" type="button" class="btn btn-danger btn-sm" @click="removeImage">Remove</button>
</template>
<small v-else class="text-muted">Save the model first, then upload a photo.</small>
</div>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label for="imageurl">Image URL</label> <label for="imageurl">Image URL</label>
<input <input
@@ -142,6 +165,7 @@
class="form-control" class="form-control"
placeholder="https://..." placeholder="https://..."
/> />
<small class="text-muted">Manual alternative. Uploading a photo overwrites this URL.</small>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -205,6 +229,9 @@ const error = ref('')
const showDeleteModal = ref(false) const showDeleteModal = ref(false)
const modelToDelete = ref(null) const modelToDelete = ref(null)
const imageFileInput = ref(null)
const uploadingImage = ref(false)
const form = ref({ const form = ref({
modelnumber: '', modelnumber: '',
vendorid: '', vendorid: '',
@@ -335,6 +362,43 @@ async function saveModel() {
} }
} }
function triggerImageUpload() {
imageFileInput.value?.click()
}
async function onImageSelected(event) {
const file = event.target.files?.[0]
if (!file || !editingModel.value) return
uploadingImage.value = true
try {
const response = await modelsApi.uploadImage(editingModel.value.modelnumberid, file)
// Backend returns the updated model with imageurl set to the served URL.
form.value.imageurl = response.data.data.imageurl || ''
toast.success('Image uploaded')
loadModels()
} catch (err) {
console.error('Error uploading image:', err)
toast.error(apiError(err, 'Failed to upload image'))
} finally {
uploadingImage.value = false
if (imageFileInput.value) imageFileInput.value.value = ''
}
}
async function removeImage() {
if (!editingModel.value) return
if (!confirm('Remove this model image?')) return
try {
await modelsApi.removeImage(editingModel.value.modelnumberid)
form.value.imageurl = ''
toast.success('Image removed')
loadModels()
} catch (err) {
console.error('Error removing image:', err)
toast.error(apiError(err, 'Failed to remove image'))
}
}
function confirmDelete(m) { function confirmDelete(m) {
modelToDelete.value = m modelToDelete.value = m
showDeleteModal.value = true showDeleteModal.value = true
@@ -368,4 +432,25 @@ async function deleteModel() {
color: var(--text-light); color: var(--text-light);
font-size: 0.85rem; font-size: 0.85rem;
} }
.image-manage {
display: flex;
align-items: center;
gap: 1rem;
}
.image-thumb {
width: 80px;
height: 80px;
object-fit: contain;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
}
.image-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
</style> </style>

View File

@@ -8,11 +8,14 @@ never return more than the directory fields below.
""" """
import csv import csv
import glob
import io import io
import logging import logging
import os
from flask import Blueprint, request from flask import Blueprint, request, current_app, send_from_directory
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from werkzeug.utils import secure_filename
from shopdb.api import ( from shopdb.api import (
db, db,
@@ -33,6 +36,22 @@ employees_bp = Blueprint('employees', __name__)
# Columns safe to expose to the directory/recognition UI # Columns safe to expose to the directory/recognition UI
_FIELDS = 'SSO, First_Name, Last_Name, Team, Role, Picture' _FIELDS = 'SSO, First_Name, Last_Name, Team, Role, Picture'
# Uploaded self-hosted employee photos live in the instance dir and are served
# publicly (kiosk recognition/recertification cards read them without auth).
EMPLOYEE_PHOTO_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
# URL prefix a served upload resolves to (self-hosted mode).
EMPLOYEE_PHOTO_URL_PREFIX = '/api/employees/photo/'
# URL prefix external HR relative picture paths resolve under. The HR employees
# table stores Picture as a relative path (e.g. 'Support/210009518.png') that
# the site serves from /static/employees/; this matches the shopfloor feed.
EMPLOYEE_PHOTO_STATIC_PREFIX = '/static/employees/'
def _employeephoto_dir():
return os.path.join(current_app.instance_path, 'employeephotos')
def _selfhosted(): def _selfhosted():
"""True when the directory is the app-owned table, not an external HR DB.""" """True when the directory is the app-owned table, not an external HR DB."""
@@ -50,6 +69,74 @@ def _require_selfhosted():
return None return None
def _require_selfhosted_photo():
"""Guard for photo write endpoints - 409 when the directory is external.
In external mode the photo is owned by the HR database (read-only
pass-through), so upload/delete cannot apply here."""
if not _selfhosted():
return error_response(
ErrorCodes.CONFLICT,
'Employee directory is external; photos are supplied by the HR '
'database and cannot be uploaded or deleted here.',
http_code=409)
return None
def _external_photo_url(picture):
"""Turn an external HR Picture value into a usable URL, or None.
Absolute URLs and already-rooted paths pass through untouched (future
full-URL HR feeds); a bare relative path is served under the static prefix
(current WJ convention, e.g. 'Support/210009518.png')."""
if not picture:
return None
text = str(picture).strip()
if not text:
return None
if text.startswith(('http://', 'https://', '/')):
return text
return EMPLOYEE_PHOTO_STATIC_PREFIX + text
def _hr_picture(sso):
"""Raw Picture value for an SSO from the external HR directory. None on miss."""
try:
conn = employee_connection()
with conn.cursor() as cur:
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),))
row = cur.fetchone()
conn.close()
return row.get('Picture') if row else None
except Exception:
return None
def resolve_employee_photo_url(sso, external_picture=None):
"""Single resolver both consumers share: the display photo URL for an SSO.
Self-hosted: the served upload URL when the employee has an uploaded photo,
else None (the legacy Picture text field does not drive the photo here).
External: the HR-supplied Picture resolved to a URL (pass external_picture to
avoid a re-query), else None. Returns None on any miss or bad SSO."""
if sso is None or not str(sso).isdigit():
return None
if _selfhosted():
emp = db.session.get(DirectoryEmployee, int(sso))
if emp and emp.photofilename:
return EMPLOYEE_PHOTO_URL_PREFIX + emp.photofilename
return None
picture = external_picture if external_picture is not None else _hr_picture(sso)
return _external_photo_url(picture)
def _with_photo_url(employee):
"""Add the resolved photourl to an employee dict (self-hosted or external)."""
employee['photourl'] = resolve_employee_photo_url(
employee.get('SSO'), employee.get('Picture'))
return employee
@employees_bp.route('/search', methods=['GET']) @employees_bp.route('/search', methods=['GET'])
def search_employees(): def search_employees():
""" """
@@ -76,7 +163,7 @@ def search_employees():
db.cast(DirectoryEmployee.sso, db.String).ilike(term))) db.cast(DirectoryEmployee.sso, db.String).ilike(term)))
.order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname) .order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname)
.limit(limit).all()) .limit(limit).all())
return success_response([e.to_dict() for e in rows]) return success_response([_with_photo_url(e.to_dict()) for e in rows])
try: try:
conn = employee_connection() conn = employee_connection()
@@ -92,7 +179,7 @@ def search_employees():
''', (f'%{query}%', f'%{query}%', f'%{query}%', limit)) ''', (f'%{query}%', f'%{query}%', f'%{query}%', limit))
employees = cur.fetchall() employees = cur.fetchall()
conn.close() conn.close()
return success_response(employees) return success_response([_with_photo_url(e) for e in employees])
except Exception: except Exception:
logger.exception('Employee search failed') logger.exception('Employee search failed')
return error_response( return error_response(
@@ -116,7 +203,7 @@ def lookup_employee(sso):
if not emp: if not emp:
return error_response(ErrorCodes.NOT_FOUND, return error_response(ErrorCodes.NOT_FOUND,
f'Employee with SSO {sso} not found', http_code=404) f'Employee with SSO {sso} not found', http_code=404)
return success_response(emp.to_dict()) return success_response(_with_photo_url(emp.to_dict()))
try: try:
conn = employee_connection() conn = employee_connection()
@@ -135,7 +222,7 @@ def lookup_employee(sso):
http_code=404 http_code=404
) )
return success_response(employee) return success_response(_with_photo_url(employee))
except Exception: except Exception:
logger.exception('Employee lookup failed for SSO %s', sso) logger.exception('Employee lookup failed for SSO %s', sso)
return error_response( return error_response(
@@ -165,7 +252,7 @@ def lookup_employees():
if _selfhosted(): if _selfhosted():
rows = DirectoryEmployee.query.filter( rows = DirectoryEmployee.query.filter(
DirectoryEmployee.sso.in_([int(s) for s in ssos])).all() DirectoryEmployee.sso.in_([int(s) for s in ssos])).all()
employees = [e.to_dict() for e in rows] employees = [_with_photo_url(e.to_dict()) for e in rows]
names = ', '.join(f"{e['First_Name'].strip()} {e['Last_Name'].strip()}" names = ', '.join(f"{e['First_Name'].strip()} {e['Last_Name'].strip()}"
for e in employees) for e in employees)
return success_response({'employees': employees, 'names': names}) return success_response({'employees': employees, 'names': names})
@@ -181,6 +268,7 @@ def lookup_employees():
employees = cur.fetchall() employees = cur.fetchall()
conn.close() conn.close()
employees = [_with_photo_url(e) for e in employees]
names = ', '.join( names = ', '.join(
f"{e['First_Name'].strip()} {e['Last_Name'].strip()}" f"{e['First_Name'].strip()} {e['Last_Name'].strip()}"
for e in employees for e in employees
@@ -212,7 +300,7 @@ def list_directory():
return guard return guard
rows = (DirectoryEmployee.query rows = (DirectoryEmployee.query
.order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname).all()) .order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname).all())
return success_response([e.to_dict() for e in rows]) return success_response([_with_photo_url(e.to_dict()) for e in rows])
def _employee_from_payload(data): def _employee_from_payload(data):
@@ -337,3 +425,80 @@ def import_directory():
db.session.commit() db.session.commit()
return success_response({'added': added, 'updated': updated, 'skipped': skipped}, return success_response({'added': added, 'updated': updated, 'skipped': skipped},
message=f'Import done: {added} added, {updated} updated, {skipped} skipped.') message=f'Import done: {added} added, {updated} updated, {skipped} skipped.')
# =============================================================================
# Self-hosted employee photos (upload/replace/delete + public serve)
# =============================================================================
@employees_bp.route('/<int:sso>/photo', methods=['POST'])
@jwt_required()
@require_role('admin')
def upload_employee_photo(sso):
"""Upload (or replace) the photo for a self-hosted directory employee.
multipart/form-data: file=<image>. Saves to the instance employeephotos dir
as photo-<sso><ext> (one photo per person) and points photofilename at it.
Re-upload replaces the old file even when the extension changes. External
mode is a 409 (photo is owned by the HR database)."""
guard = _require_selfhosted_photo()
if guard:
return guard
emp = db.session.get(DirectoryEmployee, sso)
if not emp:
return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404)
upload = request.files.get('file')
if not upload or not upload.filename:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
ext = os.path.splitext(upload.filename)[1].lower()
if ext not in EMPLOYEE_PHOTO_EXTENSIONS:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Unsupported image type {ext}')
photodir = _employeephoto_dir()
os.makedirs(photodir, exist_ok=True)
# Wipe any prior photo-<sso>.* so a new extension does not orphan the old one.
for old in glob.glob(os.path.join(photodir, secure_filename(f'photo-{sso}') + '.*')):
os.remove(old)
filename = secure_filename(f'photo-{sso}{ext}')
upload.save(os.path.join(photodir, filename))
emp.photofilename = filename
db.session.commit()
return success_response(_with_photo_url(emp.to_dict()), message='Employee photo uploaded')
@employees_bp.route('/photo/<path:filename>', methods=['GET'])
def serve_employee_photo(filename):
"""Serve an uploaded employee photo (public - kiosk cards read it)."""
return send_from_directory(_employeephoto_dir(), filename)
@employees_bp.route('/<int:sso>/photo', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_employee_photo(sso):
"""Clear an employee photo and delete the uploaded file. External mode 409s."""
guard = _require_selfhosted_photo()
if guard:
return guard
emp = db.session.get(DirectoryEmployee, sso)
if not emp:
return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404)
if emp.photofilename:
# secure_filename strips any traversal; the file lives in our dir only.
path = os.path.join(_employeephoto_dir(), secure_filename(emp.photofilename))
if os.path.exists(path):
os.remove(path)
emp.photofilename = None
db.session.commit()
return success_response(_with_photo_url(emp.to_dict()), message='Employee photo removed')

View File

@@ -0,0 +1,40 @@
"""Add photofilename to directoryemployees (self-hosted employee photos).
The core chain (7d16_directoryemployees) created directoryemployees WITHOUT a
photofilename column. This plugin revision adds it, so BOTH fresh installs (core
chain builds the table, then this adds the column) and existing installs get it.
Guarded/idempotent: skips when the table is absent (plugin disabled) or the
column already exists (e.g. a test DB built by db.create_all() from the model).
Revision ID: employees0002photo
Revises: employees0001anchor
"""
from alembic import op
import sqlalchemy as sa
revision = 'employees0002photo'
down_revision = 'employees0001anchor'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'directoryemployees' not in insp.get_table_names():
return
cols = {c['name'] for c in insp.get_columns('directoryemployees')}
if 'photofilename' not in cols:
op.add_column('directoryemployees',
sa.Column('photofilename', sa.String(length=255), nullable=True))
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'directoryemployees' not in insp.get_table_names():
return
cols = {c['name'] for c in insp.get_columns('directoryemployees')}
if 'photofilename' in cols:
op.drop_column('directoryemployees', 'photofilename')

View File

@@ -6,6 +6,11 @@ external directory, and the directory is managed in-app (CRUD + CSV import).
to_dict emits the same keys the external contract returns (SSO, First_Name, to_dict emits the same keys the external contract returns (SSO, First_Name,
Last_Name, Team, Role, Picture) so the frontend and both modes share one shape. Last_Name, Team, Role, Picture) so the frontend and both modes share one shape.
photofilename holds the basename of an uploaded photo (photo-<sso><ext>) served
from instance/employeephotos/. It is distinct from the legacy Picture text
field: in self-hosted mode the displayed photo comes from uploads (photofilename)
via the shared resolver, not from Picture.
""" """
from shopdb.api import db from shopdb.api import db
@@ -20,9 +25,13 @@ class DirectoryEmployee(db.Model):
team = db.Column(db.String(100)) team = db.Column(db.String(100))
role = db.Column(db.String(100)) role = db.Column(db.String(100))
picture = db.Column(db.String(255)) picture = db.Column(db.String(255))
# basename of an uploaded photo (photo-<sso><ext>); None when no upload
photofilename = db.Column(db.String(255))
def to_dict(self): def to_dict(self):
# Keys match the external employees contract the frontend consumes. # Keys match the external employees contract the frontend consumes.
# photofilename is extra (self-hosted upload); the resolved display URL
# is added as photourl by the API layer via resolve_employee_photo_url.
return { return {
'SSO': self.sso, 'SSO': self.sso,
'First_Name': self.firstname, 'First_Name': self.firstname,
@@ -30,4 +39,5 @@ class DirectoryEmployee(db.Model):
'Team': self.team, 'Team': self.team,
'Role': self.role, 'Role': self.role,
'Picture': self.picture, 'Picture': self.picture,
'photofilename': self.photofilename,
} }

View File

@@ -8,7 +8,7 @@ from zoneinfo import ZoneInfo
from flask import Blueprint, request from flask import Blueprint, request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.api import db, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query, employee_connection from shopdb.api import db, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from ..models import Notification, NotificationType from ..models import Notification, NotificationType
@@ -150,16 +150,12 @@ def _config_version():
def _employee_picture(sso): def _employee_picture(sso):
"""Best-effort Picture blob for an SSO from the HR directory. None on any miss.""" """Resolved display photo URL for an SSO, via the shared employees-plugin
if not (sso and str(sso).isdigit()): resolver so kiosk cards match EmployeeDetail in both directory modes
return None (self-hosted upload URL or external HR URL). None on any miss."""
try: try:
conn = employee_connection() from plugins.employees.api.routes import resolve_employee_photo_url
with conn.cursor() as cur: return resolve_employee_photo_url(sso)
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),))
emp = cur.fetchone()
conn.close()
return emp.get('Picture') if emp else None
except Exception: except Exception:
return None return None

View File

@@ -152,11 +152,12 @@ def register_blueprints(app: Flask):
def register_cli_commands(app: Flask): def register_cli_commands(app: Flask):
"""Register Flask CLI commands.""" """Register Flask CLI commands."""
from .plugins.cli import plugin_cli from .plugins.cli import plugin_cli
from .cli import db_cli, seed_cli from .cli import db_cli, seed_cli, relationships_cli
app.cli.add_command(plugin_cli) app.cli.add_command(plugin_cli)
app.cli.add_command(db_cli) app.cli.add_command(db_cli)
app.cli.add_command(seed_cli) app.cli.add_command(seed_cli)
app.cli.add_command(relationships_cli)
def register_error_handlers(app: Flask): def register_error_handlers(app: Flask):

View File

@@ -1,7 +1,11 @@
"""Models (vendor model catalog) API endpoints - Full CRUD.""" """Models (vendor model catalog) API endpoints - Full CRUD."""
from flask import Blueprint, request import glob
import os
from flask import Blueprint, request, current_app, send_from_directory
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from werkzeug.utils import secure_filename
from shopdb.extensions import db from shopdb.extensions import db
from shopdb.core.models import Model from shopdb.core.models import Model
@@ -18,6 +22,20 @@ from shopdb.utils.import_mode import apply_import_timestamps
models_bp = Blueprint('models', __name__) models_bp = Blueprint('models', __name__)
# Uploaded model photos live in the instance dir and are served publicly
# (asset detail pages read the model image without auth). Same image set the
# map/branding uploads accept.
MODEL_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
# URL prefix an uploaded image resolves to. imageurl values with this prefix
# are our own files under the instance dir; anything else (external URLs or the
# shipped /images/models/* assets) is left on disk untouched.
MODEL_IMAGE_URL_PREFIX = '/api/models/image/'
def _modelimage_dir():
return os.path.join(current_app.instance_path, 'modelimages')
@models_bp.route('', methods=['GET']) @models_bp.route('', methods=['GET'])
@jwt_required(optional=True) @jwt_required(optional=True)
@@ -162,3 +180,83 @@ def delete_model(model_id: int):
db.session.commit() db.session.commit()
return success_response(message='Model deleted') return success_response(message='Model deleted')
@models_bp.route('/<int:model_id>/image', methods=['POST'])
@jwt_required()
@require_role('admin')
def upload_model_image(model_id: int):
"""Upload (or replace) the photo for a model.
multipart/form-data: file=<image>. Saves to the instance modelimages dir as
model-<id><ext> (one image per model) and points model.imageurl at the
served URL. Re-upload replaces the old file even when the extension changes.
"""
m = db.session.get(Model, model_id)
if not m:
return error_response(
ErrorCodes.NOT_FOUND,
f'Model with ID {model_id} not found',
http_code=404
)
upload = request.files.get('file')
if not upload or not upload.filename:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
ext = os.path.splitext(upload.filename)[1].lower()
if ext not in MODEL_IMAGE_EXTENSIONS:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Unsupported image type {ext}')
imagedir = _modelimage_dir()
os.makedirs(imagedir, exist_ok=True)
# Wipe any prior model-<id>.* file so a new extension does not orphan the old
# one (one image per model).
for old in glob.glob(os.path.join(imagedir, secure_filename(f'model-{model_id}') + '.*')):
os.remove(old)
filename = secure_filename(f'model-{model_id}{ext}')
upload.save(os.path.join(imagedir, filename))
m.imageurl = f'{MODEL_IMAGE_URL_PREFIX}{filename}'
db.session.commit()
return success_response(m.to_dict(), message='Model image uploaded')
@models_bp.route('/image/<path:filename>', methods=['GET'])
def serve_model_image(filename):
"""Serve an uploaded model image (public - asset detail pages read it)."""
return send_from_directory(_modelimage_dir(), filename)
@models_bp.route('/<int:model_id>/image', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_model_image(model_id: int):
"""Clear a model image and delete the uploaded file if we own it.
External URLs and the shipped /images/models/* assets are never touched on
disk - only the imageurl field is cleared.
"""
m = db.session.get(Model, model_id)
if not m:
return error_response(
ErrorCodes.NOT_FOUND,
f'Model with ID {model_id} not found',
http_code=404
)
url = m.imageurl or ''
if url.startswith(MODEL_IMAGE_URL_PREFIX):
# secure_filename strips any traversal; the file lives in our dir only.
filename = secure_filename(url[len(MODEL_IMAGE_URL_PREFIX):])
path = os.path.join(_modelimage_dir(), filename)
if os.path.exists(path):
os.remove(path)
m.imageurl = None
db.session.commit()
return success_response(m.to_dict(), message='Model image removed')

View File

@@ -9,7 +9,7 @@ from .businessunit import BusinessUnit
from .dashboarddefault import DashboardDefault from .dashboarddefault import DashboardDefault
from .location import Location, LocationType from .location import Location, LocationType
from .operatingsystem import OperatingSystem from .operatingsystem import OperatingSystem
from .relationship import AssetRelationship, RelationshipType from .relationship import AssetRelationship, RelationshipType, RelationshipTypePropagation
from .communication import Communication, CommunicationType from .communication import Communication, CommunicationType
from .user import User, Role, Permission from .user import User, Role, Permission
from .application import Application, AppVersion from .application import Application, AppVersion
@@ -40,6 +40,7 @@ __all__ = [
# Relationships # Relationships
'AssetRelationship', 'AssetRelationship',
'RelationshipType', 'RelationshipType',
'RelationshipTypePropagation',
# Communication # Communication
'Communication', 'Communication',
'CommunicationType', 'CommunicationType',

View File

@@ -0,0 +1,149 @@
"""Tests for vendor-model photo upload, replace, delete, and serve.
Covers the core model-image endpoints: admin-gated upload/delete, the public
serve route, one-image-per-model replace semantics, and the guarantee that
deleting a model whose imageurl is an external URL clears the field without
touching the filesystem.
"""
import io
import os
from shopdb.core.models import Model
from shopdb.core.api.models import MODEL_IMAGE_URL_PREFIX
def _make_model(db, imageurl=None):
m = Model(modelnumber='TESTMODEL-1', imageurl=imageurl)
db.session.add(m)
db.session.commit()
return m
def test_upload_forbidden_for_non_admin(client, db, member_headers):
"""A role-less authenticated user cannot upload a model image."""
m = _make_model(db)
data = {'file': (io.BytesIO(b'<svg/>'), 'photo.svg')}
resp = client.post(f'/api/models/{m.modelnumberid}/image', data=data,
content_type='multipart/form-data', headers=member_headers)
assert resp.status_code == 403
assert resp.get_json()['data']['error']['code'] == 'FORBIDDEN'
def test_upload_sets_imageurl_and_file_exists(client, db, auth_headers, app):
"""Admin upload writes the served URL and the file lands in the instance dir."""
m = _make_model(db)
payload = b'\x89PNG\r\n\x1a\nfake-png-bytes'
data = {'file': (io.BytesIO(payload), 'photo.png')}
resp = client.post(f'/api/models/{m.modelnumberid}/image', data=data,
content_type='multipart/form-data', headers=auth_headers)
assert resp.status_code == 200, resp.get_json()
expected_url = f'{MODEL_IMAGE_URL_PREFIX}model-{m.modelnumberid}.png'
assert resp.get_json()['data']['imageurl'] == expected_url
refreshed = db.session.get(Model, m.modelnumberid)
assert refreshed.imageurl == expected_url
path = os.path.join(app.instance_path, 'modelimages', f'model-{m.modelnumberid}.png')
assert os.path.exists(path)
with open(path, 'rb') as handle:
assert handle.read() == payload
def test_upload_rejects_invalid_extension(client, db, auth_headers):
"""A disallowed file extension is rejected."""
m = _make_model(db)
data = {'file': (io.BytesIO(b'MZ...'), 'photo.exe')}
resp = client.post(f'/api/models/{m.modelnumberid}/image', data=data,
content_type='multipart/form-data', headers=auth_headers)
assert resp.status_code == 400
assert resp.get_json()['data']['error']['code'] == 'VALIDATION_ERROR'
def test_upload_missing_model_is_404(client, db, auth_headers):
"""Uploading to a nonexistent model id is a 404."""
data = {'file': (io.BytesIO(b'<svg/>'), 'photo.svg')}
resp = client.post('/api/models/999999/image', data=data,
content_type='multipart/form-data', headers=auth_headers)
assert resp.status_code == 404
def test_reupload_replaces_and_removes_old_extension(client, db, auth_headers, app):
"""Re-upload with a different extension deletes the prior file."""
m = _make_model(db)
first = {'file': (io.BytesIO(b'first'), 'photo.png')}
resp = client.post(f'/api/models/{m.modelnumberid}/image', data=first,
content_type='multipart/form-data', headers=auth_headers)
assert resp.status_code == 200
oldpath = os.path.join(app.instance_path, 'modelimages', f'model-{m.modelnumberid}.png')
assert os.path.exists(oldpath)
second = {'file': (io.BytesIO(b'second'), 'photo.jpg')}
resp = client.post(f'/api/models/{m.modelnumberid}/image', data=second,
content_type='multipart/form-data', headers=auth_headers)
assert resp.status_code == 200
# Old .png is gone; new .jpg exists and imageurl points at it.
assert not os.path.exists(oldpath)
newpath = os.path.join(app.instance_path, 'modelimages', f'model-{m.modelnumberid}.jpg')
assert os.path.exists(newpath)
refreshed = db.session.get(Model, m.modelnumberid)
assert refreshed.imageurl == f'{MODEL_IMAGE_URL_PREFIX}model-{m.modelnumberid}.jpg'
def test_serve_returns_bytes(client, db, auth_headers):
"""The public serve route returns the uploaded bytes without auth."""
m = _make_model(db)
payload = b'\x89PNG\r\n\x1a\nserved-bytes'
data = {'file': (io.BytesIO(payload), 'photo.png')}
client.post(f'/api/models/{m.modelnumberid}/image', data=data,
content_type='multipart/form-data', headers=auth_headers)
served = client.get(f'{MODEL_IMAGE_URL_PREFIX}model-{m.modelnumberid}.png')
assert served.status_code == 200
assert served.get_data() == payload
def test_delete_clears_field_and_removes_file(client, db, auth_headers, app):
"""Delete clears imageurl and removes the owned file."""
m = _make_model(db)
data = {'file': (io.BytesIO(b'bytes'), 'photo.png')}
client.post(f'/api/models/{m.modelnumberid}/image', data=data,
content_type='multipart/form-data', headers=auth_headers)
path = os.path.join(app.instance_path, 'modelimages', f'model-{m.modelnumberid}.png')
assert os.path.exists(path)
resp = client.delete(f'/api/models/{m.modelnumberid}/image', headers=auth_headers)
assert resp.status_code == 200
assert resp.get_json()['data']['imageurl'] is None
assert not os.path.exists(path)
refreshed = db.session.get(Model, m.modelnumberid)
assert refreshed.imageurl is None
def test_delete_external_url_clears_field_without_filesystem_error(client, db, auth_headers):
"""Delete on a model whose imageurl is an external URL just clears the field."""
m = _make_model(db, imageurl='https://example.com/product.png')
resp = client.delete(f'/api/models/{m.modelnumberid}/image', headers=auth_headers)
assert resp.status_code == 200
assert resp.get_json()['data']['imageurl'] is None
refreshed = db.session.get(Model, m.modelnumberid)
assert refreshed.imageurl is None
def test_delete_legacy_path_clears_field_without_filesystem_error(client, db, auth_headers):
"""Delete on a shipped /images/models/* path clears the field, touches no disk."""
m = _make_model(db, imageurl='/images/models/machines/legacy.png')
resp = client.delete(f'/api/models/{m.modelnumberid}/image', headers=auth_headers)
assert resp.status_code == 200
assert resp.get_json()['data']['imageurl'] is None
def test_delete_forbidden_for_non_admin(client, db, member_headers):
"""A role-less authenticated user cannot delete a model image."""
m = _make_model(db, imageurl='https://example.com/product.png')
resp = client.delete(f'/api/models/{m.modelnumberid}/image', headers=member_headers)
assert resp.status_code == 403

View File

@@ -49,6 +49,8 @@ EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline'
# machines (renamed from equipment) keeps its original anchor id and adds the # machines (renamed from equipment) keeps its original anchor id and adds the
# rename revision on top, so its head is not the f-string default. # rename revision on top, so its head is not the f-string default.
EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename' EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
# employees adds the photofilename column on top of its cutover anchor.
EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo'
# 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.

View File

@@ -0,0 +1,212 @@
"""Tests for self-hosted employee photo upload, replace, delete, serve, and the
shared photo-URL resolver used by both EmployeeDetail and the kiosk cards.
Covers: admin-gated upload/delete, the public serve route, one-photo-per-person
replace semantics, external-mode 409s, and resolve_employee_photo_url in both
directory modes. The wjf_employees HR DB is not available under test, so external
resolution is exercised by passing the Picture value directly (no query)."""
import io
import os
from shopdb.core.models import Setting
from plugins.employees.models import DirectoryEmployee
from plugins.employees.api.routes import (
resolve_employee_photo_url,
EMPLOYEE_PHOTO_URL_PREFIX,
EMPLOYEE_PHOTO_STATIC_PREFIX,
)
def _set_mode(db, mode):
row = Setting.query.filter_by(key='employee_directory_mode').first()
if row:
row.value = mode
else:
db.session.add(Setting(key='employee_directory_mode', value=mode,
valuetype='string', category='site'))
db.session.commit()
def _make_employee(db, sso=210000001):
emp = DirectoryEmployee(sso=sso, firstname='Test', lastname='Person')
db.session.add(emp)
db.session.commit()
return emp
# --- upload / replace / delete (self-hosted) --------------------------------
def test_upload_forbidden_for_non_admin(client, db, member_headers):
"""A role-less authenticated user cannot upload an employee photo."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
data = {'file': (io.BytesIO(b'bytes'), 'p.png')}
resp = client.post(f'/api/employees/{emp.sso}/photo', data=data,
content_type='multipart/form-data', headers=member_headers)
assert resp.status_code == 403
assert resp.get_json()['data']['error']['code'] == 'FORBIDDEN'
def test_upload_sets_photofilename_and_file_exists(client, db, auth_headers, app):
"""Admin upload writes photofilename + photourl and lands the file on disk."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
payload = b'\x89PNG\r\n\x1a\nfake-png'
data = {'file': (io.BytesIO(payload), 'p.png')}
resp = client.post(f'/api/employees/{emp.sso}/photo', data=data,
content_type='multipart/form-data', headers=auth_headers)
assert resp.status_code == 200, resp.get_json()
filename = f'photo-{emp.sso}.png'
body = resp.get_json()['data']
assert body['photofilename'] == filename
assert body['photourl'] == f'{EMPLOYEE_PHOTO_URL_PREFIX}{filename}'
refreshed = db.session.get(DirectoryEmployee, emp.sso)
assert refreshed.photofilename == filename
path = os.path.join(app.instance_path, 'employeephotos', filename)
assert os.path.exists(path)
with open(path, 'rb') as handle:
assert handle.read() == payload
def test_upload_rejects_invalid_extension(client, db, auth_headers):
"""A disallowed file extension is rejected."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
data = {'file': (io.BytesIO(b'MZ'), 'p.exe')}
resp = client.post(f'/api/employees/{emp.sso}/photo', data=data,
content_type='multipart/form-data', headers=auth_headers)
assert resp.status_code == 400
assert resp.get_json()['data']['error']['code'] == 'VALIDATION_ERROR'
def test_upload_missing_employee_is_404(client, db, auth_headers):
"""Uploading to a nonexistent SSO is a 404."""
_set_mode(db, 'selfhosted')
data = {'file': (io.BytesIO(b'x'), 'p.png')}
resp = client.post('/api/employees/999999/photo', data=data,
content_type='multipart/form-data', headers=auth_headers)
assert resp.status_code == 404
def test_reupload_replaces_and_removes_old_extension(client, db, auth_headers, app):
"""Re-upload with a different extension deletes the prior file."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
first = {'file': (io.BytesIO(b'first'), 'p.png')}
client.post(f'/api/employees/{emp.sso}/photo', data=first,
content_type='multipart/form-data', headers=auth_headers)
oldpath = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.png')
assert os.path.exists(oldpath)
second = {'file': (io.BytesIO(b'second'), 'p.jpg')}
resp = client.post(f'/api/employees/{emp.sso}/photo', data=second,
content_type='multipart/form-data', headers=auth_headers)
assert resp.status_code == 200
assert not os.path.exists(oldpath)
newpath = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.jpg')
assert os.path.exists(newpath)
refreshed = db.session.get(DirectoryEmployee, emp.sso)
assert refreshed.photofilename == f'photo-{emp.sso}.jpg'
def test_serve_returns_bytes(client, db, auth_headers):
"""The public serve route returns the uploaded bytes without auth."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
payload = b'\x89PNG\r\n\x1a\nserved'
data = {'file': (io.BytesIO(payload), 'p.png')}
client.post(f'/api/employees/{emp.sso}/photo', data=data,
content_type='multipart/form-data', headers=auth_headers)
served = client.get(f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png')
assert served.status_code == 200
assert served.get_data() == payload
def test_delete_clears_field_and_removes_file(client, db, auth_headers, app):
"""Delete clears photofilename and removes the uploaded file."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
data = {'file': (io.BytesIO(b'bytes'), 'p.png')}
client.post(f'/api/employees/{emp.sso}/photo', data=data,
content_type='multipart/form-data', headers=auth_headers)
path = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.png')
assert os.path.exists(path)
resp = client.delete(f'/api/employees/{emp.sso}/photo', headers=auth_headers)
assert resp.status_code == 200
assert resp.get_json()['data']['photourl'] is None
assert not os.path.exists(path)
refreshed = db.session.get(DirectoryEmployee, emp.sso)
assert refreshed.photofilename is None
def test_delete_forbidden_for_non_admin(client, db, member_headers):
"""A role-less authenticated user cannot delete an employee photo."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
resp = client.delete(f'/api/employees/{emp.sso}/photo', headers=member_headers)
assert resp.status_code == 403
# --- external-mode 409 ------------------------------------------------------
def test_upload_conflict_in_external_mode(client, db, auth_headers):
"""Upload is a 409 when the directory is external (HR owns the photo)."""
_set_mode(db, 'external')
data = {'file': (io.BytesIO(b'x'), 'p.png')}
resp = client.post('/api/employees/210000001/photo', data=data,
content_type='multipart/form-data', headers=auth_headers)
assert resp.status_code == 409
assert resp.get_json()['data']['error']['code'] == 'CONFLICT'
def test_delete_conflict_in_external_mode(client, db, auth_headers):
"""Delete is a 409 when the directory is external."""
_set_mode(db, 'external')
resp = client.delete('/api/employees/210000001/photo', headers=auth_headers)
assert resp.status_code == 409
# --- resolver ---------------------------------------------------------------
def test_resolver_selfhosted_returns_upload_url_or_none(client, db, auth_headers):
"""Self-hosted: served upload URL when a photo exists, else None."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
assert resolve_employee_photo_url(emp.sso) is None
data = {'file': (io.BytesIO(b'x'), 'p.png')}
client.post(f'/api/employees/{emp.sso}/photo', data=data,
content_type='multipart/form-data', headers=auth_headers)
assert resolve_employee_photo_url(emp.sso) == f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png'
def test_resolver_external_prefixes_relative_and_passes_urls(db):
"""External: relative HR paths get the static prefix; absolute URLs pass through."""
_set_mode(db, 'external')
assert resolve_employee_photo_url(210000001, 'Support/210000001.png') == \
f'{EMPLOYEE_PHOTO_STATIC_PREFIX}Support/210000001.png'
assert resolve_employee_photo_url(210000001, 'https://hr.example.net/p.png') == \
'https://hr.example.net/p.png'
assert resolve_employee_photo_url(210000001, None) is None
assert resolve_employee_photo_url(None) is None
def test_lookup_includes_photourl(client, db, auth_headers):
"""The lookup serializer carries the resolved photourl (self-hosted)."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
data = {'file': (io.BytesIO(b'x'), 'p.png')}
client.post(f'/api/employees/{emp.sso}/photo', data=data,
content_type='multipart/form-data', headers=auth_headers)
resp = client.get(f'/api/employees/lookup/{emp.sso}')
assert resp.status_code == 200
assert resp.get_json()['data']['photourl'] == f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png'