v0.10.0 - Add session archive/activate feature with access controls

This commit is contained in:
Javier
2026-01-25 02:23:18 -06:00
parent ca368cbbfb
commit 672591c736
8 changed files with 190 additions and 22 deletions

24
app.py
View File

@@ -36,7 +36,7 @@ app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=1)
# 1. Define the version # 1. Define the version
APP_VERSION = '0.9.0' APP_VERSION = '0.10.0'
# 2. Inject it into all templates automatically # 2. Inject it into all templates automatically
@app.context_processor @app.context_processor
@@ -94,7 +94,25 @@ def dashboard():
if role in ['owner', 'admin']: if role in ['owner', 'admin']:
# Admin dashboard # Admin dashboard
active_sessions = query_db(''' show_archived = request.args.get('show_archived', '0') == '1'
if show_archived:
# Show all sessions (active and archived)
sessions_list = query_db('''
SELECT s.*, u.full_name as created_by_name,
COUNT(DISTINCT lc.location_count_id) as total_locations,
SUM(CASE WHEN lc.status = 'completed' THEN 1 ELSE 0 END) as completed_locations,
SUM(CASE WHEN lc.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress_locations
FROM CountSessions s
LEFT JOIN Users u ON s.created_by = u.user_id
LEFT JOIN LocationCounts lc ON s.session_id = lc.session_id
WHERE s.status IN ('active', 'archived')
GROUP BY s.session_id
ORDER BY s.status ASC, s.created_timestamp DESC
''')
else:
# Show only active sessions
sessions_list = query_db('''
SELECT s.*, u.full_name as created_by_name, SELECT s.*, u.full_name as created_by_name,
COUNT(DISTINCT lc.location_count_id) as total_locations, COUNT(DISTINCT lc.location_count_id) as total_locations,
SUM(CASE WHEN lc.status = 'completed' THEN 1 ELSE 0 END) as completed_locations, SUM(CASE WHEN lc.status = 'completed' THEN 1 ELSE 0 END) as completed_locations,
@@ -107,7 +125,7 @@ def dashboard():
ORDER BY s.created_timestamp DESC ORDER BY s.created_timestamp DESC
''') ''')
return render_template('admin_dashboard.html', sessions=active_sessions) return render_template('admin_dashboard.html', sessions=sessions_list, show_archived=show_archived)
else: else:
# Staff dashboard # Staff dashboard

View File

@@ -3,6 +3,12 @@ from db import query_db, execute_db, get_db
from utils import login_required from utils import login_required
counting_bp = Blueprint('counting', __name__) counting_bp = Blueprint('counting', __name__)
def get_active_session(session_id):
"""Get session if it exists and is not archived. Returns None if invalid."""
sess = query_db('SELECT * FROM CountSessions WHERE session_id = ?', [session_id], one=True)
if not sess or sess['status'] == 'archived':
return None
return sess
@counting_bp.route('/count/<int:session_id>') @counting_bp.route('/count/<int:session_id>')
@login_required @login_required
@@ -29,6 +35,10 @@ def my_counts(session_id):
flash('Session not found', 'danger') flash('Session not found', 'danger')
return redirect(url_for('dashboard')) return redirect(url_for('dashboard'))
if sess['status'] == 'archived':
flash('This session has been archived', 'warning')
return redirect(url_for('dashboard'))
# Get this user's active bins # Get this user's active bins
active_bins = query_db(''' active_bins = query_db('''
SELECT lc.*, SELECT lc.*,
@@ -65,6 +75,11 @@ def my_counts(session_id):
@login_required @login_required
def start_bin_count(session_id): def start_bin_count(session_id):
"""Start counting a new bin""" """Start counting a new bin"""
sess = get_active_session(session_id)
if not sess:
flash('Session not found or archived', 'warning')
return redirect(url_for('dashboard'))
location_name = request.form.get('location_name', '').strip().upper() location_name = request.form.get('location_name', '').strip().upper()
if not location_name: if not location_name:
@@ -125,7 +140,10 @@ def complete_location(location_count_id):
def count_location(session_id, location_count_id): def count_location(session_id, location_count_id):
"""Count lots in a specific location""" """Count lots in a specific location"""
# Get session info to determine type (Cycle Count vs Physical) # Get session info to determine type (Cycle Count vs Physical)
sess = query_db('SELECT * FROM CountSessions WHERE session_id = ?', [session_id], one=True) sess = get_active_session(session_id)
if not sess:
flash('Session not found or archived', 'warning')
return redirect(url_for('dashboard'))
location = query_db(''' location = query_db('''
SELECT * FROM LocationCounts SELECT * FROM LocationCounts
@@ -181,6 +199,10 @@ def count_location(session_id, location_count_id):
@login_required @login_required
def scan_lot(session_id, location_count_id): def scan_lot(session_id, location_count_id):
"""Process a lot scan with duplicate detection""" """Process a lot scan with duplicate detection"""
sess = get_active_session(session_id)
if not sess:
return jsonify({'success': False, 'message': 'Session not found or archived'})
data = request.get_json() data = request.get_json()
lot_number = data.get('lot_number', '').strip() lot_number = data.get('lot_number', '').strip()
weight = data.get('weight') weight = data.get('weight')
@@ -586,6 +608,10 @@ def recalculate_duplicate_status(session_id, lot_number, current_location):
@login_required @login_required
def finish_location(session_id, location_count_id): def finish_location(session_id, location_count_id):
"""Finish counting a location""" """Finish counting a location"""
sess = get_active_session(session_id)
if not sess:
return jsonify({'success': False, 'message': 'Session not found or archived'})
# Get location info # Get location info
location = query_db('SELECT * FROM LocationCounts WHERE location_count_id = ?', location = query_db('SELECT * FROM LocationCounts WHERE location_count_id = ?',
[location_count_id], one=True) [location_count_id], one=True)

View File

@@ -209,3 +209,36 @@ def get_status_details(session_id, status):
except Exception as e: except Exception as e:
print(f"Error in get_status_details: {str(e)}") print(f"Error in get_status_details: {str(e)}")
return jsonify({'success': False, 'message': f'Error: {str(e)}'}) return jsonify({'success': False, 'message': f'Error: {str(e)}'})
@sessions_bp.route('/session/<int:session_id>/archive', methods=['POST'])
@role_required('owner', 'admin')
def archive_session(session_id):
"""Archive a count session"""
sess = query_db('SELECT * FROM CountSessions WHERE session_id = ?', [session_id], one=True)
if not sess:
return jsonify({'success': False, 'message': 'Session not found'})
if sess['status'] == 'archived':
return jsonify({'success': False, 'message': 'Session is already archived'})
execute_db('UPDATE CountSessions SET status = ? WHERE session_id = ?', ['archived', session_id])
return jsonify({'success': True, 'message': 'Session archived successfully'})
@sessions_bp.route('/session/<int:session_id>/activate', methods=['POST'])
@role_required('owner', 'admin')
def activate_session(session_id):
"""Reactivate an archived session"""
sess = query_db('SELECT * FROM CountSessions WHERE session_id = ?', [session_id], one=True)
if not sess:
return jsonify({'success': False, 'message': 'Session not found'})
if sess['status'] != 'archived':
return jsonify({'success': False, 'message': 'Session is not archived'})
execute_db('UPDATE CountSessions SET status = ? WHERE session_id = ?', ['active', session_id])
return jsonify({'success': True, 'message': 'Session activated successfully'})

View File

@@ -2119,3 +2119,36 @@ body {
.scroll-to-bottom { .scroll-to-bottom {
display: none; display: none;
} }
/* ==================== ARCHIVED SESSIONS ==================== */
.session-archived {
opacity: 0.7;
border-color: var(--color-text-dim);
}
.session-archived:hover {
opacity: 0.85;
}
.archived-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
background: var(--color-text-dim);
color: var(--color-bg);
border-radius: var(--radius-sm);
font-size: 0.65rem;
font-weight: 700;
margin-left: var(--space-sm);
vertical-align: middle;
}
/* Session Detail Header Actions */
.session-detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.session-actions-header {
flex-shrink: 0;
}

View File

@@ -23,18 +23,34 @@
</script> </script>
<div class="dashboard-header"> <div class="dashboard-header">
<div class="header-left">
<h1 class="page-title">Admin Dashboard</h1> <h1 class="page-title">Admin Dashboard</h1>
<label class="filter-toggle">
<input type="checkbox" id="showArchived" {% if show_archived %}checked{% endif %} onchange="toggleArchived()">
<span class="filter-label">Show Archived</span>
</label>
</div>
<a href="{{ url_for('sessions.create_session') }}" class="btn btn-primary"> <a href="{{ url_for('sessions.create_session') }}" class="btn btn-primary">
<span class="btn-icon">+</span> New Session <span class="btn-icon">+</span> New Session
</a> </a>
</div> </div>
<script>
function toggleArchived() {
const checked = document.getElementById('showArchived').checked;
window.location.href = '{{ url_for("dashboard") }}' + (checked ? '?show_archived=1' : '');
}
</script>
{% if sessions %} {% if sessions %}
<div class="sessions-grid"> <div class="sessions-grid">
{% for session in sessions %} {% for session in sessions %}
<div class="session-card"> <div class="session-card {% if session.status == 'archived' %}session-archived{% endif %}">
<div class="session-card-header"> <div class="session-card-header">
<h3 class="session-name">{{ session.session_name }}</h3> <h3 class="session-name">
{{ session.session_name }}
{% if session.status == 'archived' %}<span class="archived-badge">ARCHIVED</span>{% endif %}
</h3>
<span class="session-type-badge session-type-{{ session.session_type }}"> <span class="session-type-badge session-type-{{ session.session_type }}">
{{ 'Full Physical' if session.session_type == 'full_physical' else 'Cycle Count' }} {{ 'Full Physical' if session.session_type == 'full_physical' else 'Cycle Count' }}
</span> </span>

View File

@@ -6,13 +6,22 @@
<div class="session-detail-container"> <div class="session-detail-container">
<div class="session-detail-header"> <div class="session-detail-header">
<div> <div>
<a href="{{ url_for('dashboard') }}" class="breadcrumb">← Back to Dashboard</a> <a href="{{ url_for('dashboard') }}{% if count_session.status == 'archived' %}?show_archived=1{% endif %}" class="breadcrumb">← Back to Dashboard</a>
<h1 class="page-title">{{ count_session.session_name }}</h1> <h1 class="page-title">
<!-- Fixed variable name from session.session_type to count_session.session_type --> {{ count_session.session_name }}
{% if count_session.status == 'archived' %}<span class="archived-badge">ARCHIVED</span>{% endif %}
</h1>
<span class="session-type-badge session-type-{{ count_session.session_type }}"> <span class="session-type-badge session-type-{{ count_session.session_type }}">
{{ 'Full Physical' if count_session.session_type == 'full_physical' else 'Cycle Count' }} {{ 'Full Physical' if count_session.session_type == 'full_physical' else 'Cycle Count' }}
</span> </span>
</div> </div>
<div class="session-actions-header">
{% if count_session.status == 'archived' %}
<button class="btn btn-success" onclick="activateSession()">✓ Activate Session</button>
{% else %}
<button class="btn btn-secondary" onclick="archiveSession()">📦 Archive Session</button>
{% endif %}
</div>
</div> </div>
<!-- Baseline Upload Section --> <!-- Baseline Upload Section -->
@@ -675,5 +684,38 @@ document.addEventListener('keydown', function(e) {
closeReopenConfirm(); closeReopenConfirm();
} }
}); });
function archiveSession() {
if (!confirm('Archive this session? It will be hidden from the main dashboard but can be reactivated later.')) return;
fetch('{{ url_for("sessions.archive_session", session_id=count_session.session_id) }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(r => r.json())
.then(data => {
if (data.success) {
window.location.href = '{{ url_for("dashboard") }}';
} else {
alert(data.message || 'Error archiving session');
}
});
}
function activateSession() {
if (!confirm('Reactivate this session? It will appear on the main dashboard again.')) return;
fetch('{{ url_for("sessions.activate_session", session_id=count_session.session_id) }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert(data.message || 'Error activating session');
}
});
}
</script> </script>
{% endblock %} {% endblock %}