05/25 Fix lost inspection's photos after flagging an issue
This commit is contained in:
@@ -499,6 +499,33 @@ def save_draft_ajax(inspection_id):
|
||||
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 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:inspection_id>')
|
||||
|
||||
@@ -413,13 +413,17 @@
|
||||
{% elif field.type == 'image' %}
|
||||
<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.
|
||||
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 }}">
|
||||
<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">
|
||||
<i class="bi bi-cloud-upload"></i>
|
||||
<span class="upload-prompt">Tap to take / choose photo</span>
|
||||
<i class="bi bi-cloud-upload" id="icon_{{ fid }}"></i>
|
||||
<span class="upload-prompt" id="prompt_{{ fid }}">Tap to take / choose photo</span>
|
||||
</div>
|
||||
<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>
|
||||
@@ -560,33 +564,79 @@ function setRating(btn) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── File upload: show filename + reveal the chosen panel ────────────────────
|
||||
function showFileName(input) {
|
||||
const fid = input.id.replace('field_', '');
|
||||
const fname = document.getElementById('fname_' + fid);
|
||||
const chosen = document.getElementById('chosen_' + fid);
|
||||
// ── Photo upload on select: immediately uploads to server ────────────────────
|
||||
// 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 fname = document.getElementById('fname_' + 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 (fname) fname.textContent = input.files[0].name;
|
||||
if (chosen) chosen.classList.add('has-file');
|
||||
if (!input.files || !input.files.length) return;
|
||||
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');
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Clear a photo upload field ────────────────────────────────────────────────
|
||||
// iOS: input.files is read-only — replace the element to truly reset it.
|
||||
function clearUpload(fid) {
|
||||
const input = document.getElementById('field_' + fid);
|
||||
const fname = document.getElementById('fname_' + fid);
|
||||
const chosen = document.getElementById('chosen_' + fid);
|
||||
const input = document.getElementById('field_' + fid);
|
||||
const fname = document.getElementById('fname_' + 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) {
|
||||
const newInput = input.cloneNode(false);
|
||||
newInput.value = '';
|
||||
newInput.addEventListener('change', function() { showFileName(this); });
|
||||
newInput.addEventListener('change', function() { uploadPhotoField(this); });
|
||||
input.parentNode.replaceChild(newInput, input);
|
||||
}
|
||||
if (fname) fname.textContent = '';
|
||||
if (chosen) chosen.classList.remove('has-file');
|
||||
if (fname) fname.textContent = '';
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
@@ -800,6 +850,14 @@ document.getElementById('inspectionForm').addEventListener('submit', async funct
|
||||
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.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Saving…';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user