diff --git a/app/models/evaluation.py b/app/models/evaluation.py index 0492eb9..4feb240 100644 --- a/app/models/evaluation.py +++ b/app/models/evaluation.py @@ -31,3 +31,49 @@ class Evaluation(db.Model): __table_args__ = ( db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'), ) + + #: The nine criteria, in the order the form shows them. The overall score + #: is their mean; a criterion left blank is left out of the mean rather + #: than counted as a zero, which is why this list exists rather than the + #: route summing nine named variables (ARCH-005, QUA-003). + CRITERIA = ( + 'mecanics_score', + 'cohesion_score', + 'communication_score', + 'gamesense_score', + 'versatility_score', + 'discipline_score', + 'analysis_score', + 'sport_ethics_score', + 'mental_score', + ) + + @classmethod + def overall_from(cls, scores): + """Mean of the criteria that were actually filled in. + + Args: + scores: Mapping of criterion name to score or None. + + Returns: + float | None: None when nothing was scored — which is not the + same as zero, and must not become one. A player nobody could + assess has no overall score; a player who scored zero on + everything cannot exist, the scale starts at one. + """ + given = [scores.get(name) for name in cls.CRITERIA] + given = [score for score in given if score is not None] + if not given: + return None + return sum(given) / len(given) + + def apply_scores(self, scores): + """Write these criteria onto the record and recompute the overall. + + Every criterion is assigned, including the ones left blank: an edit + that clears a score has to clear it, and the mean has to be the mean + of what is on the record afterwards. + """ + for name in self.CRITERIA: + setattr(self, name, scores.get(name)) + self.overall_score = self.overall_from(scores) diff --git a/app/routes/evaluations.py b/app/routes/evaluations.py index 2f57a4b..457076b 100644 --- a/app/routes/evaluations.py +++ b/app/routes/evaluations.py @@ -6,10 +6,12 @@ Uses polymorphic isinstance checks instead of role-string comparisons. 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 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 ( GAME_POSITIONS, Admin, @@ -19,23 +21,11 @@ from app.models import ( TryoutRegistration, User, ) +from app.validators import EvaluationSchema evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations') -def validate_score(score_value): - """Validate that a score is between 1 and 10.""" - if score_value is None: - return None - try: - score = int(score_value) - if 1 <= score <= 10: - return score - return None - except (ValueError, TypeError): - return None - - @evaluations_bp.route('') @login_required def list_evaluations(): @@ -163,92 +153,53 @@ def evaluate_player(tryout_id, player_id): evaluator_id=current_user.id, ).first() - if request.method == 'POST': - mecanics = validate_score(request.form.get('mecanics_score')) - cohesion = validate_score(request.form.get('cohesion_score')) - communication = validate_score(request.form.get('communication_score')) - gamesense = validate_score(request.form.get('gamesense_score')) - versatility = validate_score(request.form.get('versatility_score')) - discipline = validate_score(request.form.get('discipline_score')) - analysis = validate_score(request.form.get('analysis_score')) - sport_ethics = validate_score(request.form.get('sport_ethics_score')) - mental = validate_score(request.form.get('mental_score')) - comments = request.form.get('comments') - position = request.form.get('position_recommendation') - - scores = [ - s - for s in [ - mecanics, - cohesion, - communication, - gamesense, - versatility, - discipline, - analysis, - sport_ethics, - mental, + def render_evaluation_form(): + evaluators = None + if isinstance(current_user, Admin): + all_evaluations = Evaluation.query.filter_by( + tryout_id=tryout_id, + player_id=player_id, + ).all() + evaluators = [ + {'evaluator': User.query.get(e.evaluator_id), 'eval': e} for e in all_evaluations ] - if s is not None - ] - overall = sum(scores) / len(scores) if scores else None - if existing_eval: - existing_eval.mecanics_score = mecanics - existing_eval.cohesion_score = cohesion - existing_eval.communication_score = communication - existing_eval.gamesense_score = gamesense - existing_eval.versatility_score = versatility - existing_eval.discipline_score = discipline - existing_eval.analysis_score = analysis - existing_eval.sport_ethics_score = sport_ethics - existing_eval.mental_score = mental - existing_eval.overall_score = overall - existing_eval.comments = comments - existing_eval.position_recommendation = position - flash(_('Evaluation updated!'), 'success') - else: + return render_template( + 'pages/evaluate_player.html', + tryout=tryout, + player=player, + existing_eval=existing_eval, + evaluators=evaluators, + game_positions=GAME_POSITIONS, + ) + + if request.method == 'POST': + try: + data = EvaluationSchema().load(form_payload(list_fields=(), optional_blank=())) + except ValidationError as err: + flash_validation_errors(err) + return render_evaluation_form() + + evaluation = existing_eval + if evaluation is None: evaluation = Evaluation( tryout_id=tryout_id, player_id=player_id, evaluator_id=current_user.id, - mecanics_score=mecanics, - cohesion_score=cohesion, - communication_score=communication, - gamesense_score=gamesense, - versatility_score=versatility, - discipline_score=discipline, - analysis_score=analysis, - sport_ethics_score=sport_ethics, - mental_score=mental, - overall_score=overall, - comments=comments, - position_recommendation=position, ) db.session.add(evaluation) flash(_('Evaluation submitted successfully!'), 'success') + else: + flash(_('Evaluation updated!'), 'success') + + evaluation.apply_scores(data) + evaluation.comments = data['comments'] + evaluation.position_recommendation = data['position_recommendation'] db.session.commit() return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) - evaluators = None - if isinstance(current_user, Admin): - all_evaluations = Evaluation.query.filter_by( - tryout_id=tryout_id, - player_id=player_id, - ).all() - evaluators = [ - {'evaluator': User.query.get(e.evaluator_id), 'eval': e} for e in all_evaluations - ] - - return render_template( - 'pages/evaluate_player.html', - tryout=tryout, - player=player, - existing_eval=existing_eval, - evaluators=evaluators, - game_positions=GAME_POSITIONS, - ) + return render_evaluation_form() @evaluations_bp.route('//players') diff --git a/app/routes/tryouts.py b/app/routes/tryouts.py index d304bb2..df213be 100644 --- a/app/routes/tryouts.py +++ b/app/routes/tryouts.py @@ -9,8 +9,10 @@ from datetime import datetime from flask import Blueprint, abort, 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 app.extensions import db +from app.forms import flash_validation_errors, form_payload from app.models import ( ESPORT_GAMES, GAME_POSITIONS, @@ -30,6 +32,7 @@ from app.models import ( TryoutRegistration, User, ) +from app.validators import TryoutSchema tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts') @@ -39,6 +42,24 @@ def can_manage(): return isinstance(current_user, (Admin, Manager)) +def tryout_form_payload(): + """The tryout form, shaped for marshmallow (ARCH-005).""" + return form_payload(list_fields=('coach_ids',), optional_blank=()) + + +def coaches_from_ids(coach_ids): + """The coach accounts behind these ids. + + Filtered by role, which the previous `User.id.in_(...)` was not: the form + posts a list of ids and nothing stopped a hand-made submission from + naming a player, who then appeared as a coach of the tryout and inherited + every permission that comes with it. + """ + if not coach_ids: + return [] + return User.query.filter(User.id.in_(coach_ids), User.role == 'coach').all() + + def _users_by_id(user_ids): """Load these users in one query, keyed by id. @@ -85,89 +106,46 @@ def create_tryout(): User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all() ) + def rerender(): + return render_template( + 'pages/tryout_form.html', + tryout=None, + org_teams=org_teams, + managers=managers, + coaches=coaches, + esport_games=ESPORT_GAMES, + ) + if request.method == 'POST': - title = request.form.get('title') - description = request.form.get('description') - game = request.form.get('game') - date_str = request.form.get('date') - end_date_str = request.form.get('end_date') - location = request.form.get('location') - max_players = request.form.get('max_players') - target_org_team_id = request.form.get('target_org_team_id') - manager_id = request.form.get('manager_id') - coach_ids = request.form.getlist('coach_ids') - try: - date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() - except (ValueError, TypeError): - flash(_('Invalid start date format.'), 'danger') - return render_template( - 'pages/tryout_form.html', - tryout=None, - org_teams=org_teams, - managers=managers, - coaches=coaches, - esport_games=ESPORT_GAMES, - ) - - end_date_obj = None - if end_date_str: - try: - end_date_obj = datetime.strptime(end_date_str, '%Y-%m-%d').date() - if end_date_obj < date_obj: - flash(_('End date cannot be before start date.'), 'danger') - return render_template( - 'pages/tryout_form.html', - tryout=None, - org_teams=org_teams, - managers=managers, - coaches=coaches, - esport_games=ESPORT_GAMES, - ) - except (ValueError, TypeError): - flash(_('Invalid end date format.'), 'danger') - return render_template( - 'pages/tryout_form.html', - tryout=None, - org_teams=org_teams, - managers=managers, - coaches=coaches, - esport_games=ESPORT_GAMES, - ) + data = TryoutSchema().load(tryout_form_payload()) + except ValidationError as err: + flash_validation_errors(err) + return rerender() tryout = Tryout( - title=title, - description=description, - game=game, - date=date_obj, - end_date=end_date_obj, - location=location, - max_players=int(max_players) if max_players else None, + title=data['title'], + description=data['description'], + game=data['game'], + date=data['date'], + end_date=data['end_date'], + location=data['location'], + max_players=data['max_players'], created_by=current_user.id, status='upcoming', - target_org_team_id=int(target_org_team_id) if target_org_team_id else None, - manager_id=int(manager_id) if manager_id else None, + target_org_team_id=data['target_org_team_id'], + manager_id=data['manager_id'], ) db.session.add(tryout) db.session.flush() - # Assign coaches via many-to-many - if coach_ids: - coach_users = User.query.filter(User.id.in_([int(c) for c in coach_ids])).all() - tryout.coaches = coach_users + tryout.coaches = coaches_from_ids(data['coach_ids']) db.session.commit() flash(_('Tryout created successfully!'), 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) - return render_template( - 'pages/tryout_form.html', - tryout=None, - org_teams=org_teams, - managers=managers, - coaches=coaches, - esport_games=ESPORT_GAMES, - ) + return rerender() @tryouts_bp.route('//edit', methods=['GET', 'POST']) @@ -192,85 +170,39 @@ def edit_tryout(tryout_id): User.query.filter_by(role='coach', is_active_account=True).order_by(User.full_name).all() ) + def rerender(): + return render_template( + 'pages/tryout_form.html', + tryout=tryout, + org_teams=org_teams, + managers=managers, + coaches=coaches, + esport_games=ESPORT_GAMES, + ) + if request.method == 'POST': - title = request.form.get('title') - description = request.form.get('description') - game = request.form.get('game') - date_str = request.form.get('date') - end_date_str = request.form.get('end_date') - location = request.form.get('location') - max_players = request.form.get('max_players') - target_org_team_id = request.form.get('target_org_team_id') - manager_id = request.form.get('manager_id') - coach_ids = request.form.getlist('coach_ids') - try: - date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() - except (ValueError, TypeError): - flash(_('Invalid start date format.'), 'danger') - return render_template( - 'pages/tryout_form.html', - tryout=tryout, - org_teams=org_teams, - managers=managers, - coaches=coaches, - esport_games=ESPORT_GAMES, - ) + data = TryoutSchema().load(tryout_form_payload()) + except ValidationError as err: + flash_validation_errors(err) + return rerender() - end_date_obj = None - if end_date_str: - try: - end_date_obj = datetime.strptime(end_date_str, '%Y-%m-%d').date() - if end_date_obj < date_obj: - flash(_('End date cannot be before start date.'), 'danger') - return render_template( - 'pages/tryout_form.html', - tryout=tryout, - org_teams=org_teams, - managers=managers, - coaches=coaches, - esport_games=ESPORT_GAMES, - ) - except (ValueError, TypeError): - flash(_('Invalid end date format.'), 'danger') - return render_template( - 'pages/tryout_form.html', - tryout=tryout, - org_teams=org_teams, - managers=managers, - coaches=coaches, - esport_games=ESPORT_GAMES, - ) - - tryout.title = title - tryout.description = description - tryout.game = game - tryout.date = date_obj - tryout.end_date = end_date_obj - tryout.location = location - tryout.max_players = int(max_players) if max_players else None - tryout.target_org_team_id = int(target_org_team_id) if target_org_team_id else None - tryout.manager_id = int(manager_id) if manager_id else None - - # Update coaches via many-to-many - if coach_ids: - coach_users = User.query.filter(User.id.in_([int(c) for c in coach_ids])).all() - tryout.coaches = coach_users - else: - tryout.coaches = [] + tryout.title = data['title'] + tryout.description = data['description'] + tryout.game = data['game'] + tryout.date = data['date'] + tryout.end_date = data['end_date'] + tryout.location = data['location'] + tryout.max_players = data['max_players'] + tryout.target_org_team_id = data['target_org_team_id'] + tryout.manager_id = data['manager_id'] + tryout.coaches = coaches_from_ids(data['coach_ids']) db.session.commit() flash(_('Tryout updated successfully!'), 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) - return render_template( - 'pages/tryout_form.html', - tryout=tryout, - org_teams=org_teams, - managers=managers, - coaches=coaches, - esport_games=ESPORT_GAMES, - ) + return rerender() @tryouts_bp.route('/') diff --git a/app/translations/en/LC_MESSAGES/messages.mo b/app/translations/en/LC_MESSAGES/messages.mo index 780c525..281ff22 100644 Binary files a/app/translations/en/LC_MESSAGES/messages.mo and b/app/translations/en/LC_MESSAGES/messages.mo differ diff --git a/app/translations/en/LC_MESSAGES/messages.po b/app/translations/en/LC_MESSAGES/messages.po index 6f09505..0fce2b3 100644 --- a/app/translations/en/LC_MESSAGES/messages.po +++ b/app/translations/en/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: team-tryouts VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-08-11 12:05-0400\n" +"POT-Creation-Date: 2026-08-11 13:32-0400\n" "PO-Revision-Date: 2026-08-07 20:22-0400\n" "Last-Translator: FULL NAME \n" "Language: en\n" @@ -19,6 +19,12 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" +#: app/forms.py:37 app/routes/auth.py:224 app/routes/auth.py:374 +#: app/routes/users/contracts.py:95 +#, python-format +msgid "%(field)s: %(msg)s" +msgstr "%(field)s: %(msg)s" + #: app/validators.py:50 msgid "" "Password must be at least 8 characters with uppercase, lowercase, and a " @@ -43,68 +49,134 @@ msgstr "Discord User ID must be a 17-20 digit number." msgid "Invalid phone number format." msgstr "Invalid phone number format." -#: app/validators.py:164 +#: app/validators.py:201 msgid "Username is required." msgstr "Username is required." -#: app/validators.py:168 +#: app/validators.py:205 msgid "Password is required." msgstr "Password is required." -#: app/validators.py:190 app/validators.py:262 +#: app/validators.py:227 app/validators.py:299 msgid "Username must be 3-80 characters." msgstr "Username must be 3-80 characters." -#: app/validators.py:196 +#: app/validators.py:233 msgid "Email must be 120 characters or less." msgstr "Email must be 120 characters or less." -#: app/validators.py:209 app/validators.py:277 app/validators.py:308 -#: app/validators.py:372 +#: app/validators.py:246 app/validators.py:314 app/validators.py:345 +#: app/validators.py:409 msgid "Full name is required." msgstr "Full name is required." -#: app/validators.py:244 +#: app/validators.py:281 msgid "Passwords do not match." msgstr "Passwords do not match." -#: app/validators.py:281 app/validators.py:316 +#: app/validators.py:318 app/validators.py:353 msgid "Invalid role selected." msgstr "Invalid role selected." -#: app/validators.py:417 +#: app/validators.py:454 msgid "Player must be selected." msgstr "Player must be selected." -#: app/validators.py:420 +#: app/validators.py:457 msgid "Notes must be 2000 characters or less." msgstr "Notes must be 2000 characters or less." -#: app/validators.py:439 +#: app/validators.py:476 msgid "Date must be in YYYY-MM-DD format." msgstr "Date must be in YYYY-MM-DD format." -#: app/validators.py:444 app/validators.py:471 +#: app/validators.py:481 app/validators.py:508 msgid "Start time must be in HH:MM format." msgstr "Start time must be in HH:MM format." -#: app/validators.py:448 +#: app/validators.py:485 msgid "End time must be in HH:MM format." msgstr "End time must be in HH:MM format." -#: app/validators.py:451 +#: app/validators.py:488 msgid "Points must be 2000 characters or less." msgstr "Points must be 2000 characters or less." -#: app/validators.py:467 +#: app/validators.py:504 msgid "Day must be 0 (Monday) to 6 (Sunday)." msgstr "Day must be 0 (Monday) to 6 (Sunday)." -#: app/routes/auth.py:224 app/routes/auth.py:374 app/routes/users/_shared.py:64 -#: app/routes/users/contracts.py:95 -#, python-format -msgid "%(field)s: %(msg)s" -msgstr "%(field)s: %(msg)s" +#: app/validators.py:535 +msgid "Player selection is malformed." +msgstr "Player selection is malformed." + +#: app/validators.py:561 app/validators.py:671 +msgid "A title is required." +msgstr "A title is required." + +#: app/validators.py:570 +msgid "Invalid date format." +msgstr "Invalid date format." + +#: app/validators.py:575 app/validators.py:582 +msgid "Invalid time format." +msgstr "Invalid time format." + +#: app/validators.py:576 +msgid "Start time is required. Please select a time slot." +msgstr "Start time is required. Please select a time slot." + +#: app/validators.py:590 +msgid "Unknown match status." +msgstr "Unknown match status." + +#: app/validators.py:606 +msgid "The end time must come after the start time." +msgstr "The end time must come after the start time." + +#: app/validators.py:620 +msgid "Unknown match type." +msgstr "Unknown match type." + +#: app/validators.py:632 +msgid "A team cannot play against itself." +msgstr "A team cannot play against itself." + +#: app/validators.py:680 +msgid "Unknown game." +msgstr "Unknown game." + +#: app/validators.py:685 +msgid "Invalid start date format." +msgstr "Invalid start date format." + +#: app/validators.py:686 +msgid "A start date is required." +msgstr "A start date is required." + +#: app/validators.py:692 +msgid "Invalid end date format." +msgstr "Invalid end date format." + +#: app/validators.py:700 +msgid "A tryout must allow at least one player." +msgstr "A tryout must allow at least one player." + +#: app/validators.py:703 +msgid "The player limit must be a whole number." +msgstr "The player limit must be a whole number." + +#: app/validators.py:715 +msgid "End date cannot be before start date." +msgstr "End date cannot be before start date." + +#: app/validators.py:724 +msgid "Scores run from 1 to 10." +msgstr "Scores run from 1 to 10." + +#: app/validators.py:725 +msgid "A score must be a whole number from 1 to 10." +msgstr "A score must be a whole number from 1 to 10." #: app/routes/auth.py:241 msgid "This account has been deactivated." @@ -175,40 +247,40 @@ msgstr "Discord account connected! Your profile has been pre-filled." msgid "You have been logged out." msgstr "You have been logged out." -#: app/routes/evaluations.py:46 +#: app/routes/evaluations.py:36 msgid "You do not have permission to view evaluations." msgstr "You do not have permission to view evaluations." -#: app/routes/evaluations.py:136 +#: app/routes/evaluations.py:126 msgid "You do not have permission to evaluate players." msgstr "You do not have permission to evaluate players." -#: app/routes/evaluations.py:141 app/routes/evaluations.py:264 +#: app/routes/evaluations.py:131 app/routes/evaluations.py:215 msgid "You do not have permission to evaluate players in this tryout." msgstr "You do not have permission to evaluate players in this tryout." -#: app/routes/evaluations.py:152 +#: app/routes/evaluations.py:142 msgid "Player is not registered for this tryout." msgstr "Player is not registered for this tryout." -#: app/routes/evaluations.py:157 +#: app/routes/evaluations.py:147 msgid "Can only evaluate players." msgstr "Can only evaluate players." -#: app/routes/evaluations.py:209 -msgid "Evaluation updated!" -msgstr "Evaluation updated!" - -#: app/routes/evaluations.py:229 +#: app/routes/evaluations.py:191 msgid "Evaluation submitted successfully!" msgstr "Evaluation submitted successfully!" -#: app/routes/evaluations.py:259 app/routes/teams.py:270 +#: app/routes/evaluations.py:193 +msgid "Evaluation updated!" +msgstr "Evaluation updated!" + +#: app/routes/evaluations.py:210 app/routes/teams.py:270 #: app/routes/teams.py:311 app/routes/teams.py:352 app/routes/teams.py:377 -#: app/routes/teams.py:402 app/routes/teams.py:437 app/routes/tryouts.py:505 -#: app/routes/tryouts.py:521 app/routes/tryouts.py:541 -#: app/routes/tryouts.py:580 app/routes/tryouts.py:616 -#: app/routes/tryouts.py:635 +#: app/routes/teams.py:402 app/routes/teams.py:437 app/routes/tryouts.py:437 +#: app/routes/tryouts.py:453 app/routes/tryouts.py:473 +#: app/routes/tryouts.py:512 app/routes/tryouts.py:548 +#: app/routes/tryouts.py:567 msgid "Permission denied." msgstr "Permission denied." @@ -216,76 +288,47 @@ msgstr "Permission denied." msgid "That language is not available." msgstr "That language is not available." -#: app/routes/matches.py:307 +#: app/routes/matches.py:361 msgid "You do not have permission to schedule matches for this tryout." msgstr "You do not have permission to schedule matches for this tryout." -#: app/routes/matches.py:311 app/routes/matches.py:473 +#: app/routes/matches.py:365 app/routes/matches.py:445 msgid "This tryout has ended. Matches can no longer be created or modified." msgstr "This tryout has ended. Matches can no longer be created or modified." -#: app/routes/matches.py:332 -msgid "Start time is required. Please select a time slot." -msgstr "Start time is required. Please select a time slot." - -#: app/routes/matches.py:344 app/routes/matches.py:496 -#: app/routes/team_matches.py:153 app/routes/team_matches.py:247 -msgid "Invalid date format." -msgstr "Invalid date format." - -#: app/routes/matches.py:364 app/routes/team_matches.py:174 -msgid "Invalid time format." -msgstr "Invalid time format." - -#: app/routes/matches.py:449 +#: app/routes/matches.py:427 msgid "Match scheduled successfully!" msgstr "Match scheduled successfully!" -#: app/routes/matches.py:469 app/routes/team_matches.py:234 +#: app/routes/matches.py:441 app/routes/team_matches.py:211 msgid "You do not have permission to edit this match." msgstr "You do not have permission to edit this match." -#: app/routes/matches.py:507 -msgid "Start time is required." -msgstr "Start time is required." - -#: app/routes/matches.py:616 app/routes/team_matches.py:276 +#: app/routes/matches.py:536 app/routes/team_matches.py:241 msgid "Match updated successfully!" msgstr "Match updated successfully!" -#: app/routes/matches.py:669 app/routes/team_matches.py:291 +#: app/routes/matches.py:571 app/routes/team_matches.py:256 msgid "You do not have permission to delete this match." msgstr "You do not have permission to delete this match." -#: app/routes/matches.py:672 +#: app/routes/matches.py:574 msgid "This tryout has ended. Matches can no longer be deleted." msgstr "This tryout has ended. Matches can no longer be deleted." -#: app/routes/matches.py:685 app/routes/team_matches.py:295 +#: app/routes/matches.py:587 app/routes/team_matches.py:260 msgid "Match deleted successfully." msgstr "Match deleted successfully." -#: app/routes/team_matches.py:100 +#: app/routes/team_matches.py:104 msgid "You do not have permission to schedule matches for this team." msgstr "You do not have permission to schedule matches for this team." -#: app/routes/team_matches.py:142 -msgid "Date is required." -msgstr "Date is required." - -#: app/routes/team_matches.py:220 +#: app/routes/team_matches.py:196 #, python-format msgid "Team match \"%(title)s\" scheduled successfully!" msgstr "Team match \"%(title)s\" scheduled successfully!" -#: app/routes/team_matches.py:259 -msgid "Invalid start time format." -msgstr "Invalid start time format." - -#: app/routes/team_matches.py:267 -msgid "Invalid end time format." -msgstr "Invalid end time format." - #: app/routes/teams.py:40 msgid "Use My Team(s) to view your teams." msgstr "Use My Team(s) to view your teams." @@ -380,7 +423,7 @@ msgstr "Coach removed from %(name)s." msgid "Manager removed from %(name)s." msgstr "Manager removed from %(name)s." -#: app/routes/teams.py:408 app/routes/tryouts.py:545 app/routes/tryouts.py:646 +#: app/routes/teams.py:408 app/routes/tryouts.py:477 app/routes/tryouts.py:578 msgid "Please select a player." msgstr "Please select a player." @@ -426,124 +469,112 @@ msgstr "Can only add notes for players." msgid "Note added for %(username)s!" msgstr "Note added for %(username)s!" -#: app/routes/tryouts.py:77 +#: app/routes/tryouts.py:98 msgid "You do not have permission to create tryouts." msgstr "You do not have permission to create tryouts." -#: app/routes/tryouts.py:103 app/routes/tryouts.py:210 -msgid "Invalid start date format." -msgstr "Invalid start date format." - -#: app/routes/tryouts.py:118 app/routes/tryouts.py:225 -msgid "End date cannot be before start date." -msgstr "End date cannot be before start date." - -#: app/routes/tryouts.py:128 app/routes/tryouts.py:235 -msgid "Invalid end date format." -msgstr "Invalid end date format." - -#: app/routes/tryouts.py:160 +#: app/routes/tryouts.py:145 msgid "Tryout created successfully!" msgstr "Tryout created successfully!" -#: app/routes/tryouts.py:180 +#: app/routes/tryouts.py:158 msgid "You do not have permission to edit this tryout." msgstr "You do not have permission to edit this tryout." -#: app/routes/tryouts.py:184 +#: app/routes/tryouts.py:162 msgid "This tryout has ended and can no longer be modified." msgstr "This tryout has ended and can no longer be modified." -#: app/routes/tryouts.py:263 +#: app/routes/tryouts.py:202 msgid "Tryout updated successfully!" msgstr "Tryout updated successfully!" -#: app/routes/tryouts.py:310 +#: app/routes/tryouts.py:242 msgid "You do not have permission to view this tryout." msgstr "You do not have permission to view this tryout." -#: app/routes/tryouts.py:472 +#: app/routes/tryouts.py:404 msgid "Only players can register for tryouts." msgstr "Only players can register for tryouts." -#: app/routes/tryouts.py:476 +#: app/routes/tryouts.py:408 msgid "This tryout is not accepting registrations." msgstr "This tryout is not accepting registrations." -#: app/routes/tryouts.py:483 +#: app/routes/tryouts.py:415 msgid "You are already registered for this tryout." msgstr "You are already registered for this tryout." -#: app/routes/tryouts.py:489 app/routes/tryouts.py:564 +#: app/routes/tryouts.py:421 app/routes/tryouts.py:496 msgid "This tryout is full." msgstr "This tryout is full." -#: app/routes/tryouts.py:495 +#: app/routes/tryouts.py:427 msgid "Successfully registered for tryout!" msgstr "Successfully registered for tryout!" -#: app/routes/tryouts.py:511 +#: app/routes/tryouts.py:443 #, python-format msgid "Tryout status updated to %(new_status)s." msgstr "Tryout status updated to %(new_status)s." -#: app/routes/tryouts.py:531 +#: app/routes/tryouts.py:463 msgid "Registration status updated." msgstr "Registration status updated." -#: app/routes/tryouts.py:550 +#: app/routes/tryouts.py:482 msgid "Can only register players." msgstr "Can only register players." -#: app/routes/tryouts.py:556 +#: app/routes/tryouts.py:488 #, python-format msgid "%(username)s is already registered for this tryout." msgstr "%(username)s is already registered for this tryout." -#: app/routes/tryouts.py:570 +#: app/routes/tryouts.py:502 #, python-format msgid "%(username)s registered for tryout!" msgstr "%(username)s registered for tryout!" -#: app/routes/tryouts.py:606 +#: app/routes/tryouts.py:538 #, python-format msgid "%(username)s removed from tryout." msgstr "%(username)s removed from tryout." -#: app/routes/tryouts.py:624 +#: app/routes/tryouts.py:556 #, python-format msgid "Team \"%(team_name)s\" created!" msgstr "Team \"%(team_name)s\" created!" -#: app/routes/tryouts.py:655 +#: app/routes/tryouts.py:587 msgid "That player is not registered for this tryout." msgstr "That player is not registered for this tryout." -#: app/routes/tryouts.py:661 +#: app/routes/tryouts.py:593 msgid "Player is already on this team." msgstr "Player is already on this team." -#: app/routes/tryouts.py:666 +#: app/routes/tryouts.py:598 msgid "Player added to team!" msgstr "Player added to team!" -#: app/routes/tryouts.py:676 +#: app/routes/tryouts.py:608 msgid "You do not have permission to delete this tryout." msgstr "You do not have permission to delete this tryout." -#: app/routes/tryouts.py:712 +#: app/routes/tryouts.py:644 msgid "Tryout deleted successfully." msgstr "Tryout deleted successfully." -#: app/routes/users/_shared.py:46 +#: app/routes/users/_shared.py:51 msgid "No file selected." msgstr "No file selected." -#: app/routes/users/_shared.py:50 +#: app/routes/users/_shared.py:55 msgid "Only PDF files are allowed for contracts." msgstr "Only PDF files are allowed for contracts." -#: app/routes/users/_shared.py:55 +#: app/routes/users/_shared.py:60 msgid "That file is not a PDF, whatever its name says." msgstr "That file is not a PDF, whatever its name says." @@ -2880,3 +2911,9 @@ msgstr "View Profile" #~ msgid "Answer" #~ msgstr "Answer" +#~ msgid "Invalid start time format." +#~ msgstr "Invalid start time format." + +#~ msgid "Invalid end time format." +#~ msgstr "Invalid end time format." + diff --git a/app/translations/fr/LC_MESSAGES/messages.mo b/app/translations/fr/LC_MESSAGES/messages.mo index fcd5752..5d78727 100644 Binary files a/app/translations/fr/LC_MESSAGES/messages.mo and b/app/translations/fr/LC_MESSAGES/messages.mo differ diff --git a/app/translations/fr/LC_MESSAGES/messages.po b/app/translations/fr/LC_MESSAGES/messages.po index dbec288..89827a1 100644 --- a/app/translations/fr/LC_MESSAGES/messages.po +++ b/app/translations/fr/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: team-tryouts VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-08-11 12:05-0400\n" +"POT-Creation-Date: 2026-08-11 13:32-0400\n" "PO-Revision-Date: 2026-08-07 20:22-0400\n" "Last-Translator: FULL NAME \n" "Language: fr\n" @@ -19,6 +19,12 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" +#: app/forms.py:37 app/routes/auth.py:224 app/routes/auth.py:374 +#: app/routes/users/contracts.py:95 +#, python-format +msgid "%(field)s: %(msg)s" +msgstr "%(field)s : %(msg)s" + #: app/validators.py:50 msgid "" "Password must be at least 8 characters with uppercase, lowercase, and a " @@ -45,68 +51,134 @@ msgstr "L’identifiant Discord doit être un nombre de 17 à 20 chiffres." msgid "Invalid phone number format." msgstr "Format de numéro de téléphone invalide." -#: app/validators.py:164 +#: app/validators.py:201 msgid "Username is required." msgstr "Le nom d’utilisateur est obligatoire." -#: app/validators.py:168 +#: app/validators.py:205 msgid "Password is required." msgstr "Le mot de passe est obligatoire." -#: app/validators.py:190 app/validators.py:262 +#: app/validators.py:227 app/validators.py:299 msgid "Username must be 3-80 characters." msgstr "Le nom d’utilisateur doit compter de 3 à 80 caractères." -#: app/validators.py:196 +#: app/validators.py:233 msgid "Email must be 120 characters or less." msgstr "L’adresse courriel ne doit pas dépasser 120 caractères." -#: app/validators.py:209 app/validators.py:277 app/validators.py:308 -#: app/validators.py:372 +#: app/validators.py:246 app/validators.py:314 app/validators.py:345 +#: app/validators.py:409 msgid "Full name is required." msgstr "Le nom complet est obligatoire." -#: app/validators.py:244 +#: app/validators.py:281 msgid "Passwords do not match." msgstr "Les mots de passe ne concordent pas." -#: app/validators.py:281 app/validators.py:316 +#: app/validators.py:318 app/validators.py:353 msgid "Invalid role selected." msgstr "Rôle sélectionné invalide." -#: app/validators.py:417 +#: app/validators.py:454 msgid "Player must be selected." msgstr "Vous devez choisir un joueur." -#: app/validators.py:420 +#: app/validators.py:457 msgid "Notes must be 2000 characters or less." msgstr "Les notes ne doivent pas dépasser 2000 caractères." -#: app/validators.py:439 +#: app/validators.py:476 msgid "Date must be in YYYY-MM-DD format." msgstr "La date doit être au format AAAA-MM-JJ." -#: app/validators.py:444 app/validators.py:471 +#: app/validators.py:481 app/validators.py:508 msgid "Start time must be in HH:MM format." msgstr "L’heure de début doit être au format HH:MM." -#: app/validators.py:448 +#: app/validators.py:485 msgid "End time must be in HH:MM format." msgstr "L’heure de fin doit être au format HH:MM." -#: app/validators.py:451 +#: app/validators.py:488 msgid "Points must be 2000 characters or less." msgstr "Les points ne doivent pas dépasser 2000 caractères." -#: app/validators.py:467 +#: app/validators.py:504 msgid "Day must be 0 (Monday) to 6 (Sunday)." msgstr "Le jour doit aller de 0 (lundi) à 6 (dimanche)." -#: app/routes/auth.py:224 app/routes/auth.py:374 app/routes/users/_shared.py:64 -#: app/routes/users/contracts.py:95 -#, python-format -msgid "%(field)s: %(msg)s" -msgstr "%(field)s : %(msg)s" +#: app/validators.py:535 +msgid "Player selection is malformed." +msgstr "La sélection de joueurs est mal formée." + +#: app/validators.py:561 app/validators.py:671 +msgid "A title is required." +msgstr "Un titre est requis." + +#: app/validators.py:570 +msgid "Invalid date format." +msgstr "Format de date invalide." + +#: app/validators.py:575 app/validators.py:582 +msgid "Invalid time format." +msgstr "Format d’heure invalide." + +#: app/validators.py:576 +msgid "Start time is required. Please select a time slot." +msgstr "L’heure de début est obligatoire. Choisissez une plage horaire." + +#: app/validators.py:590 +msgid "Unknown match status." +msgstr "Statut de match inconnu." + +#: app/validators.py:606 +msgid "The end time must come after the start time." +msgstr "L'heure de fin doit être postérieure à l'heure de début." + +#: app/validators.py:620 +msgid "Unknown match type." +msgstr "Type de match inconnu." + +#: app/validators.py:632 +msgid "A team cannot play against itself." +msgstr "Une équipe ne peut pas jouer contre elle-même." + +#: app/validators.py:680 +msgid "Unknown game." +msgstr "Jeu inconnu." + +#: app/validators.py:685 +msgid "Invalid start date format." +msgstr "Format de date de début invalide." + +#: app/validators.py:686 +msgid "A start date is required." +msgstr "Une date de début est requise." + +#: app/validators.py:692 +msgid "Invalid end date format." +msgstr "Format de date de fin invalide." + +#: app/validators.py:700 +msgid "A tryout must allow at least one player." +msgstr "Une sélection doit accepter au moins un joueur." + +#: app/validators.py:703 +msgid "The player limit must be a whole number." +msgstr "La limite de joueurs doit être un nombre entier." + +#: app/validators.py:715 +msgid "End date cannot be before start date." +msgstr "La date de fin ne peut pas précéder la date de début." + +#: app/validators.py:724 +msgid "Scores run from 1 to 10." +msgstr "Les notes vont de 1 à 10." + +#: app/validators.py:725 +msgid "A score must be a whole number from 1 to 10." +msgstr "Une note doit être un nombre entier de 1 à 10." #: app/routes/auth.py:241 msgid "This account has been deactivated." @@ -177,40 +249,40 @@ msgstr "Compte Discord connecté. Votre profil a été pré-rempli." msgid "You have been logged out." msgstr "Vous avez été déconnecté." -#: app/routes/evaluations.py:46 +#: app/routes/evaluations.py:36 msgid "You do not have permission to view evaluations." msgstr "Vous n’avez pas les droits pour consulter les évaluations." -#: app/routes/evaluations.py:136 +#: app/routes/evaluations.py:126 msgid "You do not have permission to evaluate players." msgstr "Vous n’avez pas les droits pour évaluer des joueurs." -#: app/routes/evaluations.py:141 app/routes/evaluations.py:264 +#: app/routes/evaluations.py:131 app/routes/evaluations.py:215 msgid "You do not have permission to evaluate players in this tryout." msgstr "Vous n’avez pas les droits pour évaluer des joueurs dans cette sélection." -#: app/routes/evaluations.py:152 +#: app/routes/evaluations.py:142 msgid "Player is not registered for this tryout." msgstr "Ce joueur n’est pas inscrit à cette sélection." -#: app/routes/evaluations.py:157 +#: app/routes/evaluations.py:147 msgid "Can only evaluate players." msgstr "Seuls des joueurs peuvent être évalués." -#: app/routes/evaluations.py:209 -msgid "Evaluation updated!" -msgstr "Évaluation mise à jour." - -#: app/routes/evaluations.py:229 +#: app/routes/evaluations.py:191 msgid "Evaluation submitted successfully!" msgstr "Évaluation enregistrée." -#: app/routes/evaluations.py:259 app/routes/teams.py:270 +#: app/routes/evaluations.py:193 +msgid "Evaluation updated!" +msgstr "Évaluation mise à jour." + +#: app/routes/evaluations.py:210 app/routes/teams.py:270 #: app/routes/teams.py:311 app/routes/teams.py:352 app/routes/teams.py:377 -#: app/routes/teams.py:402 app/routes/teams.py:437 app/routes/tryouts.py:505 -#: app/routes/tryouts.py:521 app/routes/tryouts.py:541 -#: app/routes/tryouts.py:580 app/routes/tryouts.py:616 -#: app/routes/tryouts.py:635 +#: app/routes/teams.py:402 app/routes/teams.py:437 app/routes/tryouts.py:437 +#: app/routes/tryouts.py:453 app/routes/tryouts.py:473 +#: app/routes/tryouts.py:512 app/routes/tryouts.py:548 +#: app/routes/tryouts.py:567 msgid "Permission denied." msgstr "Accès refusé." @@ -218,78 +290,49 @@ msgstr "Accès refusé." msgid "That language is not available." msgstr "Cette langue n’est pas disponible." -#: app/routes/matches.py:307 +#: app/routes/matches.py:361 msgid "You do not have permission to schedule matches for this tryout." msgstr "Vous n’avez pas les droits pour planifier des matchs pour cette sélection." -#: app/routes/matches.py:311 app/routes/matches.py:473 +#: app/routes/matches.py:365 app/routes/matches.py:445 msgid "This tryout has ended. Matches can no longer be created or modified." msgstr "" "Cette sélection est terminée. Les matchs ne peuvent plus être créés ni " "modifiés." -#: app/routes/matches.py:332 -msgid "Start time is required. Please select a time slot." -msgstr "L’heure de début est obligatoire. Choisissez une plage horaire." - -#: app/routes/matches.py:344 app/routes/matches.py:496 -#: app/routes/team_matches.py:153 app/routes/team_matches.py:247 -msgid "Invalid date format." -msgstr "Format de date invalide." - -#: app/routes/matches.py:364 app/routes/team_matches.py:174 -msgid "Invalid time format." -msgstr "Format d’heure invalide." - -#: app/routes/matches.py:449 +#: app/routes/matches.py:427 msgid "Match scheduled successfully!" msgstr "Match planifié." -#: app/routes/matches.py:469 app/routes/team_matches.py:234 +#: app/routes/matches.py:441 app/routes/team_matches.py:211 msgid "You do not have permission to edit this match." msgstr "Vous n’avez pas les droits pour modifier ce match." -#: app/routes/matches.py:507 -msgid "Start time is required." -msgstr "L’heure de début est obligatoire." - -#: app/routes/matches.py:616 app/routes/team_matches.py:276 +#: app/routes/matches.py:536 app/routes/team_matches.py:241 msgid "Match updated successfully!" msgstr "Match mis à jour." -#: app/routes/matches.py:669 app/routes/team_matches.py:291 +#: app/routes/matches.py:571 app/routes/team_matches.py:256 msgid "You do not have permission to delete this match." msgstr "Vous n’avez pas les droits pour supprimer ce match." -#: app/routes/matches.py:672 +#: app/routes/matches.py:574 msgid "This tryout has ended. Matches can no longer be deleted." msgstr "Cette sélection est terminée. Les matchs ne peuvent plus être supprimés." -#: app/routes/matches.py:685 app/routes/team_matches.py:295 +#: app/routes/matches.py:587 app/routes/team_matches.py:260 msgid "Match deleted successfully." msgstr "Match supprimé." -#: app/routes/team_matches.py:100 +#: app/routes/team_matches.py:104 msgid "You do not have permission to schedule matches for this team." msgstr "Vous n’avez pas les droits pour planifier des matchs pour cette équipe." -#: app/routes/team_matches.py:142 -msgid "Date is required." -msgstr "La date est obligatoire." - -#: app/routes/team_matches.py:220 +#: app/routes/team_matches.py:196 #, python-format msgid "Team match \"%(title)s\" scheduled successfully!" msgstr "Match d’équipe « %(title)s » planifié." -#: app/routes/team_matches.py:259 -msgid "Invalid start time format." -msgstr "Format d’heure de début invalide." - -#: app/routes/team_matches.py:267 -msgid "Invalid end time format." -msgstr "Format d’heure de fin invalide." - #: app/routes/teams.py:40 msgid "Use My Team(s) to view your teams." msgstr "Utilisez « Mon ou mes équipes » pour consulter vos équipes." @@ -384,7 +427,7 @@ msgstr "Coach retiré de %(name)s." msgid "Manager removed from %(name)s." msgstr "Gérant retiré de %(name)s." -#: app/routes/teams.py:408 app/routes/tryouts.py:545 app/routes/tryouts.py:646 +#: app/routes/teams.py:408 app/routes/tryouts.py:477 app/routes/tryouts.py:578 msgid "Please select a player." msgstr "Veuillez choisir un joueur." @@ -430,124 +473,112 @@ msgstr "Il n’est possible d’ajouter des notes que pour des joueurs." msgid "Note added for %(username)s!" msgstr "Note ajoutée pour %(username)s." -#: app/routes/tryouts.py:77 +#: app/routes/tryouts.py:98 msgid "You do not have permission to create tryouts." msgstr "Vous n’avez pas les droits pour créer une sélection." -#: app/routes/tryouts.py:103 app/routes/tryouts.py:210 -msgid "Invalid start date format." -msgstr "Format de date de début invalide." - -#: app/routes/tryouts.py:118 app/routes/tryouts.py:225 -msgid "End date cannot be before start date." -msgstr "La date de fin ne peut pas précéder la date de début." - -#: app/routes/tryouts.py:128 app/routes/tryouts.py:235 -msgid "Invalid end date format." -msgstr "Format de date de fin invalide." - -#: app/routes/tryouts.py:160 +#: app/routes/tryouts.py:145 msgid "Tryout created successfully!" msgstr "Sélection créée." -#: app/routes/tryouts.py:180 +#: app/routes/tryouts.py:158 msgid "You do not have permission to edit this tryout." msgstr "Vous n’avez pas les droits pour modifier cette sélection." -#: app/routes/tryouts.py:184 +#: app/routes/tryouts.py:162 msgid "This tryout has ended and can no longer be modified." msgstr "Cette sélection est terminée et ne peut plus être modifiée." -#: app/routes/tryouts.py:263 +#: app/routes/tryouts.py:202 msgid "Tryout updated successfully!" msgstr "Sélection mise à jour." -#: app/routes/tryouts.py:310 +#: app/routes/tryouts.py:242 msgid "You do not have permission to view this tryout." msgstr "Vous n’avez pas les droits pour consulter cette sélection." -#: app/routes/tryouts.py:472 +#: app/routes/tryouts.py:404 msgid "Only players can register for tryouts." msgstr "Seuls les joueurs peuvent s’inscrire à une sélection." -#: app/routes/tryouts.py:476 +#: app/routes/tryouts.py:408 msgid "This tryout is not accepting registrations." msgstr "Cette sélection n’accepte pas d’inscriptions." -#: app/routes/tryouts.py:483 +#: app/routes/tryouts.py:415 msgid "You are already registered for this tryout." msgstr "Vous êtes déjà inscrit à cette sélection." -#: app/routes/tryouts.py:489 app/routes/tryouts.py:564 +#: app/routes/tryouts.py:421 app/routes/tryouts.py:496 msgid "This tryout is full." msgstr "Cette sélection est complète." -#: app/routes/tryouts.py:495 +#: app/routes/tryouts.py:427 msgid "Successfully registered for tryout!" msgstr "Inscription à la sélection réussie." -#: app/routes/tryouts.py:511 +#: app/routes/tryouts.py:443 #, python-format msgid "Tryout status updated to %(new_status)s." msgstr "Statut de la sélection mis à jour : %(new_status)s." -#: app/routes/tryouts.py:531 +#: app/routes/tryouts.py:463 msgid "Registration status updated." msgstr "Statut d’inscription mis à jour." -#: app/routes/tryouts.py:550 +#: app/routes/tryouts.py:482 msgid "Can only register players." msgstr "Seuls des joueurs peuvent être inscrits." -#: app/routes/tryouts.py:556 +#: app/routes/tryouts.py:488 #, python-format msgid "%(username)s is already registered for this tryout." msgstr "%(username)s est déjà inscrit à cette sélection." -#: app/routes/tryouts.py:570 +#: app/routes/tryouts.py:502 #, python-format msgid "%(username)s registered for tryout!" msgstr "%(username)s est inscrit à la sélection." -#: app/routes/tryouts.py:606 +#: app/routes/tryouts.py:538 #, python-format msgid "%(username)s removed from tryout." msgstr "%(username)s a été retiré de la sélection." -#: app/routes/tryouts.py:624 +#: app/routes/tryouts.py:556 #, python-format msgid "Team \"%(team_name)s\" created!" msgstr "Équipe « %(team_name)s » créée." -#: app/routes/tryouts.py:655 +#: app/routes/tryouts.py:587 msgid "That player is not registered for this tryout." msgstr "Ce joueur n’est pas inscrit à cette sélection." -#: app/routes/tryouts.py:661 +#: app/routes/tryouts.py:593 msgid "Player is already on this team." msgstr "Ce joueur est déjà dans cette équipe." -#: app/routes/tryouts.py:666 +#: app/routes/tryouts.py:598 msgid "Player added to team!" msgstr "Joueur ajouté à l’équipe." -#: app/routes/tryouts.py:676 +#: app/routes/tryouts.py:608 msgid "You do not have permission to delete this tryout." msgstr "Vous n’avez pas les droits pour supprimer cette sélection." -#: app/routes/tryouts.py:712 +#: app/routes/tryouts.py:644 msgid "Tryout deleted successfully." msgstr "Sélection supprimée." -#: app/routes/users/_shared.py:46 +#: app/routes/users/_shared.py:51 msgid "No file selected." msgstr "Aucun fichier sélectionné." -#: app/routes/users/_shared.py:50 +#: app/routes/users/_shared.py:55 msgid "Only PDF files are allowed for contracts." msgstr "Seuls les fichiers PDF sont acceptés pour les contrats." -#: app/routes/users/_shared.py:55 +#: app/routes/users/_shared.py:60 msgid "That file is not a PDF, whatever its name says." msgstr "Ce fichier n’est pas un PDF, quel que soit son nom." @@ -2904,3 +2935,9 @@ msgstr "Voir le profil" #~ msgid "Answer" #~ msgstr "Réponse" +#~ msgid "Invalid start time format." +#~ msgstr "Format d’heure de début invalide." + +#~ msgid "Invalid end time format." +#~ msgstr "Format d’heure de fin invalide." + diff --git a/app/validators.py b/app/validators.py index b52369d..af2818d 100644 --- a/app/validators.py +++ b/app/validators.py @@ -23,7 +23,7 @@ from marshmallow import ( validates_schema, ) -from app.models import USER_TYPES +from app.models import ESPORT_GAMES, USER_TYPES # ============================================================================= # Custom Validators @@ -652,3 +652,112 @@ class TeamMatchSchema(ScheduledEventSchema): load_default=None, ) is_practice = fields.Boolean(load_default=False) + + +class TryoutSchema(StripMixin): + """A tryout event, created or edited. + + `game` is checked against ESPORT_GAMES: it drives the position list and + the gamertag fields shown to registering players, so an unknown value + produced a tryout nobody could be evaluated for. It was accepted as any + string. + + `max_players` was `int(x) if x else None`, which raised on 'twelve' and + happily stored -3. + """ + + title = fields.String( + required=True, + validate=validate.Length(min=1, max=200, error=_l('A title is required.')), + ) + description = fields.String( + validate=validate.Length(max=5000), + allow_none=True, + load_default=None, + ) + game = fields.String( + required=True, + validate=validate.OneOf(ESPORT_GAMES, error=_l('Unknown game.')), + ) + date = fields.Date( + required=True, + error_messages={ + 'invalid': _l('Invalid start date format.'), + 'required': _l('A start date is required.'), + }, + ) + end_date = fields.Date( + allow_none=True, + load_default=None, + error_messages={'invalid': _l('Invalid end date format.')}, + ) + location = fields.String( + validate=validate.Length(max=200), + allow_none=True, + load_default=None, + ) + max_players = fields.Integer( + validate=validate.Range(min=1, error=_l('A tryout must allow at least one player.')), + allow_none=True, + load_default=None, + error_messages={'invalid': _l('The player limit must be a whole number.')}, + ) + target_org_team_id = fields.Integer(allow_none=True, load_default=None) + manager_id = fields.Integer(allow_none=True, load_default=None) + coach_ids = fields.List(fields.Integer(), load_default=list) + + @validates_schema + def validate_span(self, data, **kwargs): + """A tryout cannot end before it starts.""" + end = data.get('end_date') + if end and data.get('date') and end < data['date']: + raise ValidationError( + _l('End date cannot be before start date.'), field_name='end_date' + ) + + +def score_field(): + """One evaluation criterion: 1 to 10, or not scored at all.""" + return fields.Integer( + allow_none=True, + load_default=None, + validate=validate.Range(min=1, max=10, error=_l('Scores run from 1 to 10.')), + error_messages={'invalid': _l('A score must be a whole number from 1 to 10.')}, + ) + + +class EvaluationSchema(StripMixin): + """A coach's assessment of one player in one tryout (ARCH-005). + + Each criterion is scored 1 to 10, or left blank. `validate_score` used to + turn anything else — 11, 0, 'good' — into None: the criterion silently + vanished from the average and the page reported the evaluation as + submitted. A coach could score a player 11 out of 10 and have it counted + as no score at all. + + The nine are spelled out rather than generated from Evaluation.CRITERIA, + because a schema is worth reading. test_evaluations.py asserts that the + two lists match, so adding a tenth criterion to the model and forgetting + this file fails the suite rather than silently dropping the field. + """ + + mecanics_score = score_field() + cohesion_score = score_field() + communication_score = score_field() + gamesense_score = score_field() + versatility_score = score_field() + discipline_score = score_field() + analysis_score = score_field() + sport_ethics_score = score_field() + mental_score = score_field() + + comments = fields.String( + validate=validate.Length(max=5000), + allow_none=True, + load_default=None, + ) + position_recommendation = fields.String( + validate=validate.Length(max=50), + allow_none=True, + load_default=None, + ) diff --git a/tests/test_evaluations.py b/tests/test_evaluations.py new file mode 100644 index 0000000..65d5925 --- /dev/null +++ b/tests/test_evaluations.py @@ -0,0 +1,181 @@ +"""Evaluation scoring — the arithmetic and the boundary (ARCH-005, QUA-003). + +The audit's note was short: "le calcul des scores d'évaluation n'a aucun +test". It had two defects worth the trouble of writing some. + +`validate_score` mapped anything outside 1..10 to None, so a coach typing 11 +or 0 had that criterion quietly dropped from the mean and was told the +evaluation had been submitted. Nothing distinguished "not assessed" from +"assessed, rejected, and forgotten". + +And the mean itself lived inline in the route, summing nine named local +variables. It could not be exercised without an HTTP request, an +authenticated session and a database — which is TEST-002's point, and the +reason it had no tests at all. +""" + +from datetime import date + +import pytest +from marshmallow import ValidationError + +from app.extensions import db +from app.models import Evaluation, OrgTeam, Tryout, TryoutRegistration +from app.validators import EvaluationSchema + + +class TestOverallScore: + """Pure arithmetic. No request, no session, no database.""" + + def test_the_overall_is_the_mean_of_what_was_scored(self): + scores = dict.fromkeys(Evaluation.CRITERIA, 6) + + assert Evaluation.overall_from(scores) == 6 + + def test_a_blank_criterion_is_left_out_rather_than_counted_as_zero(self): + scores = dict.fromkeys(Evaluation.CRITERIA, None) + scores['mecanics_score'] = 8 + scores['mental_score'] = 6 + + assert Evaluation.overall_from(scores) == 7 + + def test_nothing_scored_is_no_score_at_all(self): + """None, not 0. The scale starts at one, so a zero would be a score + no player can be given, sorted below everyone in the listing.""" + assert Evaluation.overall_from(dict.fromkeys(Evaluation.CRITERIA, None)) is None + assert Evaluation.overall_from({}) is None + + def test_the_mean_is_not_rounded(self): + scores = dict.fromkeys(Evaluation.CRITERIA, None) + scores['mecanics_score'] = 7 + scores['mental_score'] = 8 + + assert Evaluation.overall_from(scores) == 7.5 + + def test_a_key_outside_the_criteria_is_ignored(self): + """apply_scores is handed the whole validated payload, comments and + all. Only the nine criteria may reach the mean.""" + scores = dict.fromkeys(Evaluation.CRITERIA, 5) + scores['comments'] = 'excellent' + scores['position_recommendation'] = 'Support' + + assert Evaluation.overall_from(scores) == 5 + + +class TestSchemaMatchesModel: + def test_every_criterion_has_a_field(self): + """The nine are spelled out in the schema for readability. This is + what stops the two lists drifting apart: a tenth criterion added to + the model and forgotten in validators.py would otherwise be accepted + unvalidated and dropped from the mean.""" + declared = set(EvaluationSchema().fields) + + assert set(Evaluation.CRITERIA) <= declared + + def test_no_field_claims_to_be_a_criterion_and_is_not(self): + extra = set(EvaluationSchema().fields) - set(Evaluation.CRITERIA) + + assert extra == {'comments', 'position_recommendation'} + + +class TestScoreValidation: + @pytest.mark.parametrize('bad', ['11', '0', '-3', 'good', '7.5']) + def test_a_score_outside_the_scale_is_refused(self, bad): + """It used to become None: silently dropped, evaluation reported as + submitted.""" + with pytest.raises(ValidationError): + EvaluationSchema().load({'mecanics_score': bad}) + + @pytest.mark.parametrize('good', ['1', '10', '6']) + def test_the_ends_of_the_scale_are_accepted(self, good): + assert EvaluationSchema().load({'mecanics_score': good})['mecanics_score'] == int(good) + + def test_a_blank_score_means_not_assessed(self): + """The form submits every criterion it renders, so an untouched one + arrives as an empty string rather than not arriving.""" + data = EvaluationSchema().load({'mecanics_score': '', 'cohesion_score': '4'}) + + assert data['mecanics_score'] is None + assert data['cohesion_score'] == 4 + + +@pytest.fixture +def evaluation_setup(app, as_role, make_user): + """A coach who runs a tryout, and a player registered for it.""" + coach_id = as_role('coach') + player_id = make_user('player') + + with app.app_context(): + org_team = OrgTeam(name='Varsity', created_by=coach_id) + db.session.add(org_team) + db.session.flush() + + tryout = Tryout( + title='Spring', + game='Valorant', + date=date(2030, 4, 1), + created_by=coach_id, + target_org_team_id=org_team.id, + ) + db.session.add(tryout) + db.session.flush() + + from app.models import User + + # Attached through the m2m relation, not the inherited coach_id + # column: ARCH-002 made the permission read the former. + tryout.coaches = [db.session.get(User, coach_id)] + db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id)) + db.session.commit() + + return {'tryout_id': tryout.id, 'player_id': player_id, 'coach_id': coach_id} + + +class TestThroughTheForm: + def _submit(self, client, setup, **fields): + return client.post( + f'/evaluations/{setup["tryout_id"]}/{setup["player_id"]}', + data=fields, + follow_redirects=True, + ) + + def test_a_valid_evaluation_is_stored_with_its_mean(self, app, client, evaluation_setup): + self._submit( + client, + evaluation_setup, + mecanics_score='8', + mental_score='6', + comments='Solid.', + ) + + with app.app_context(): + evaluation = Evaluation.query.one() + assert evaluation.mecanics_score == 8 + assert evaluation.overall_score == 7 + assert evaluation.comments == 'Solid.' + + def test_an_out_of_range_score_stores_nothing(self, app, client, evaluation_setup): + self._submit(client, evaluation_setup, mecanics_score='11', mental_score='6') + + with app.app_context(): + assert Evaluation.query.count() == 0 + + def test_a_second_submission_replaces_the_first(self, app, client, evaluation_setup): + self._submit(client, evaluation_setup, mecanics_score='8', mental_score='8') + self._submit(client, evaluation_setup, mecanics_score='4', mental_score='4') + + with app.app_context(): + evaluation = Evaluation.query.one() + assert evaluation.overall_score == 4 + + def test_clearing_a_score_clears_it_and_moves_the_mean(self, app, client, evaluation_setup): + """Every criterion is reassigned on edit, blanks included. Assigning + only the ones that came back filled would leave the old value on the + record and disagree with the mean beside it.""" + self._submit(client, evaluation_setup, mecanics_score='10', mental_score='4') + self._submit(client, evaluation_setup, mecanics_score='', mental_score='4') + + with app.app_context(): + evaluation = Evaluation.query.one() + assert evaluation.mecanics_score is None + assert evaluation.overall_score == 4 diff --git a/tests/test_tryout_form.py b/tests/test_tryout_form.py new file mode 100644 index 0000000..e8fb138 --- /dev/null +++ b/tests/test_tryout_form.py @@ -0,0 +1,119 @@ +"""Creating and editing a tryout, through the form (ARCH-005). + +Same shape as the match routes: ten fields read off `request.form`, two of +them checked and the rest believed. + + - `game` drives the position list and the gamertag fields a registering + player is shown. It was accepted as any string, so a typo produced a + tryout nobody could be evaluated for; + - `max_players` was `int(x) if x else None` — a 500 on 'twelve', and a + cheerful -3 otherwise; + - `coach_ids` were loaded with `User.id.in_(...)` and no role filter, so a + hand-made submission could name a player as coach of a tryout, which is + a permission grant. +""" + +from datetime import date + +import pytest + +from app.extensions import db +from app.models import OrgTeam, Tryout + + +@pytest.fixture +def form_context(app, as_role, make_user): + admin_id = as_role('admin') + coach_id = make_user('coach') + player_id = make_user('player') + + with app.app_context(): + org_team = OrgTeam(name='Varsity', created_by=admin_id) + db.session.add(org_team) + db.session.commit() + return {'org_team_id': org_team.id, 'coach_id': coach_id, 'player_id': player_id} + + +VALID = { + 'title': 'Spring tryout', + 'game': 'Valorant', + 'date': '2030-04-01', + 'end_date': '2030-04-03', + 'location': 'Arena', + 'max_players': '12', +} + + +def _create(client, **overrides): + return client.post('/tryouts/create', data=dict(VALID, **overrides), follow_redirects=True) + + +def _only_tryout(app): + with app.app_context(): + return Tryout.query.one_or_none() + + +class TestCreating: + def test_a_valid_tryout_is_stored_with_typed_values(self, app, client, form_context): + _create(client) + + tryout = _only_tryout(app) + assert tryout is not None + assert tryout.date == date(2030, 4, 1) + assert tryout.end_date == date(2030, 4, 3) + assert tryout.max_players == 12 + + def test_no_end_date_is_allowed(self, app, client, form_context): + _create(client, end_date='') + + assert _only_tryout(app).end_date is None + + def test_no_player_limit_is_allowed(self, app, client, form_context): + _create(client, max_players='') + + assert _only_tryout(app).max_players is None + + def test_a_coach_can_be_attached(self, app, client, form_context): + _create(client, coach_ids=str(form_context['coach_id'])) + + with app.app_context(): + tryout = Tryout.query.one() + assert [c.id for c in tryout.coaches] == [form_context['coach_id']] + + +class TestRefusals: + def test_an_unknown_game_is_refused(self, app, client, form_context): + _create(client, game='Pong') + + assert _only_tryout(app) is None + + def test_an_empty_title_is_refused(self, app, client, form_context): + _create(client, title='') + + assert _only_tryout(app) is None + + def test_a_non_numeric_player_limit_is_refused_not_crashed(self, app, client, form_context): + response = _create(client, max_players='twelve') + + assert response.status_code == 200 + assert _only_tryout(app) is None + + def test_a_negative_player_limit_is_refused(self, app, client, form_context): + _create(client, max_players='-3') + + assert _only_tryout(app) is None + + def test_an_end_before_the_start_is_refused(self, app, client, form_context): + _create(client, date='2030-04-05', end_date='2030-04-01') + + assert _only_tryout(app) is None + + def test_a_player_cannot_be_slipped_in_as_a_coach(self, app, client, form_context): + """Not a form the interface offers — the select lists coaches. It is + a hand-made submission, and it used to work: being a coach of a + tryout carries the right to manage it and evaluate its players.""" + _create(client, coach_ids=str(form_context['player_id'])) + + with app.app_context(): + tryout = Tryout.query.one() + assert list(tryout.coaches) == []