From 0df35b015ba8c8547779bcfb3538f404704f108d Mon Sep 17 00:00:00 2001 From: Javier Date: Wed, 11 Feb 2026 11:51:05 -0600 Subject: [PATCH] feat: add archive/delete/restore sessions with header preview --- app.py | 2 +- modules/conssheets/migrations.py | 36 ++++ modules/conssheets/routes.py | 127 +++++++++-- .../templates/conssheets/staff_index.html | 204 ++++++++++-------- static/css/style.css | 132 ++++++++++++ 5 files changed, 400 insertions(+), 101 deletions(-) diff --git a/app.py b/app.py index 84ca09e..0c6476c 100644 --- a/app.py +++ b/app.py @@ -28,7 +28,7 @@ app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=1) # 1. Define the version -APP_VERSION = '0.18.1' +APP_VERSION = '0.18.2' # 2. Inject it into all templates automatically @app.context_processor diff --git a/modules/conssheets/migrations.py b/modules/conssheets/migrations.py index d7ce353..82c26f6 100644 --- a/modules/conssheets/migrations.py +++ b/modules/conssheets/migrations.py @@ -148,10 +148,46 @@ def get_migrations(): ''') print(" Created cons_process_router table") + def migration_006_add_deleted_status(conn): + """Add 'deleted' to the status CHECK constraint""" + cursor = conn.cursor() + + # SQLite doesn't support ALTER COLUMN, so we need to recreate the table + # First, create a new table with the updated constraint + cursor.execute(''' + CREATE TABLE cons_sessions_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + process_id INTEGER NOT NULL, + created_by INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + status TEXT DEFAULT 'active' CHECK(status IN ('active', 'archived', 'deleted')), + FOREIGN KEY (process_id) REFERENCES cons_processes(id), + FOREIGN KEY (created_by) REFERENCES Users(user_id) + ) + ''') + + # Copy data from old table + cursor.execute(''' + INSERT INTO cons_sessions_new (id, process_id, created_by, created_at, status) + SELECT id, process_id, created_by, created_at, status + FROM cons_sessions + ''') + + # Drop old table and rename new one + cursor.execute('DROP TABLE cons_sessions') + cursor.execute('ALTER TABLE cons_sessions_new RENAME TO cons_sessions') + + # Recreate indexes + cursor.execute('CREATE INDEX IF NOT EXISTS idx_cons_sessions_process ON cons_sessions(process_id, status)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_cons_sessions_user ON cons_sessions(created_by, status)') + + print(" Updated cons_sessions status constraint to include 'deleted'") + return [ (1, 'add_is_duplicate_key', migration_001_add_is_duplicate_key), (2, 'add_detail_end_row', migration_002_add_detail_end_row), (3, 'add_page_height', migration_003_add_page_height), (4, 'add_print_columns', migration_004_add_print_columns), (5, 'create_router_table', migration_005_create_router_table), + (6, 'add_deleted_status', migration_006_add_deleted_status), ] \ No newline at end of file diff --git a/modules/conssheets/routes.py b/modules/conssheets/routes.py index b1fd477..9a7b9df 100644 --- a/modules/conssheets/routes.py +++ b/modules/conssheets/routes.py @@ -637,29 +637,73 @@ def register_routes(bp): flash('You do not have access to this module', 'danger') return redirect(url_for('home')) - # Get user's active sessions with process info and scan counts - active_sessions = query_db(''' - SELECT cs.*, cp.process_name, cp.process_key - FROM cons_sessions cs - JOIN cons_processes cp ON cs.process_id = cp.id - WHERE cs.created_by = ? AND cs.status = 'active' - ORDER BY cs.created_at DESC - ''', [user_id]) + # Check if user wants to see archived/deleted sessions + show_archived = request.args.get('show_archived') == '1' + + # Get sessions based on filter + if show_archived: + # Show active + archived + last 20 deleted + all_sessions = query_db(''' + SELECT cs.*, cp.process_name, cp.process_key + FROM cons_sessions cs + JOIN cons_processes cp ON cs.process_id = cp.id + WHERE cs.created_by = ? + ORDER BY + CASE cs.status + WHEN 'active' THEN 1 + WHEN 'archived' THEN 2 + WHEN 'deleted' THEN 3 + END, + cs.created_at DESC + ''', [user_id]) + + # Separate active, archived, and deleted (limit deleted to 20) + active_sessions = [s for s in all_sessions if s['status'] == 'active'] + archived_sessions = [s for s in all_sessions if s['status'] == 'archived'] + deleted_sessions = [s for s in all_sessions if s['status'] == 'deleted'][:20] + + combined_sessions = active_sessions + archived_sessions + deleted_sessions + else: + # Show only active sessions + combined_sessions = query_db(''' + SELECT cs.*, cp.process_name, cp.process_key + FROM cons_sessions cs + JOIN cons_processes cp ON cs.process_id = cp.id + WHERE cs.created_by = ? AND cs.status = 'active' + ORDER BY cs.created_at DESC + ''', [user_id]) - # Get scan counts for each session from their dynamic tables + # Get scan counts and header values for each session sessions_with_counts = [] - for sess in active_sessions: + for sess in combined_sessions: + sess_dict = dict(sess) + + # Get scan count table_name = get_detail_table_name(sess['process_key']) try: count_result = query_db(f''' SELECT COUNT(*) as scan_count FROM {table_name} WHERE session_id = ? AND is_deleted = 0 ''', [sess['id']], one=True) - sess_dict = dict(sess) sess_dict['scan_count'] = count_result['scan_count'] if count_result else 0 except: - sess_dict = dict(sess) sess_dict['scan_count'] = 0 + + # Get first 5 required header fields with their values + header_fields = query_db(''' + SELECT cpf.field_label, cshv.field_value + FROM cons_process_fields cpf + LEFT JOIN cons_session_header_values cshv + ON cpf.id = cshv.field_id AND cshv.session_id = ? + WHERE cpf.process_id = ? + AND cpf.table_type = 'header' + AND cpf.is_required = 1 + AND cpf.is_active = 1 + ORDER BY cpf.sort_order, cpf.id + LIMIT 5 + ''', [sess['id'], sess['process_id']]) + + sess_dict['header_preview'] = header_fields sessions_with_counts.append(sess_dict) # Get available process types for creating new sessions @@ -668,8 +712,9 @@ def register_routes(bp): ''') return render_template('conssheets/staff_index.html', - sessions=sessions_with_counts, - processes=processes) + sessions=sessions_with_counts, + processes=processes, + show_archived=show_archived) @bp.route('/new/', methods=['GET', 'POST']) @@ -982,6 +1027,60 @@ def register_routes(bp): return jsonify({'success': True}) + @bp.route('/session//unarchive', methods=['POST']) + @login_required + def unarchive_session(session_id): + """Unarchive a session back to active""" + sess = query_db('SELECT * FROM cons_sessions WHERE id = ?', [session_id], one=True) + + if not sess: + return jsonify({'success': False, 'message': 'Session not found'}) + + # Check permission + if sess['created_by'] != session['user_id'] and session['role'] not in ['owner', 'admin']: + return jsonify({'success': False, 'message': 'Permission denied'}) + + execute_db('UPDATE cons_sessions SET status = "active" WHERE id = ?', [session_id]) + + return jsonify({'success': True}) + + + @bp.route('/session//delete', methods=['POST']) + @role_required('owner', 'admin') + def delete_session(session_id): + """Delete a session (admin only) - soft delete session and all detail rows""" + sess = query_db('SELECT cs.*, cp.process_key FROM cons_sessions cs JOIN cons_processes cp ON cs.process_id = cp.id WHERE cs.id = ?', [session_id], one=True) + + if not sess: + return jsonify({'success': False, 'message': 'Session not found'}) + + # Update session status + execute_db('UPDATE cons_sessions SET status = "deleted" WHERE id = ?', [session_id]) + + # Mark all detail rows as deleted + table_name = get_detail_table_name(sess['process_key']) + execute_db(f'UPDATE {table_name} SET is_deleted = 1 WHERE session_id = ?', [session_id]) + + return jsonify({'success': True}) + + + @bp.route('/session//restore', methods=['POST']) + @role_required('owner', 'admin') + def restore_session(session_id): + """Restore a deleted session (admin only) - restore session and all detail rows""" + sess = query_db('SELECT cs.*, cp.process_key FROM cons_sessions cs JOIN cons_processes cp ON cs.process_id = cp.id WHERE cs.id = ?', [session_id], one=True) + + if not sess: + return jsonify({'success': False, 'message': 'Session not found'}) + + # Update session status to active + execute_db('UPDATE cons_sessions SET status = "active" WHERE id = ?', [session_id]) + + # Restore all detail rows + table_name = get_detail_table_name(sess['process_key']) + execute_db(f'UPDATE {table_name} SET is_deleted = 0 WHERE session_id = ?', [session_id]) + + return jsonify({'success': True}) @bp.route('/session//template') @login_required diff --git a/modules/conssheets/templates/conssheets/staff_index.html b/modules/conssheets/templates/conssheets/staff_index.html index 084801b..44a699d 100644 --- a/modules/conssheets/templates/conssheets/staff_index.html +++ b/modules/conssheets/templates/conssheets/staff_index.html @@ -30,10 +30,16 @@ {% if sessions %}
-

