app.routes

Routes for user authentication and dashboard management

This file handles user login/logout, dashboard rendering for staff and manager roles, staff training workflows, and profile photo upload.

  1# Login functionality was developed with the support of Miguel Grinberg's The 
  2# Flash Mega Tutorial series
  3# https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-v-user-logins
  4
  5"""Routes for user authentication and dashboard management
  6
  7This file handles user login/logout, dashboard rendering for staff and 
  8manager roles, staff training workflows, and profile photo upload.
  9"""
 10import os
 11import secrets
 12from datetime import datetime, timezone
 13from urllib.parse import urlsplit
 14
 15import sqlalchemy as sa
 16from flask import (
 17    render_template, 
 18    flash, redirect, 
 19    url_for, 
 20    request, 
 21    current_app
 22)
 23from flask_login import (
 24    current_user, 
 25    login_user, 
 26    logout_user, 
 27    login_required
 28)
 29from werkzeug.utils import secure_filename
 30
 31from app.forms import LoginForm
 32from app import app, db
 33from app.models import (
 34    User, 
 35    TrainingModule, 
 36    UserModuleProgress, 
 37    Option, 
 38    UserQuestionAnswer
 39)
 40from config import Config
 41
 42@app.route('/login', methods=['GET', 'POST'])
 43def login():      
 44    """Authenticate a user and redirect to role-specific dashboard.
 45
 46    Displays the login form and processes credentials on POST. On successful
 47    authentication, redirects users based on their role. Rejects invalid
 48    credentials with a flash message.
 49
 50    Returns:
 51        - Rendered login template on GET or invalid credentials.
 52        - Redirect to admin, manager, or staff dashboard on successful login.
 53    """
 54    if current_user.is_authenticated:
 55        if current_user.role.role_name == "admin":
 56            return redirect(url_for('admin_dashboard'))
 57        elif current_user.role.role_name == "manager":
 58            return redirect(url_for('manager_dashboard'))
 59        elif current_user.role.role_name == "staff":
 60            return redirect(url_for('staff_dashboard'))
 61        else:
 62            return redirect(url_for('logout'))
 63    
 64    form = LoginForm()
 65    if form.validate_on_submit():
 66        user = db.session.scalar(
 67            sa.select(User).where(User.username == form.username.data)
 68        )
 69        
 70        if user is None or not user.check_password(form.password.data):
 71            flash('Invalid username or password')
 72            return redirect(url_for('login'))
 73        
 74        login_user(user, remember=form.remember_me.data)
 75
 76        next_page = request.args.get('next')
 77        if next_page and urlsplit(next_page).netloc == '':                
 78            return redirect(next_page)
 79        
 80        if user.role.role_name == "admin":
 81            return redirect(url_for('admin_dashboard'))            
 82        elif user.role.role_name == "manager":
 83            return redirect(url_for('manager_dashboard'))
 84        elif user.role.role_name == "staff":
 85            return redirect(url_for('staff_dashboard'))
 86        else:
 87            return redirect(url_for('logout'))
 88
 89    return render_template(
 90        'login.html', 
 91        title='Sign In', 
 92        form=form
 93    )
 94
 95
 96@app.route('/')
 97def index():
 98    """Redirect to the login page."""
 99    return redirect(url_for('login'))
