From cfd8cfb57f2fdbe175609edaa018045caa34e967 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Mon, 15 Dec 2025 13:55:46 -0500 Subject: [PATCH] Implement photo verification for violation check-in/out --- app.py | 171 ++++++++- logger_handler.py | 6 + models/attendance.py | 21 +- static/js/qr_destination.js | 245 +++++++++++- templates/qr_destination.html | 350 ++++++++++++++++- templates/verification_review.html | 597 +++++++++++++++++++++++++++++ 6 files changed, 1385 insertions(+), 5 deletions(-) create mode 100644 templates/verification_review.html diff --git a/app.py b/app.py index 5c908cf..13aa303 100644 --- a/app.py +++ b/app.py @@ -40,6 +40,11 @@ app.config['SESSION_COOKIE_SECURE'] = os.environ.get('SESSION_COOKIE_SECURE') app.config['SESSION_COOKIE_HTTPONLY'] = os.environ.get('SESSION_COOKIE_HTTPONLY') app.config['SESSION_COOKIE_SAMESITE'] = os.environ.get('SESSION_COOKIE_SAMESITE') +# Photo Verification Configuration +PHOTO_VERIFICATION_ENABLED = os.environ.get('ENABLE_PHOTO_VERIFICATION', 'true').lower() == 'true' +DISTANCE_THRESHOLD_FOR_VERIFICATION = float(os.environ.get('PHOTO_VERIFICATION_DISTANCE_THRESHOLD', '0.3')) +VERIFICATION_PHOTO_MAX_SIZE = int(os.environ.get('VERIFICATION_PHOTO_MAX_SIZE', str(5 * 1024 * 1024))) + # Initialize database db = SQLAlchemy(app) @@ -4279,7 +4284,9 @@ def qr_checkin(qr_url): altitude=location_data['altitude'], location_source=location_data['source'], address=location_data['address'], - status='present' + status='present', + verification_required=False, # Will be set below if needed + verification_status=None ) print(f"✅ Created base attendance record") @@ -4333,6 +4340,47 @@ def qr_checkin(qr_url): else: print(f"⚠️ Could not calculate location accuracy - calculation returned None") + # CHECK DISTANCE THRESHOLD FOR PHOTO VERIFICATION + print(f"\n📸 CHECKING PHOTO VERIFICATION REQUIREMENT:") + print(f" Photo Verification Enabled: {PHOTO_VERIFICATION_ENABLED}") + requires_verification = False + verification_photo_data = None + + if PHOTO_VERIFICATION_ENABLED and location_accuracy is not None and location_accuracy > DISTANCE_THRESHOLD_FOR_VERIFICATION: + print(f"⚠️ Distance ({location_accuracy:.3f} mi) exceeds threshold ({DISTANCE_THRESHOLD_FOR_VERIFICATION} mi)") + + # Check if photo was provided + verification_photo_data = request.form.get('verification_photo', None) + + if verification_photo_data: + print(f"✅ Verification photo provided (size: {len(verification_photo_data)} chars)") + + # Validate photo data (basic validation) + if verification_photo_data.startswith('data:image/'): + attendance.verification_photo = verification_photo_data + attendance.verification_required = True + attendance.verification_status = 'pending' + attendance.verification_timestamp = datetime.utcnow() + print(f"✅ Photo verification set to PENDING status") + else: + print(f"⚠️ Invalid photo format provided") + return jsonify({ + 'success': False, + 'message': 'Invalid photo format. Please try again.', + 'requires_verification': True + }), 400 + else: + print(f"❌ Photo verification REQUIRED but not provided") + return jsonify({ + 'success': False, + 'message': 'Photo verification required. Distance from location is too far.', + 'requires_verification': True, + 'distance': round(location_accuracy, 3), + 'threshold': DISTANCE_THRESHOLD_FOR_VERIFICATION + }), 400 + else: + print(f"✅ Distance within threshold - no verification needed") + except Exception as e: print(f"❌ Error in location accuracy calculation: {e}") print(f"❌ Full traceback: {traceback.format_exc()}") @@ -4351,6 +4399,15 @@ def qr_checkin(qr_url): db.session.add(attendance) db.session.commit() + # Log verification if required + if attendance.verification_required: + logger_handler.log_photo_verification( + employee_id=attendance.employee_id, + qr_code_id=qr_code.id, + distance=location_accuracy, + status='pending' + ) + # VERIFICATION: Read back from database saved_record = AttendanceData.query.get(attendance.id) print(f"✅ Successfully saved attendance record with ID: {attendance.id}") @@ -5227,6 +5284,118 @@ def delete_attendance(record_id): else: flash('Error deleting attendance record. Please try again.', 'error') return redirect(url_for('attendance_report')) + +@app.route('/verification-review') +@login_required +def verification_review(): + """Admin page to review pending photo verifications""" + try: + # Only admins can access + if session.get('role') != 'admin': + flash('Unauthorized access.', 'error') + return redirect(url_for('dashboard')) + + # Get filter parameters + status_filter = request.args.get('status', 'pending') + date_from = request.args.get('date_from', '') + date_to = request.args.get('date_to', '') + + # Build query + query = AttendanceData.query.filter( + AttendanceData.verification_required == True + ) + + if status_filter and status_filter != 'all': + query = query.filter(AttendanceData.verification_status == status_filter) + + if date_from: + query = query.filter(AttendanceData.check_in_date >= date_from) + + if date_to: + query = query.filter(AttendanceData.check_in_date <= date_to) + + # Get records with QR code information + verifications = query.join(QRCode).order_by( + AttendanceData.verification_timestamp.desc() + ).all() + + # Get counts for status badges + pending_count = AttendanceData.query.filter( + AttendanceData.verification_status == 'pending' + ).count() + + approved_count = AttendanceData.query.filter( + AttendanceData.verification_status == 'approved' + ).count() + + rejected_count = AttendanceData.query.filter( + AttendanceData.verification_status == 'rejected' + ).count() + + return render_template('verification_review.html', + verifications=verifications, + pending_count=pending_count, + approved_count=approved_count, + rejected_count=rejected_count, + status_filter=status_filter, + date_from=date_from, + date_to=date_to) + + except Exception as e: + logger_handler.logger.error(f"Error in verification review: {e}") + flash('Error loading verification review.', 'error') + return redirect(url_for('dashboard')) + +@app.route('/verification-review//update', methods=['POST']) +@login_required +@log_database_operations('verification_update') +def update_verification_status(record_id): + """Update verification status (approve/reject)""" + try: + # Only admins can update + if session.get('role') != 'admin': + return jsonify({ + 'success': False, + 'message': 'Unauthorized access' + }), 403 + + record = AttendanceData.query.get_or_404(record_id) + + new_status = request.json.get('status') + admin_note = request.json.get('note', '') + + if new_status not in ['approved', 'rejected']: + return jsonify({ + 'success': False, + 'message': 'Invalid status' + }), 400 + + # Update record + record.verification_status = new_status + record.edit_note = f"Verification {new_status} by {session.get('username')}. {admin_note}" + + db.session.commit() + + # Log the action + logger_handler.log_photo_verification( + employee_id=record.employee_id, + qr_code_id=record.qr_code_id, + distance=record.location_accuracy or 0, + status=new_status + ) + + return jsonify({ + 'success': True, + 'message': f'Verification {new_status} successfully' + }) + + except Exception as e: + db.session.rollback() + logger_handler.logger.error(f"Error updating verification: {e}") + return jsonify({ + 'success': False, + 'message': 'Error updating verification status' + }), 500 @app.route('/api/attendance/stats') @admin_required diff --git a/logger_handler.py b/logger_handler.py index d16cd29..d13b4a9 100644 --- a/logger_handler.py +++ b/logger_handler.py @@ -473,6 +473,12 @@ class AppLogger: 'data': event_data })) + def log_photo_verification(self, employee_id, qr_code_id, distance, status='pending'): + """Log photo verification event""" + self.logger.info( + f"Photo Verification - Employee: {employee_id}, QR: {qr_code_id}, Distance: {distance:.3f} mi, Status: {status}" + ) + def log_qr_code_generated(self, data_length, fill_color, back_color, box_size, border, error_correction): """Log QR code generation with customization details""" try: diff --git a/models/attendance.py b/models/attendance.py index 443af71..f50d58e 100644 --- a/models/attendance.py +++ b/models/attendance.py @@ -33,6 +33,10 @@ class AttendanceData(base.db.Model): altitude = base.db.Column(base.db.Float, nullable=True) location_source = base.db.Column(base.db.String(50), default='manual') address = base.db.Column(base.db.String(500), nullable=True) + verification_photo = base.db.Column(base.db.Text, nullable=True) # Base64 encoded image + verification_required = base.db.Column(base.db.Boolean, default=False) + verification_status = base.db.Column(base.db.String(20), nullable=True) # 'pending', 'approved', 'rejected' + verification_timestamp = base.db.Column(base.db.DateTime, nullable=True) edit_note = base.db.Column(base.db.Text, nullable=True) # Relationships qr_code = base.db.relationship('QRCode', backref=base.db.backref('attendance_records', lazy='dynamic')) @@ -55,4 +59,19 @@ class AttendanceData(base.db.Model): elif self.accuracy <= 20: return 'medium' else: - return 'low' \ No newline at end of file + return 'low' + + @property + def needs_photo_verification(self): + """Check if this check-in requires photo verification""" + return self.verification_required == True + + @property + def is_verification_pending(self): + """Check if photo verification is pending approval""" + return self.verification_status == 'pending' + + @property + def is_verification_approved(self): + """Check if photo verification was approved""" + return self.verification_status == 'approved' \ No newline at end of file diff --git a/static/js/qr_destination.js b/static/js/qr_destination.js index b53d069..c7d99a8 100644 --- a/static/js/qr_destination.js +++ b/static/js/qr_destination.js @@ -7,6 +7,11 @@ let isSubmitting = false; let currentTime = new Date(); +// Camera verification variables +let cameraStream = null; +let capturedPhotoData = null; +let verificationAttemptData = null; + // Location tracking variables let userLocation = { latitude: null, @@ -318,7 +323,14 @@ function submitCheckin() { return response.json(); }) .then((data) => { - handleCheckinResponse(data); + // Check if photo verification is required + if (data.requires_verification) { + console.log('⚠️ Photo verification required'); + verificationAttemptData = formData; // Store for retry with photo + showVerificationModal(data.distance || 0, data.threshold || 0.3); + } else { + handleCheckinResponse(data); + } }) .catch((error) => { handleCheckinError(error); @@ -867,7 +879,7 @@ function checkLocationServicesStatus() { }, { enableHighAccuracy: false, - timeout: 4000, + timeout: 10000, maximumAge: 30000, } ); @@ -1131,3 +1143,232 @@ function initializeLocationServicesCheck() { } }, 1500); } + +// =================================== +// CAMERA VERIFICATION FUNCTIONALITY +// =================================== + +function showVerificationModal(distance, threshold) { + console.log(`📸 Showing verification modal - Distance: ${distance}, Threshold: ${threshold}`); + + const modal = document.getElementById('verificationModal'); + const distanceSpan = document.getElementById('verificationDistance'); + const thresholdSpan = document.getElementById('verificationThreshold'); + + if (distanceSpan) distanceSpan.textContent = distance.toFixed(3); + if (thresholdSpan) thresholdSpan.textContent = threshold.toFixed(3); + + modal.classList.add('active'); + startCamera(); +} + +function hideVerificationModal() { + const modal = document.getElementById('verificationModal'); + modal.classList.remove('active'); + stopCamera(); + resetCameraInterface(); +} + +function startCamera() { + const video = document.getElementById('cameraVideo'); + + console.log('📸 Starting camera...'); + + const constraints = { + video: { + facingMode: 'environment', // Use back camera on mobile + width: { ideal: 1280 }, + height: { ideal: 720 } + }, + audio: false + }; + + navigator.mediaDevices.getUserMedia(constraints) + .then(stream => { + cameraStream = stream; + video.srcObject = stream; + video.style.display = 'block'; + console.log('✅ Camera started successfully'); + }) + .catch(error => { + console.error('❌ Camera error:', error); + alert('Unable to access camera. Please check permissions and try again. / No se puede acceder a la cámara.'); + hideVerificationModal(); + isSubmitting = false; + updateSubmitButton(false); + }); +} + +function stopCamera() { + if (cameraStream) { + cameraStream.getTracks().forEach(track => track.stop()); + cameraStream = null; + console.log('📸 Camera stopped'); + } +} + +function capturePhoto() { + const video = document.getElementById('cameraVideo'); + const canvas = document.getElementById('cameraCanvas'); + const capturedImage = document.getElementById('capturedPhoto'); + const captureBtn = document.getElementById('captureBtn'); + const retakeBtn = document.getElementById('retakeBtn'); + const submitBtn = document.getElementById('submitVerificationBtn'); + + // Set canvas dimensions to match video + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + + // Draw video frame to canvas + const context = canvas.getContext('2d'); + context.drawImage(video, 0, 0, canvas.width, canvas.height); + + // Get photo data as base64 + capturedPhotoData = canvas.toDataURL('image/jpeg', 0.8); + + // Show captured photo + capturedImage.src = capturedPhotoData; + capturedImage.style.display = 'block'; + video.style.display = 'none'; + + // Update button visibility + captureBtn.style.display = 'none'; + retakeBtn.style.display = 'inline-flex'; + submitBtn.style.display = 'inline-flex'; + + console.log('📸 Photo captured'); +} + +function retakePhoto() { + const video = document.getElementById('cameraVideo'); + const capturedImage = document.getElementById('capturedPhoto'); + const captureBtn = document.getElementById('captureBtn'); + const retakeBtn = document.getElementById('retakeBtn'); + const submitBtn = document.getElementById('submitVerificationBtn'); + + // Reset interface + capturedImage.style.display = 'none'; + video.style.display = 'block'; + + captureBtn.style.display = 'inline-flex'; + retakeBtn.style.display = 'none'; + submitBtn.style.display = 'none'; + + capturedPhotoData = null; + + console.log('📸 Ready to retake photo'); +} + +function resetCameraInterface() { + const video = document.getElementById('cameraVideo'); + const capturedImage = document.getElementById('capturedPhoto'); + const captureBtn = document.getElementById('captureBtn'); + const retakeBtn = document.getElementById('retakeBtn'); + const submitBtn = document.getElementById('submitVerificationBtn'); + + if (capturedImage) capturedImage.style.display = 'none'; + if (video) video.style.display = 'block'; + + if (captureBtn) captureBtn.style.display = 'inline-flex'; + if (retakeBtn) retakeBtn.style.display = 'none'; + if (submitBtn) submitBtn.style.display = 'none'; + + capturedPhotoData = null; + verificationAttemptData = null; +} + +function submitWithVerification() { + if (!capturedPhotoData) { + alert('Please capture a photo first. / Por favor capture una foto primero.'); + return; + } + + console.log('📸 Submitting check-in with verification photo...'); + + const submitBtn = document.getElementById('submitVerificationBtn'); + submitBtn.disabled = true; + submitBtn.innerHTML = ' Submitting...'; + + // Use stored form data from initial attempt + if (verificationAttemptData) { + verificationAttemptData.append('verification_photo', capturedPhotoData); + + const currentUrl = window.location.pathname; + const checkinUrl = `${currentUrl}/checkin`; + + fetch(checkinUrl, { + method: "POST", + body: verificationAttemptData, + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + }) + .then((response) => response.json()) + .then((data) => { + hideVerificationModal(); + + if (data.success) { + showCustomStatusMessage( + "Submission successful! Photo pending review. / ¡Envío exitoso! Foto pendiente de revisión.", + "success" + ); + handleCheckinSuccess(data); + } else { + showCustomStatusMessage( + data.message || "Submission failed / Envío fallido", + "error" + ); + } + }) + .catch((error) => { + hideVerificationModal(); + console.error('❌ Verification submit error:', error); + showLocalizedStatusMessage("networkError", "error"); + }) + .finally(() => { + isSubmitting = false; + updateSubmitButton(false); + submitBtn.disabled = false; + submitBtn.innerHTML = ' Submit with Photo'; + }); + } +} + +function cancelVerification() { + hideVerificationModal(); + isSubmitting = false; + updateSubmitButton(false); +} + +// Initialize camera button event listeners +function initializeCameraButtons() { + const captureBtn = document.getElementById('captureBtn'); + const retakeBtn = document.getElementById('retakeBtn'); + const submitBtn = document.getElementById('submitVerificationBtn'); + const cancelBtn = document.getElementById('cancelVerificationBtn'); + + if (captureBtn) { + captureBtn.addEventListener('click', capturePhoto); + } + + if (retakeBtn) { + retakeBtn.addEventListener('click', retakePhoto); + } + + if (submitBtn) { + submitBtn.addEventListener('click', submitWithVerification); + } + + if (cancelBtn) { + cancelBtn.addEventListener('click', cancelVerification); + } + + console.log('📸 Camera verification buttons initialized'); +} + +// Call initialization when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeCameraButtons); +} else { + initializeCameraButtons(); +} \ No newline at end of file diff --git a/templates/qr_destination.html b/templates/qr_destination.html index 7942d22..e87cb80 100644 --- a/templates/qr_destination.html +++ b/templates/qr_destination.html @@ -385,6 +385,146 @@ transform: none !important; } + /* Camera Verification Styles */ + .verification-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.9); + z-index: 10000; + justify-content: center; + align-items: center; + } + + .verification-modal.active { + display: flex; + } + + .verification-content { + background: white; + border-radius: var(--border-radius); + padding: 2rem; + max-width: 600px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + } + + .verification-header { + text-align: center; + margin-bottom: 1.5rem; + } + + .verification-header h2 { + color: var(--text-primary); + margin-bottom: 0.5rem; + } + + .verification-header .distance-warning { + color: #dc2626; + font-weight: 600; + margin-top: 0.5rem; + } + + .camera-preview { + position: relative; + width: 100%; + background: #000; + border-radius: var(--border-radius); + overflow: hidden; + margin: 1rem 0; + } + + .camera-preview video { + width: 100%; + height: auto; + display: block; + } + + .camera-preview canvas { + display: none; + } + + .camera-preview img { + width: 100%; + height: auto; + display: block; + } + + .camera-controls { + display: flex; + gap: 1rem; + justify-content: center; + margin-top: 1rem; + } + + .camera-btn { + padding: 0.75rem 1.5rem; + border: none; + border-radius: var(--border-radius); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + } + + .camera-btn.capture { + background: var(--primary-color); + color: white; + } + + .camera-btn.capture:hover { + background: var(--primary-dark); + } + + .camera-btn.retake { + background: #f59e0b; + color: white; + } + + .camera-btn.retake:hover { + background: #d97706; + } + + .camera-btn.submit-verification { + background: #10b981; + color: white; + } + + .camera-btn.submit-verification:hover { + background: #059669; + } + + .camera-btn.cancel { + background: #6b7280; + color: white; + } + + .camera-btn.cancel:hover { + background: #4b5563; + } + + .camera-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .verification-instructions { + background: #fef3c7; + border-left: 4px solid #f59e0b; + padding: 1rem; + margin-bottom: 1rem; + border-radius: 4px; + } + + .verification-instructions p { + margin: 0.5rem 0; + color: #78350f; + } + .success-card { background: rgba(255, 255, 255, 0.95); border-radius: var(--border-radius); @@ -583,6 +723,159 @@ background: #15803d; } + /* Camera Verification Styles */ + .verification-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.9); + z-index: 10000; + justify-content: center; + align-items: center; + } + + .verification-modal.active { + display: flex; + } + + .verification-content { + background: white; + border-radius: var(--border-radius); + padding: 2rem; + max-width: 600px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + } + + .verification-header h2 { + color: var(--text-primary); + margin-bottom: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + } + + .verification-header .distance-warning { + color: #dc2626; + font-weight: 600; + margin-top: 0.5rem; + font-size: 1rem; + } + + .camera-preview { + position: relative; + width: 100%; + background: #000; + border-radius: var(--border-radius); + overflow: hidden; + margin: 1rem 0; + min-height: 300px; + display: flex; + align-items: center; + justify-content: center; + } + + .camera-preview video { + width: 100%; + height: auto; + display: block; + } + + .camera-preview canvas { + display: none; + } + + .camera-preview img { + width: 100%; + height: auto; + display: block; + } + + .camera-controls { + display: flex; + gap: 1rem; + justify-content: center; + margin-top: 1rem; + flex-wrap: wrap; + } + + .camera-btn { + padding: 0.75rem 1.5rem; + border: none; + border-radius: var(--border-radius); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .camera-btn.capture { + background: var(--primary-color); + color: white; + } + + .camera-btn.capture:hover { + background: var(--primary-hover); + } + + .camera-btn.retake { + background: #f59e0b; + color: white; + } + + .camera-btn.retake:hover { + background: #d97706; + } + + .camera-btn.submit-verification { + background: #10b981; + color: white; + } + + .camera-btn.submit-verification:hover { + background: #059669; + } + + .camera-btn.cancel { + background: #6b7280; + color: white; + } + + .camera-btn.cancel:hover { + background: #4b5563; + } + + .camera-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .verification-instructions { + background: #fef3c7; + border-left: 4px solid #f59e0b; + padding: 1rem; + margin-bottom: 1rem; + border-radius: 4px; + } + + .verification-instructions p { + margin: 0.5rem 0; + color: #78350f; + font-size: 0.95rem; + } + + .verification-instructions strong { + color: #92400e; + } + /* Responsive Design */ @media (max-width: 640px) { .destination-container { @@ -918,7 +1211,54 @@ + +
+
+
+