📋 My Active Sessions

+
+

📋 My Sessions

+ +
{% for s in sessions %} -
+
→
- + + {% if s.status == 'active' %} + + {% if session.role in ['owner', 'admin'] %} + + {% endif %} + {% elif s.status == 'archived' %} + + {% if session.role in ['owner', 'admin'] %} + + {% endif %} + {% elif s.status == 'deleted' and session.role in ['owner', 'admin'] %} + + {% endif %}
{% endfor %}
@@ -63,89 +107,20 @@ {% endif %}
- - + {% endblock %} diff --git a/static/css/style.css b/static/css/style.css index 5b05525..dff69c8 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -707,6 +707,138 @@ body { color: var(--color-primary); } +/* Session List - Archive/Delete Actions */ + +.new-session-section { + background: var(--color-surface); + border: 2px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-xl); + margin-bottom: var(--space-xl); +} + +.process-buttons { + display: flex; + flex-wrap: wrap; + gap: var(--space-md); + margin-top: var(--space-md); +} + +.section-card { + margin-top: var(--space-xl); +} + +.session-list-item-container { + display: flex; + align-items: stretch; + gap: 0; /* Remove gap */ + margin-bottom: var(--space-sm); + border: 2px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); + overflow: hidden; +} + +.session-list-item { + flex: 1; + display: flex; + justify-content: space-between; + align-items: center; + background: transparent; /* Remove duplicate background */ + border: none; /* Remove border since container has it */ + border-radius: 0; + padding: var(--space-lg); + text-decoration: none; + transition: var(--transition); +} + +.session-list-item:hover { + border-color: var(--color-primary); + transform: translateX(4px); +} + +.session-list-info { + flex: 1; +} + +.session-list-action { + display: flex; + align-items: center; +} + +.session-list-meta { + display: flex; + gap: var(--space-sm); + font-size: 0.875rem; + color: var(--color-text-muted); +} + +.checkbox-toggle { + display: flex; + align-items: center; + gap: var(--space-sm); + font-size: 0.875rem; + color: var(--color-text-muted); + cursor: pointer; +} + +.checkbox-toggle input[type="checkbox"] { + cursor: pointer; +} + +.btn-session-action { + background: transparent; + border: none; + border-left: 2px solid var(--color-border); + border-radius: 0; + padding: var(--space-xl); + cursor: pointer; + font-size: 2rem; + transition: var(--transition); + min-width: 80px; + display: flex; + align-items: center; + justify-content: center; +} + +.btn-session-action.btn-archive:hover { + border-color: var(--color-warning); + background: rgba(255, 170, 0, 0.1); +} + +.btn-session-action.btn-delete:hover { + border-color: var(--color-danger); + background: rgba(255, 51, 102, 0.1); +} + +.btn-session-action.btn-unarchive:hover { + border-color: var(--color-primary); + background: rgba(0, 212, 255, 0.1); +} + +.btn-session-action.btn-restore:hover { + border-color: var(--color-success); + background: rgba(0, 255, 136, 0.1); +} + +.session-status-archived { + opacity: 1; +} + +.session-status-archived .session-list-item-container { + border-color: var(--color-warning); + background: rgba(255, 170, 0, 0.08); +} + +.session-status-deleted { + opacity: 1; +} + +.session-status-deleted .session-list-item-container { + border-color: var(--color-danger); + background: rgba(255, 51, 102, 0.08); +} + /* ==================== FORM CONTAINER ==================== */ .form-container {