diff --git a/checkin-system/app.py b/checkin-system/app.py new file mode 100644 index 0000000..4a0967e --- /dev/null +++ b/checkin-system/app.py @@ -0,0 +1,1119 @@ +from flask import Flask, render_template, request, redirect, url_for, flash, jsonify +from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user +from werkzeug.security import generate_password_hash, check_password_hash +from werkzeug.utils import secure_filename +import sqlite3 +import json +import os +from datetime import datetime +import pandas as pd +import unicodedata +from flask import send_file + +app = Flask(__name__) +app.secret_key = os.urandom(24) +app.config['UPLOAD_FOLDER'] = 'uploads' +app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size + +# Create uploads folder if it doesn't exist +os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) + +# Flask-Login setup +login_manager = LoginManager() +login_manager.init_app(app) +login_manager.login_view = 'login' + +# Database connection +def get_db(): + db = sqlite3.connect('checkin.db') + db.row_factory = sqlite3.Row + db.execute('PRAGMA foreign_keys = ON') + return db + +# Database migration function +def migrate_database(): + """Add new columns if they don't exist""" + db = get_db() + cursor = db.cursor() + + # Check existing columns + existing_columns = cursor.execute("PRAGMA table_info(events)").fetchall() + column_names = [col[1] for col in existing_columns] + + # Add is_passed column if it doesn't exist + if 'is_passed' not in column_names: + try: + cursor.execute('ALTER TABLE events ADD COLUMN is_passed BOOLEAN DEFAULT 0') + print("Added is_passed column to events table") + except sqlite3.OperationalError as e: + print(f"Error adding is_passed column: {e}") + + # Check if event_columns table exists + tables = cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='event_columns'").fetchall() + if not tables: + # Create event_columns table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS event_columns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL, + column_name TEXT NOT NULL, + column_order INTEGER NOT NULL, + FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE + ) + ''') + print("Created event_columns table") + + # Check if additional_data column exists in guests table + existing_guest_columns = cursor.execute("PRAGMA table_info(guests)").fetchall() + guest_column_names = [col[1] for col in existing_guest_columns] + + if 'additional_data' not in guest_column_names: + try: + cursor.execute('ALTER TABLE guests ADD COLUMN additional_data TEXT') + print("Added additional_data column to guests table") + except sqlite3.OperationalError as e: + print(f"Error adding additional_data column: {e}") + + # Migrate old data if needed + if 'additional_data' in guest_column_names and all(col in guest_column_names for col in ['data1', 'data2', 'data3', 'data4', 'data5']): + # Check if there's old data to migrate + old_guests = cursor.execute( + 'SELECT id, data1, data2, data3, data4, data5 FROM guests WHERE additional_data IS NULL' + ).fetchall() + + if old_guests: + for guest in old_guests: + additional_data = {} + if guest['data1']: + additional_data['data1'] = guest['data1'] + if guest['data2']: + additional_data['data2'] = guest['data2'] + if guest['data3']: + additional_data['data3'] = guest['data3'] + if guest['data4']: + additional_data['data4'] = guest['data4'] + if guest['data5']: + additional_data['data5'] = guest['data5'] + + if additional_data: + cursor.execute( + 'UPDATE guests SET additional_data = ? WHERE id = ?', + (json.dumps(additional_data), guest['id']) + ) + print(f"Migrated {len(old_guests)} old guest records") + + db.commit() + db.close() + print("Database migration completed") + +# Initialize database +def init_db(): + with app.app_context(): + db = get_db() + + # Create tables if they don't exist + with app.open_resource('schema.sql', mode='r') as f: + db.cursor().executescript(f.read()) + + # Create default users if they don't exist + admin = db.execute('SELECT * FROM users WHERE username = ?', ('admin',)).fetchone() + if not admin: + db.execute( + 'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', + ('admin', generate_password_hash('admin123'), 'admin') + ) + + manager = db.execute('SELECT * FROM users WHERE username = ?', ('manager',)).fetchone() + if not manager: + db.execute( + 'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', + ('manager', generate_password_hash('manager123'), 'manager') + ) + + db.commit() + db.close() + + # Run migration to add new columns + migrate_database() + +# User class for Flask-Login +class User(UserMixin): + def __init__(self, id, username, role): + self.id = id + self.username = username + self.role = role + +@login_manager.user_loader +def load_user(user_id): + db = get_db() + user = db.execute('SELECT * FROM users WHERE id = ?', (user_id,)).fetchone() + db.close() + if user: + return User(user['id'], user['username'], user['role']) + return None + +# Helper function to process Excel file +def process_excel_file(file_path): + try: + # Read Excel file + df = pd.read_excel(file_path) + + # Print available columns for debugging + print(f"Excel columns found: {list(df.columns)}") + + # Create a mapping of possible column names to standard names + column_mapping = {} + + # Define all possible name mappings + name_variants = ['имя', 'name', 'first_name', 'first name', 'firstname', 'имя гостя'] + surname_variants = ['фамилия', 'surname', 'last_name', 'last name', 'lastname', 'фамилия гостя'] + second_name_variants = ['отчество', 'second_name', 'second name', 'middlename', 'middle_name', 'middle name', 'patronymic'] + + for col in df.columns: + col_original = str(col) + col_lower = col_original.lower().strip() + + # Check if this is a name column + if col_lower in name_variants: + column_mapping[col] = 'name' + print(f"Mapped '{col}' -> 'name'") + # Check if this is a surname column + elif col_lower in surname_variants: + column_mapping[col] = 'surname' + print(f"Mapped '{col}' -> 'surname'") + # Check if this is a second name column + elif col_lower in second_name_variants: + column_mapping[col] = 'second_name' + print(f"Mapped '{col}' -> 'second_name'") + else: + # Keep original column name for additional data + # Don't lowercase to preserve original formatting + column_mapping[col] = col_original + + # Rename columns based on mapping + df = df.rename(columns=column_mapping) + + # Now check for required columns + if 'name' not in df.columns and 'surname' not in df.columns: + raise ValueError( + f"Excel file must contain 'Фамилия' and 'Имя' columns.\n" + f"Found columns: {', '.join(df.columns)}\n" + f"Please ensure your Excel file has columns named 'Фамилия' and 'Имя'" + ) + elif 'name' not in df.columns: + raise ValueError( + f"Excel file must contain an 'Имя' column.\n" + f"Found columns: {', '.join(df.columns)}\n" + f"Please add a column named 'Имя'" + ) + elif 'surname' not in df.columns: + raise ValueError( + f"Excel file must contain a 'Фамилия' column.\n" + f"Found columns: {', '.join(df.columns)}\n" + f"Please add a column named 'Фамилия'" + ) + + # Separate required columns from additional columns + required_cols = ['name', 'surname'] + if 'second_name' in df.columns: + required_cols.append('second_name') + + # Get additional columns (keep original names from Excel) + additional_cols = [col for col in df.columns if col not in required_cols] + + return df, required_cols, additional_cols + + except Exception as e: + raise Exception(f"Error processing Excel file: {str(e)}") + + +# Routes +@app.route('/') +def index(): + if current_user.is_authenticated: + if current_user.role == 'admin': + return redirect(url_for('admin_dashboard')) + else: + return redirect(url_for('manager_dashboard')) + return redirect(url_for('login')) + +@app.route('/login', methods=['GET', 'POST']) +def login(): + if request.method == 'POST': + username = request.form['username'] + password = request.form['password'] + + db = get_db() + user = db.execute('SELECT * FROM users WHERE username = ?', (username,)).fetchone() + db.close() + + if user and check_password_hash(user['password_hash'], password): + user_obj = User(user['id'], user['username'], user['role']) + login_user(user_obj) + flash('Успешно вошли!', 'success') + + if user['role'] == 'admin': + return redirect(url_for('admin_dashboard')) + else: + return redirect(url_for('manager_dashboard')) + else: + flash('Неправильные логин или пароль', 'error') + + return render_template('login.html') + +@app.route('/logout') +@login_required +def logout(): + logout_user() + return redirect(url_for('login')) + + +@app.route('/manager/event//export') +@login_required +def export_event_stats(event_id): + if current_user.role not in ['manager', 'admin']: + flash('Access denied', 'error') + return redirect(url_for('index')) + + db = get_db() + + # Get event details + event = db.execute('SELECT * FROM events WHERE id = ?', (event_id,)).fetchone() + if not event: + flash('Event not found', 'error') + return redirect(url_for('manager_dashboard')) + + # Get column definitions + try: + columns = db.execute( + 'SELECT column_name FROM event_columns WHERE event_id = ? ORDER BY column_order', + (event_id,) + ).fetchall() + column_list = [c['column_name'] for c in columns] + except: + column_list = ['name', 'surname'] + + # Get all guests for this event + guests = db.execute( + '''SELECT id, name, surname, second_name, additional_data, checked_in, checked_in_at + FROM guests + WHERE event_id = ? + ORDER BY surname, name''', + (event_id,) + ).fetchall() + + # Get statistics + total_guests = len(guests) + checked_in_count = sum(1 for g in guests if g['checked_in']) + not_checked_in_count = total_guests - checked_in_count + check_in_percentage = round((checked_in_count / total_guests * 100), 2) if total_guests > 0 else 0 + + db.close() + + # Create Excel file in memory + from io import BytesIO + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + from openpyxl.utils import get_column_letter + + wb = Workbook() + + # ===== Sheet 1: Summary Statistics ===== + ws_summary = wb.active + ws_summary.title = "Check-in Summary" + + # Title + ws_summary.merge_cells('A1:D1') + ws_summary['A1'] = f"Event Check-in Statistics: {event['name']}" + ws_summary['A1'].font = Font(size=16, bold=True, color='2c3e50') + ws_summary['A1'].alignment = Alignment(horizontal='center') + + # Event info + ws_summary['A3'] = 'Event Name:' + ws_summary['B3'] = event['name'] + ws_summary['A3'].font = Font(bold=True) + + ws_summary['A4'] = 'Created Date:' + ws_summary['B4'] = event['created_at'] + ws_summary['A4'].font = Font(bold=True) + + ws_summary['A5'] = 'Export Date:' + ws_summary['B5'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + ws_summary['A5'].font = Font(bold=True) + + # Statistics header + ws_summary['A7'] = 'Statistics' + ws_summary['A7'].font = Font(size=14, bold=True, color='2c3e50') + + # Statistics data + stats = [ + ('Total Guests', total_guests), + ('Checked In', checked_in_count), + ('Not Checked In', not_checked_in_count), + ('Check-in Percentage', f'{check_in_percentage}%') + ] + + header_fill = PatternFill(start_color='3498db', end_color='3498db', fill_type='solid') + header_font = Font(bold=True, color='ffffff') + + ws_summary['A9'] = 'Metric' + ws_summary['B9'] = 'Value' + ws_summary['A9'].fill = header_fill + ws_summary['B9'].fill = header_fill + ws_summary['A9'].font = header_font + ws_summary['B9'].font = header_font + + for idx, (metric, value) in enumerate(stats, start=10): + ws_summary[f'A{idx}'] = metric + ws_summary[f'B{idx}'] = value + if idx % 2 == 0: + ws_summary[f'A{idx}'].fill = PatternFill(start_color='ecf0f1', end_color='ecf0f1', fill_type='solid') + ws_summary[f'B{idx}'].fill = PatternFill(start_color='ecf0f1', end_color='ecf0f1', fill_type='solid') + + # Adjust column widths for summary + ws_summary.column_dimensions['A'].width = 25 + ws_summary.column_dimensions['B'].width = 30 + ws_summary.column_dimensions['C'].width = 20 + ws_summary.column_dimensions['D'].width = 20 + + # ===== Sheet 2: Detailed Guest List ===== + ws_guests = wb.create_sheet("Guest List") + + # Headers + headers = ['#', 'Surname', 'Name'] + if 'second_name' in column_list: + headers.append('Second Name') + + # Add additional columns + additional_cols = [col for col in column_list if col not in ['name', 'surname', 'second_name']] + for col in additional_cols: + headers.append(col.replace('_', ' ').title()) + + headers.extend(['Check-in Status', 'Check-in Time']) + + # Style for headers + for col_idx, header in enumerate(headers, 1): + cell = ws_guests.cell(row=1, column=col_idx, value=header) + cell.fill = PatternFill(start_color='2c3e50', end_color='2c3e50', fill_type='solid') + cell.font = Font(bold=True, color='ffffff', size=11) + cell.alignment = Alignment(horizontal='center', vertical='center') + + # Border style + thin_border = Border( + left=Side(style='thin'), + right=Side(style='thin'), + top=Side(style='thin'), + bottom=Side(style='thin') + ) + + # Add data rows + checked_in_fill = PatternFill(start_color='d4edda', end_color='d4edda', fill_type='solid') + not_checked_fill = PatternFill(start_color='f8d7da', end_color='f8d7da', fill_type='solid') + + for row_idx, guest in enumerate(guests, 2): + # Row number + ws_guests.cell(row=row_idx, column=1, value=row_idx - 1) + + # Basic info + ws_guests.cell(row=row_idx, column=2, value=guest['surname']) + ws_guests.cell(row=row_idx, column=3, value=guest['name']) + + col_offset = 4 + if 'second_name' in column_list: + ws_guests.cell(row=row_idx, column=col_offset, value=guest['second_name'] or '') + col_offset += 1 + + # Additional data + additional_data = {} + if guest['additional_data']: + try: + additional_data = json.loads(guest['additional_data']) + except: + pass + + for col in additional_cols: + ws_guests.cell(row=row_idx, column=col_offset, value=additional_data.get(col, '')) + col_offset += 1 + + # Check-in status + status = 'Checked In' if guest['checked_in'] else 'Not Checked In' + ws_guests.cell(row=row_idx, column=col_offset, value=status) + + # Check-in time + checkin_time = guest['checked_in_at'] if guest['checked_in_at'] else '-' + ws_guests.cell(row=row_idx, column=col_offset + 1, value=checkin_time) + + # Apply row styling based on check-in status + for col_idx in range(1, len(headers) + 1): + cell = ws_guests.cell(row=row_idx, column=col_idx) + cell.border = thin_border + cell.alignment = Alignment(vertical='center') + + if guest['checked_in']: + cell.fill = checked_in_fill + else: + cell.fill = not_checked_fill + + # Auto-adjust column widths for guest list + for col_idx in range(1, len(headers) + 1): + column_letter = get_column_letter(col_idx) + max_length = 0 + + for row in ws_guests.iter_rows(min_col=col_idx, max_col=col_idx, values_only=True): + for cell_value in row: + if cell_value: + max_length = max(max_length, len(str(cell_value))) + + adjusted_width = min(max_length + 2, 50) + ws_guests.column_dimensions[column_letter].width = max(adjusted_width, 12) + + # Freeze header row + ws_guests.freeze_panes = 'A2' + + # Add autofilter + ws_guests.auto_filter.ref = f"A1:{get_column_letter(len(headers))}{len(guests) + 1}" + + # ===== Sheet 3: Checked-in Guests Only ===== + ws_checked = wb.create_sheet("Checked-in Guests") + + # Copy headers + for col_idx, header in enumerate(headers, 1): + cell = ws_checked.cell(row=1, column=col_idx, value=header) + cell.fill = PatternFill(start_color='27ae60', end_color='27ae60', fill_type='solid') + cell.font = Font(bold=True, color='ffffff', size=11) + cell.alignment = Alignment(horizontal='center', vertical='center') + + # Add only checked-in guests + checked_guests = [g for g in guests if g['checked_in']] + for row_idx, guest in enumerate(checked_guests, 2): + ws_checked.cell(row=row_idx, column=1, value=row_idx - 1) + ws_checked.cell(row=row_idx, column=2, value=guest['surname']) + ws_checked.cell(row=row_idx, column=3, value=guest['name']) + + col_offset = 4 + if 'second_name' in column_list: + ws_checked.cell(row=row_idx, column=col_offset, value=guest['second_name'] or '') + col_offset += 1 + + additional_data = {} + if guest['additional_data']: + try: + additional_data = json.loads(guest['additional_data']) + except: + pass + + for col in additional_cols: + ws_checked.cell(row=row_idx, column=col_offset, value=additional_data.get(col, '')) + col_offset += 1 + + ws_checked.cell(row=row_idx, column=col_offset, value='Checked In') + ws_checked.cell(row=row_idx, column=col_offset + 1, value=guest['checked_in_at'] if guest['checked_in_at'] else '') + + for col_idx in range(1, len(headers) + 1): + cell = ws_checked.cell(row=row_idx, column=col_idx) + cell.border = thin_border + cell.alignment = Alignment(vertical='center') + cell.fill = PatternFill(start_color='d4edda', end_color='d4edda', fill_type='solid') + + # Auto-adjust columns for checked-in sheet + for col_idx in range(1, len(headers) + 1): + column_letter = get_column_letter(col_idx) + max_length = 0 + for row in ws_checked.iter_rows(min_col=col_idx, max_col=col_idx, values_only=True): + for cell_value in row: + if cell_value: + max_length = max(max_length, len(str(cell_value))) + adjusted_width = min(max_length + 2, 50) + ws_checked.column_dimensions[column_letter].width = max(adjusted_width, 12) + + ws_checked.freeze_panes = 'A2' + ws_checked.auto_filter.ref = f"A1:{get_column_letter(len(headers))}{len(checked_guests) + 1}" + + # Save to BytesIO + output = BytesIO() + wb.save(output) + output.seek(0) + + # Generate filename + filename = f"checkin_stats_{event['name'].replace(' ', '_')}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=filename + ) + + +# Admin routes +@app.route('/admin/dashboard') +@login_required +def admin_dashboard(): + if current_user.role != 'admin': + flash('Access denied', 'error') + return redirect(url_for('index')) + + db = get_db() + + # Check if is_passed column exists + columns = db.execute("PRAGMA table_info(events)").fetchall() + column_names = [col[1] for col in columns] + + if 'is_passed' not in column_names: + events = db.execute( + 'SELECT *, 0 as is_passed FROM events ORDER BY created_at DESC' + ).fetchall() + else: + events = db.execute( + 'SELECT * FROM events ORDER BY created_at DESC' + ).fetchall() + + # Get guest counts for each event + event_data = [] + for event in events: + guest_count = db.execute( + 'SELECT COUNT(*) as count FROM guests WHERE event_id = ?', + (event['id'],) + ).fetchone()['count'] + + checked_in_count = db.execute( + 'SELECT COUNT(*) as count FROM guests WHERE event_id = ? AND checked_in = 1', + (event['id'],) + ).fetchone()['count'] + + # Safely get is_passed + is_passed = event['is_passed'] if 'is_passed' in event.keys() else 0 + + event_data.append({ + 'id': event['id'], + 'name': event['name'], + 'created_at': event['created_at'], + 'is_active': event['is_active'], + 'is_passed': is_passed, + 'guest_count': guest_count, + 'checked_in_count': checked_in_count + }) + + db.close() + return render_template('admin_dashboard.html', events=event_data) + +@app.route('/admin/create_event', methods=['GET', 'POST']) +@login_required +def create_event(): + if current_user.role != 'admin': + flash('Access denied', 'error') + return redirect(url_for('index')) + + if request.method == 'POST': + event_name = request.form['event_name'] + excel_file = request.files.get('guest_list') + + if not event_name: + flash('Event name is required', 'error') + return render_template('create_event.html') + + if not excel_file or not excel_file.filename: + flash('Excel file is required', 'error') + return render_template('create_event.html') + + if not excel_file.filename.endswith(('.xlsx', '.xls')): + flash('Only Excel files (.xlsx, .xls) are allowed', 'error') + return render_template('create_event.html') + + db = get_db() + + try: + # Save uploaded file + filename = secure_filename(f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{excel_file.filename}") + file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename) + excel_file.save(file_path) + + # Process Excel file + df, required_cols, additional_cols = process_excel_file(file_path) + + cursor = db.cursor() + + # Create event + cursor.execute( + 'INSERT INTO events (name, created_by) VALUES (?, ?)', + (event_name, current_user.id) + ) + event_id = cursor.lastrowid + + # Save column definitions + all_columns = required_cols + additional_cols + for idx, col in enumerate(all_columns): + cursor.execute( + 'INSERT INTO event_columns (event_id, column_name, column_order) VALUES (?, ?, ?)', + (event_id, col, idx) + ) + + # Import guests + for _, row in df.iterrows(): + # Prepare required data + name = str(row.get('name', '')).strip() + surname = str(row.get('surname', '')).strip() + second_name = str(row.get('second_name', '')).strip() if 'second_name' in df.columns else '' + + # Prepare additional data as JSON + additional_data = {} + for col in additional_cols: + value = row[col] + if pd.notna(value): + additional_data[col] = str(value).strip() + else: + additional_data[col] = '' + + cursor.execute( + '''INSERT INTO guests + (event_id, name, surname, second_name, additional_data) + VALUES (?, ?, ?, ?, ?)''', + (event_id, name, surname, second_name, json.dumps(additional_data)) + ) + + db.commit() + + # Clean up uploaded file + if os.path.exists(file_path): + os.remove(file_path) + + flash(f'Event created successfully with {len(df)} guests imported!', 'success') + return redirect(url_for('admin_dashboard')) + + except Exception as e: + db.rollback() + flash(f'Error: {str(e)}', 'error') + return render_template('create_event.html') + finally: + db.close() + + return render_template('create_event.html') + +@app.route('/admin/edit_event/', methods=['GET', 'POST']) +@login_required +def edit_event(event_id): + if current_user.role != 'admin': + flash('Access denied', 'error') + return redirect(url_for('index')) + + db = get_db() + event = db.execute('SELECT * FROM events WHERE id = ?', (event_id,)).fetchone() + + if not event: + flash('Event not found', 'error') + return redirect(url_for('admin_dashboard')) + + if request.method == 'POST': + event_name = request.form['event_name'] + excel_file = request.files.get('guest_list') + + if not event_name: + flash('Event name is required', 'error') + return render_template('edit_event.html', event=event) + + try: + # Update event name + db.execute('UPDATE events SET name = ? WHERE id = ?', (event_name, event_id)) + + # If new Excel file is uploaded, reset guest data + if excel_file and excel_file.filename: + if not excel_file.filename.endswith(('.xlsx', '.xls')): + flash('Only Excel files (.xlsx, .xls) are allowed', 'error') + return render_template('edit_event.html', event=event) + + # Save uploaded file + filename = secure_filename(f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{excel_file.filename}") + file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename) + excel_file.save(file_path) + + # Process Excel file + df, required_cols, additional_cols = process_excel_file(file_path) + + # Delete old guests and columns + db.execute('DELETE FROM guests WHERE event_id = ?', (event_id,)) + db.execute('DELETE FROM event_columns WHERE event_id = ?', (event_id,)) + + # Save new column definitions + all_columns = required_cols + additional_cols + for idx, col in enumerate(all_columns): + db.execute( + 'INSERT INTO event_columns (event_id, column_name, column_order) VALUES (?, ?, ?)', + (event_id, col, idx) + ) + + # Import new guests + for _, row in df.iterrows(): + name = str(row.get('name', '')).strip() + surname = str(row.get('surname', '')).strip() + second_name = str(row.get('second_name', '')).strip() if 'second_name' in df.columns else '' + + additional_data = {} + for col in additional_cols: + value = row[col] + if pd.notna(value): + additional_data[col] = str(value).strip() + else: + additional_data[col] = '' + + db.execute( + '''INSERT INTO guests + (event_id, name, surname, second_name, additional_data) + VALUES (?, ?, ?, ?, ?)''', + (event_id, name, surname, second_name, json.dumps(additional_data)) + ) + + # Clean up uploaded file + if os.path.exists(file_path): + os.remove(file_path) + + flash(f'Event updated and {len(df)} guests imported!', 'success') + else: + flash('Event name updated successfully!', 'success') + + db.commit() + return redirect(url_for('admin_dashboard')) + + except Exception as e: + db.rollback() + flash(f'Error: {str(e)}', 'error') + return render_template('edit_event.html', event=event) + finally: + db.close() + + # Get current columns for display + try: + columns = db.execute( + 'SELECT column_name FROM event_columns WHERE event_id = ? ORDER BY column_order', + (event_id,) + ).fetchall() + column_list = [c['column_name'] for c in columns] + except: + column_list = [] + + db.close() + return render_template('edit_event.html', event=event, columns=column_list) + +@app.route('/admin/toggle_event_passed/', methods=['POST']) +@login_required +def toggle_event_passed(event_id): + if current_user.role != 'admin': + return jsonify({'success': False, 'message': 'Access denied'}), 403 + + db = get_db() + event = db.execute('SELECT * FROM events WHERE id = ?', (event_id,)).fetchone() + + if event: + # Safely get is_passed value + try: + current_status = event['is_passed'] + except (KeyError, IndexError): + current_status = 0 + + new_status = 0 if current_status else 1 + + try: + db.execute('UPDATE events SET is_passed = ? WHERE id = ?', (new_status, event_id)) + except sqlite3.OperationalError: + # If column doesn't exist, add it first + db.execute('ALTER TABLE events ADD COLUMN is_passed BOOLEAN DEFAULT 0') + db.execute('UPDATE events SET is_passed = ? WHERE id = ?', (new_status, event_id)) + + db.commit() + + status_text = 'marked as passed' if new_status else 'marked as active' + flash(f'Event {status_text} successfully!', 'success') + else: + flash('Event not found', 'error') + + db.close() + return redirect(url_for('admin_dashboard')) + +# Manager routes +@app.route('/manager/dashboard') +@login_required +def manager_dashboard(): + if current_user.role not in ['manager', 'admin']: + flash('Access denied', 'error') + return redirect(url_for('index')) + + db = get_db() + + # Check if is_passed column exists + columns = db.execute("PRAGMA table_info(events)").fetchall() + column_names = [col[1] for col in columns] + + if 'is_passed' not in column_names: + events = db.execute( + 'SELECT *, 0 as is_passed FROM events WHERE is_active = 1 ORDER BY created_at DESC' + ).fetchall() + else: + events = db.execute( + 'SELECT * FROM events WHERE is_active = 1 AND is_passed = 0 ORDER BY created_at DESC' + ).fetchall() + + db.close() + return render_template('event_select.html', events=events) + +@app.route('/manager/event/') +@login_required +def guest_search(event_id): + if current_user.role not in ['manager', 'admin']: + flash('Access denied', 'error') + return redirect(url_for('index')) + + db = get_db() + event = db.execute('SELECT * FROM events WHERE id = ?', (event_id,)).fetchone() + + if not event: + flash('Event not found', 'error') + return redirect(url_for('manager_dashboard')) + + # Get column definitions + try: + columns = db.execute( + 'SELECT column_name FROM event_columns WHERE event_id = ? ORDER BY column_order', + (event_id,) + ).fetchall() + column_list = [c['column_name'] for c in columns] + except: + # If event_columns table doesn't exist or no columns defined + column_list = ['name', 'surname'] + + # Get all unique first letters of surnames for this event + letters = db.execute( + '''SELECT DISTINCT UPPER(SUBSTR(surname, 1, 1)) as letter + FROM guests + WHERE event_id = ? AND surname != '' + ORDER BY letter''', + (event_id,) + ).fetchall() + + db.close() + return render_template( + 'guest_search.html', + event=event, + letters=[l['letter'] for l in letters], + columns=column_list + ) + +@app.route('/api/search_guests/') +@login_required +def search_guests(event_id): + search_term = request.args.get('term', '') + + # Debug logging + app.logger.info(f"Search term received: '{search_term}'") + app.logger.info(f"Search term bytes: {search_term.encode('utf-8')}") + + db = get_db() + + if search_term and search_term != 'ALL': + # Use LIKE directly without UPPER() to avoid Unicode issues + # This works because SQLite LIKE is case-insensitive for ASCII + # For Unicode, we'll do a case-insensitive comparison in a different way + + # Method 1: Direct LIKE comparison (works for both Latin and Cyrillic in most cases) + search_pattern = f'{search_term}%' + + app.logger.info(f"Search pattern: '{search_pattern}'") + + # Try the search + guests = db.execute( + '''SELECT id, name, surname, second_name, additional_data, checked_in, checked_in_at + FROM guests + WHERE event_id = ? AND surname LIKE ? + ORDER BY surname, name''', + (event_id, search_pattern) + ).fetchall() + + app.logger.info(f"Found {len(guests)} guests with direct LIKE") + + # If no results and search term might be mixed case, try case-insensitive approach + if len(guests) == 0: + # Get all guests and filter manually (less efficient but works for Unicode) + all_guests = db.execute( + '''SELECT id, name, surname, second_name, additional_data, checked_in, checked_in_at + FROM guests + WHERE event_id = ? + ORDER BY surname, name''', + (event_id,) + ).fetchall() + + # Manual filtering + search_upper = search_term.upper() + guests = [g for g in all_guests if g['surname'].upper().startswith(search_upper)] + + app.logger.info(f"Found {len(guests)} guests with manual filtering") + else: + # Show all guests + guests = db.execute( + '''SELECT id, name, surname, second_name, additional_data, checked_in, checked_in_at + FROM guests + WHERE event_id = ? + ORDER BY surname, name''', + (event_id,) + ).fetchall() + + # Get column definitions + try: + columns = db.execute( + 'SELECT column_name FROM event_columns WHERE event_id = ? ORDER BY column_order', + (event_id,) + ).fetchall() + column_list = [c['column_name'] for c in columns] + except: + column_list = ['name', 'surname'] + + db.close() + + # Process guests data + guest_list = [] + for g in guests: + additional_data = {} + if g['additional_data']: + try: + additional_data = json.loads(g['additional_data']) + except: + additional_data = {} + + guest_list.append({ + 'id': g['id'], + 'name': g['name'], + 'surname': g['surname'], + 'second_name': g['second_name'] or '', + 'additional_data': additional_data, + 'checked_in': g['checked_in'], + 'checked_in_at': g['checked_in_at'] if g['checked_in_at'] else None + }) + + return jsonify({ + 'guests': guest_list, + 'columns': column_list + }) + +@app.route('/api/checkin/', methods=['POST']) +@login_required +def checkin_guest(guest_id): + if current_user.role not in ['manager', 'admin']: + return jsonify({'success': False, 'message': 'Access denied'}), 403 + + data = request.get_json() + check_in = data.get('check_in', True) + + db = get_db() + try: + if check_in: + db.execute( + 'UPDATE guests SET checked_in = 1, checked_in_at = ? WHERE id = ? AND checked_in = 0', + (datetime.now(), guest_id) + ) + else: + db.execute( + 'UPDATE guests SET checked_in = 0, checked_in_at = NULL WHERE id = ? AND checked_in = 1', + (guest_id,) + ) + + db.commit() + + if db.total_changes > 0: + action = 'checked in' if check_in else 'unchecked' + return jsonify({ + 'success': True, + 'message': f'Guest {action} successfully', + 'checked_in': check_in, + 'checked_in_at': datetime.now().isoformat() if check_in else None + }) + else: + status = 'already checked in' if check_in else 'not checked in' + return jsonify({'success': False, 'message': f'Guest {status}'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + finally: + db.close() + +@app.route('/api/update_guest/', methods=['POST']) +@login_required +def update_guest(guest_id): + if current_user.role not in ['manager', 'admin']: + return jsonify({'success': False, 'message': 'Access denied'}), 403 + + data = request.get_json() + + db = get_db() + try: + # Get current guest data + guest = db.execute('SELECT * FROM guests WHERE id = ?', (guest_id,)).fetchone() + + if not guest: + return jsonify({'success': False, 'message': 'Guest not found'}), 404 + + # Update basic fields + name = data.get('name', guest['name']) + surname = data.get('surname', guest['surname']) + second_name = data.get('second_name', guest['second_name']) + + # Update additional data + additional_data = {} + if guest['additional_data']: + try: + additional_data = json.loads(guest['additional_data']) + except: + pass + + # Update additional fields from the request + if 'additional_data' in data: + for key, value in data['additional_data'].items(): + additional_data[key] = value + + # Update the database + db.execute( + '''UPDATE guests + SET name = ?, surname = ?, second_name = ?, additional_data = ? + WHERE id = ?''', + (name, surname, second_name, json.dumps(additional_data), guest_id) + ) + + db.commit() + + # Fetch updated guest + updated_guest = db.execute( + '''SELECT id, name, surname, second_name, additional_data, checked_in, checked_in_at + FROM guests WHERE id = ?''', + (guest_id,) + ).fetchone() + + # Process additional data for response + updated_additional_data = {} + if updated_guest['additional_data']: + try: + updated_additional_data = json.loads(updated_guest['additional_data']) + except: + pass + + return jsonify({ + 'success': True, + 'message': 'Guest updated successfully', + 'guest': { + 'id': updated_guest['id'], + 'name': updated_guest['name'], + 'surname': updated_guest['surname'], + 'second_name': updated_guest['second_name'] or '', + 'additional_data': updated_additional_data, + 'checked_in': updated_guest['checked_in'], + 'checked_in_at': updated_guest['checked_in_at'] if updated_guest['checked_in_at'] else None + } + }) + + except Exception as e: + db.rollback() + return jsonify({'success': False, 'message': str(e)}), 500 + finally: + db.close() + + + +if __name__ == '__main__': + init_db() + app.run(debug=True, host='0.0.0.0', port=5000) + + diff --git a/checkin-system/schema.sql b/checkin-system/schema.sql new file mode 100644 index 0000000..a4a731a --- /dev/null +++ b/checkin-system/schema.sql @@ -0,0 +1,36 @@ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('admin', 'manager')) +); + +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_by INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT 1, + is_passed BOOLEAN DEFAULT 0, + FOREIGN KEY (created_by) REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS event_columns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL, + column_name TEXT NOT NULL, + column_order INTEGER NOT NULL, + FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS guests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL, + name TEXT NOT NULL, + surname TEXT NOT NULL, + second_name TEXT, + additional_data TEXT, + checked_in BOOLEAN DEFAULT 0, + checked_in_at TIMESTAMP, + FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE +); diff --git a/checkin-system/templates/admin_dashboard.html b/checkin-system/templates/admin_dashboard.html new file mode 100644 index 0000000..8e2c0e7 --- /dev/null +++ b/checkin-system/templates/admin_dashboard.html @@ -0,0 +1,114 @@ +{% extends "base.html" %} + +{% block content %} + + +{% if events %} +
+ + + + + + + + + + + + + {% for event in events %} + + + + + + + + + {% endfor %} + +
НазваниеСозданоГостейЗачекинилисьСтатусДействия
{{ event.name }}{{ event.created_at }}{{ event.guest_count }}{{ event.checked_in_count }}/{{ event.guest_count }} + {% if event.is_passed %} + Завершено + {% elif event.is_active %} + Идёт + {% else %} + Неактивно + {% endif %} + +
+ Смотреть + Редактировать +
+ +
+
+
+
+{% else %} +
+