+ + Photo Verification Required +

+

+ + You are checking in from a location that is too far from the designated area. +

+

+ Distance: -- miles + (Threshold: 0.3 miles) +

+
+
+

Please take a photo of your current location:

+

• Make sure the photo clearly shows your surroundings

+

• The photo will be reviewed by management

+

• You can retake the photo if needed

+
+ +
+ + + +
+ +
+ + + + +
+
+
+ @@ -1206,6 +1546,14 @@ "success" ); showEnhancedSuccessCard(data); + } else if (data.requires_verification) { + // Photo verification required + console.log('⚠️ Photo verification required'); + verificationAttemptData = formData; // Store for retry with photo + showVerificationModal( + data.distance || 0, + data.threshold || 0.3 + ); } else { showStatusMessage( data.message || "Submission failed / Envío fallido", @@ -1346,4 +1694,4 @@ } - + \ No newline at end of file diff --git a/templates/verification_review.html b/templates/verification_review.html new file mode 100644 index 0000000..62cd69c --- /dev/null +++ b/templates/verification_review.html @@ -0,0 +1,597 @@ +{% extends "base_authenticated.html" %} + +{% block title %}Photo Verification Review{% endblock %} + +{% block content %} +
+ + + + +
+
+
+ +
+
+

{{ pending_count }}

+

Pending Review

+
+
+ +
+
+ +
+
+

{{ approved_count }}

+

Approved

+
+
+ +
+
+ +
+
+

{{ rejected_count }}

+

Rejected

+
+
+
+ + +
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+ + +
+ {% if verifications %} +
+ {% for record in verifications %} +
+
+
+

+ + Employee {{ record.employee_id }} +

+
+ + + {{ record.check_in_date.strftime('%b %d, %Y') }} + + + + {{ record.check_in_time.strftime('%I:%M %p') }} + + + + {{ record.location_name }} + +
+
+
+ {% if record.verification_status == 'pending' %} + + Pending + + {% elif record.verification_status == 'approved' %} + + Approved + + {% elif record.verification_status == 'rejected' %} + + Rejected + + {% endif %} +
+
+ +
+
+
+ {% if record.verification_photo %} + Verification Photo + {% else %} +
+ +

No photo available

+
+ {% endif %} +
+
+ +
+
+ + Distance from Location: + + + {% if record.location_accuracy %} + {{ "%.3f"|format(record.location_accuracy) }} miles + {% else %} + N/A + {% endif %} + +
+ +
+ + QR Location: + + + {{ record.qr_code.location_address if record.qr_code.location_address else 'N/A' }} + +
+ +
+ + Check-in Address: + + + {{ record.address if record.address else 'N/A' }} + +
+ + {% if record.latitude and record.longitude %} + + {% endif %} + +
+ + Verification Submitted: + + + {{ record.verification_timestamp.strftime('%b %d, %Y %I:%M %p') if record.verification_timestamp else 'N/A' }} + +
+ + {% if record.edit_note %} +
+ + Note: + + + {{ record.edit_note }} + +
+ {% endif %} +
+
+ + {% if record.verification_status == 'pending' %} +
+ + +
+ {% endif %} +
+ {% endfor %} +
+ {% else %} +
+ +

No Verifications Found

+

There are no photo verifications matching your current filters.

+
+ {% endif %} +
+
+ + + + +{% endblock %} \ No newline at end of file