05/25 Fix lost inspection's photos after flagging an issue

This commit is contained in:
2026-05-25 11:55:47 -04:00
parent cd23fcb134
commit e375f1f70e
3 changed files with 105 additions and 19 deletions
+2 -1
View File
@@ -3,7 +3,8 @@
"allow": [ "allow": [
"Bash(Get-ChildItem \"d:\\\\Projects\\\\LT_Janitorial_Quality_Control\\\\migrations\\\\versions\\\\\" -Name)", "Bash(Get-ChildItem \"d:\\\\Projects\\\\LT_Janitorial_Quality_Control\\\\migrations\\\\versions\\\\\" -Name)",
"PowerShell(Remove-Item \"d:\\\\Projects\\\\LT_Janitorial_Quality_Control\\\\app\\\\templates\\\\issues\\\\issues_view.html\" -Confirm:$false)", "PowerShell(Remove-Item \"d:\\\\Projects\\\\LT_Janitorial_Quality_Control\\\\app\\\\templates\\\\issues\\\\issues_view.html\" -Confirm:$false)",
"PowerShell(Remove-Item \"d:\\\\Projects\\\\LT_Janitorial_Quality_Control\\\\app\\\\templates\\\\issues\\\\issues_list.html\" -Confirm:$false)" "PowerShell(Remove-Item \"d:\\\\Projects\\\\LT_Janitorial_Quality_Control\\\\app\\\\templates\\\\issues\\\\issues_list.html\" -Confirm:$false)",
"Bash(Get-ChildItem -Path \"d:\\\\Projects\\\\LT_Janitorial_Quality_Control\\\\app\\\\templates\\\\inspections\\\\\" -Name)"
] ]
} }
} }
+27
View File
@@ -499,6 +499,33 @@ def save_draft_ajax(inspection_id):
return jsonify({'ok': True}) return jsonify({'ok': True})
# ── AJAX: upload a single inspection photo ────────────────────────────────────
@bp.route('/<int:inspection_id>/upload-photo', methods=['POST'])
@login_required
def upload_photo_ajax(inspection_id):
inspection = db.session.get(Inspection, inspection_id)
if inspection is None:
return jsonify({'ok': False, 'error': 'Not found'}), 404
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id:
return jsonify({'ok': False, 'error': 'Access denied'}), 403
if inspection.status == 'completed':
return jsonify({'ok': False, 'error': 'Inspection already completed'}), 400
file_obj = request.files.get('photo')
path = _save_photo(file_obj, subfolder='inspection_photos')
if not path:
return jsonify({'ok': False, 'error': 'Invalid file or unsupported format'}), 400
current_app.logger.info(
'INSPECTION PHOTO UPLOADED (AJAX) | inspection_id=%s | path=%s | by=%s',
inspection_id, path, current_user.username
)
return jsonify({'ok': True, 'path': path})
# ── View ────────────────────────────────────────────────────────────────────── # ── View ──────────────────────────────────────────────────────────────────────
@bp.route('/<int:inspection_id>') @bp.route('/<int:inspection_id>')
+67 -9
View File
@@ -413,13 +413,17 @@
{% elif field.type == 'image' %} {% elif field.type == 'image' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label> <label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
{# Single <label> element: tap anywhere to pick a photo. {# Single <label> element: tap anywhere to pick a photo.
Filename + remove button appear on the right when a file is chosen. #} Filename + remove button appear on the right when a file is chosen.
The hidden server_path input stores the AJAX-uploaded path so it is
included in the Flag-Issue draft save (file inputs can't be serialised). #}
<label class="upload-zone" for="field_{{ fid }}" id="zone_{{ fid }}"> <label class="upload-zone" for="field_{{ fid }}" id="zone_{{ fid }}">
<input type="file" name="field_{{ fid }}" id="field_{{ fid }}" accept="image/*" <input type="file" name="field_{{ fid }}" id="field_{{ fid }}" accept="image/*"
onchange="showFileName(this)"> onchange="uploadPhotoField(this)"
data-upload-url="{{ url_for('inspections.upload_photo_ajax', inspection_id=inspection.id) }}">
<input type="hidden" id="field_{{ fid }}_server_path" value="{{ saved or '' }}">
<div class="upload-main"> <div class="upload-main">
<i class="bi bi-cloud-upload"></i> <i class="bi bi-cloud-upload" id="icon_{{ fid }}"></i>
<span class="upload-prompt">Tap to take / choose photo</span> <span class="upload-prompt" id="prompt_{{ fid }}">Tap to take / choose photo</span>
</div> </div>
<div class="upload-chosen{% if saved %} has-file{% endif %}" id="chosen_{{ fid }}"> <div class="upload-chosen{% if saved %} has-file{% endif %}" id="chosen_{{ fid }}">
<span class="upload-fname" id="fname_{{ fid }}">{% if saved %}{{ saved.split('/')[-1] }}{% endif %}</span> <span class="upload-fname" id="fname_{{ fid }}">{% if saved %}{{ saved.split('/')[-1] }}{% endif %}</span>
@@ -560,15 +564,55 @@ function setRating(btn) {
}); });
} }
// ── File upload: show filename + reveal the chosen panel ──────────────────── // ── Photo upload on select: immediately uploads to server ────────────────────
function showFileName(input) { // Stores the server path in a hidden field so the Flag-Issue AJAX draft save
// can include it — file inputs cannot be JSON-serialised or sent in a JSON body.
async function uploadPhotoField(input) {
const fid = input.id.replace('field_', ''); const fid = input.id.replace('field_', '');
const fname = document.getElementById('fname_' + fid); const fname = document.getElementById('fname_' + fid);
const chosen = document.getElementById('chosen_' + fid); const chosen = document.getElementById('chosen_' + fid);
const pathHidden = document.getElementById('field_' + fid + '_server_path');
const iconEl = document.getElementById('icon_' + fid);
const promptEl = document.getElementById('prompt_' + fid);
if (input.files && input.files.length > 0) { if (!input.files || !input.files.length) return;
if (fname) fname.textContent = input.files[0].name; const file = input.files[0];
// Show the filename chip immediately (optimistic UI)
if (fname) fname.textContent = file.name;
if (chosen) chosen.classList.add('has-file'); if (chosen) chosen.classList.add('has-file');
// Show uploading state
if (iconEl) iconEl.className = 'bi bi-hourglass-split';
if (promptEl) promptEl.textContent = 'Uploading…';
try {
const uploadUrl = input.dataset.uploadUrl;
const csrfToken = document.querySelector('input[name="csrf_token"]').value;
const compressed = await compressImageFile(file);
const fd = new FormData();
fd.append('photo', compressed, file.name);
fd.append('csrf_token', csrfToken);
const res = await fetch(uploadUrl, { method: 'POST', body: fd });
const json = await res.json();
if (json.ok && json.path) {
if (pathHidden) pathHidden.value = json.path;
if (iconEl) iconEl.className = 'bi bi-check-circle-fill';
if (promptEl) promptEl.textContent = 'Photo saved';
} else {
// Upload failed — file is still in the input, will be sent on full form submit
if (iconEl) iconEl.className = 'bi bi-cloud-upload';
if (promptEl) promptEl.textContent = 'Tap to take / choose photo';
console.error('Photo AJAX upload failed:', json.error);
}
} catch (err) {
// Network error — same fallback: file stays in input for final form submit
if (iconEl) iconEl.className = 'bi bi-cloud-upload';
if (promptEl) promptEl.textContent = 'Tap to take / choose photo';
console.error('Photo upload error:', err);
} }
} }
@@ -578,15 +622,21 @@ function clearUpload(fid) {
const input = document.getElementById('field_' + fid); const input = document.getElementById('field_' + fid);
const fname = document.getElementById('fname_' + fid); const fname = document.getElementById('fname_' + fid);
const chosen = document.getElementById('chosen_' + fid); const chosen = document.getElementById('chosen_' + fid);
const pathHidden = document.getElementById('field_' + fid + '_server_path');
const iconEl = document.getElementById('icon_' + fid);
const promptEl = document.getElementById('prompt_' + fid);
if (input) { if (input) {
const newInput = input.cloneNode(false); const newInput = input.cloneNode(false);
newInput.value = ''; newInput.value = '';
newInput.addEventListener('change', function() { showFileName(this); }); newInput.addEventListener('change', function() { uploadPhotoField(this); });
input.parentNode.replaceChild(newInput, input); input.parentNode.replaceChild(newInput, input);
} }
if (fname) fname.textContent = ''; if (fname) fname.textContent = '';
if (chosen) chosen.classList.remove('has-file'); if (chosen) chosen.classList.remove('has-file');
if (pathHidden) pathHidden.value = '';
if (iconEl) iconEl.className = 'bi bi-cloud-upload';
if (promptEl) promptEl.textContent = 'Tap to take / choose photo';
} }
// ── Signature pads ──────────────────────────────────────────────────────────── // ── Signature pads ────────────────────────────────────────────────────────────
@@ -800,6 +850,14 @@ document.getElementById('inspectionForm').addEventListener('submit', async funct
responses[el.name.replace('field_', '')] = el.value; responses[el.name.replace('field_', '')] = el.value;
}); });
// Include server paths for image fields that were already AJAX-uploaded.
// These are stored in no-name hidden inputs (id="field_<fid>_server_path")
// so they don't interfere with the multipart form POST.
form.querySelectorAll('input[id$="_server_path"]').forEach(function(pathEl) {
const m = pathEl.id.match(/^field_(.+)_server_path$/);
if (m && pathEl.value) responses[m[1]] = pathEl.value;
});
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Saving…'; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Saving…';