From 4a2e2f053fef14b082224adbe83a836393a56d23 Mon Sep 17 00:00:00 2001 From: cedrick2711 Date: Tue, 8 Sep 2026 11:27:53 -0400 Subject: [PATCH] Changement du refresh handler, soft reset d'ajouter sur beaucoup de formulaire de modification --- app/app.py | 54 ++++- app/models/__init__.py | 2 +- app/models/admin_settings.py | 2 +- app/models/tryout/tryout.py | 8 +- app/responders.py | 84 ++++++++ app/routes/admin.py | 103 ++++++---- app/routes/evaluations.py | 58 +++--- app/routes/matches.py | 21 +- app/routes/team_matches.py | 29 +-- app/routes/teams.py | 93 ++++----- app/routes/tryouts.py | 91 ++++----- app/routes/users/contracts.py | 33 ++-- app/routes/users/notes.py | 39 ++-- app/routes/users/one_on_one.py | 23 +-- app/routes/users/profile.py | 3 +- app/static/js/main.js | 209 ++++++++++++++++++-- app/supporting_scripts/get_refresh_token.py | 2 +- app/templates/layouts/base.html | 2 +- app/templates/pages/add_note.html | 2 +- app/templates/pages/admin.html | 8 +- app/templates/pages/batch_evaluate.html | 2 +- app/templates/pages/evaluate_player.html | 2 +- app/templates/pages/match_form.html | 2 +- app/templates/pages/my_teams.html | 2 +- app/templates/pages/notes.html | 8 +- app/templates/pages/one_on_one.html | 2 +- app/templates/pages/personal_notes.html | 2 +- app/templates/pages/team_match_form.html | 2 +- app/templates/pages/team_matches.html | 6 +- app/templates/pages/team_notes.html | 2 +- app/templates/pages/teams.html | 14 +- app/templates/pages/view_tryout.html | 77 ++++---- tests/test_admin_app_settings.py | 2 +- tests/test_admin_audit_log.py | 2 +- tests/test_admin_backup.py | 23 ++- tests/test_admin_csrf.py | 2 +- tests/test_admin_panel.py | 2 +- tests/test_admin_route_map.py | 2 +- tests/test_admin_season.py | 6 +- tests/test_admin_template.py | 2 +- tests/test_admin_tryout_toggle.py | 10 +- tests/test_admin_wipe.py | 17 +- 42 files changed, 722 insertions(+), 333 deletions(-) create mode 100644 app/responders.py diff --git a/app/app.py b/app/app.py index ea7141d..cb05868 100644 --- a/app/app.py +++ b/app/app.py @@ -26,8 +26,10 @@ from sqlalchemy import text from werkzeug.exceptions import HTTPException from app import i18n +from app.api import json_endpoint from app.extensions import babel, csrf, db, limiter, login_manager from app.pagination import page_url +from app.responders import current_revision, is_ajax_submit load_dotenv() @@ -347,6 +349,42 @@ def create_app(config=None): response.headers['X-Request-Id'] = g.get('request_id', '-') return response + @app.before_request + def reject_stale_ajax_write(): + """Reject an AJAX edit whose view is stale, instead of overwriting. + + The AJAX form handler in main.js sends the revision it last saw in the + X-Revision header. If another client has committed since then the + server's revision has moved on, and clobbering their change would be + silent last-write-wins. Answer 409 so the client asks the user to + reload the page first. + """ + if request.method != 'POST': + return None + if not is_ajax_submit(): + return None + sent = request.headers.get('X-Revision') + if sent is None: + return None + try: + sent = int(sent) + except (TypeError, ValueError): + return None + if sent != current_revision(): + return ( + jsonify( + { + 'error': 'conflict', + 'message': _( + 'This page was changed by someone else. Reload to ' + 'see the latest changes before saving.' + ), + } + ), + 409, + ) + return None + @app.url_defaults def version_static_urls(endpoint, values): """Stamp every static URL with the file's modification time. @@ -403,6 +441,7 @@ def create_app(config=None): configure_logging(app) + from app.routes.admin import admin_bp from app.routes.auth import auth_bp from app.routes.evaluations import evaluations_bp from app.routes.main import main_bp @@ -411,7 +450,6 @@ def create_app(config=None): from app.routes.teams import teams_bp from app.routes.tryouts import tryouts_bp from app.routes.users import users_bp - from app.routes.admin import admin_bp app.register_blueprint(auth_bp) app.register_blueprint(tryouts_bp) @@ -531,6 +569,18 @@ def create_app(config=None): return jsonify(health_data), 200 + @app.route('/api/revision') + @json_endpoint + def data_revision(): + """The current dataset revision, for the AJAX form handler in main.js. + + One primary-key lookup in app_settings. The client compares this with + the revision it last saw to decide whether a focus refresh is needed, + and sends it back with every AJAX mutation so a stale edit is rejected + with a 409 instead of overwriting someone else's change. + """ + return jsonify({'revision': current_revision()}) + # ========================================================================= # Custom Error Handlers # ========================================================================= @@ -725,4 +775,4 @@ def create_app(config=None): # own host, port and debug default — `python app/app.py` bound 0.0.0.0:10000 # while `python run.py` bound 127.0.0.2:5000 with the debugger on. This # module defines the factory; run.py starts it for development, wsgi.py for -# production (ARCH-007). \ No newline at end of file +# production (ARCH-007). diff --git a/app/models/__init__.py b/app/models/__init__.py index 88258f5..e73a455 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -96,4 +96,4 @@ from app.models.personal_note import PersonalNote from app.models.one_on_one_request import OneOnOneRequest from app.models.admin_settings import AppSettings from app.models.backup_record import BackupRecord -from app.models.audit_log import AuditLog \ No newline at end of file +from app.models.audit_log import AuditLog diff --git a/app/models/admin_settings.py b/app/models/admin_settings.py index 3346598..341dad4 100644 --- a/app/models/admin_settings.py +++ b/app/models/admin_settings.py @@ -53,4 +53,4 @@ class AppSettings(db.Model): @staticmethod def set_bool(key, value): """Store a boolean setting as 'true' / 'false'.""" - AppSettings.set(key, 'true' if value else 'false') \ No newline at end of file + AppSettings.set(key, 'true' if value else 'false') diff --git a/app/models/tryout/tryout.py b/app/models/tryout/tryout.py index 1d4d2b9..db6dd01 100644 --- a/app/models/tryout/tryout.py +++ b/app/models/tryout/tryout.py @@ -20,14 +20,18 @@ class Tryout(db.Model): max_players = db.Column(db.Integer, nullable=True) created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True) - manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # deprecated, kept for migration + manager_id = db.Column( + db.Integer, db.ForeignKey('users.id'), nullable=True + ) # deprecated, kept for migration coach_id = db.Column( db.Integer, db.ForeignKey('users.id'), nullable=True ) # deprecated, kept for migration created_at = db.Column(db.DateTime, default=utc_now_naive) creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts') - manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts') # deprecated + manager = db.relationship( + 'User', foreign_keys=[manager_id], backref='managed_tryouts' + ) # deprecated coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts') coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts') managers = db.relationship('User', secondary=tryout_managers, backref='managed_tryouts_m2m') diff --git a/app/responders.py b/app/responders.py new file mode 100644 index 0000000..8bdaa9c --- /dev/null +++ b/app/responders.py @@ -0,0 +1,84 @@ +"""Helpers for mutating routes that should not force a full page reload. + +The site is server-rendered, and every mutating route used to end with +``return redirect(url_for(...))``, so each change reloaded the whole page: +scroll reset, open dialogs closed, and chaining several edits meant waiting +for a full render between each one. + +These helpers let a route answer an AJAX submit (the ``data-ajax`` forms in +main.js) with a small JSON payload instead of a redirect, while the plain +HTML form path keeps behaving exactly as before. The client then fetches the +page again and swaps only the content region, so the page never reloads. + +The counterpart that performs the swap lives in app/static/js/main.js. +""" + +from flask import get_flashed_messages, jsonify, redirect, request, session + +from app.models import AppSettings + +#: Key holding a coarse, monotonically increasing stamp for the whole dataset. +#: It advances after every committed mutation so a stale client can be told +#: (with a 409) that someone else has changed what it is looking at. +REVISION_KEY = 'data_revision' + + +def is_ajax_submit(): + """Whether this POST came from the fetch-based form handler in main.js.""" + return request.headers.get('X-Requested-With') == 'XMLHttpRequest' + + +def current_revision(): + """The current revision stamp, as an int (0 when nothing has been bumped).""" + try: + return int(AppSettings.get(REVISION_KEY, '0') or '0') + except (TypeError, ValueError): + return 0 + + +def bump_revision(): + """Advance the revision stamp after a committed change. + + Stored through AppSettings so it survives restarts and is shared by every + worker, unlike a module-level counter. + """ + AppSettings.set(REVISION_KEY, str(current_revision() + 1)) + + +def _flashed_success(): + """Whether a success flash is pending, without consuming it. + + The HTML path still redirects and must leave the flash for the next page + to render; peeking at the session is the only way to know without clearing + it. Only a successful commit advances the revision -- a rejected edit must + not make other users reload for nothing. + """ + flashes = session.get('_flashes', []) + return any(category == 'success' for category, _message in flashes) + + +def respond_after_change(redirect_url): + """Answer a committed (or rejected) mutation. + + For a normal HTML form submit this is exactly the ``redirect(...)`` the + routes used to return. For an AJAX submit it returns JSON telling the + client where to fetch the refreshed fragment and which flash messages to + show, so the page can update in place instead of reloading. + """ + if _flashed_success(): + bump_revision() + + if is_ajax_submit(): + messages = [ + {'category': category, 'text': message} + for category, message in get_flashed_messages(with_categories=True) + ] + return jsonify( + { + 'ok': True, + 'redirect': redirect_url, + 'messages': messages, + 'revision': current_revision(), + } + ) + return redirect(redirect_url) diff --git a/app/routes/admin.py b/app/routes/admin.py index 0fdb3a9..88d3ebc 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -9,19 +9,35 @@ import os from datetime import datetime from flask import ( - Blueprint, render_template, redirect, url_for, flash, request, + Blueprint, + flash, + redirect, + render_template, + request, send_file, + url_for, ) -from flask_login import login_required, current_user +from flask_login import current_user, login_required from app.extensions import db from app.models import ( - Admin, User, Tryout, OrgTeam, TeamPlayer, TeamMatch, - TeamMatchParticipant, AppSettings, BackupRecord, AuditLog, + Admin, + AppSettings, + AuditLog, + BackupRecord, + OrgTeam, + TeamMatch, + TeamMatchParticipant, + TeamPlayer, + Tryout, + User, ) +from app.responders import respond_after_change from app.supporting_scripts.backup import ( - BACKUP_DIR, BackupError, backup_database, backup_documents, - create_backup_dir, parse_database_url, verify_backup, + BackupError, + backup_database, + create_backup_dir, + parse_database_url, ) admin_bp = Blueprint('admin', __name__, url_prefix='/admin') @@ -56,6 +72,7 @@ def _log(action, details=None): # Dashboard # --------------------------------------------------------------------------- + @admin_bp.route('') @login_required def dashboard(): @@ -96,6 +113,7 @@ def dashboard(): # Backups # --------------------------------------------------------------------------- + def _create_backup_record(backup_type='manual', notes=None): """Run a database backup and return a BackupRecord, or raise BackupError.""" create_backup_dir() @@ -130,11 +148,11 @@ def create_backup(): except BackupError as e: flash(f'Backup failed: {e}', 'danger') _log('backup_failed', f'Error: {e}') - return redirect(url_for('admin.dashboard')) + return respond_after_change(url_for('admin.dashboard')) _log('backup_created', f'File: {record.filename} ({record.size_bytes} bytes)') flash(f'Backup created successfully: {record.filename}', 'success') - return redirect(url_for('admin.dashboard')) + return respond_after_change(url_for('admin.dashboard')) @admin_bp.route('/backup//download') @@ -147,7 +165,7 @@ def download_backup(backup_id): record = BackupRecord.query.get_or_404(backup_id) if not os.path.exists(record.file_path): flash('Backup file is missing from disk.', 'danger') - return redirect(url_for('admin.dashboard')) + return respond_after_change(url_for('admin.dashboard')) _log('backup_downloaded', f'File: {record.filename}') return send_file(record.file_path, as_attachment=True, download_name=record.filename) @@ -166,13 +184,13 @@ def delete_backup(backup_id): os.remove(record.file_path) except OSError as e: flash(f'Could not remove backup file: {e}', 'danger') - return redirect(url_for('admin.dashboard')) + return respond_after_change(url_for('admin.dashboard')) _log('backup_deleted', f'File: {record.filename}') db.session.delete(record) db.session.commit() flash(f'Backup {record.filename} deleted.', 'success') - return redirect(url_for('admin.dashboard')) + return respond_after_change(url_for('admin.dashboard')) @admin_bp.route('/backup//restore', methods=['POST']) @@ -189,7 +207,7 @@ def restore_backup(backup_id): record = BackupRecord.query.get_or_404(backup_id) if not os.path.exists(record.file_path): flash('Backup file is missing from disk.', 'danger') - return redirect(url_for('admin.dashboard')) + return respond_after_change(url_for('admin.dashboard')) # Create a safety backup of the current state before restoring. try: @@ -199,22 +217,31 @@ def restore_backup(backup_id): ) except BackupError as e: flash(f'Could not create safety backup, restore aborted: {e}', 'danger') - return redirect(url_for('admin.dashboard')) + return respond_after_change(url_for('admin.dashboard')) # Restore using pg_restore import subprocess + from app.supporting_scripts.backup import ( - PG_RESTORE, dump_environment, parse_database_url, + PG_RESTORE, + dump_environment, + parse_database_url, ) conn = parse_database_url(os.getenv('DATABASE_URL')) cmd = [ PG_RESTORE, - '--host', conn['host'], - '--port', conn['port'], - '--username', conn['user'], - '--dbname', conn['dbname'], - '--clean', '--if-exists', '--no-owner', + '--host', + conn['host'], + '--port', + conn['port'], + '--username', + conn['user'], + '--dbname', + conn['dbname'], + '--clean', + '--if-exists', + '--no-owner', record.file_path, ] try: @@ -230,20 +257,21 @@ def restore_backup(backup_id): except (subprocess.TimeoutExpired, FileNotFoundError, RuntimeError) as e: flash(f'Restore failed: {e}', 'danger') _log('backup_restore_failed', f'File: {record.filename}, Error: {e}') - return redirect(url_for('admin.dashboard')) + return respond_after_change(url_for('admin.dashboard')) _log('backup_restored', f'File: {record.filename}') flash( f'Backup {record.filename} restored successfully. The database has been rolled back.', 'success', ) - return redirect(url_for('admin.dashboard')) + return respond_after_change(url_for('admin.dashboard')) # --------------------------------------------------------------------------- # Tryouts open/close toggle # --------------------------------------------------------------------------- + @admin_bp.route('/toggle-tryouts', methods=['POST']) @login_required def toggle_tryouts(): @@ -258,13 +286,14 @@ def toggle_tryouts(): state = 'opened' if new_value else 'closed' _log('tryouts_toggled', f'Tryouts {state}') flash(f'Tryouts are now {state}.', 'success') - return redirect(url_for('admin.dashboard')) + return respond_after_change(url_for('admin.dashboard')) # --------------------------------------------------------------------------- # Season lifecycle # --------------------------------------------------------------------------- + @admin_bp.route('/season/start', methods=['POST']) @login_required def start_season(): @@ -274,19 +303,19 @@ def start_season(): if AppSettings.get_bool('season_active', default=False): flash('A season is already active. End it before starting a new one.', 'danger') - return redirect(url_for('admin.dashboard')) + return respond_after_change(url_for('admin.dashboard')) name = request.form.get('season_name', '').strip() start_date = request.form.get('season_start', '').strip() if not start_date: flash('A season start date is required.', 'danger') - return redirect(url_for('admin.dashboard')) + return respond_after_change(url_for('admin.dashboard')) try: datetime.strptime(start_date, '%Y-%m-%d') except ValueError: flash('Invalid season start date format.', 'danger') - return redirect(url_for('admin.dashboard')) + return respond_after_change(url_for('admin.dashboard')) AppSettings.set('season_name', name or 'Untitled Season') AppSettings.set('season_start', start_date) @@ -295,7 +324,7 @@ def start_season(): _log('season_started', f'Season: {name or "Untitled Season"}, Start: {start_date}') flash(f'Season "{name or "Untitled Season"}" has begun.', 'success') - return redirect(url_for('admin.dashboard')) + return respond_after_change(url_for('admin.dashboard')) @admin_bp.route('/season/end', methods=['POST']) @@ -307,18 +336,18 @@ def end_season(): if not AppSettings.get_bool('season_active', default=False): flash('No season is currently active.', 'danger') - return redirect(url_for('admin.dashboard')) + return respond_after_change(url_for('admin.dashboard')) end_date = request.form.get('season_end', '').strip() if not end_date: flash('A season end date is required.', 'danger') - return redirect(url_for('admin.dashboard')) + return respond_after_change(url_for('admin.dashboard')) try: datetime.strptime(end_date, '%Y-%m-%d') except ValueError: flash('Invalid season end date format.', 'danger') - return redirect(url_for('admin.dashboard')) + return respond_after_change(url_for('admin.dashboard')) name = AppSettings.get('season_name') AppSettings.set('season_end', end_date) @@ -326,7 +355,9 @@ def end_season(): _log('season_ended', f'Season: {name}, End: {end_date}') flash(f'Season "{name}" has ended.', 'success') - return redirect(url_for('admin.dashboard')) + return respond_after_change(url_for('admin.dashboard')) + + @admin_bp.route('/toggle-document-uploads', methods=['POST']) @login_required def toggle_document_uploads(): @@ -341,16 +372,14 @@ def toggle_document_uploads(): state = 'enabled' if new_value else 'disabled' _log('document_uploads_toggled', f'Document uploads {state}') flash(f'Document uploads are now {state}.', 'success') - return redirect(url_for('admin.dashboard')) - - - + return respond_after_change(url_for('admin.dashboard')) # --------------------------------------------------------------------------- # Wipe teams for new season # --------------------------------------------------------------------------- + @admin_bp.route('/teams/wipe', methods=['POST']) @login_required def wipe_teams(): @@ -366,7 +395,7 @@ def wipe_teams(): confirm = request.form.get('confirm', '').strip() if confirm != 'WIPE': flash("Type 'WIPE' in the confirmation box to proceed.", 'danger') - return redirect(url_for('admin.dashboard')) + return respond_after_change(url_for('admin.dashboard')) # Safety backup before destructive operation try: @@ -376,7 +405,7 @@ def wipe_teams(): ) except BackupError as e: flash(f'Wipe aborted — could not create safety backup: {e}', 'danger') - return redirect(url_for('admin.dashboard')) + return respond_after_change(url_for('admin.dashboard')) roster_count = TeamPlayer.query.count() match_count = TeamMatch.query.count() @@ -393,4 +422,4 @@ def wipe_teams(): f'and {match_count} regular-season matches.', 'success', ) - return redirect(url_for('admin.dashboard')) \ No newline at end of file + return respond_after_change(url_for('admin.dashboard')) diff --git a/app/routes/evaluations.py b/app/routes/evaluations.py index 6470ed7..a8a8798 100644 --- a/app/routes/evaluations.py +++ b/app/routes/evaluations.py @@ -7,20 +7,13 @@ from flask import Blueprint, flash, redirect, render_template, request, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required from marshmallow import ValidationError -from flask import Blueprint, render_template, redirect, url_for, flash, request -from flask_login import login_required, current_user -from app.extensions import db -from app.models import ( - Admin, Coach, Manager, Player, - User, Tryout, Evaluation, TryoutRegistration, - OrgTeam, GAME_POSITIONS, EVALUATION_CRITERIA, -) from sqlalchemy import func from sqlalchemy.orm import aliased from app.extensions import db from app.forms import flash_validation_errors, form_payload from app.models import ( + EVALUATION_CRITERIA, GAME_POSITIONS, Admin, Evaluation, @@ -30,6 +23,7 @@ from app.models import ( User, ) from app.pagination import paginate +from app.responders import respond_after_change from app.validators import EvaluationSchema evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations') @@ -157,12 +151,12 @@ def evaluate_player(tryout_id, player_id): ) if not is_registered: flash(_('Player is not registered for this tryout.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) player = db.get_or_404(User, player_id) if not isinstance(player, Player): flash(_('Can only evaluate players.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) existing_eval = Evaluation.query.filter_by( tryout_id=tryout_id, @@ -216,7 +210,7 @@ def evaluate_player(tryout_id, player_id): evaluation.position_recommendation = data['position_recommendation'] db.session.commit() - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return render_evaluation_form() @@ -291,23 +285,31 @@ def batch_evaluate(tryout_id): player = User.query.get(pid) if not player or not isinstance(player, Player): continue - is_registered = TryoutRegistration.query.filter_by( - tryout_id=tryout_id, player_id=pid, - ).first() is not None + is_registered = ( + TryoutRegistration.query.filter_by( + tryout_id=tryout_id, + player_id=pid, + ).first() + is not None + ) if not is_registered: continue existing = Evaluation.query.filter_by( - tryout_id=tryout_id, player_id=pid, evaluator_id=current_user.id, + tryout_id=tryout_id, + player_id=pid, + evaluator_id=current_user.id, ).first() existing_scores = { field_name: getattr(existing, field_name) if existing else None for field_name, _ in EVALUATION_CRITERIA } - players.append({ - 'player': player, - 'existing': existing, - 'existing_scores': existing_scores, - }) + players.append( + { + 'player': player, + 'existing': existing, + 'existing_scores': existing_scores, + } + ) if not players: flash('No valid players selected for evaluation.', 'danger') @@ -329,7 +331,8 @@ def batch_evaluate(tryout_id): _apply_evaluation(existing, scores, comments, position) else: evaluation = Evaluation( - tryout_id=tryout_id, player_id=pid, + tryout_id=tryout_id, + player_id=pid, evaluator_id=current_user.id, ) _apply_evaluation(evaluation, scores, comments, position) @@ -338,9 +341,12 @@ def batch_evaluate(tryout_id): db.session.commit() flash(f'Saved evaluations for {saved} player(s).', 'success') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) - return render_template('pages/batch_evaluate.html', - tryout=tryout, players=players, - evaluation_criteria=EVALUATION_CRITERIA, - game_positions=GAME_POSITIONS) + return render_template( + 'pages/batch_evaluate.html', + tryout=tryout, + players=players, + evaluation_criteria=EVALUATION_CRITERIA, + game_positions=GAME_POSITIONS, + ) diff --git a/app/routes/matches.py b/app/routes/matches.py index c42b135..906366f 100644 --- a/app/routes/matches.py +++ b/app/routes/matches.py @@ -5,7 +5,7 @@ Uses polymorphic isinstance checks instead of role-string comparisons. from datetime import datetime, timedelta -from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for +from flask import Blueprint, flash, jsonify, render_template, request, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required from marshmallow import ValidationError @@ -33,6 +33,7 @@ from app.models import ( User, ) from app.permissions import visible_org_teams +from app.responders import respond_after_change from app.services.scheduling import notify_participants, zip_participants from app.validators import MatchEditSchema, MatchSchema @@ -445,11 +446,11 @@ def create_match(tryout_id): tryout = db.get_or_404(Tryout, tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('You do not have permission to schedule matches for this tryout.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if tryout.is_ended: flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) teams = Team.query.filter_by(tryout_id=tryout_id).all() all_players = registered_players(tryout_id) @@ -507,7 +508,7 @@ def create_match(tryout_id): ) flash(_('Match scheduled successfully!'), 'success') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return rerender() @@ -521,11 +522,11 @@ def edit_match(match_id): if not current_user.can_manage_this_tryout(tryout): flash(_('You do not have permission to edit this match.'), 'danger') - return redirect(url_for('matches.calendar')) + return respond_after_change(url_for('matches.calendar')) if tryout.is_ended: flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id)) teams = Team.query.filter_by(tryout_id=tryout.id).all() all_players = registered_players(tryout.id) @@ -636,7 +637,7 @@ def edit_match(match_id): ) flash(_('Match updated successfully!'), 'success') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id)) return rerender() @@ -672,10 +673,10 @@ def delete_match(match_id): tryout = match.tryout if not current_user.can_manage_this_tryout(tryout): flash(_('You do not have permission to delete this match.'), 'danger') - return redirect(url_for('matches.calendar')) + return respond_after_change(url_for('matches.calendar')) if tryout.is_ended: flash(_('This tryout has ended. Matches can no longer be deleted.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id)) # Notes outlive the match they were taken during: a coach's observation # keeps its value, and deleting it here would destroy unrelated content. @@ -688,7 +689,7 @@ def delete_match(match_id): db.session.delete(match) db.session.commit() flash(_('Match deleted successfully.'), 'success') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id)) def get_players_available_at_time(date_str, time_str): diff --git a/app/routes/team_matches.py b/app/routes/team_matches.py index 6ea7971..d5d993a 100644 --- a/app/routes/team_matches.py +++ b/app/routes/team_matches.py @@ -3,7 +3,7 @@ Uses polymorphic isinstance checks instead of role-string comparisons. """ -from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for +from flask import Blueprint, flash, jsonify, render_template, request, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required from marshmallow import ValidationError @@ -13,6 +13,7 @@ from app.extensions import db from app.forms import flash_validation_errors, form_payload from app.models import ( Admin, + AppSettings, Coach, Manager, OrgTeam, @@ -20,10 +21,10 @@ from app.models import ( TeamMatch, TeamMatchParticipant, TeamPlayer, - AppSettings, ) from app.pagination import paginate from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams +from app.responders import respond_after_change from app.routes.matches import default_end_time from app.services.scheduling import notify_participants, zip_participants from app.time_utils import utc_now_naive @@ -121,12 +122,14 @@ def create_match(team_id): """Create a new regular-season team match.""" team = db.get_or_404(OrgTeam, team_id) if season_locked(): - flash('The regular season is not active. Begin a season before scheduling matches.', 'warning') - return redirect(url_for('team_matches.list_matches')) + flash( + 'The regular season is not active. Begin a season before scheduling matches.', 'warning' + ) + return respond_after_change(url_for('team_matches.list_matches')) if not can_manage_team_match(team): flash(_('You do not have permission to schedule matches for this team.'), 'danger') - return redirect(url_for('team_matches.list_matches')) + return respond_after_change(url_for('team_matches.list_matches')) team_players = TeamPlayer.query.filter_by(org_team_id=team_id).all() prefill_date = request.args.get('date', '') @@ -219,7 +222,7 @@ def create_match(team_id): flash( _('Team match "%(title)s" scheduled successfully!', title=team_match.title), 'success' ) - return redirect(url_for('team_matches.list_matches')) + return respond_after_change(url_for('team_matches.list_matches')) return render_template('pages/team_match_form.html', team=team, team_players=team_players) @@ -233,7 +236,7 @@ def edit_match(match_id): if not can_manage_team_match(team): flash(_('You do not have permission to edit this match.'), 'danger') - return redirect(url_for('team_matches.list_matches')) + return respond_after_change(url_for('team_matches.list_matches')) if request.method == 'POST': # The three date and time fields used to be checked one at a time, @@ -263,7 +266,7 @@ def edit_match(match_id): db.session.commit() flash(_('Match updated successfully!'), 'success') - return redirect(url_for('team_matches.list_matches')) + return respond_after_change(url_for('team_matches.list_matches')) return render_template( 'pages/team_match_form.html', match=team_match, team=team, team_players=[] @@ -277,17 +280,19 @@ def delete_match(match_id): team_match = db.get_or_404(TeamMatch, match_id) team = team_match.org_team if season_locked(): - flash('The regular season is not active. Begin a season before deleting matches.', 'warning') - return redirect(url_for('team_matches.list_matches')) + flash( + 'The regular season is not active. Begin a season before deleting matches.', 'warning' + ) + return respond_after_change(url_for('team_matches.list_matches')) if not can_manage_team_match(team): flash(_('You do not have permission to delete this match.'), 'danger') - return redirect(url_for('team_matches.list_matches')) + return respond_after_change(url_for('team_matches.list_matches')) db.session.delete(team_match) db.session.commit() flash(_('Match deleted successfully.'), 'success') - return redirect(url_for('team_matches.list_matches')) + return respond_after_change(url_for('team_matches.list_matches')) @team_matches_bp.route('/api/manageable-teams') diff --git a/app/routes/teams.py b/app/routes/teams.py index 5c677ad..179445c 100644 --- a/app/routes/teams.py +++ b/app/routes/teams.py @@ -27,6 +27,7 @@ from app.models import ( User, ) from app.permissions import visible_org_teams +from app.responders import respond_after_change from app.time_utils import utc_now_naive from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema @@ -76,7 +77,7 @@ def my_teams(): """View the player's own teams with upcoming matches.""" if not isinstance(current_user, Player): flash(_('This page is for players.'), 'info') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) from app.models import TeamMatch, TeamMatchParticipant @@ -183,18 +184,18 @@ def create_team(): """Create a new organization team.""" if not current_user.can_manage_teams(): flash(_('You do not have permission to create teams.'), 'danger') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) try: data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids'))) except ValidationError as err: flash_validation_errors(err) - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) name = data['name'] if OrgTeam.query.filter_by(name=name).first(): flash(_('Team "%(name)s" already exists.', name=name), 'danger') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) coach = _staff_member(data['coach_id'], Coach) manager = _staff_member(data['manager_id'], Manager) @@ -215,7 +216,7 @@ def create_team(): db.session.commit() flash(_('Team "%(name)s" created successfully!', name=name), 'success') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) @teams_bp.route('//edit', methods=['POST']) @@ -225,18 +226,18 @@ def edit_team(team_id): team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('You do not have permission to edit this team.'), 'danger') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) try: data = OrgTeamSchema().load(form_payload(list_fields=('coach_ids', 'manager_ids'))) except ValidationError as err: flash_validation_errors(err) - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) name = data['name'] if OrgTeam.query.filter(OrgTeam.name == name, OrgTeam.id != team_id).first(): flash(_('Team "%(name)s" already exists.', name=name), 'danger') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) team.name = name @@ -269,7 +270,7 @@ def edit_team(team_id): db.session.commit() flash(_('Team "%(name)s" updated successfully!', name=name), 'success') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) @teams_bp.route('//delete', methods=['POST']) @@ -297,7 +298,7 @@ def delete_team(team_id): if not (current_user.can_manage_teams() and current_user.can_manage_this_org_team(team)): flash(_('You do not have permission to delete teams.'), 'danger') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) name = team.name @@ -328,7 +329,7 @@ def delete_team(team_id): db.session.delete(team) db.session.commit() flash(_('Team "%(name)s" deleted successfully.', name=name), 'success') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) @teams_bp.route('//add_coach', methods=['POST']) @@ -338,19 +339,19 @@ def add_coach(team_id): team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('Permission denied.'), 'danger') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) data = _posted(TeamStaffSchema()) if data is None: - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) if not data['coach_id']: flash(_('Please select a coach.'), 'danger') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) coach = db.session.get(User, data['coach_id']) if not coach or not _assignable(coach, Coach): flash(_('Only coaches can be assigned as coach.'), 'danger') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) if team.coaches.filter_by(id=coach.id).first(): flash( @@ -361,7 +362,7 @@ def add_coach(team_id): ), 'info', ) - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) team.coaches.append(coach) if not team.coach_id: @@ -371,7 +372,7 @@ def add_coach(team_id): _('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name), 'success', ) - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) @teams_bp.route('//add_manager', methods=['POST']) @@ -381,19 +382,19 @@ def add_manager(team_id): team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('Permission denied.'), 'danger') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) data = _posted(TeamStaffSchema()) if data is None: - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) if not data['manager_id']: flash(_('Please select a manager.'), 'danger') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) manager = db.session.get(User, data['manager_id']) if not manager or not _assignable(manager, Manager): flash(_('Only managers can be assigned as manager.'), 'danger') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) if team.managers.filter_by(id=manager.id).first(): flash( @@ -404,7 +405,7 @@ def add_manager(team_id): ), 'info', ) - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) team.managers.append(manager) if not team.manager_id: @@ -414,7 +415,7 @@ def add_manager(team_id): _('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name), 'success', ) - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) @teams_bp.route('//remove_coach', methods=['POST']) @@ -424,11 +425,11 @@ def remove_coach(team_id): team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('Permission denied.'), 'danger') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) data = _posted(TeamStaffSchema()) if data is None: - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) if data['coach_id']: coach = db.session.get(User, data['coach_id']) @@ -442,7 +443,7 @@ def remove_coach(team_id): db.session.commit() flash(_('Coach removed from %(name)s.', name=team.name), 'success') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) @teams_bp.route('//remove_manager', methods=['POST']) @@ -452,11 +453,11 @@ def remove_manager(team_id): team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('Permission denied.'), 'danger') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) data = _posted(TeamStaffSchema()) if data is None: - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) if data['manager_id']: manager = db.session.get(User, data['manager_id']) @@ -470,7 +471,7 @@ def remove_manager(team_id): db.session.commit() flash(_('Manager removed from %(name)s.', name=team.name), 'success') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) @teams_bp.route('//add_player', methods=['POST']) @@ -480,20 +481,20 @@ def add_player(team_id): team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('Permission denied.'), 'danger') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) data = _posted(TeamPlayerSchema()) if data is None: - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) if not data['player_id']: flash(_('Please select a player.'), 'danger') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) status = data['status'] player = db.session.get(User, data['player_id']) if not player or not _assignable(player, Player): flash(_('Can only assign players to teams.'), 'danger') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first() if existing: @@ -501,13 +502,13 @@ def add_player(team_id): _('%(username)s is already on %(name)s.', username=player.username, name=team.name), 'info', ) - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status) db.session.add(tp) db.session.commit() flash(_('%(username)s added to %(name)s!', username=player.username, name=team.name), 'success') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) @teams_bp.route('//remove_player/', methods=['POST']) @@ -517,7 +518,7 @@ def remove_player(team_id, player_id): team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('Permission denied.'), 'danger') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) player = db.get_or_404(User, player_id) tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first() @@ -526,7 +527,7 @@ def remove_player(team_id, player_id): _('%(username)s is not on %(name)s.', username=player.username, name=team.name), 'danger', ) - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) db.session.delete(tp) db.session.commit() @@ -534,7 +535,7 @@ def remove_player(team_id, player_id): _('%(username)s removed from %(name)s.', username=player.username, name=team.name), 'success', ) - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) @teams_bp.route('//toggle_status/', methods=['POST']) @@ -569,19 +570,19 @@ def add_team_note(team_id): team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('You do not have permission to add notes to this team.'), 'danger') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) try: data = NoteContentSchema().load(form_payload(list_fields=())) except ValidationError as err: flash_validation_errors(err) - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=data['content']) db.session.add(note) db.session.commit() flash(_('Team notes added successfully!'), 'success') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) @teams_bp.route('//add-player-note/', methods=['POST']) @@ -591,12 +592,12 @@ def add_player_note(team_id, player_id): team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('You do not have permission to add notes to this team.'), 'danger') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) player = db.get_or_404(User, player_id) if not isinstance(player, Player): flash(_('Can only add notes for players.'), 'danger') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first() if not tp: @@ -604,16 +605,16 @@ def add_player_note(team_id, player_id): _('%(username)s is not on %(name)s.', username=player.username, name=team.name), 'danger', ) - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) try: data = NoteContentSchema().load(form_payload(list_fields=())) except ValidationError as err: flash_validation_errors(err) - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=data['content']) db.session.add(note) db.session.commit() flash(_('Note added for %(username)s!', username=player.username), 'success') - return redirect(url_for('teams.list_teams')) + return respond_after_change(url_for('teams.list_teams')) diff --git a/app/routes/tryouts.py b/app/routes/tryouts.py index 636941a..7b9e47e 100644 --- a/app/routes/tryouts.py +++ b/app/routes/tryouts.py @@ -32,6 +32,7 @@ from app.models import ( TryoutRegistration, User, ) +from app.responders import respond_after_change from app.time_utils import utc_now_naive from app.validators import ( PlayerSelectionSchema, @@ -477,31 +478,31 @@ def register_for_tryout(tryout_id): """Register a player for a tryout. Only Players can self-register.""" if not isinstance(current_user, Player): flash(_('Only players can register for tryouts.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) tryout = locked_tryout_or_404(tryout_id) if tryout.status not in ['upcoming', 'in_progress']: flash(_('This tryout is not accepting registrations.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) existing = TryoutRegistration.query.filter_by( tryout_id=tryout_id, player_id=current_user.id ).first() if existing: flash(_('You are already registered for this tryout.'), 'info') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if tryout.max_players: count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count() if count >= tryout.max_players: flash(_('This tryout is full.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) registration = TryoutRegistration(tryout_id=tryout_id, player_id=current_user.id) db.session.add(registration) db.session.commit() flash(_('Successfully registered for tryout!'), 'success') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @tryouts_bp.route('//status', methods=['POST']) @@ -514,7 +515,7 @@ def update_status(tryout_id): 'Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger', ) - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') @@ -523,12 +524,12 @@ def update_status(tryout_id): data = TryoutStatusSchema().load(form_payload(list_fields=())) except ValidationError as err: flash_validation_errors(err) - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) tryout.status = data['status'] db.session.commit() flash(_('Tryout status updated to %(new_status)s.', new_status=data['status']), 'success') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @tryouts_bp.route('//registration//status', methods=['POST']) @@ -541,7 +542,7 @@ def update_registration_status(tryout_id, player_id): 'Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger', ) - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') @@ -554,12 +555,12 @@ def update_registration_status(tryout_id, player_id): data = TryoutRegistrationStatusSchema().load(form_payload(list_fields=())) except ValidationError as err: flash_validation_errors(err) - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) registration.status = data['status'] db.session.commit() flash(_('Registration status updated.'), 'success') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @tryouts_bp.route('//register_player', methods=['POST']) @@ -572,20 +573,20 @@ def register_player(tryout_id): 'Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger', ) - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) try: data = PlayerSelectionSchema().load(form_payload()) except ValidationError as err: flash_validation_errors(err) - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if not data['player_id']: flash(_('Please select a player.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) # Same two checks as the team roster (SEC-16): the right role, and an # account that has not been deactivated. The select this comes from now @@ -593,7 +594,7 @@ def register_player(tryout_id): player = db.session.get(User, data['player_id']) if not player or not isinstance(player, Player) or not player.is_active_account: flash(_('Can only register players.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) existing = TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player.id).first() if existing: @@ -601,19 +602,19 @@ def register_player(tryout_id): _('%(username)s is already registered for this tryout.', username=player.username), 'info', ) - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if tryout.max_players: count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count() if count >= tryout.max_players: flash(_('This tryout is full.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) registration = TryoutRegistration(tryout_id=tryout_id, player_id=player.id) db.session.add(registration) db.session.commit() flash(_('%(username)s registered for tryout!', username=player.username), 'success') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @tryouts_bp.route('//remove_player/', methods=['POST']) @@ -626,7 +627,7 @@ def remove_player(tryout_id, player_id): 'Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger', ) - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') @@ -656,7 +657,7 @@ def remove_player(tryout_id, player_id): db.session.commit() flash(_('%(username)s removed from tryout.', username=player.username), 'success') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @tryouts_bp.route('//team/create', methods=['POST']) @@ -669,23 +670,23 @@ def create_team(tryout_id): 'Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger', ) - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) try: data = TryoutTeamSchema().load(form_payload(list_fields=())) except ValidationError as err: flash_validation_errors(err) - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) team = Team(tryout_id=tryout_id, name=data['team_name'], created_by=current_user.id) db.session.add(team) db.session.commit() flash(_('Team "%(team_name)s" created!', team_name=data['team_name']), 'success') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @tryouts_bp.route('//team//add', methods=['POST']) @@ -699,11 +700,11 @@ def add_to_team(tryout_id, team_id): 'Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger', ) - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) # The two ids arrive independently in the URL. Without this check, being # allowed to manage tryout A was enough to modify a team belonging to @@ -715,7 +716,7 @@ def add_to_team(tryout_id, team_id): data = TryoutTeamMemberSchema().load(form_payload(list_fields=())) except ValidationError as err: flash_validation_errors(err) - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) player_id = data['player_id'] # Only players registered for this tryout may be placed on its teams. @@ -725,7 +726,7 @@ def add_to_team(tryout_id, team_id): ) if not is_registered: flash(_('That player is not registered for this tryout.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first() if existing: @@ -735,7 +736,7 @@ def add_to_team(tryout_id, team_id): db.session.add(member) db.session.commit() flash(_('Player added to team!'), 'success') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @tryouts_bp.route('//team//edit', methods=['POST']) @@ -749,11 +750,11 @@ def edit_team(tryout_id, team_id): 'Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger', ) - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if team.tryout_id != tryout_id: abort(404) @@ -762,12 +763,12 @@ def edit_team(tryout_id, team_id): data = TryoutTeamSchema().load(form_payload(list_fields=())) except ValidationError as err: flash_validation_errors(err) - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) team.name = data['team_name'] db.session.commit() flash(_('Team renamed to "%(team_name)s".', team_name=data['team_name']), 'success') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @tryouts_bp.route('//team//delete', methods=['POST']) @@ -781,11 +782,11 @@ def delete_team(tryout_id, team_id): 'Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger', ) - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if team.tryout_id != tryout_id: abort(404) @@ -811,7 +812,7 @@ def delete_team(tryout_id, team_id): db.session.delete(team) db.session.commit() flash(_('Team "%(name)s" deleted.', name=name), 'success') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @tryouts_bp.route('//team//remove/', methods=['POST']) @@ -825,11 +826,11 @@ def remove_from_team(tryout_id, team_id, player_id): 'Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger', ) - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if team.tryout_id != tryout_id: abort(404) @@ -846,7 +847,7 @@ def remove_from_team(tryout_id, team_id, player_id): ).delete(synchronize_session=False) db.session.commit() flash(_('Player removed from team.'), 'success') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @tryouts_bp.route( @@ -863,11 +864,11 @@ def update_member_position(tryout_id, team_id, member_id): 'Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger', ) - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if team.tryout_id != tryout_id or member.team_id != team_id: abort(404) @@ -876,12 +877,12 @@ def update_member_position(tryout_id, team_id, member_id): data = TryoutTeamMemberPositionSchema().load(form_payload(list_fields=())) except ValidationError as err: flash_validation_errors(err) - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) member.position = data['position'] or None db.session.commit() flash(_('Position updated.'), 'success') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @tryouts_bp.route('//delete', methods=['POST']) @@ -894,7 +895,7 @@ def delete_tryout(tryout_id): 'Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger', ) - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if not current_user.can_manage_this_tryout(tryout): flash(_('You do not have permission to delete this tryout.'), 'danger') diff --git a/app/routes/users/contracts.py b/app/routes/users/contracts.py index 39ee655..6335f0e 100644 --- a/app/routes/users/contracts.py +++ b/app/routes/users/contracts.py @@ -10,6 +10,7 @@ from marshmallow import ValidationError from werkzeug.utils import secure_filename from app.extensions import db +from app.responders import respond_after_change from app.models import Admin, Coach, Contract, Manager, Player, User from app.models.admin_settings import AppSettings from app.permissions import can_manage_player_contract, coach_player_ids @@ -102,11 +103,11 @@ def upload_contract(): """Upload a contract for a player.""" if not isinstance(current_user, (Admin, Manager, Coach)): flash(_('Only presidents, managers, and coaches can upload contracts.'), 'danger') - return redirect(url_for('users.list_contracts')) + return respond_after_change(url_for('users.list_contracts')) if document_uploads_locked(): flash(_('Document uploads are currently disabled.'), 'danger') - return redirect(url_for('users.list_contracts')) + return respond_after_change(url_for('users.list_contracts')) players = manageable_players() @@ -125,13 +126,13 @@ def upload_contract(): if not can_manage_player_contract(current_user, player_id): flash(_('You do not have permission to upload a contract for this player.'), 'danger') - return redirect(url_for('users.upload_contract')) + return respond_after_change(url_for('users.upload_contract')) file = request.files.get('contract_file') error = pdf_upload_error(file, ALLOWED_CONTRACT_EXTENSIONS) if error: flash(error, 'danger') - return redirect(url_for('users.upload_contract')) + return respond_after_change(url_for('users.upload_contract')) player = db.get_or_404(User, player_id) player_teams = player.get_org_teams() @@ -154,7 +155,7 @@ def upload_contract(): flash( _('Contract storage is temporarily unavailable. Please try again later.'), 'danger' ) - return redirect(url_for('users.upload_contract')) + return respond_after_change(url_for('users.upload_contract')) contract = Contract( player_id=player_id, @@ -171,7 +172,7 @@ def upload_contract(): _('Contract uploaded successfully for %(username)s!', username=player.username), 'success', ) - return redirect(url_for('users.list_contracts')) + return respond_after_change(url_for('users.list_contracts')) return render_template('pages/upload_contract.html', players=players) @@ -183,17 +184,17 @@ def upload_signed_contract(contract_id): contract = db.get_or_404(Contract, contract_id) if not contract.can_upload_signed(current_user): flash(_('Only the player can upload their signed contract.'), 'danger') - return redirect(url_for('users.list_contracts')) + return respond_after_change(url_for('users.list_contracts')) if document_uploads_locked(): flash(_('Document uploads are currently disabled.'), 'danger') - return redirect(url_for('users.list_contracts')) + return respond_after_change(url_for('users.list_contracts')) file = request.files.get('signed_file') error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS) if error: flash(error, 'danger') - return redirect(url_for('users.list_contracts')) + return respond_after_change(url_for('users.list_contracts')) signed_filename = f"signed_{contract.stored_filename}" # Legacy local records keep their signed copy beside the original. Drive @@ -208,7 +209,7 @@ def upload_signed_contract(contract_id): stored_signed_path = store_uploaded_document(file, signed_path) except GoogleDriveStorageError: flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger') - return redirect(url_for('users.list_contracts')) + return respond_after_change(url_for('users.list_contracts')) contract.signed_filename = signed_filename contract.signed_file_path = stored_signed_path @@ -216,7 +217,7 @@ def upload_signed_contract(contract_id): contract.signed_at = utc_now_naive() db.session.commit() flash(_('Signed contract uploaded successfully!'), 'success') - return redirect(url_for('users.list_contracts')) + return respond_after_change(url_for('users.list_contracts')) @users_bp.route('/contracts//download') @@ -226,12 +227,12 @@ def download_contract(contract_id): contract = db.get_or_404(Contract, contract_id) if not contract.can_view(current_user): flash(_('You do not have permission to download this contract.'), 'danger') - return redirect(url_for('users.list_contracts')) + return respond_after_change(url_for('users.list_contracts')) try: document = open_document(contract.file_path) except GoogleDriveStorageError: flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger') - return redirect(url_for('users.list_contracts')) + return respond_after_change(url_for('users.list_contracts')) return send_file(document, as_attachment=True, download_name=contract.original_filename) @@ -242,13 +243,13 @@ def download_signed_contract(contract_id): contract = db.get_or_404(Contract, contract_id) if not contract.can_view(current_user): flash(_('You do not have permission to download this contract.'), 'danger') - return redirect(url_for('users.list_contracts')) + return respond_after_change(url_for('users.list_contracts')) if not contract.signed_file_path: flash(_('No signed contract available.'), 'danger') - return redirect(url_for('users.list_contracts')) + return respond_after_change(url_for('users.list_contracts')) try: document = open_document(contract.signed_file_path) except GoogleDriveStorageError: flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger') - return redirect(url_for('users.list_contracts')) + return respond_after_change(url_for('users.list_contracts')) return send_file(document, as_attachment=True, download_name=contract.signed_filename) diff --git a/app/routes/users/notes.py b/app/routes/users/notes.py index 2433e1e..d2e80ec 100644 --- a/app/routes/users/notes.py +++ b/app/routes/users/notes.py @@ -10,6 +10,7 @@ from flask_login import current_user, login_required from marshmallow import ValidationError from app.extensions import db +from app.responders import respond_after_change from app.forms import flash_validation_errors, form_payload from app.models import ( Coach, @@ -176,14 +177,14 @@ def manage_team_notes(): org_teams = coach_org_teams(current_user) if not org_teams: flash(_('You are not assigned to a team.'), 'danger') - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) org_team = org_teams[0] try: data = NoteContentSchema().load(form_payload(list_fields=())) except ValidationError as err: flash_validation_errors(err) - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) note = TeamNote( org_team_id=org_team.id, @@ -194,7 +195,7 @@ def manage_team_notes(): db.session.commit() flash(_('Team notes saved successfully!'), 'success') - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) # --------------------------------------------------------------------------- @@ -214,17 +215,17 @@ def manage_personal_notes(): data = PersonalNoteSchema().load(form_payload(list_fields=())) except ValidationError as err: flash_validation_errors(err) - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) player_id = data['player_id'] player = db.get_or_404(User, player_id) if not isinstance(player, Player): flash(_('Can only add notes for players.'), 'danger') - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) if not coach_can_access_player(current_user, player_id): flash(_('You can only write notes about players you work with.'), 'danger') - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) note = PersonalNote( player_id=player_id, @@ -234,7 +235,7 @@ def manage_personal_notes(): db.session.add(note) db.session.commit() flash(_('Note added for %(username)s.', username=player.username), 'success') - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) # --------------------------------------------------------------------------- @@ -254,44 +255,44 @@ def add_personal_note(): data = PersonalNoteSchema().load(form_payload(list_fields=())) except ValidationError as err: flash_validation_errors(err) - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) player_id = data['player_id'] player = db.get_or_404(User, player_id) if not isinstance(player, Player): flash(_('Can only add notes for players.'), 'danger') - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) if not coach_can_access_player(current_user, player_id): flash(_('You can only write notes about players you work with.'), 'danger') - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) if data['match_id']: match = db.get_or_404(Match, data['match_id']) if not current_user.can_manage_this_tryout(match.tryout): flash(_('You cannot use that match as note context.'), 'danger') - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) if not MatchParticipant.query.filter_by(match_id=match.id, player_id=player_id).first(): flash(_('That player did not participate in the selected match.'), 'danger') - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) if data['tryout_id']: tryout = db.get_or_404(Tryout, data['tryout_id']) if not current_user.can_manage_this_tryout(tryout): flash(_('You cannot use that tryout as note context.'), 'danger') - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) if not TryoutRegistration.query.filter_by(tryout_id=tryout.id, player_id=player_id).first(): flash(_('That player is not registered for the selected tryout.'), 'danger') - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) if data['team_id']: team = db.get_or_404(Team, data['team_id']) if not current_user.can_manage_this_tryout(team.tryout): flash(_('You cannot use that team as note context.'), 'danger') - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) if not TeamMember.query.filter_by(team_id=team.id, player_id=player_id).first(): flash(_('That player is not on the selected team.'), 'danger') - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) note = PersonalNote( player_id=player_id, @@ -304,7 +305,7 @@ def add_personal_note(): db.session.add(note) db.session.commit() flash(_('Note added for %(username)s.', username=player.username), 'success') - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) # --------------------------------------------------------------------------- @@ -323,7 +324,7 @@ def add_note_from_tryout(tryout_id): tryout = db.get_or_404(Tryout, tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('You do not have permission to add notes for this tryout.'), 'danger') - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) preselected_player_id = request.args.get('player_id', type=int) # Get registrations as players for the select list @@ -387,7 +388,7 @@ def add_note_from_match(match_id): match_obj = db.get_or_404(Match, match_id) if not current_user.can_manage_this_tryout(match_obj.tryout): flash(_('You do not have permission to add notes for this match.'), 'danger') - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) # Get participants as players for the select list participants = MatchParticipant.query.filter_by(match_id=match_id).all() diff --git a/app/routes/users/one_on_one.py b/app/routes/users/one_on_one.py index 6211b19..19b8cf7 100644 --- a/app/routes/users/one_on_one.py +++ b/app/routes/users/one_on_one.py @@ -6,6 +6,7 @@ from flask_login import current_user, login_required from marshmallow import ValidationError from app.extensions import db +from app.responders import respond_after_change from app.forms import flash_validation_errors, form_payload from app.models import Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote from app.routes.users.blueprint import users_bp @@ -64,13 +65,13 @@ def one_on_one(): if request.method == 'POST': if not coach: flash(_('Cannot request One on One - no coach assigned.'), 'danger') - return redirect(url_for('users.one_on_one')) + return respond_after_change(url_for('users.one_on_one')) try: data = OneOnOneRequestSchema().load(form_payload()) except ValidationError as err: flash_validation_errors(err) - return redirect(url_for('users.one_on_one')) + return respond_after_change(url_for('users.one_on_one')) date_obj = data['date'] start_time = data['start_time'] @@ -90,7 +91,7 @@ def one_on_one(): if not is_available: flash(_("The requested time is not within the coach's availability."), 'danger') - return redirect(url_for('users.one_on_one')) + return respond_after_change(url_for('users.one_on_one')) request_obj = OneOnOneRequest( player_id=current_user.id, @@ -118,7 +119,7 @@ def one_on_one(): ) flash(_('Your One on One request has been submitted!'), 'success') - return redirect(url_for('users.one_on_one')) + return respond_after_change(url_for('users.one_on_one')) # Build list of upcoming dates that have coach availability from datetime import date as date_cls @@ -169,11 +170,11 @@ def accept_one_on_one(request_id): if request_obj.coach_id != current_user.id: flash(_('This request is not for you.'), 'danger') - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) if request_obj.status != 'pending': flash(_('This request has already been processed.'), 'info') - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) player = request_obj.player request_obj.status = 'approved' @@ -204,7 +205,7 @@ def accept_one_on_one(request_id): ), 'success', ) - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) @users_bp.route('/one-on-one//reject', methods=['POST']) @@ -219,17 +220,17 @@ def reject_one_on_one(request_id): if request_obj.coach_id != current_user.id: flash(_('This request is not for you.'), 'danger') - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) if request_obj.status != 'pending': flash(_('This request has already been processed.'), 'info') - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) try: data = OneOnOneRejectionSchema().load(form_payload(list_fields=())) except ValidationError as err: flash_validation_errors(err) - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) rejection_reason = data['rejection_reason'] player = request_obj.player @@ -264,4 +265,4 @@ def reject_one_on_one(request_id): ), 'info', ) - return redirect(url_for('users.notes_dashboard')) + return respond_after_change(url_for('users.notes_dashboard')) diff --git a/app/routes/users/profile.py b/app/routes/users/profile.py index ab56e07..04b8bc3 100644 --- a/app/routes/users/profile.py +++ b/app/routes/users/profile.py @@ -6,6 +6,7 @@ from flask_login import current_user, login_required from marshmallow import ValidationError from app.extensions import db, hash_password +from app.responders import respond_after_change from app.logging_config import log_auth_event from app.models import ( ESPORT_GAMES, @@ -129,7 +130,7 @@ def edit_profile(): db.session.commit() flash(_('Profile updated successfully!'), 'success') - return redirect(url_for('users.profile')) + return respond_after_change(url_for('users.profile')) return render_template( 'pages/edit_profile.html', diff --git a/app/static/js/main.js b/app/static/js/main.js index 7ddacd1..bed874c 100644 --- a/app/static/js/main.js +++ b/app/static/js/main.js @@ -420,22 +420,9 @@ document.addEventListener('DOMContentLoaded', function() { initDraggableBlocks(); /** - * Auto-dismiss flash alerts after 5 seconds. + * Auto-dismiss flash alerts after 5 seconds (see armAlertAutoDismiss). */ - const alerts = document.querySelectorAll('.alert-dismissible'); - alerts.forEach(function(alert) { - setTimeout(function() { - if (alert.parentElement) { - alert.style.opacity = '0'; - alert.style.transition = 'opacity 0.3s ease'; - setTimeout(function() { - if (alert.parentElement) { - alert.remove(); - } - }, 300); - } - }, 5000); - }); + armAlertAutoDismiss(); }); /* ========================================================================= Declarative behaviours — replacing inline event handlers @@ -593,7 +580,7 @@ document.addEventListener('input', function (event) { document.addEventListener('change', function (event) { const control = event.target.closest('[data-submit-on-change]'); if (control && control.form) { - control.form.submit(); + submitForm(control.form); } }); @@ -613,3 +600,193 @@ document.addEventListener('change', function (event) { window.location.href = select.getAttribute('data-navigate') .replace('{value}', encodeURIComponent(select.value)); }); + +/* ========================================================================= + AJAX forms — server-rendered changes without a full page reload + ========================================================================= + + A
is submitted with fetch() instead of a + navigation. The route answers a JSON payload (see app/responders.py) that + says where to fetch the refreshed content and which flash messages to + show; the content region is then swapped in place, so scroll position and + open state survive between edits. + + A stale edit — the page changed since this copy was loaded — is answered + 409, and the user is asked to reload rather than silently overwrite the + other person's change. + ========================================================================= */ + +let TT_REVISION = null; + +function armAlertAutoDismiss() { + const alerts = document.querySelectorAll('.alert-dismissible'); + alerts.forEach(function (alert) { + if (alert.getAttribute('data-armed')) return; + alert.setAttribute('data-armed', '1'); + setTimeout(function () { + if (alert.parentElement) { + alert.style.opacity = '0'; + alert.style.transition = 'opacity 0.3s ease'; + setTimeout(function () { + if (alert.parentElement) { + alert.remove(); + } + }, 300); + } + }, 5000); + }); +} + +function fetchRevision() { + return fetch('/api/revision', { + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }).then(function (r) { return r.json(); }).then(function (data) { + TT_REVISION = data.revision; + return data.revision; + }).catch(function () { + // Revision tracking is best-effort; a failed lookup just disables it. + }); +} + +function showFlashMessages(messages) { + const container = document.querySelector('.flash-messages'); + if (!container || !messages || !messages.length) return; + messages.forEach(function (m) { + const alert = document.createElement('div'); + alert.className = 'alert alert-' + (m.category || 'info') + ' alert-dismissible'; + const span = document.createElement('span'); + span.textContent = m.text; + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'alert-close'; + close.setAttribute('data-action', 'dismiss-alert'); + close.setAttribute('aria-label', 'Dismiss'); + close.innerHTML = '×'; + alert.appendChild(span); + alert.appendChild(close); + container.appendChild(alert); + }); + armAlertAutoDismiss(); +} + +function replaceContent(html, url) { + const holder = document.createElement('div'); + holder.innerHTML = html; + const next = holder.querySelector('#page-content'); + const target = document.getElementById('page-content'); + if (next && target) { + target.innerHTML = next.innerHTML; + } + if (url && window.history && window.history.replaceState) { + window.history.replaceState(null, '', url); + } + initDraggableBlocks(); + document.dispatchEvent(new Event('fragment:updated')); +} + +function refreshPage(url) { + return fetch(url, { credentials: 'same-origin' }) + .then(function (r) { return r.text(); }) + .then(function (html) { replaceContent(html, url); }); +} + +function submitAjaxForm(form) { + const headers = { + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json' + }; + const token = form.querySelector('input[name="csrf_token"]'); + if (token) headers['X-CSRFToken'] = token.value; + if (TT_REVISION !== null && TT_REVISION !== undefined) { + headers['X-Revision'] = String(TT_REVISION); + } + + fetch(form.action, { + method: (form.method || 'POST').toUpperCase(), + body: new FormData(form), + headers: headers, + credentials: 'same-origin' + }).then(function (response) { + return response.json().then(function (data) { + return { status: response.status, data: data }; + }).catch(function () { + return { status: response.status, data: null }; + }); + }).then(function (result) { + if (result.status === 409) { + const message = (result.data && result.data.message) || + 'This page was changed by someone else. Reload to see the latest changes.'; + if (window.confirm(message)) { + window.location.reload(); + } + return null; + } + if (result.status >= 400) { + const message = (result.data && (result.data.message || result.data.error)) || + 'Something went wrong. Please try again.'; + window.alert(message); + return null; + } + return result.data; + }).then(function (data) { + if (!data) return; + if (data.revision !== undefined && data.revision !== null) { + TT_REVISION = data.revision; + } + showFlashMessages(data.messages); + if (data.redirect) { + return refreshPage(data.redirect); + } + return undefined; + }).catch(function (err) { + console.error('AJAX form submission failed:', err); + }); +} + +function submitForm(form) { + if (form.hasAttribute('data-ajax')) { + submitAjaxForm(form); + } else { + form.submit(); + } +} + +function checkForRemoteChanges() { + return fetchRevision().then(function (rev) { + if (TT_REVISION !== null && rev !== TT_REVISION) { + TT_REVISION = rev; + return refreshPage(window.location.href); + } + return undefined; + }).catch(function () { /* best-effort */ }); +} + +// data-submit-on-change and data-ajax both funnel through submitForm so a +//
- +