1 Commits

Author SHA1 Message Date
Javier
0df35b015b feat: add archive/delete/restore sessions with header preview 2026-02-11 11:51:05 -06:00
5 changed files with 400 additions and 101 deletions

2
app.py
View File

@@ -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

View File

@@ -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),
]

View File

@@ -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 scan counts for each session from their dynamic tables
# 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 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/<int:process_id>', methods=['GET', 'POST'])
@@ -982,6 +1027,60 @@ def register_routes(bp):
return jsonify({'success': True})
@bp.route('/session/<int:session_id>/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/<int:session_id>/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/<int:session_id>/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/<int:session_id>/template')
@login_required

View File

@@ -30,10 +30,16 @@
<!-- Active Sessions -->
{% if sessions %}
<div class="section-card">
<h2 class="section-title">📋 My Active Sessions</h2>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md);">
<h2 class="section-title">📋 My Sessions</h2>
<label class="checkbox-toggle">
<input type="checkbox" id="showArchivedToggle" {% if show_archived %}checked{% endif %} onchange="toggleArchived(this.checked)">
<span>Show archived/deleted</span>
</label>
</div>
<div class="sessions-list">
{% for s in sessions %}
<div class="session-list-item-container">
<div class="session-list-item-container session-status-{{ s.status }}">
<a href="{{ url_for('conssheets.scan_session', session_id=s.id) }}" class="session-list-item">
<div class="session-list-info">
<h3 class="session-list-name">{{ s.process_name }}</h3>
@@ -42,14 +48,52 @@
<span></span>
<span>{{ s.scan_count or 0 }} lots scanned</span>
</div>
{% if s.header_preview %}
<div class="session-list-meta" style="margin-top: var(--space-xs); font-size: 0.8rem;">
{% for hf in s.header_preview %}
<span><strong>{{ hf.field_label }}:</strong> {{ hf.field_value or '—' }}</span>
{% if not loop.last %}<span></span>{% endif %}
{% endfor %}
</div>
{% endif %}
{% if s.status == 'archived' %}
<div class="session-list-meta" style="margin-top: var(--space-xs);">
<span style="color: var(--color-warning);">📦 Archived</span>
</div>
{% elif s.status == 'deleted' %}
<div class="session-list-meta" style="margin-top: var(--space-xs);">
<span style="color: var(--color-danger);">🗑️ Deleted</span>
</div>
{% endif %}
</div>
<div class="session-list-action">
<span class="arrow-icon"></span>
</div>
</a>
<button class="btn-archive" onclick="archiveSession({{ s.id }}, '{{ s.process_name }}')" title="Archive this session">
🗑️
</button>
{% if s.status == 'active' %}
<button class="btn-session-action btn-archive" onclick="archiveSession({{ s.id }}, '{{ s.process_name }}')" title="Archive this session">
📦
</button>
{% if session.role in ['owner', 'admin'] %}
<button class="btn-session-action btn-delete" onclick="deleteSession({{ s.id }}, '{{ s.process_name }}')" title="Delete this session">
</button>
{% endif %}
{% elif s.status == 'archived' %}
<button class="btn-session-action btn-unarchive" onclick="unarchiveSession({{ s.id }}, '{{ s.process_name }}')" title="Restore to active">
↩️
</button>
{% if session.role in ['owner', 'admin'] %}
<button class="btn-session-action btn-delete" onclick="deleteSession({{ s.id }}, '{{ s.process_name }}')" title="Delete this session">
</button>
{% endif %}
{% elif s.status == 'deleted' and session.role in ['owner', 'admin'] %}
<button class="btn-session-action btn-restore" onclick="restoreSession({{ s.id }}, '{{ s.process_name }}')" title="Restore from deleted">
🔄
</button>
{% endif %}
</div>
{% endfor %}
</div>
@@ -63,89 +107,20 @@
{% endif %}
</div>
<style>
.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: var(--space-sm);
margin-bottom: var(--space-sm);
}
.session-list-item {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-lg);
text-decoration: none;
transition: var(--transition);
}
.session-list-item:hover {
border-color: var(--color-primary);
transform: translateX(4px);
}
.session-list-name {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text);
margin: 0 0 var(--space-xs) 0;
}
.session-list-meta {
display: flex;
gap: var(--space-sm);
font-size: 0.875rem;
color: var(--color-text-muted);
}
.arrow-icon {
font-size: 1.5rem;
color: var(--color-primary);
}
.btn-archive {
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-md);
cursor: pointer;
font-size: 1.25rem;
transition: var(--transition);
}
.btn-archive:hover {
border-color: var(--color-danger);
background: rgba(255, 51, 102, 0.1);
}
</style>
<!-- Styles moved to static/css/style.css for maintainability -->
<script>
function toggleArchived(checked) {
const url = new URL(window.location.href);
if (checked) {
url.searchParams.set('show_archived', '1');
} else {
url.searchParams.delete('show_archived');
}
window.location.href = url.toString();
}
function archiveSession(sessionId, processName) {
if (!confirm(`Archive this ${processName} session?\n\nYou can still view it later from the admin panel.`)) {
if (!confirm(`Archive this ${processName} session?\n\nYou can restore it later.`)) {
return;
}
@@ -162,5 +137,62 @@ function archiveSession(sessionId, processName) {
}
});
}
function unarchiveSession(sessionId, processName) {
if (!confirm(`Restore ${processName} session to active?`)) {
return;
}
fetch(`/conssheets/session/${sessionId}/unarchive`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert(data.message || 'Error restoring session');
}
});
}
function deleteSession(sessionId, processName) {
if (!confirm(`⚠️ DELETE ${processName} session?\n\nThis will mark all scanned data as deleted.\nThis action cannot be undone easily.\n\nAre you absolutely sure?`)) {
return;
}
fetch(`/conssheets/session/${sessionId}/delete`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert(data.message || 'Error deleting session');
}
});
}
function restoreSession(sessionId, processName) {
if (!confirm(`Restore ${processName} from deleted?\n\nThis will reactivate the session and all its data.`)) {
return;
}
fetch(`/conssheets/session/${sessionId}/restore`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert(data.message || 'Error restoring session');
}
});
}
</script>
{% endblock %}

View File

@@ -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 {