Еще нет мероприятий.

+ Создайте Ваше первое мероприятие +
+{% endif %} + + +{% endblock %} diff --git a/checkin-system/templates/base.html b/checkin-system/templates/base.html new file mode 100644 index 0000000..b7a2909 --- /dev/null +++ b/checkin-system/templates/base.html @@ -0,0 +1,160 @@ + + + + + + Check-in System + + + +
+ + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + diff --git a/checkin-system/templates/create_event.html b/checkin-system/templates/create_event.html new file mode 100644 index 0000000..bc07446 --- /dev/null +++ b/checkin-system/templates/create_event.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} + +{% block content %} +

Создать новое мероприятие

+ +
+
+ + +
+ +
+ + + + Файл Excel должен содержать как минимум столбцы с заголовком 'Фамилия' и 'Имя'. + Дополинтельные столбцы будут добавлены в систему автоматически при наличии. + +
+ + + Отмена +
+ +
+

Пример Excel формата:

+

Содержимое таблицы должно выглядет примерно так:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ФамилияИмяОтчествоНомер столикаДиетические предпочтенияТелефон
ИвановИванИванович5Веган+1234567890
ПетроваАннаСергеевна3-+0987654321
+
+ Обязательные столбцы: Фамилия, Имя
+ Опциональный столбец: Отчество
+ Дополнительные столбцы: любой дополнительный столбец будет добавлен динамически при наличии +
+
+{% endblock %} diff --git a/checkin-system/templates/edit_event.html b/checkin-system/templates/edit_event.html new file mode 100644 index 0000000..52bf977 --- /dev/null +++ b/checkin-system/templates/edit_event.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} + +{% block content %} +