100
101
102@app.route('/logout')
103def logout():
104    """Logs out the current user and redirects to the login page."""
105    logout_user()
106    return redirect(url_for('login'))
107
108
109@app.route('/dashboard_staff')
110@login_required
111def staff_dashboard():
112    if current_user.role.role_name != "staff":
113        return redirect(url_for('login'))
114
115    manager = current_user.manager
116
117    steps = (
118        current_user.onboarding_path.steps
119        if current_user.onboarding_path 
120        else []
121    )
122
123    active_modules = [
124        step.training_module 
125        for step in steps 
126        if step.training_module.active
127    ]
128
129    passing_threshold = 0.5
130    to_do_list = []
131
132    for module in active_modules:
133        progress = UserModuleProgress.query.filter_by(
134            user_id=current_user.id,
135            training_module_id=module.id
136        ).order_by(UserModuleProgress.id.desc()).first()
137
138        if (
139            progress is None
140            or progress.completed_date is None
141            or (
142                progress.score is not None
143                and (progress.score / max(len(module.questions), 1)) < passing_threshold
144            )
145        ):
146            to_do_list.append(module)
147
148    return render_template(
149        'dashboard_staff.html',
150        title='Staff Dashboard',
151        to_do_list=to_do_list,
152        manager=manager
153    )
154
155
156@app.route('/dashboard_manager')
157@login_required
158def manager_dashboard():
159    """Render the manager dashboard if user is a manager."""
160    if current_user.role.role_name != "manager":
161        return redirect(url_for('login'))
162    
163    return render_template(
164        '/dashboard_manager.html', 
165        title='Manager Dashboard'
166    )
167
168
169@app.route('/dashboard_training', methods = ['GET'])
170@login_required
171def training_dashboard():
172    """Display training modules for staff users.
173
174    Renders a dashboard showing training modules that are either completed,
175    in progress, or yet to be started by the staff user.
176
177    Returns:
178        - Redirect to logout if the current user isn't staff.
179        - Rendered “dashboard_training.html” with three lists:
180            `to_be_completed_modules`, `in_progress_modules`, and `completed_modules`.
181    """
182    if current_user.role.role_name != "staff":
183        return redirect(url_for('logout'))
184
185    onboarding_path = current_user.onboarding_path
186    steps = onboarding_path.steps if onboarding_path else []
187    active_modules = [
188        step.training_module for step in steps if step.training_module.active
189    ]
190
191    completed_modules = []
192    to_be_completed_modules = []
193    in_progress_modules = []
194    passing_threshold = 0.5
195
196    for module in active_modules:
197        progress = UserModuleProgress.query.filter_by(
198            user_id=current_user.id,
199            training_module_id=module.id
200        ).order_by(UserModuleProgress.id.desc()).first()
201
202        if not progress:
203            to_be_completed_modules.append(module)
204        else:
205            total_questions = len(module.questions) or 1 
206            if progress.completed_date:
207                if (progress.score is not None
208                        and (progress.score / total_questions) >= passing_threshold):
209                    completed_modules.append({
210                        'module': module,
211                        'score': progress.score,
212                        'passed': True
213                    })
214                else:
215                    to_be_completed_modules.append(module)
216            else:
217                in_progress_modules.append(module)
218
219    return render_template(
220        'dashboard_training.html', 
221        title="Training Dashboard", 
222        to_be_completed_modules=to_be_completed_modules, 
223        in_progress_modules=in_progress_modules, 
224        completed_modules=completed_modules
225    )
226
227
228@app.route('/staff/take_training_module/<int:module_id>', methods = ['GET', 'POST'])
229@login_required
230def take_training_module(module_id):
231    """Handles the staff users training module session.
232
233    On GET:
234      - Retrieves or creates a UserModuleProgress record for the given module.
235      - If the last attempt was failed, starts a fresh attempt.
236      - Leaves in-progress attempts intact for continuation.
237
238    On POST:
239      - Records each submitted answer, updating existing answers or creating new ones.
240      - If `action == "save"`, commits progress without completing the module.
241      - Otherwise, computes the final score, marks completion, flashes a pass/fail message,
242        and commits.
243
244    Args:
245        module_id (int): The ID of the training module to be taken.
246   
247    Returns:
248        - Redirects to logout if the user is not in staff role.
249        - Renders 'take_training_module.html' with the module and any of the
250            users previous answers.
251        - Redirects to the training dashboard after saving or submitting 
252            answers.
253    """
254    if current_user.role.role_name != 'staff':
255        return redirect(url_for('logout'))
256    
257    module = TrainingModule.query.get_or_404(module_id)
258    passing_threshold = 0.5
259
260    progress = UserModuleProgress.query.filter_by(
261        user_id=current_user.id,
262        training_module_id=module_id
263    ).order_by(UserModuleProgress.id.desc()).first()
264
265    if request.method == 'GET':
266        if progress:
267            total_questions = len(module.questions) or 1
268            if progress.completed_date:
269                if (progress.score is not None
270                        and (progress.score / total_questions) < passing_threshold):
271                    progress = UserModuleProgress(
272                        user_id=current_user.id,
273                        training_module_id=module_id,
274                        start_date=datetime.now(timezone.utc)
275                    )
276                    db.session.add(progress)
277                    db.session.commit()
278        else:
279            progress = UserModuleProgress(
280                user_id=current_user.id,
281                training_module_id=module_id,
282                start_date=datetime.now(timezone.utc)
283            )
284            db.session.add(progress)
285            db.session.commit()
286
287    if request.method == 'POST':
288        action = request.form.get('action', 'submit')
289        
290        if not progress:
291            progress = UserModuleProgress(
292                user_id = current_user.id,
293                training_module_id = module_id,
294                start_date = datetime.now(timezone.utc)
295            )
296            db.session.add(progress)
297            db.session.flush()
298
299        existing_answers = {ans.question_id: ans for ans in progress.answers}
300
301        for question in module.questions:
302            selected_option_id = request.form.get(f'question_{question.id}')
303            if selected_option_id:
304                selected_option = Option.query.get(selected_option_id)
305                is_correct = selected_option.is_correct if selected_option else False
306
307                if question.id in existing_answers:
308                    existing_answers[question.id].selected_option = selected_option
309                    existing_answers[question.id].is_correct = is_correct
310                else:
311                    new_answer = UserQuestionAnswer(
312                        progress = progress, 
313                        question = question, 
314                        selected_option = selected_option, 
315                        is_correct = is_correct
316                    )
317                    db.session.add(new_answer)
318
319        if action == "save":
320            db.session.commit()
321            flash("Your progress has been saved")
322            return redirect(url_for('training_dashboard'))
323        else:
324            correct_answers = sum(1 for ans in progress.answers if ans.is_correct)
325            total_questions = len(module.questions)
326            progress.score = correct_answers
327            progress.completed_date = datetime.now(timezone.utc)
328
329            if (correct_answers / total_questions) >= passing_threshold:
330                flash("Module completed! You passed.")
331            else:
332                flash("Module failed, please retake module.")
333            
334            db.session.commit() 
335            return redirect(url_for('training_dashboard'))
336
337    user_answers = {}
338    if progress and not progress.completed_date:
339        for ans in progress.answers:
340            user_answers[ans.question_id] = ans.selected_option_id
341
342    return render_template(
343        'take_training_module.html',
344        module=module,
345        title=module.module_title,
346        user_answers=user_answers
347    )
348
349
350@app.route('/update_profile_photo', methods = ['POST'])
351@login_required
352def update_profile_photo():
353    """Upload and set a new profile photo for the current user.
354
355    Validates and saves an uploaded image file, replacing any existing custom
356    profile photo. 
357
358    Details:
359        - Rejects requests with no file or an empty filename.
360        - Ensures the uploaded file has an allowed extension.
361        - Generates a random filename.
362        - Removes previously uploaded photo file.
363        - Saves the new file under `PROFILE_PHOTO_FOLDER` and updates the user 
364        record.
365
366    Returns:
367        - Redirect to `next` URL.
368    """
369    photo = request.files.get('photo')
370    if not photo or photo.filename == '':
371        return redirect(request.form.get('next'))
372
373    if not current_app.config['ALLOWED_EXTENSIONS'] or \
374       not Config.allowed_file(photo.filename):
375        return redirect(request.form.get('next'))
376
377    rand = secrets.token_hex(8)
378    _, ext = os.path.splitext(secure_filename(photo.filename))
379    filename = rand + ext.lower()
380
381    old = current_user.profile_photo or ''
382    if old != 'profileDefault.png':
383        old_path = os.path.join(
384            current_app.config['PROFILE_PHOTO_FOLDER'], 
385            old
386        )
387        if os.path.exists(old_path):
388            os.remove(old_path)
389
390    save_path = os.path.join(
391        current_app.config['PROFILE_PHOTO_FOLDER'], 
392        filename
393    )
394    photo.save(save_path)
395
396    current_user.profile_photo = filename
397    db.session.commit()
398
399    return redirect(request.form.get('next'))
@app.route('/login', methods=['GET', 'POST'])
def login():
43@app.route('/login', methods=['GET', 'POST'])
44def login():      
45    """Authenticate a user and redirect to role-specific dashboard.
46
47    Displays the login form and processes credentials on POST. On successful
48    authentication, redirects users based on their role. Rejects invalid
49    credentials with a flash message.
50
51    Returns:
52        - Rendered login template on GET or invalid credentials.
53        - Redirect to admin, manager, or staff dashboard on successful login.
54    """
55    if current_user.is_authenticated:
56        if current_user.role.role_name == "admin":
57            return redirect(url_for('admin_dashboard'))
58        elif current_user.role.role_name == "manager":
59            return redirect(url_for('manager_dashboard'))
60        elif current_user.role.role_name == "staff":
61            return redirect(url_for('staff_dashboard'))
62        else:
63            return redirect(url_for('logout'))
64    
65    form = LoginForm()
66    if form.validate_on_submit():
67        user = db.session.scalar(
68            sa.select(User).where(User.username == form.username.data)
69        )
70        
71        if user is None or not user.check_password(form.password.data):
72            flash('Invalid username or password')
73            return redirect(url_for('login'))
74        
75        login_user(user, remember=form.remember_me.data)
76
77        next_page = request.args.get('next')
78        if next_page and urlsplit(next_page).netloc == '':                
79            return redirect(next_page)
80        
81        if user.role.role_name == "admin":
82            return redirect(url_for('admin_dashboard'))            
83        elif user.role.role_name == "manager":
84            return redirect(url_for('manager_dashboard'))
85        elif user.role.role_name == "staff":
86            return redirect(url_for('staff_dashboard'))
87        else:
88            return redirect(url_for('logout'))
89
90    return render_template(
91        'login.html', 
92        title='Sign In', 
93        form=form
94    )

