added PDF support and autoupdating on refresh
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,398 @@
|
||||
from flask import Flask, render_template, request, jsonify, session
|
||||
from flask_cors import CORS
|
||||
import json
|
||||
from datetime import datetime
|
||||
from data_manager import DataManager
|
||||
import os
|
||||
from functools import wraps
|
||||
|
||||
app = Flask(__name__, static_folder='static', template_folder='templates')
|
||||
CORS(app)
|
||||
app.secret_key = os.environ.get('SECRET_KEY', 'rfid-detection-secret-key-change-in-production')
|
||||
|
||||
# Create data directory if it doesn't exist
|
||||
os.makedirs('data', exist_ok=True)
|
||||
|
||||
# Global variables
|
||||
data_manager = DataManager("data/participants.json", "data/scan_logs.json")
|
||||
last_scan_result = None
|
||||
|
||||
# Admin credentials (change this to your desired password)
|
||||
ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', 'Pleterski')
|
||||
|
||||
def require_admin(f):
|
||||
"""Decorator to require admin authentication"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'admin' not in session or not session['admin']:
|
||||
return jsonify({'success': False, 'message': 'Unauthorized'}), 401
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def process_card(uid):
|
||||
"""Process scanned card or key"""
|
||||
global last_scan_result
|
||||
participant = data_manager.get_participant_by_card(uid)
|
||||
|
||||
# Get card and key UIDs from participant
|
||||
card_uid = participant.get('card_uid') if participant else None
|
||||
key_uid = participant.get('key_uid') if participant else None
|
||||
|
||||
if participant and not data_manager.is_card_expired(participant):
|
||||
data_manager.log_scan(uid, "PASS", participant['name'], card_uid=card_uid, key_uid=key_uid)
|
||||
last_scan_result = {
|
||||
'uid': uid,
|
||||
'name': participant['name'],
|
||||
'status': 'PASS',
|
||||
'color': 'green',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
else:
|
||||
# FAIL if participant not found or card is expired
|
||||
name = participant['name'] if participant else "Unknown"
|
||||
reason = "Expired" if participant and data_manager.is_card_expired(participant) else "Unknown"
|
||||
# For FAIL scans, log the scanned UID as card_uid
|
||||
data_manager.log_scan(uid, "FAIL", name, reason, card_uid=uid, key_uid=None)
|
||||
last_scan_result = {
|
||||
'uid': uid,
|
||||
'name': name if participant else 'Unknown',
|
||||
'status': 'FAIL',
|
||||
'color': 'red',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return last_scan_result
|
||||
|
||||
# ===== AUTHENTICATION ROUTES =====
|
||||
|
||||
@app.route('/api/login', methods=['POST'])
|
||||
def login():
|
||||
"""Admin login"""
|
||||
try:
|
||||
data = request.json
|
||||
password = data.get('password', '').strip()
|
||||
|
||||
if password == ADMIN_PASSWORD:
|
||||
session['admin'] = True
|
||||
return jsonify({'success': True, 'message': 'Logged in successfully'})
|
||||
else:
|
||||
return jsonify({'success': False, 'message': 'Invalid password'}), 401
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 400
|
||||
|
||||
@app.route('/api/logout', methods=['POST'])
|
||||
def logout():
|
||||
"""Admin logout"""
|
||||
session.pop('admin', None)
|
||||
return jsonify({'success': True, 'message': 'Logged out successfully'})
|
||||
|
||||
@app.route('/api/check-admin', methods=['GET'])
|
||||
def check_admin():
|
||||
"""Check if user is logged in as admin"""
|
||||
is_admin = 'admin' in session and session['admin']
|
||||
return jsonify({'is_admin': is_admin})
|
||||
|
||||
# ===== API Routes =====
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
|
||||
|
||||
@app.route('/api/scan', methods=['POST'])
|
||||
def receive_wifi_scan():
|
||||
"""Receive card scan from WiFi-enabled ESP8266"""
|
||||
try:
|
||||
data = request.json
|
||||
uid = data.get('uid', '').strip()
|
||||
|
||||
if not uid:
|
||||
return jsonify({'success': False, 'message': 'No UID provided'}), 400
|
||||
|
||||
print(f"[WiFi Scan] Card scanned via WiFi: {uid}")
|
||||
result = process_card(uid)
|
||||
print(f"[WiFi Scan] Result: {result}")
|
||||
return jsonify({'success': True, 'result': result})
|
||||
except Exception as e:
|
||||
print(f"[WiFi Scan Error] {str(e)}")
|
||||
return jsonify({'success': False, 'message': str(e)}), 400
|
||||
|
||||
@app.route('/scan', methods=['POST'])
|
||||
def receive_wifi_scan_root():
|
||||
"""Fallback route for /scan (without /api prefix)"""
|
||||
try:
|
||||
data = request.json
|
||||
uid = data.get('uid', '').strip()
|
||||
|
||||
if not uid:
|
||||
return jsonify({'success': False, 'message': 'No UID provided'}), 400
|
||||
|
||||
print(f"[WiFi Scan Root] Card scanned via WiFi: {uid}")
|
||||
result = process_card(uid)
|
||||
print(f"[WiFi Scan Root] Result: {result}")
|
||||
return jsonify({'success': True, 'result': result})
|
||||
except Exception as e:
|
||||
print(f"[WiFi Scan Root Error] {str(e)}")
|
||||
return jsonify({'success': False, 'message': str(e)}), 400
|
||||
|
||||
@app.route('/api/participants', methods=['GET'])
|
||||
def get_participants():
|
||||
"""Get all participants"""
|
||||
participants = data_manager.get_all_participants()
|
||||
return jsonify(participants)
|
||||
|
||||
@app.route('/api/participants', methods=['POST'])
|
||||
@require_admin
|
||||
def add_participant():
|
||||
"""Add a new participant (admin only)"""
|
||||
try:
|
||||
data = request.json
|
||||
if not data:
|
||||
return jsonify({'success': False, 'message': 'Invalid JSON'}), 400
|
||||
|
||||
name = (data.get('name') or '').strip()
|
||||
card_uid = (data.get('card_uid') or '').strip() or None
|
||||
key_uid = (data.get('key_uid') or '').strip() or None
|
||||
expiration_date = (data.get('expiration_date') or '').strip() or None
|
||||
|
||||
if not name:
|
||||
return jsonify({'success': False, 'message': 'Name is required'}), 400
|
||||
|
||||
if not card_uid and not key_uid:
|
||||
return jsonify({'success': False, 'message': 'At least one of card UID or key UID is required'}), 400
|
||||
|
||||
if data_manager.add_participant(name, card_uid, expiration_date, key_uid):
|
||||
return jsonify({'success': True, 'message': 'Participant added'})
|
||||
else:
|
||||
return jsonify({'success': False, 'message': 'Duplicate UID or error'}), 400
|
||||
except Exception as e:
|
||||
print(f"Error adding participant: {e}")
|
||||
return jsonify({'success': False, 'message': str(e)}), 400
|
||||
|
||||
@app.route('/api/participants/<int:participant_id>', methods=['PUT'])
|
||||
@require_admin
|
||||
def update_participant(participant_id):
|
||||
"""Update a participant (admin only)"""
|
||||
try:
|
||||
data = request.json
|
||||
if not data:
|
||||
return jsonify({'success': False, 'message': 'Invalid JSON'}), 400
|
||||
|
||||
name = (data.get('name') or '').strip()
|
||||
card_uid = (data.get('card_uid') or '').strip() or None
|
||||
key_uid = (data.get('key_uid') or '').strip() or None
|
||||
expiration_date = (data.get('expiration_date') or '').strip() or None
|
||||
|
||||
if not name:
|
||||
return jsonify({'success': False, 'message': 'Name is required'}), 400
|
||||
|
||||
if not card_uid and not key_uid:
|
||||
return jsonify({'success': False, 'message': 'At least one of card UID or key UID is required'}), 400
|
||||
|
||||
if data_manager.update_participant(participant_id, name, card_uid, expiration_date, key_uid):
|
||||
return jsonify({'success': True, 'message': 'Participant updated'})
|
||||
else:
|
||||
return jsonify({'success': False, 'message': 'Update failed'}), 400
|
||||
except Exception as e:
|
||||
print(f"Error updating participant: {e}")
|
||||
return jsonify({'success': False, 'message': str(e)}), 400
|
||||
|
||||
@app.route('/api/participants/<int:participant_id>', methods=['DELETE'])
|
||||
@require_admin
|
||||
def delete_participant(participant_id):
|
||||
"""Delete a participant (admin only)"""
|
||||
if data_manager.delete_participant(participant_id):
|
||||
return jsonify({'success': True, 'message': 'Participant deleted'})
|
||||
else:
|
||||
return jsonify({'success': False, 'message': 'Delete failed'}), 400
|
||||
|
||||
@app.route('/api/logs', methods=['GET'])
|
||||
def get_logs():
|
||||
"""Get scan logs"""
|
||||
filter_type = request.args.get('filter', 'All')
|
||||
logs = data_manager.get_scan_history(1000)
|
||||
|
||||
if filter_type == 'PASS':
|
||||
logs = [log for log in logs if log['scan_result'] == 'PASS']
|
||||
elif filter_type == 'FAIL':
|
||||
logs = [log for log in logs if log['scan_result'] == 'FAIL']
|
||||
|
||||
return jsonify(logs)
|
||||
|
||||
@app.route('/api/logs/unregistered', methods=['POST'])
|
||||
def add_unregistered_log():
|
||||
"""Add unregistered user to logs"""
|
||||
try:
|
||||
data = request.json
|
||||
if not data:
|
||||
return jsonify({'success': False, 'message': 'Invalid JSON'}), 400
|
||||
|
||||
name = (data.get('name') or '').strip()
|
||||
timestamp = (data.get('timestamp') or '').strip() or None
|
||||
|
||||
if not name:
|
||||
return jsonify({'success': False, 'message': 'Name is required'}), 400
|
||||
|
||||
if data_manager.log_unregistered_user(name, timestamp):
|
||||
return jsonify({'success': True, 'message': 'Unregistered user logged'})
|
||||
else:
|
||||
return jsonify({'success': False, 'message': 'Error logging user'}), 400
|
||||
except Exception as e:
|
||||
print(f"Error adding unregistered log: {e}")
|
||||
return jsonify({'success': False, 'message': str(e)}), 400
|
||||
|
||||
@app.route('/api/logs/<int:log_id>', methods=['DELETE'])
|
||||
@require_admin
|
||||
def delete_log(log_id):
|
||||
"""Delete a log entry (admin only)"""
|
||||
try:
|
||||
if data_manager.delete_log(log_id):
|
||||
return jsonify({'success': True, 'message': 'Log deleted'})
|
||||
else:
|
||||
return jsonify({'success': False, 'message': 'Log not found'}), 404
|
||||
except Exception as e:
|
||||
print(f"Error deleting log: {e}")
|
||||
return jsonify({'success': False, 'message': str(e)}), 400
|
||||
|
||||
@app.route('/api/export', methods=['GET'])
|
||||
def export_logs():
|
||||
"""Export logs to Excel - returns file data for client-side saving"""
|
||||
try:
|
||||
import openpyxl
|
||||
from openpyxl.styles import PatternFill, Font, Alignment
|
||||
import io
|
||||
import base64
|
||||
from datetime import datetime as dt
|
||||
|
||||
logs = data_manager.get_scan_history(10000)
|
||||
|
||||
# Apply filters from query parameters
|
||||
filter_type = request.args.get('filter', 'All')
|
||||
participant_filter = request.args.get('participant', '')
|
||||
start_date = request.args.get('startDate', '')
|
||||
end_date = request.args.get('endDate', '')
|
||||
|
||||
# Filter by result type
|
||||
if filter_type == 'PASS':
|
||||
logs = [log for log in logs if log['scan_result'] == 'PASS']
|
||||
elif filter_type == 'FAIL':
|
||||
logs = [log for log in logs if log['scan_result'] == 'FAIL']
|
||||
|
||||
# Filter by participant
|
||||
if participant_filter:
|
||||
logs = [log for log in logs if log.get('name') == participant_filter]
|
||||
|
||||
# Filter by date range
|
||||
if start_date:
|
||||
start_dt = dt.strptime(start_date, '%Y-%m-%d')
|
||||
logs = [log for log in logs if dt.fromisoformat(log['timestamp'].replace('Z', '+00:00')) >= start_dt]
|
||||
|
||||
if end_date:
|
||||
end_dt = dt.strptime(end_date, '%Y-%m-%d')
|
||||
end_dt = end_dt.replace(hour=23, minute=59, second=59)
|
||||
logs = [log for log in logs if dt.fromisoformat(log['timestamp'].replace('Z', '+00:00')) <= end_dt]
|
||||
|
||||
if not logs:
|
||||
return jsonify({'success': False, 'message': 'No logs to export'}), 400
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Scan Logs"
|
||||
|
||||
headers = ["ID", "Name", "Card UID", "Key UID", "Result", "Date", "Time"]
|
||||
ws.append(headers)
|
||||
|
||||
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
|
||||
for cell in ws[1]:
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
|
||||
for log in logs:
|
||||
timestamp = log['timestamp']
|
||||
date_part = timestamp[:10]
|
||||
time_part = timestamp[11:19]
|
||||
|
||||
ws.append([
|
||||
log['id'],
|
||||
log['name'],
|
||||
log['card_uid'],
|
||||
log.get('key_uid', ''),
|
||||
log['scan_result'],
|
||||
date_part,
|
||||
time_part
|
||||
])
|
||||
|
||||
pass_fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
|
||||
pass_font = Font(color="006100")
|
||||
fail_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
|
||||
fail_font = Font(color="9C0006")
|
||||
|
||||
for idx, row in enumerate(ws.iter_rows(min_row=2, max_row=ws.max_row), start=2):
|
||||
result_cell = row[4] # Result is now column 5 (index 4)
|
||||
if result_cell.value == "PASS":
|
||||
result_cell.fill = pass_fill
|
||||
result_cell.font = pass_font
|
||||
else:
|
||||
result_cell.fill = fail_fill
|
||||
result_cell.font = fail_font
|
||||
|
||||
ws.column_dimensions['A'].width = 8
|
||||
ws.column_dimensions['B'].width = 20
|
||||
ws.column_dimensions['C'].width = 20
|
||||
ws.column_dimensions['D'].width = 20
|
||||
ws.column_dimensions['E'].width = 10
|
||||
ws.column_dimensions['F'].width = 12
|
||||
ws.column_dimensions['G'].width = 10
|
||||
|
||||
# Generate filename
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"scan_logs_{timestamp}.xlsx"
|
||||
|
||||
# Save to bytes buffer
|
||||
buffer = io.BytesIO()
|
||||
wb.save(buffer)
|
||||
buffer.seek(0)
|
||||
|
||||
# Encode to base64 for transfer
|
||||
file_data = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'filename': filename,
|
||||
'data': file_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
@app.route('/api/stats', methods=['GET'])
|
||||
def get_stats():
|
||||
"""Get statistics"""
|
||||
stats = data_manager.get_statistics()
|
||||
return jsonify(stats)
|
||||
|
||||
@app.route('/api/documents', methods=['GET'])
|
||||
def get_documents():
|
||||
"""List PDF files in static/PDF folder"""
|
||||
pdf_dir = os.path.join(app.static_folder, 'PDF')
|
||||
os.makedirs(pdf_dir, exist_ok=True)
|
||||
files = [f for f in os.listdir(pdf_dir) if f.lower().endswith('.pdf')]
|
||||
files.sort()
|
||||
return jsonify(files)
|
||||
|
||||
@app.route('/api/last-scan', methods=['GET'])
|
||||
def get_last_scan():
|
||||
"""Get last scan result"""
|
||||
return jsonify(last_scan_result if last_scan_result else {
|
||||
'uid': '-',
|
||||
'name': '-',
|
||||
'status': '-',
|
||||
'color': 'gray'
|
||||
})
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Matej",
|
||||
"card_uid": "11 11",
|
||||
"key_uid": null,
|
||||
"expiration_date": null,
|
||||
"date_added": "2026-02-07T08:06:13.526959"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"card_uid": null,
|
||||
"key_uid": null,
|
||||
"name": "Dejan",
|
||||
"scan_result": "MANUAL",
|
||||
"reason": "Unregistered",
|
||||
"timestamp": "2026-02-12T13:34:11.034695"
|
||||
}
|
||||
]
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
class DataManager:
|
||||
def __init__(self, participants_file="participants.json", logs_file="scan_logs.json"):
|
||||
self.participants_file = participants_file
|
||||
self.logs_file = logs_file
|
||||
self.init_files()
|
||||
|
||||
def init_files(self):
|
||||
"""Initialize JSON files if they don't exist"""
|
||||
if not os.path.exists(self.participants_file):
|
||||
self.save_participants([])
|
||||
if not os.path.exists(self.logs_file):
|
||||
self.save_logs([])
|
||||
|
||||
def load_participants(self) -> List[Dict]:
|
||||
"""Load participants from JSON file"""
|
||||
try:
|
||||
with open(self.participants_file, 'r') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Error loading participants: {e}")
|
||||
return []
|
||||
|
||||
def save_participants(self, participants: List[Dict]):
|
||||
"""Save participants to JSON file"""
|
||||
try:
|
||||
with open(self.participants_file, 'w') as f:
|
||||
json.dump(participants, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"Error saving participants: {e}")
|
||||
|
||||
def load_logs(self) -> List[Dict]:
|
||||
"""Load scan logs from JSON file"""
|
||||
try:
|
||||
with open(self.logs_file, 'r') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Error loading logs: {e}")
|
||||
return []
|
||||
|
||||
def save_logs(self, logs: List[Dict]):
|
||||
"""Save scan logs to JSON file"""
|
||||
try:
|
||||
with open(self.logs_file, 'w') as f:
|
||||
json.dump(logs, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"Error saving logs: {e}")
|
||||
|
||||
def add_participant(self, name: str, card_uid: str = None, expiration_date: str = None, key_uid: str = None) -> bool:
|
||||
"""Add a new participant"""
|
||||
try:
|
||||
participants = self.load_participants()
|
||||
|
||||
# Check if card UID already exists
|
||||
if card_uid and any(p.get('card_uid') == card_uid for p in participants):
|
||||
return False
|
||||
|
||||
# Check if key UID already exists
|
||||
if key_uid and any(p.get('key_uid') == key_uid for p in participants):
|
||||
return False
|
||||
|
||||
new_participant = {
|
||||
'id': len(participants) + 1,
|
||||
'name': name,
|
||||
'card_uid': card_uid,
|
||||
'key_uid': key_uid,
|
||||
'expiration_date': expiration_date,
|
||||
'date_added': datetime.now().isoformat()
|
||||
}
|
||||
participants.append(new_participant)
|
||||
self.save_participants(participants)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error adding participant: {e}")
|
||||
return False
|
||||
|
||||
def get_participant_by_card(self, card_uid: str) -> Optional[Dict]:
|
||||
"""Get participant by card UID or key UID"""
|
||||
try:
|
||||
participants = self.load_participants()
|
||||
for p in participants:
|
||||
if p.get('card_uid') == card_uid or p.get('key_uid') == card_uid:
|
||||
return p
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error getting participant: {e}")
|
||||
return None
|
||||
|
||||
def is_card_expired(self, participant: Dict) -> bool:
|
||||
"""Check if participant's card is expired"""
|
||||
try:
|
||||
expiration_date = participant.get('expiration_date')
|
||||
if not expiration_date:
|
||||
return False
|
||||
|
||||
exp_date = datetime.fromisoformat(expiration_date.replace('Z', '+00:00'))
|
||||
current_date = datetime.now()
|
||||
return current_date > exp_date
|
||||
except Exception as e:
|
||||
print(f"Error checking expiration: {e}")
|
||||
return False
|
||||
|
||||
def get_all_participants(self) -> List[Dict]:
|
||||
"""Get all participants"""
|
||||
return self.load_participants()
|
||||
|
||||
def update_participant(self, participant_id: int, name: str, card_uid: str = None, expiration_date: str = None, key_uid: str = None) -> bool:
|
||||
"""Update participant information"""
|
||||
try:
|
||||
participants = self.load_participants()
|
||||
|
||||
# Find the participant being updated
|
||||
participant_to_update = None
|
||||
for p in participants:
|
||||
if p['id'] == participant_id:
|
||||
participant_to_update = p
|
||||
break
|
||||
|
||||
if not participant_to_update:
|
||||
return False
|
||||
|
||||
# Check if new card_uid is already used by another participant (skip if same as current)
|
||||
if card_uid and card_uid != participant_to_update.get('card_uid'):
|
||||
for p in participants:
|
||||
if p['id'] != participant_id and p.get('card_uid') == card_uid:
|
||||
return False
|
||||
|
||||
# Check if new key_uid is already used by another participant (skip if same as current)
|
||||
if key_uid and key_uid != participant_to_update.get('key_uid'):
|
||||
for p in participants:
|
||||
if p['id'] != participant_id and p.get('key_uid') == key_uid:
|
||||
return False
|
||||
|
||||
# Update name (always)
|
||||
participant_to_update['name'] = name
|
||||
|
||||
# Update UIDs and expiration only if explicitly provided (not None)
|
||||
# card_uid and key_uid can be explicitly set to empty string to clear them
|
||||
if card_uid is not None:
|
||||
participant_to_update['card_uid'] = card_uid if card_uid else None
|
||||
if key_uid is not None:
|
||||
participant_to_update['key_uid'] = key_uid if key_uid else None
|
||||
if expiration_date is not None:
|
||||
participant_to_update['expiration_date'] = expiration_date if expiration_date else None
|
||||
|
||||
self.save_participants(participants)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error updating participant: {e}")
|
||||
return False
|
||||
|
||||
def delete_participant(self, participant_id: int) -> bool:
|
||||
"""Delete participant"""
|
||||
try:
|
||||
participants = self.load_participants()
|
||||
participants = [p for p in participants if p['id'] != participant_id]
|
||||
self.save_participants(participants)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error deleting participant: {e}")
|
||||
return False
|
||||
|
||||
def log_scan(self, scanned_uid: str, result: str, name: str = "Unknown", reason: str = None, card_uid: str = None, key_uid: str = None):
|
||||
"""Log a card/key scan"""
|
||||
try:
|
||||
logs = self.load_logs()
|
||||
log_entry = {
|
||||
'id': len(logs) + 1,
|
||||
'card_uid': card_uid,
|
||||
'key_uid': key_uid,
|
||||
'name': name,
|
||||
'scan_result': result,
|
||||
'reason': reason,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
logs.append(log_entry)
|
||||
self.save_logs(logs)
|
||||
except Exception as e:
|
||||
print(f"Error logging scan: {e}")
|
||||
|
||||
def get_scan_history(self, limit: int = 100) -> List[Dict]:
|
||||
"""Get scan history (most recent first)"""
|
||||
try:
|
||||
logs = self.load_logs()
|
||||
return logs[-limit:][::-1] # Return last N items in reverse order
|
||||
except Exception as e:
|
||||
print(f"Error getting scan history: {e}")
|
||||
return []
|
||||
|
||||
def get_statistics(self) -> Dict:
|
||||
"""Get statistics from data"""
|
||||
try:
|
||||
participants = self.load_participants()
|
||||
logs = self.load_logs()
|
||||
|
||||
pass_count = sum(1 for l in logs if l['scan_result'] == 'PASS')
|
||||
fail_count = sum(1 for l in logs if l['scan_result'] == 'FAIL')
|
||||
|
||||
return {
|
||||
'total_participants': len(participants),
|
||||
'total_passes': pass_count,
|
||||
'total_fails': fail_count,
|
||||
'total_scans': len(logs)
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error getting statistics: {e}")
|
||||
return {}
|
||||
|
||||
def log_unregistered_user(self, name: str, timestamp: str = None) -> bool:
|
||||
"""Log an unregistered user manually (admin only)"""
|
||||
try:
|
||||
logs = self.load_logs()
|
||||
log_entry = {
|
||||
'id': len(logs) + 1,
|
||||
'card_uid': None,
|
||||
'key_uid': None,
|
||||
'name': name,
|
||||
'scan_result': 'MANUAL',
|
||||
'reason': 'Unregistered',
|
||||
'timestamp': timestamp if timestamp else datetime.now().isoformat()
|
||||
}
|
||||
logs.append(log_entry)
|
||||
self.save_logs(logs)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error logging unregistered user: {e}")
|
||||
return False
|
||||
|
||||
def delete_log(self, log_id: int) -> bool:
|
||||
"""Delete a log entry by ID (admin only)"""
|
||||
try:
|
||||
logs = self.load_logs()
|
||||
original_length = len(logs)
|
||||
logs = [log for log in logs if log['id'] != log_id]
|
||||
|
||||
if len(logs) == original_length:
|
||||
# Log ID not found
|
||||
return False
|
||||
|
||||
self.save_logs(logs)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error deleting log: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,4 @@
|
||||
flask==3.1.0
|
||||
flask-cors==4.0.0
|
||||
openpyxl==3.1.5
|
||||
python-dotenv==1.0.1
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,874 @@
|
||||
let editingParticipantId = null;
|
||||
let isAdmin = false;
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
checkAdminStatus();
|
||||
loadParticipants();
|
||||
// Show today's entries by default
|
||||
filterByToday();
|
||||
updateLastScanResult();
|
||||
|
||||
// Auto-refresh logs every 5 seconds
|
||||
setInterval(refreshLogs, 5000);
|
||||
setInterval(loadParticipants, 5000);
|
||||
setInterval(updateLastScanResult, 1000);
|
||||
setInterval(checkAdminStatus, 10000);
|
||||
});
|
||||
|
||||
// ===== AUTHENTICATION =====
|
||||
async function checkAdminStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/check-admin');
|
||||
const data = await response.json();
|
||||
isAdmin = data.is_admin;
|
||||
updateAdminUI();
|
||||
} catch (error) {
|
||||
console.error('Error checking admin status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateAdminUI() {
|
||||
const adminBtn = document.getElementById('adminToggleBtn');
|
||||
const addBtn = document.querySelector('[onclick="openAddParticipantModal()"]');
|
||||
const addUnregisteredBtn = document.getElementById('addUnregisteredBtn');
|
||||
|
||||
if (!adminBtn) return; // Exit if button doesn't exist yet
|
||||
|
||||
if (isAdmin) {
|
||||
adminBtn.textContent = 'Odjava Admin';
|
||||
adminBtn.onclick = adminLogout;
|
||||
adminBtn.classList.add('active');
|
||||
if (addBtn) addBtn.style.display = 'block';
|
||||
} else {
|
||||
adminBtn.textContent = 'Prijava Admin';
|
||||
adminBtn.onclick = openLoginModal;
|
||||
adminBtn.classList.remove('active');
|
||||
if (addBtn) addBtn.style.display = 'none';
|
||||
}
|
||||
// Show unregistered button to both admin and non-admin users
|
||||
if (addUnregisteredBtn) addUnregisteredBtn.style.display = 'block';
|
||||
updateUIDVisibility();
|
||||
}
|
||||
|
||||
async function adminLogin() {
|
||||
const password = document.getElementById('loginPassword').value;
|
||||
|
||||
if (!password) {
|
||||
alert('Prosimo, vnesite geslo');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
isAdmin = true;
|
||||
closeLoginModal();
|
||||
updateAdminUI();
|
||||
loadParticipants();
|
||||
alert('Uspešna prijava!');
|
||||
} else {
|
||||
alert('Napaka: ' + data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Napaka: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function adminLogout() {
|
||||
try {
|
||||
const response = await fetch('/api/logout', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
isAdmin = false;
|
||||
updateAdminUI();
|
||||
loadParticipants();
|
||||
alert('Odjavljen');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function openLoginModal() {
|
||||
document.getElementById('loginModal').classList.add('show');
|
||||
document.getElementById('loginPassword').focus();
|
||||
}
|
||||
|
||||
function closeLoginModal() {
|
||||
document.getElementById('loginModal').classList.remove('show');
|
||||
document.getElementById('loginPassword').value = '';
|
||||
}
|
||||
|
||||
function handleLoginKeypress(event) {
|
||||
if (event.key === 'Enter') {
|
||||
adminLogin();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== PARTICIPANT MANAGEMENT =====
|
||||
async function loadParticipants() {
|
||||
try {
|
||||
const response = await fetch('/api/participants');
|
||||
const participants = await response.json();
|
||||
|
||||
const tbody = document.getElementById('participantsTable');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
participants.forEach(p => {
|
||||
const row = document.createElement('tr');
|
||||
const dateAdded = p.date_added ? p.date_added.split('T')[0] : '-';
|
||||
const expiration = p.expiration_date ? p.expiration_date.split('T')[0] : '-';
|
||||
|
||||
// Check if card is expired
|
||||
let isExpired = false;
|
||||
if (expiration !== '-') {
|
||||
const expirationDate = new Date(expiration);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
isExpired = expirationDate < today;
|
||||
}
|
||||
|
||||
const keyUID = p.key_uid ? p.key_uid : '-';
|
||||
|
||||
const actionButtons = isAdmin ? `
|
||||
<button class="action-btn edit-btn" onclick="openEditModal(${p.id}, '${p.name}', '${p.card_uid || ''}', '${p.key_uid || ''}', '${expiration}')">Uredi</button>
|
||||
<button class="action-btn delete-btn" onclick="deleteParticipant(${p.id})">Izbriši</button>
|
||||
` : '';
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${p.id}</td>
|
||||
<td>${p.name}</td>
|
||||
<td>${p.card_uid || '-'}</td>
|
||||
<td>${keyUID}</td>
|
||||
<td>${expiration}</td>
|
||||
<td>${dateAdded}</td>
|
||||
<td>${actionButtons || '-'}</td>
|
||||
`;
|
||||
|
||||
// Add expired class if card is expired
|
||||
if (isExpired) {
|
||||
row.classList.add('expired');
|
||||
}
|
||||
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
|
||||
// Update participant filter dropdown
|
||||
updateParticipantFilter(participants);
|
||||
updateUIDVisibility();
|
||||
} catch (error) {
|
||||
console.error('Error loading participants:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateParticipantFilter(participants) {
|
||||
const filterSelect = document.getElementById('participantFilter');
|
||||
const currentValue = filterSelect.value;
|
||||
|
||||
// Clear existing options except the first one
|
||||
filterSelect.innerHTML = '<option value="">Vsi udeleženci</option>';
|
||||
|
||||
// Sort participants by name and add to dropdown
|
||||
participants
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.forEach(p => {
|
||||
const option = document.createElement('option');
|
||||
option.value = p.name;
|
||||
option.textContent = p.name;
|
||||
filterSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// Restore previous selection if it still exists
|
||||
filterSelect.value = currentValue;
|
||||
}
|
||||
|
||||
async function addParticipant() {
|
||||
const name = document.getElementById('inputName').value.trim();
|
||||
const cardUID = document.getElementById('inputCardUID').value.trim();
|
||||
const keyUID = document.getElementById('inputKeyUID').value.trim();
|
||||
const expiration = document.getElementById('inputExpiration').value;
|
||||
|
||||
if (!name) {
|
||||
alert('Prosimo, izpolnite polje za ime');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cardUID && !keyUID) {
|
||||
alert('Prosimo, vnesite vsaj UID kartice ali UID ključa');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/participants', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
card_uid: cardUID || null,
|
||||
key_uid: keyUID || null,
|
||||
expiration_date: expiration || null
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
alert('Napaka: ' + (errorData.message || 'Unknown error'));
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
alert('Udeleženec je bil uspešno dodan');
|
||||
closeAddParticipantModal();
|
||||
loadParticipants();
|
||||
} else {
|
||||
alert('Napaka: ' + data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Napaka: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function openEditModal(id, name, cardUID, keyUID, expiration) {
|
||||
editingParticipantId = id;
|
||||
document.getElementById('editName').value = name;
|
||||
document.getElementById('editCardUID').value = cardUID;
|
||||
document.getElementById('editKeyUID').value = keyUID;
|
||||
document.getElementById('editExpiration').value = expiration !== '-' ? expiration : '';
|
||||
document.getElementById('editModal').classList.add('show');
|
||||
}
|
||||
|
||||
function openAddParticipantModal() {
|
||||
document.getElementById('addParticipantModal').classList.add('show');
|
||||
}
|
||||
|
||||
function closeAddParticipantModal() {
|
||||
document.getElementById('addParticipantModal').classList.remove('show');
|
||||
document.getElementById('inputName').value = '';
|
||||
document.getElementById('inputCardUID').value = '';
|
||||
document.getElementById('inputKeyUID').value = '';
|
||||
document.getElementById('inputExpiration').value = '';
|
||||
}
|
||||
|
||||
function openAddUnregisteredModal() {
|
||||
document.getElementById('addUnregisteredModal').classList.add('show');
|
||||
document.getElementById('inputUnregisteredName').focus();
|
||||
}
|
||||
|
||||
function closeAddUnregisteredModal() {
|
||||
document.getElementById('addUnregisteredModal').classList.remove('show');
|
||||
document.getElementById('inputUnregisteredName').value = '';
|
||||
}
|
||||
|
||||
async function addUnregisteredUser() {
|
||||
const name = document.getElementById('inputUnregisteredName').value.trim();
|
||||
|
||||
if (!name) {
|
||||
alert('Prosimo, izpolnite polje za ime');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/logs/unregistered', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
alert('Neregistrirani udeleženec je bil uspešno dodan');
|
||||
closeAddUnregisteredModal();
|
||||
refreshLogs();
|
||||
} else {
|
||||
alert('Napaka: ' + data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Napaka: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('editModal').classList.remove('show');
|
||||
editingParticipantId = null;
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
if (!editingParticipantId) return;
|
||||
|
||||
const name = document.getElementById('editName').value.trim();
|
||||
const cardUID = document.getElementById('editCardUID').value.trim();
|
||||
const keyUID = document.getElementById('editKeyUID').value.trim();
|
||||
const expiration = document.getElementById('editExpiration').value;
|
||||
|
||||
if (!name) {
|
||||
alert('Prosimo, izpolnite polje za ime');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cardUID && !keyUID) {
|
||||
alert('Prosimo, vnesite vsaj UID kartice ali UID ključa');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/participants/${editingParticipantId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
card_uid: cardUID,
|
||||
key_uid: keyUID,
|
||||
expiration_date: expiration || null
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
alert('Napaka: ' + (errorData.message || 'Unknown error'));
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
alert('Udeleženec je bil uspešno posodobljen');
|
||||
closeModal();
|
||||
loadParticipants();
|
||||
} else {
|
||||
alert('Napaka: ' + data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Napaka: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteParticipant(id) {
|
||||
if (!confirm('Ali ste prepričani, da želite izbrisati tega udeleženca?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/participants/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
alert('Udeleženec je bil uspešno izbrisan');
|
||||
loadParticipants();
|
||||
} else {
|
||||
alert('Napaka: ' + data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Napaka: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== LOGS MANAGEMENT =====
|
||||
async function refreshLogs() {
|
||||
try {
|
||||
const filter = document.getElementById('filterSelect').value || 'All';
|
||||
const response = await fetch(`/api/logs?filter=${filter}`);
|
||||
const logs = await response.json();
|
||||
|
||||
// Apply client-side filtering
|
||||
const filteredLogs = applyLogFilters(logs);
|
||||
|
||||
const tbody = document.getElementById('logsTable');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (filteredLogs.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="8" style="text-align: center; padding: 20px; color: var(--text-secondary);">Ni najdenih dnevnikov, ki bi ustrezali filtrom</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Count occurrences of each name to detect duplicates
|
||||
const nameCounts = {};
|
||||
filteredLogs.forEach(log => {
|
||||
nameCounts[log.name] = (nameCounts[log.name] || 0) + 1;
|
||||
});
|
||||
|
||||
// Count registered vs unregistered users
|
||||
let registeredCount = 0;
|
||||
let unregisteredCount = 0;
|
||||
|
||||
filteredLogs.forEach(log => {
|
||||
const row = document.createElement('tr');
|
||||
const timestamp = log.timestamp;
|
||||
const date = timestamp.split('T')[0];
|
||||
const time = timestamp.split('T')[1].substring(0, 8);
|
||||
|
||||
// Determine status class
|
||||
let statusClass = 'pass';
|
||||
if (log.scan_result === 'FAIL') {
|
||||
statusClass = 'fail';
|
||||
} else if (log.scan_result === 'MANUAL') {
|
||||
statusClass = 'manual';
|
||||
}
|
||||
|
||||
// Check if this is an unregistered duplicate
|
||||
const isDuplicate = nameCounts[log.name] > 1;
|
||||
|
||||
const actionButtons = isAdmin ? `
|
||||
<button class="action-btn delete-btn" onclick="deleteLog(${log.id})">Izbriši</button>
|
||||
` : '';
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${log.id}</td>
|
||||
<td>${log.name}</td>
|
||||
<td>${log.card_uid || '-'}</td>
|
||||
<td>${log.key_uid || '-'}</td>
|
||||
<td><span class="${statusClass}">${log.scan_result}</span></td>
|
||||
<td>${date}</td>
|
||||
<td>${time}</td>
|
||||
<td>${actionButtons}</td>
|
||||
`;
|
||||
|
||||
// Add duplicate warning class if this is a duplicate unregistered user
|
||||
if (isDuplicate && log.scan_result === 'MANUAL') {
|
||||
row.classList.add('duplicate-warning');
|
||||
}
|
||||
|
||||
// Count registered vs unregistered
|
||||
if (log.scan_result === 'MANUAL') {
|
||||
unregisteredCount++;
|
||||
} else if (log.scan_result === 'PASS') {
|
||||
registeredCount++;
|
||||
}
|
||||
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
|
||||
// Update stats display
|
||||
document.getElementById('statRegistered').textContent = registeredCount;
|
||||
document.getElementById('statUnregistered').textContent = unregisteredCount;
|
||||
|
||||
updateUIDVisibility();
|
||||
} catch (error) {
|
||||
console.error('Error loading logs:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function applyLogFilters(logs) {
|
||||
const participantFilter = document.getElementById('participantFilter').value;
|
||||
const startDateFilter = document.getElementById('startDateFilter').value;
|
||||
const endDateFilter = document.getElementById('endDateFilter').value;
|
||||
|
||||
return logs.filter(log => {
|
||||
// Filter by participant
|
||||
if (participantFilter && log.name !== participantFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by date range
|
||||
if (startDateFilter || endDateFilter) {
|
||||
const logDate = log.timestamp.split('T')[0];
|
||||
|
||||
if (startDateFilter && logDate < startDateFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (endDateFilter && logDate > endDateFilter) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteLog(logId) {
|
||||
if (!confirm('Ali ste prepričani, da želite izbrisati ta dnevnik?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/logs/${logId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
alert('Dnevnik je bil uspešno izbrisan');
|
||||
refreshLogs();
|
||||
} else {
|
||||
alert('Napaka: ' + data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Napaka: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
document.getElementById('filterSelect').value = 'All';
|
||||
document.getElementById('participantFilter').value = '';
|
||||
document.getElementById('startDateFilter').value = '';
|
||||
document.getElementById('endDateFilter').value = '';
|
||||
// Remove active class from all quick filter buttons
|
||||
document.querySelectorAll('.btn-quick-filter').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
refreshLogs();
|
||||
}
|
||||
|
||||
function getDateString(date) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function filterByToday() {
|
||||
const today = new Date();
|
||||
document.getElementById('startDateFilter').value = getDateString(today);
|
||||
document.getElementById('endDateFilter').value = getDateString(today);
|
||||
updateQuickFilterButtons('today');
|
||||
refreshLogs();
|
||||
}
|
||||
|
||||
function filterByLastWeek() {
|
||||
const today = new Date();
|
||||
const lastWeek = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
document.getElementById('startDateFilter').value = getDateString(lastWeek);
|
||||
document.getElementById('endDateFilter').value = getDateString(today);
|
||||
updateQuickFilterButtons('week');
|
||||
refreshLogs();
|
||||
}
|
||||
|
||||
function filterByLastMonth() {
|
||||
const today = new Date();
|
||||
const lastMonth = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
document.getElementById('startDateFilter').value = getDateString(lastMonth);
|
||||
document.getElementById('endDateFilter').value = getDateString(today);
|
||||
updateQuickFilterButtons('month');
|
||||
refreshLogs();
|
||||
}
|
||||
|
||||
function filterByLastYear() {
|
||||
const today = new Date();
|
||||
const lastYear = new Date(today.getTime() - 365 * 24 * 60 * 60 * 1000);
|
||||
document.getElementById('startDateFilter').value = getDateString(lastYear);
|
||||
document.getElementById('endDateFilter').value = getDateString(today);
|
||||
updateQuickFilterButtons('year');
|
||||
refreshLogs();
|
||||
}
|
||||
|
||||
function updateQuickFilterButtons(activeFilter) {
|
||||
const buttons = document.querySelectorAll('.btn-quick-filter');
|
||||
buttons.forEach(btn => btn.classList.remove('active'));
|
||||
|
||||
if (activeFilter === 'today') buttons[0].classList.add('active');
|
||||
else if (activeFilter === 'week') buttons[1].classList.add('active');
|
||||
else if (activeFilter === 'month') buttons[2].classList.add('active');
|
||||
else if (activeFilter === 'year') buttons[3].classList.add('active');
|
||||
}
|
||||
|
||||
async function exportLogs() {
|
||||
try {
|
||||
// Get current filter values
|
||||
const filter = document.getElementById('filterSelect').value;
|
||||
const participant = document.getElementById('participantFilter').value;
|
||||
const startDate = document.getElementById('startDateFilter').value;
|
||||
const endDate = document.getElementById('endDateFilter').value;
|
||||
|
||||
// Build query string with filters
|
||||
const params = new URLSearchParams({
|
||||
filter: filter,
|
||||
participant: participant,
|
||||
startDate: startDate,
|
||||
endDate: endDate
|
||||
});
|
||||
|
||||
const response = await fetch('/api/export?' + params.toString());
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
alert('Napaka pri izvozу: ' + result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Decode base64 file data
|
||||
const binaryString = atob(result.data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
// Create blob and trigger download with file dialog
|
||||
const blob = new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
|
||||
// Try to use pywebview if available (for desktop app)
|
||||
if (window.pywebview && window.pywebview.api) {
|
||||
try {
|
||||
const success = await window.pywebview.api.save_file(result.filename, result.data);
|
||||
if (success) {
|
||||
alert('Datoteka je bila uspešno izvožena');
|
||||
return;
|
||||
} else {
|
||||
console.log('PyWebView save was cancelled, falling back to browser download');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('PyWebView save failed, falling back to browser download', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to browser download
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = result.filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
alert('Datoteka je bila uspešno izvožena');
|
||||
} catch (error) {
|
||||
alert('Napaka: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== PRINT LOGS =====
|
||||
function printLogs() {
|
||||
const start = document.getElementById('startDateFilter').value;
|
||||
const end = document.getElementById('endDateFilter').value;
|
||||
const dateRange = document.getElementById('print-date-range');
|
||||
|
||||
if (start && end) {
|
||||
dateRange.textContent = `Obdobje: ${start} – ${end}`;
|
||||
} else if (start) {
|
||||
dateRange.textContent = `Od: ${start}`;
|
||||
} else if (end) {
|
||||
dateRange.textContent = `Do: ${end}`;
|
||||
} else {
|
||||
dateRange.textContent = `Natisnjeno: ${new Date().toLocaleDateString('sl-SI')}`;
|
||||
}
|
||||
|
||||
window.print();
|
||||
}
|
||||
|
||||
// ===== DOCUMENTS =====
|
||||
async function loadDocuments() {
|
||||
try {
|
||||
const response = await fetch('/api/documents');
|
||||
const files = await response.json();
|
||||
const grid = document.getElementById('documentsGrid');
|
||||
|
||||
if (files.length === 0) {
|
||||
grid.innerHTML = `<p style="color: var(--text-secondary);">Ni dokumentov. Dodajte PDF datoteke v mapo static/PDF.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = '';
|
||||
files.forEach(filename => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'document-card';
|
||||
const displayName = filename.replace(/\.pdf$/i, '').replace(/_/g, ' ');
|
||||
card.innerHTML = `
|
||||
<div class="document-icon">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||
<polyline points="14 2 14 8 20 8"></polyline>
|
||||
<line x1="9" y1="13" x2="15" y2="13"></line>
|
||||
<line x1="9" y1="17" x2="15" y2="17"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="document-name">${displayName}</div>
|
||||
<button class="btn btn-primary document-print-btn" onclick="printDocument('${filename}')">Natisni</button>
|
||||
`;
|
||||
grid.appendChild(card);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading documents:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function printDocument(filename) {
|
||||
const url = `/static/PDF/${encodeURIComponent(filename)}`;
|
||||
let iframe = document.getElementById('print-iframe');
|
||||
if (!iframe) {
|
||||
iframe = document.createElement('iframe');
|
||||
iframe.id = 'print-iframe';
|
||||
iframe.style.cssText = 'display:none;position:fixed;top:0;left:0;width:0;height:0;';
|
||||
document.body.appendChild(iframe);
|
||||
}
|
||||
iframe.onload = () => iframe.contentWindow.print();
|
||||
iframe.src = url;
|
||||
}
|
||||
|
||||
// ===== TAB SWITCHING =====
|
||||
function switchTab(tabName, event) {
|
||||
// Hide all tabs
|
||||
document.querySelectorAll('.tab-panel').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
|
||||
// Remove active class from all buttons
|
||||
document.querySelectorAll('.tab-nav-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
|
||||
// Show selected tab
|
||||
document.getElementById(tabName + '-tab').classList.add('active');
|
||||
|
||||
// Add active class to clicked button
|
||||
event.target.classList.add('active');
|
||||
|
||||
// Load tab-specific data
|
||||
if (tabName === 'documents') loadDocuments();
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
window.onclick = function(event) {
|
||||
const editModal = document.getElementById('editModal');
|
||||
const addModal = document.getElementById('addParticipantModal');
|
||||
const loginModal = document.getElementById('loginModal');
|
||||
const addUnregisteredModal = document.getElementById('addUnregisteredModal');
|
||||
|
||||
if (event.target === editModal) {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
if (event.target === addModal) {
|
||||
closeAddParticipantModal();
|
||||
}
|
||||
|
||||
if (event.target === loginModal) {
|
||||
closeLoginModal();
|
||||
}
|
||||
|
||||
if (event.target === addUnregisteredModal) {
|
||||
closeAddUnregisteredModal();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== LAST SCAN RESULT =====
|
||||
let lastScannedUID = '-';
|
||||
let lastScanTimestamp = null;
|
||||
|
||||
async function updateLastScanResult() {
|
||||
try {
|
||||
const response = await fetch('/api/last-scan');
|
||||
const result = await response.json();
|
||||
|
||||
const resultCard = document.getElementById('resultCard');
|
||||
const uidDiv = resultCard.querySelector('.result-uid');
|
||||
const nameDiv = resultCard.querySelector('.result-name');
|
||||
const statusDiv = resultCard.querySelector('.result-status');
|
||||
|
||||
uidDiv.textContent = result.uid;
|
||||
nameDiv.textContent = result.name;
|
||||
statusDiv.textContent = result.status;
|
||||
lastScannedUID = result.uid;
|
||||
|
||||
// If a new scan just happened, immediately refresh the logs table
|
||||
if (result.timestamp && result.timestamp !== lastScanTimestamp) {
|
||||
lastScanTimestamp = result.timestamp;
|
||||
refreshLogs();
|
||||
}
|
||||
|
||||
// Update card styling based on result
|
||||
resultCard.className = 'result-card';
|
||||
if (result.status === 'PASS') {
|
||||
resultCard.classList.add('pass');
|
||||
} else if (result.status === 'FAIL') {
|
||||
resultCard.classList.add('fail');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating last scan result:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function copyUID(evt) {
|
||||
if (lastScannedUID === '-') {
|
||||
alert('Ni skeniranega UID-ja za kopiranje');
|
||||
return;
|
||||
}
|
||||
|
||||
const copyBtn = evt.currentTarget;
|
||||
|
||||
// Try using clipboard API first
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(lastScannedUID).then(() => {
|
||||
showCopySuccess(copyBtn);
|
||||
}).catch(err => {
|
||||
console.error('Clipboard API failed:', err);
|
||||
fallbackCopy(lastScannedUID, copyBtn);
|
||||
});
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
fallbackCopy(lastScannedUID, copyBtn);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text, copyBtn) {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
showCopySuccess(copyBtn);
|
||||
} catch (err) {
|
||||
console.error('Fallback copy failed:', err);
|
||||
alert('Napaka pri kopiranju UID-ja');
|
||||
}
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
function showCopySuccess(copyBtn) {
|
||||
copyBtn.style.color = 'var(--accent-green)';
|
||||
setTimeout(() => {
|
||||
copyBtn.style.color = '';
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// ===== UID VISIBILITY MANAGEMENT =====
|
||||
function updateUIDVisibility() {
|
||||
const display = isAdmin ? '' : 'none';
|
||||
|
||||
// Participants table columns
|
||||
const cardUIDHeader = document.getElementById('card-uid-header');
|
||||
const keyUIDHeader = document.getElementById('key-uid-header');
|
||||
if (cardUIDHeader) cardUIDHeader.style.display = display;
|
||||
if (keyUIDHeader) keyUIDHeader.style.display = display;
|
||||
|
||||
// Logs table columns
|
||||
const logsCardUIDHeader = document.getElementById('logs-card-uid-header');
|
||||
const logsKeyUIDHeader = document.getElementById('logs-key-uid-header');
|
||||
const logsActionsHeader = document.getElementById('logs-actions-header');
|
||||
if (logsCardUIDHeader) logsCardUIDHeader.style.display = display;
|
||||
if (logsKeyUIDHeader) logsKeyUIDHeader.style.display = display;
|
||||
if (logsActionsHeader) logsActionsHeader.style.display = display;
|
||||
|
||||
// Hide/show corresponding cells in all table rows
|
||||
hideTableColumn('participantsTable', 2, !isAdmin); // Card UID column
|
||||
hideTableColumn('participantsTable', 3, !isAdmin); // Key UID column
|
||||
hideTableColumn('logsTable', 2, !isAdmin); // Card UID column
|
||||
hideTableColumn('logsTable', 3, !isAdmin); // Key UID column
|
||||
hideTableColumn('logsTable', 7, !isAdmin); // Actions column
|
||||
|
||||
// Last scan result UID
|
||||
const scanUIDContainer = document.getElementById('scan-result-uid-container');
|
||||
if (scanUIDContainer) scanUIDContainer.style.display = display;
|
||||
}
|
||||
|
||||
function hideTableColumn(tableId, columnIndex, hide) {
|
||||
const table = document.getElementById(tableId);
|
||||
if (!table) return;
|
||||
|
||||
const rows = table.getElementsByTagName('tr');
|
||||
for (let row of rows) {
|
||||
const cell = row.cells[columnIndex];
|
||||
if (cell) {
|
||||
cell.style.display = hide ? 'none' : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RFID Detektivni Sistem</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Left Panel -->
|
||||
<div class="left-panel">
|
||||
<div class="result-section">
|
||||
<img src="{{ url_for('static', filename='assets/logo.png') }}" alt="Logo" class="result-logo" onerror="this.style.display='none'">
|
||||
<h3>Zadnji rezultat skeniranja</h3>
|
||||
<div class="result-card" id="resultCard">
|
||||
<div id="scan-result-uid-container" class="result-uid-box">
|
||||
<div class="result-uid">-</div>
|
||||
<button class="copy-btn" onclick="copyUID(event)" title="Kopiraj UID">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="result-name">-</div>
|
||||
<div class="result-status">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-section">
|
||||
<h3>Statistika</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Registrirani</div>
|
||||
<div class="stat-value" id="statRegistered">0</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Neregistrirani</div>
|
||||
<div class="stat-value" id="statUnregistered">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-section">
|
||||
<h3>Dostop Administrator</h3>
|
||||
<button id="adminToggleBtn" class="admin-btn" onclick="openLoginModal()">Prijava Admin</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel -->
|
||||
<div class="right-panel">
|
||||
<!-- Tab Navigation -->
|
||||
<div class="tab-nav">
|
||||
<button class="tab-nav-btn active" onclick="switchTab('logs', event)">Dnevnik skeniranja</button>
|
||||
<button class="tab-nav-btn" onclick="switchTab('participants', event)">Člani</button>
|
||||
<button class="tab-nav-btn" onclick="switchTab('documents', event)">Dokumenti</button>
|
||||
</div>
|
||||
|
||||
<!-- Participants Tab -->
|
||||
<div id="participants-tab" class="tab-panel">
|
||||
<div class="panel-header">
|
||||
<h2>Člani</h2>
|
||||
<button class="btn btn-primary" onclick="openAddParticipantModal()">Dodaj udeleženca</button>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Ime</th>
|
||||
<th id="card-uid-header">UID kartice</th>
|
||||
<th id="key-uid-header">UID ključa</th>
|
||||
<th>Datum veljavnosti</th>
|
||||
<th>Datum dodajanja</th>
|
||||
<th>Dejanja</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="participantsTable">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scan Logs Tab -->
|
||||
<div id="logs-tab" class="tab-panel active">
|
||||
<div class="panel-header">
|
||||
<h2>Zgodovina skeniranja</h2>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-primary" onclick="exportLogs()">Izvozi v Excel</button>
|
||||
<button class="btn btn-primary" onclick="printLogs()">Natisni</button>
|
||||
<button id="addUnregisteredBtn" class="btn btn-primary" onclick="openAddUnregisteredModal()">Dodaj neregistriranega</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-filters">
|
||||
<div class="filters-row">
|
||||
<div class="filter-group">
|
||||
<label>Rezultat:</label>
|
||||
<select id="filterSelect" onchange="refreshLogs()">
|
||||
<option value="All">Vse</option>
|
||||
<option value="PASS">Samo PASS</option>
|
||||
<option value="FAIL">Samo FAIL</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Udeleženec:</label>
|
||||
<select id="participantFilter" onchange="refreshLogs()">
|
||||
<option value="">Vsi udeleženci</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Od:</label>
|
||||
<input type="date" id="startDateFilter" onchange="refreshLogs()">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Do:</label>
|
||||
<input type="date" id="endDateFilter" onchange="refreshLogs()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="filters-quick">
|
||||
<button class="btn-quick-filter" onclick="filterByToday()">Danes</button>
|
||||
<button class="btn-quick-filter" onclick="filterByLastWeek()">Teden</button>
|
||||
<button class="btn-quick-filter" onclick="filterByLastMonth()">Mesec</button>
|
||||
<button class="btn-quick-filter" onclick="filterByLastYear()">Leto</button>
|
||||
<button class="btn-filter" onclick="resetFilters()">Resetiraj</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-content">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Ime</th>
|
||||
<th id="logs-card-uid-header">UID kartice</th>
|
||||
<th id="logs-key-uid-header">UID ključa</th>
|
||||
<th>Rezultat</th>
|
||||
<th>Datum</th>
|
||||
<th>Čas</th>
|
||||
<th id="logs-actions-header">Dejanja</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logsTable">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Documents Tab -->
|
||||
<div id="documents-tab" class="tab-panel">
|
||||
<div class="panel-header">
|
||||
<h2>Dokumenti</h2>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div id="documentsGrid" class="documents-grid">
|
||||
<p style="color: var(--text-secondary);">Nalaganje dokumentov...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Participant Modal -->
|
||||
<div id="addParticipantModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeAddParticipantModal()">×</span>
|
||||
<h2>Dodaj novega udeleženca</h2>
|
||||
<div class="form-group">
|
||||
<label>Ime:</label>
|
||||
<input type="text" id="inputName" placeholder="Ime udeleženca">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>UID kartice:</label>
|
||||
<input type="text" id="inputCardUID" placeholder="UID kartice">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>UID ključa (neobvezno):</label>
|
||||
<input type="text" id="inputKeyUID" placeholder="UID ključa">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Datum veljavnosti (neobvezno):</label>
|
||||
<input type="date" id="inputExpiration">
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="addParticipant()">Dodaj udeleženca</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<div id="editModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeModal()">×</span>
|
||||
<h2>Uredi udeleženca</h2>
|
||||
<div class="form-group">
|
||||
<label>Ime:</label>
|
||||
<input type="text" id="editName">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>UID kartice:</label>
|
||||
<input type="text" id="editCardUID">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>UID ključa (neobvezno):</label>
|
||||
<input type="text" id="editKeyUID">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Datum veljavnosti (neobvezno):</label>
|
||||
<input type="date" id="editExpiration">
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="saveChanges()">Shrani</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Modal -->
|
||||
<div id="loginModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeLoginModal()">×</span>
|
||||
<h2>Administratorski Dostop</h2>
|
||||
<div class="form-group">
|
||||
<label>Zaupno Geslo:</label>
|
||||
<input type="password" id="loginPassword" placeholder="Vnesite geslo administratorja" onkeypress="handleLoginKeypress(event)" autocomplete="off">
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="adminLogin()">Prijava v Admin Način</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Unregistered User Modal -->
|
||||
<div id="addUnregisteredModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeAddUnregisteredModal()">×</span>
|
||||
<h2>Dodaj neregistriranega udeleženca</h2>
|
||||
<div class="form-group">
|
||||
<label>Ime:</label>
|
||||
<input type="text" id="inputUnregisteredName" placeholder="Ime udeleženca">
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="addUnregisteredUser()">Dodaj v dnevnik</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="print-header" style="visibility:hidden;position:fixed;">
|
||||
<img src="{{ url_for('static', filename='assets/logo.png') }}" alt="Logo">
|
||||
<p id="print-date-range" style="font-size:13px;color:#444;margin-top:4px;"></p>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user