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
|
### 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
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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([])
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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,
|
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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
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
|
# 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.
|
||||||
|
|||||||
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