Authenticate a user and redirect to role-specific dashboard.

Displays the login form and processes credentials on POST. On successful authentication, redirects users based on their role. Rejects invalid credentials with a flash message.

Returns:
  • Rendered login template on GET or invalid credentials.
  • Redirect to admin, manager, or staff dashboard on successful login.
@app.route('/')
def index():
 97@app.route('/')
 98def index():
 99    """Redirect to the login page."""
100    return redirect(url_for('login'))

Redirect to the login page.

@app.route('/logout')
def logout():
103@app.route('/logout')
104def logout():
105    """Logs out the current user and redirects to the login page."""
106    logout_user()
107    return redirect(url_for('login'))

Logs out the current user and redirects to the login page.

@app.route('/dashboard_staff')
def staff_dashboard():
110@app.route('/dashboard_staff')
111@login_required
112def staff_dashboard():
113    if current_user.role.role_name != "staff":
114        return redirect(url_for('login'))
115
116    manager = current_user.manager
117
118    steps = (
119        current_user.onboarding_path.steps
120        if current_user.onboarding_path 
121        else []
122    )
123
124    active_modules = [
125        step.training_module 
126        for step in steps 
127        if step.training_module.active
128    ]
129
130    passing_threshold = 0.5
131    to_do_list = []
132
133    for module in active_modules:
134        progress = UserModuleProgress.query.filter_by(
135            user_id=current_user.id,
136            training_module_id=module.id
137        ).order_by(UserModuleProgress.id.desc()).first()
138
139        if (
140            progress is None
141            or progress.completed_date is None
142            or (
143                progress.score is not None
144                and (progress.score / max(len(module.questions), 1)) < passing_threshold
145            )
146        ):
147            to_do_list.append(module)
148
149    return render_template(
150        'dashboard_staff.html',
151        title='Staff Dashboard',
152        to_do_list=to_do_list,
153        manager=manager
154    )
@app.route('/dashboard_manager')
def manager_dashboard():
157@app.route('/dashboard_manager')
158@login_required
159def manager_dashboard():
160    """Render the manager dashboard if user is a manager."""
161    if current_user.role.role_name != "manager":
162        return redirect(url_for('login'))
163    
164    return render_template(
165        '/dashboard_manager.html', 
166        title='Manager Dashboard'
167    )

