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'))
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.
97@app.route('/') 98def index(): 99 """Redirect to the login page.""" 100 return redirect(url_for('login'))
Redirect to the login page.
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.
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 )
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.
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, andcompleted_modules.
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.
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_FOLDERand updates the user record.
Returns:
- Redirect to
nextURL.