Add photo management for models and employees; fix stale detail navigation
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:
28
CHANGELOG.md
28
CHANGELOG.md
@@ -12,6 +12,19 @@ ADR-007 and ADR-002.
|
||||
|
||||
### 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
|
||||
supportteams/appowners pair. New core `supportteamcontacts` table (multiple
|
||||
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
|
||||
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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -18,6 +18,8 @@ app pointing at floor plans and logos that no longer exist.
|
||||
|------|----------|-----|
|
||||
| Database | MySQL `shopdb_flask` | All application data. |
|
||||
| `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/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. |
|
||||
|
||||
@@ -194,10 +194,16 @@ Per-plugin extension fields:
|
||||
| `vendors` | `POST /api/vendors` | `vendor` -> `vendor` | `vendor` |
|
||||
| `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` |
|
||||
|
||||
`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` |
|
||||
| `operatingsystems` | `POST /api/operatingsystems` | `operatingsystem` -> `osname` | `osname` (+`osversion`) |
|
||||
| `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` |
|
||||
| `pctype` | `POST /api/computers/types` | `typename` -> `computertype`, `description` | `computertype` |
|
||||
| `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.
|
||||
|
||||
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
|
||||
|
||||
For a legacy column with no target field (for example `machines.logicmonitorurl`,
|
||||
|
||||
@@ -389,6 +389,15 @@ export const modelsApi = {
|
||||
},
|
||||
delete(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) {
|
||||
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`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,10 @@
|
||||
</span>
|
||||
</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>
|
||||
<ToastHost />
|
||||
</div>
|
||||
@@ -83,7 +86,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import ToastHost from '../components/ToastHost.vue'
|
||||
import {
|
||||
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
|
||||
@@ -95,6 +98,7 @@ import { dashboardApi, notificationsApi } from '../api'
|
||||
import { getFacilityName, getSiteLogo, getServicenowUrls } from '../utils/siteSettings'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const searchQuery = ref('')
|
||||
const navItems = ref([])
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<div class="recognition-photo-container">
|
||||
<img
|
||||
v-if="rec.employeepicture"
|
||||
:src="`/static/employees/${rec.employeepicture}`"
|
||||
:src="rec.employeepicture"
|
||||
:alt="rec.employeename"
|
||||
class="recognition-photo"
|
||||
@error="handlePhotoError"
|
||||
@@ -91,7 +91,7 @@
|
||||
>
|
||||
<img
|
||||
v-if="rec.employeepicture"
|
||||
:src="`/static/employees/${rec.employeepicture}`"
|
||||
:src="rec.employeepicture"
|
||||
:alt="rec.employeename"
|
||||
class="recert-photo"
|
||||
@error="handlePhotoError"
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
<template v-else-if="employee">
|
||||
<div class="hero-card">
|
||||
<div class="hero-image" v-if="employee.Picture">
|
||||
<img :src="employee.Picture" :alt="fullName" />
|
||||
<div class="hero-image" v-if="employee.photourl">
|
||||
<img :src="employee.photourl" :alt="fullName" />
|
||||
</div>
|
||||
<div class="hero-image placeholder" v-else>
|
||||
<span class="initials">{{ initials }}</span>
|
||||
|
||||
@@ -35,7 +35,10 @@
|
||||
<td>{{ e.First_Name }} {{ e.Last_Name }}</td>
|
||||
<td>{{ e.Team || '-' }}</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">
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(e)">Edit</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>Role</label><input v-model="form.Role" type="text" class="form-control" /></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>
|
||||
<div class="modal-footer">
|
||||
@@ -128,8 +153,10 @@ const form = ref(blank())
|
||||
const showImport = ref(false)
|
||||
const csvText = ref('')
|
||||
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 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) {
|
||||
if (!confirm(`Remove ${e.First_Name} ${e.Last_Name}?`)) return
|
||||
try {
|
||||
@@ -231,4 +295,8 @@ async function doImport() {
|
||||
.form-row .form-group { flex: 1; }
|
||||
.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; }
|
||||
.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>
|
||||
|
||||
@@ -133,6 +133,29 @@
|
||||
/>
|
||||
</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">
|
||||
<label for="imageurl">Image URL</label>
|
||||
<input
|
||||
@@ -142,6 +165,7 @@
|
||||
class="form-control"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
<small class="text-muted">Manual alternative. Uploading a photo overwrites this URL.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -205,6 +229,9 @@ const error = ref('')
|
||||
const showDeleteModal = ref(false)
|
||||
const modelToDelete = ref(null)
|
||||
|
||||
const imageFileInput = ref(null)
|
||||
const uploadingImage = ref(false)
|
||||
|
||||
const form = ref({
|
||||
modelnumber: '',
|
||||
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) {
|
||||
modelToDelete.value = m
|
||||
showDeleteModal.value = true
|
||||
@@ -368,4 +432,25 @@ async function deleteModel() {
|
||||
color: var(--text-light);
|
||||
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>
|
||||
|
||||
@@ -8,11 +8,14 @@ never return more than the directory fields below.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import glob
|
||||
import io
|
||||
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 werkzeug.utils import secure_filename
|
||||
|
||||
from shopdb.api import (
|
||||
db,
|
||||
@@ -33,6 +36,22 @@ employees_bp = Blueprint('employees', __name__)
|
||||
# Columns safe to expose to the directory/recognition UI
|
||||
_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():
|
||||
"""True when the directory is the app-owned table, not an external HR DB."""
|
||||
@@ -50,6 +69,74 @@ def _require_selfhosted():
|
||||
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'])
|
||||
def search_employees():
|
||||
"""
|
||||
@@ -76,7 +163,7 @@ def search_employees():
|
||||
db.cast(DirectoryEmployee.sso, db.String).ilike(term)))
|
||||
.order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname)
|
||||
.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:
|
||||
conn = employee_connection()
|
||||
@@ -92,7 +179,7 @@ def search_employees():
|
||||
''', (f'%{query}%', f'%{query}%', f'%{query}%', limit))
|
||||
employees = cur.fetchall()
|
||||
conn.close()
|
||||
return success_response(employees)
|
||||
return success_response([_with_photo_url(e) for e in employees])
|
||||
except Exception:
|
||||
logger.exception('Employee search failed')
|
||||
return error_response(
|
||||
@@ -116,7 +203,7 @@ def lookup_employee(sso):
|
||||
if not emp:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
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:
|
||||
conn = employee_connection()
|
||||
@@ -135,7 +222,7 @@ def lookup_employee(sso):
|
||||
http_code=404
|
||||
)
|
||||
|
||||
return success_response(employee)
|
||||
return success_response(_with_photo_url(employee))
|
||||
except Exception:
|
||||
logger.exception('Employee lookup failed for SSO %s', sso)
|
||||
return error_response(
|
||||
@@ -165,7 +252,7 @@ def lookup_employees():
|
||||
if _selfhosted():
|
||||
rows = DirectoryEmployee.query.filter(
|
||||
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()}"
|
||||
for e in employees)
|
||||
return success_response({'employees': employees, 'names': names})
|
||||
@@ -181,6 +268,7 @@ def lookup_employees():
|
||||
employees = cur.fetchall()
|
||||
conn.close()
|
||||
|
||||
employees = [_with_photo_url(e) for e in employees]
|
||||
names = ', '.join(
|
||||
f"{e['First_Name'].strip()} {e['Last_Name'].strip()}"
|
||||
for e in employees
|
||||
@@ -212,7 +300,7 @@ def list_directory():
|
||||
return guard
|
||||
rows = (DirectoryEmployee.query
|
||||
.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):
|
||||
@@ -337,3 +425,80 @@ def import_directory():
|
||||
db.session.commit()
|
||||
return success_response({'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')
|
||||
|
||||
@@ -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')
|
||||
@@ -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,
|
||||
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
|
||||
@@ -20,9 +25,13 @@ class DirectoryEmployee(db.Model):
|
||||
team = db.Column(db.String(100))
|
||||
role = db.Column(db.String(100))
|
||||
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):
|
||||
# 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 {
|
||||
'SSO': self.sso,
|
||||
'First_Name': self.firstname,
|
||||
@@ -30,4 +39,5 @@ class DirectoryEmployee(db.Model):
|
||||
'Team': self.team,
|
||||
'Role': self.role,
|
||||
'Picture': self.picture,
|
||||
'photofilename': self.photofilename,
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ from zoneinfo import ZoneInfo
|
||||
from flask import Blueprint, request
|
||||
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
|
||||
|
||||
@@ -150,16 +150,12 @@ def _config_version():
|
||||
|
||||
|
||||
def _employee_picture(sso):
|
||||
"""Best-effort Picture blob for an SSO from the HR directory. None on any miss."""
|
||||
if not (sso and str(sso).isdigit()):
|
||||
return None
|
||||
"""Resolved display photo URL for an SSO, via the shared employees-plugin
|
||||
resolver so kiosk cards match EmployeeDetail in both directory modes
|
||||
(self-hosted upload URL or external HR URL). None on any miss."""
|
||||
try:
|
||||
conn = employee_connection()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),))
|
||||
emp = cur.fetchone()
|
||||
conn.close()
|
||||
return emp.get('Picture') if emp else None
|
||||
from plugins.employees.api.routes import resolve_employee_photo_url
|
||||
return resolve_employee_photo_url(sso)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@@ -152,11 +152,12 @@ def register_blueprints(app: Flask):
|
||||
def register_cli_commands(app: Flask):
|
||||
"""Register Flask CLI commands."""
|
||||
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(db_cli)
|
||||
app.cli.add_command(seed_cli)
|
||||
app.cli.add_command(relationships_cli)
|
||||
|
||||
|
||||
def register_error_handlers(app: Flask):
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"""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 werkzeug.utils import secure_filename
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Model
|
||||
@@ -18,6 +22,20 @@ from shopdb.utils.import_mode import apply_import_timestamps
|
||||
|
||||
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'])
|
||||
@jwt_required(optional=True)
|
||||
@@ -162,3 +180,83 @@ def delete_model(model_id: int):
|
||||
db.session.commit()
|
||||
|
||||
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')
|
||||
|
||||
@@ -9,7 +9,7 @@ from .businessunit import BusinessUnit
|
||||
from .dashboarddefault import DashboardDefault
|
||||
from .location import Location, LocationType
|
||||
from .operatingsystem import OperatingSystem
|
||||
from .relationship import AssetRelationship, RelationshipType
|
||||
from .relationship import AssetRelationship, RelationshipType, RelationshipTypePropagation
|
||||
from .communication import Communication, CommunicationType
|
||||
from .user import User, Role, Permission
|
||||
from .application import Application, AppVersion
|
||||
@@ -40,6 +40,7 @@ __all__ = [
|
||||
# Relationships
|
||||
'AssetRelationship',
|
||||
'RelationshipType',
|
||||
'RelationshipTypePropagation',
|
||||
# Communication
|
||||
'Communication',
|
||||
'CommunicationType',
|
||||
|
||||
149
tests/test_core/test_model_image.py
Normal file
149
tests/test_core/test_model_image.py
Normal 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
|
||||
@@ -49,6 +49,8 @@ EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline'
|
||||
# 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.
|
||||
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
|
||||
# core chain never owned.
|
||||
|
||||
212
tests/test_plugins/test_employee_photo.py
Normal file
212
tests/test_plugins/test_employee_photo.py
Normal 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'
|
||||
Reference in New Issue
Block a user