Render the manager dashboard if user is a manager.

@app.route('/dashboard_training', methods=['GET'])
def training_dashboard():
170@app.route('/dashboard_training', methods = ['GET'])
171@login_required
172def training_dashboard():
173    """Display training modules for staff users.
174
175    Renders a dashboard showing training modules that are either completed,
176    in progress, or yet to be started by the staff user.
177
178    Returns:
179        - Redirect to logout if the current user isn't staff.
180        - Rendered “dashboard_training.html” with three lists:
181            `to_be_completed_modules`, `in_progress_modules`, and `completed_modules`.
182    """
183    if current_user.role.role_name != "staff":
184        return redirect(url_for('logout'))
185
186    onboarding_path = current_user.onboarding_path
187    steps = onboarding_path.steps if onboarding_path else []
188    active_modules = [
189        step.training_module for step in steps if step.training_module.active
190    ]
191
192    completed_modules = []
193    to_be_completed_modules = []
194    in_progress_modules = []
195    passing_threshold = 0.5
196
197    for module in active_modules:
198        progress = UserModuleProgress.query.filter_by(
199            user_id=current_user.id,
200            training_module_id=module.id
201        ).order_by(UserModuleProgress.id.desc()).first()
202
203        if not progress:
204            to_be_completed_modules.append(module)
205        else:
206            total_questions = len(module.questions) or 1 
207            if progress.completed_date:
208                if (progress.score is not None
209                        and (progress.score / total_questions) >= passing_threshold):
210                    completed_modules.append({
211                        'module': module,
212                        'score': progress.score,
213                        'passed': True
214                    })
215                else:
216                    to_be_completed_modules.append(module)
217            else:
218                in_progress_modules.append(module)
219
220    return render_template(
221        'dashboard_training.html', 
222        title="Training Dashboard", 
223        to_be_completed_modules=to_be_completed_modules, 
224        in_progress_modules=in_progress_modules, 
225        completed_modules=completed_modules
226    )