Редактировать мероприятие: {{ event.name }}

+ +
+ ⚠️ ВНИМАНИЕ: Загрузка нового файла Excel приведет к замене текущего списка участников и сбросу статуса регистрации.. +
+ +
+
+ + +
+ +
+ + + + Формат Excel: должны быть как минимум столбцы 'Фамилия' и 'Имя' + +
+ + {% if columns %} +
+ Список столбцов в данном мероприятии: +
+ {% for column in columns %} + + {{ column }} + + {% endfor %} +
+
+ {% endif %} + +
+ + Отмена +
+
+{% endblock %} diff --git a/checkin-system/templates/event_select.html b/checkin-system/templates/event_select.html new file mode 100644 index 0000000..a752ef7 --- /dev/null +++ b/checkin-system/templates/event_select.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} + +{% block content %} +

Выберите мероприятие для регистрации

+ +{% if events %} +
+ {% for event in events %} +
+

{{ event.name }}

+

Создано: {{ event.created_at[:10] }}

+ Открыть список участников +
+ {% endfor %} +
+{% else %} +

Нет активных мероприятий.

+{% endif %} +{% endblock %} diff --git a/checkin-system/templates/guest_search.html b/checkin-system/templates/guest_search.html new file mode 100644 index 0000000..c63daa8 --- /dev/null +++ b/checkin-system/templates/guest_search.html @@ -0,0 +1,999 @@ +{% extends "base.html" %} + +{% block content %} +

Мероприятие: {{ event.name }}

+ + +
+
+
+
0
+
Всего участников
+
+
+
0
+
Зарегались
+
+
+
0
+
НЕ зарегались
+
+
+
0%
+
Ход регистрации
+
+
+ +
+ + + + + + + + + + +
+

Поиск по фамилии

+ + +
+ + {% for letter in letters %} + + {% endfor %} +
+ + + + + +
+
+

Загружаю список...

+
+
+
+ + + + +{% endblock %} diff --git a/checkin-system/templates/login.html b/checkin-system/templates/login.html new file mode 100644 index 0000000..42656a1 --- /dev/null +++ b/checkin-system/templates/login.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% block content %} +
+

Вход

+
+
+ + +
+ +
+ + +
+ + +
+ +
+
+
+{% endblock %}