Display training modules for staff users.

Renders a dashboard showing training modules that are either completed, in progress, or yet to be started by the staff user.

Returns:
  • Redirect to logout if the current user isn't staff.
  • Rendered “dashboard_training.html” with three lists: to_be_completed_modules, in_progress_modules, and completed_modules.
@app.route('/staff/take_training_module/<int:module_id>', methods=['GET', 'POST'])
def take_training_module(module_id):
229@app.route('/staff/take_training_module/<int:module_id>', methods = ['GET', 'POST'])
230@login_required
231def take_training_module(module_id):
232    """Handles the staff users training module session.
233
234    On GET:
235      - Retrieves or creates a UserModuleProgress record for the given module.
236      - If the last attempt was failed, starts a fresh attempt.
237      - Leaves in-progress attempts intact for continuation.
238
239    On POST:
240      - Records each submitted answer, updating existing answers or creating new ones.
241      - If `action == "save"`, commits progress without completing the module.
242      - Otherwise, computes the final score, marks completion, flashes a pass/fail message,
243        and commits.
244
245    Args:
246        module_id (int): The ID of the training module to be taken.
247   
248    Returns:
249        - Redirects to logout if the user is not in staff role.
250        - Renders 'take_training_module.html' with the module and any of the
251            users previous answers.
252        - Redirects to the training dashboard after saving or submitting 
253            answers.
254    """
255    if current_user.role.role_name != 'staff':
256        return redirect(url_for('logout'))
257    
258    module = TrainingModule.query.get_or_404(module_id)
259    passing_threshold = 0.5
260
261    progress = UserModuleProgress.query.filter_by(
262        user_id=current_user.id,
263        training_module_id=module_id
264    ).order_by(UserModuleProgress.id.desc()).first()
265
266    if request.method == 'GET':
267        if progress:
268            total_questions = len(module.questions) or 1
269            if progress.completed_date:
270                if (progress.score is not None
271                        and (progress.score / total_questions) < passing_threshold):
272                    progress = UserModuleProgress(
273                        user_id=current_user.id,
274                        training_module_id=module_id,
275                        start_date=datetime.now(timezone.utc)
276                    )
277                    db.session.add(progress)
278                    db.session.commit()
279        else:
280            progress = UserModuleProgress(
281                user_id=current_user.id,
282                training_module_id=module_id,
283                start_date=datetime.now(timezone.utc)
284            )
285            db.session.add(progress)
286            db.session.commit()
287
288    if request.method == 'POST':
289        action = request.form.get('action', 'submit')
290        
291        if not progress:
292            progress = UserModuleProgress(
293                user_id = current_user.id,
294                training_module_id = module_id,
295                start_date = datetime.now(timezone.utc)
296            )
297            db.session.add(progress)
298            db.session.flush()
299
300        existing_answers = {ans.question_id: ans for ans in progress.answers}
301
302        for question in module.questions:
303            selected_option_id = request.form.get(f'question_{question.id}')
304            if selected_option_id:
305                selected_option = Option.query.get(selected_option_id)
306                is_correct = selected_option.is_correct if selected_option else False
307
308                if question.id in existing_answers:
309                    existing_answers[question.id].selected_option = selected_option
310                    existing_answers[question.id].is_correct = is_correct
311                else:
312                    new_answer = UserQuestionAnswer(
313                        progress = progress, 
314                        question = question, 
315                        selected_option = selected_option, 
316                        is_correct = is_correct
317                    )
318                    db.session.add(new_answer)
319
320        if action == "save":
321            db.session.commit()
322            flash("Your progress has been saved")
323            return redirect(url_for('training_dashboard'))
324        else:
325            correct_answers = sum(1 for ans in progress.answers if ans.is_correct)
326            total_questions = len(module.questions)
327            progress.score = correct_answers
328            progress.completed_date = datetime.now(timezone.utc)
329
330            if (correct_answers / total_questions) >= passing_threshold:
331                flash("Module completed! You passed.")
332            else:
333                flash("Module failed, please retake module.")
334            
335            db.session.commit() 
336            return redirect(url_for('training_dashboard'))
337
338    user_answers = {}
339    if progress and not progress.completed_date:
340        for ans in progress.answers:
341            user_answers[ans.question_id] = ans.selected_option_id
342
343    return render_template(
344        'take_training_module.html',
345        module=module,
346        title=module.module_title,
347        user_answers=user_answers
348    )

Handles the staff users training module session.

On GET:
  • Retrieves or creates a UserModuleProgress record for the given module.
  • If the last attempt was failed, starts a fresh attempt.
  • Leaves in-progress attempts intact for continuation.
On POST:
  • Records each submitted answer, updating existing answers or creating new ones.
  • If action == "save", commits progress without completing the module.
  • Otherwise, computes the final score, marks completion, flashes a pass/fail message, and commits.
Arguments:
  • module_id (int): The ID of the training module to be taken.
Returns:
  • Redirects to logout if the user is not in staff role.
  • Renders 'take_training_module.html' with the module and any of the users previous answers.
  • Redirects to the training dashboard after saving or submitting answers.
@app.route('/update_profile_photo', methods=['POST'])
def update_profile_photo():
351@app.route('/update_profile_photo', methods = ['POST'])
352@login_required
353def update_profile_photo():
354    """Upload and set a new profile photo for the current user.
355
356    Validates and saves an uploaded image file, replacing any existing custom
357    profile photo. 
358
359    Details:
360        - Rejects requests with no file or an empty filename.
361        - Ensures the uploaded file has an allowed extension.
362        - Generates a random filename.
363        - Removes previously uploaded photo file.
364        - Saves the new file under `PROFILE_PHOTO_FOLDER` and updates the user 
365        record.
366
367    Returns:
368        - Redirect to `next` URL.
369    """
370    photo = request.files.get('photo')
371    if not photo or photo.filename == '':
372        return redirect(request.form.get('next'))
373
374    if not current_app.config['ALLOWED_EXTENSIONS'] or \
375       not Config.allowed_file(photo.filename):
376        return redirect(request.form.get('next'))
377
378    rand = secrets.token_hex(8)
379    _, ext = os.path.splitext(secure_filename(photo.filename))
380    filename = rand + ext.lower()
381
382    old = current_user.profile_photo or ''
383    if old != 'profileDefault.png':
384        old_path = os.path.join(
385            current_app.config['PROFILE_PHOTO_FOLDER'], 
386            old
387        )
388        if os.path.exists(old_path):
389            os.remove(old_path)
390
391    save_path = os.path.join(
392        current_app.config['PROFILE_PHOTO_FOLDER'], 
393        filename
394    )
395    photo.save(save_path)
396
397    current_user.profile_photo = filename
398    db.session.commit()
399
400    return redirect(request.form.get('next'))

Upload and set a new profile photo for the current user.

Validates and saves an uploaded image file, replacing any existing custom profile photo.

Details:
  • Rejects requests with no file or an empty filename.
  • Ensures the uploaded file has an allowed extension.
  • Generates a random filename.
  • Removes previously uploaded photo file.
  • Saves the new file under PROFILE_PHOTO_FOLDER and updates the user record.
Returns:
  • Redirect to next URL.