diff --git a/app/routes/teams.py b/app/routes/teams.py index b876d89..1dabb16 100644 --- a/app/routes/teams.py +++ b/app/routes/teams.py @@ -29,7 +29,7 @@ from app.models import ( User, ) from app.permissions import visible_org_teams -from app.validators import OrgTeamSchema +from app.validators import OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema teams_bp = Blueprint('teams', __name__, url_prefix='/teams') @@ -55,7 +55,12 @@ def list_teams(): managers = ( User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all() ) - all_players = User.query.filter_by(role='player').order_by(User.username).all() + # is_active_account, like the two queries above it. Without it the "add + # player" select offered accounts that had been deactivated, and + # add_player accepted them. + all_players = ( + User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all() + ) return render_template( 'pages/teams.html', teams=teams, @@ -119,27 +124,58 @@ def my_teams(): return render_template('pages/my_teams.html', team_data=team_data, now=now) -def _staff_member(user_id, expected_class): - """The user behind an id, only if they hold the role being assigned. +def _posted(schema): + """Load a form through `schema`, or None when it will not load. - Returns None for a missing id, an unknown id, or an account of the wrong - role. That last case is the point (SEC-16): the id comes from a `` the browser rendered, so it is a value the client + chooses, and nothing checked it in two of the three places that used it. + A forged submission could therefore list a player among a team's coaches + — the same defect wave G fixed in `tryouts.py`, left standing here. + + Defers to `_assignable` rather than repeating `isinstance`: two functions + in one file answering "may this account take this role" differently is + the shape of every defect this module has had. Args: - user_id: Already an int or None, thanks to OrgTeamSchema. + user_id: Already an int or None, thanks to the schema. expected_class: Coach or Manager. Returns: - User | None: The account, when it is of the expected role. + User | None: The account, when it may take the role. """ if not user_id: return None user = db.session.get(User, user_id) - return user if isinstance(user, expected_class) else None + return user if user and _assignable(user, expected_class) else None @teams_bp.route('/create', methods=['POST']) @@ -305,13 +341,15 @@ def add_coach(team_id): flash(_('Permission denied.'), 'danger') return redirect(url_for('teams.list_teams')) - coach_id = request.form.get('coach_id') - if not coach_id: + data = _posted(TeamStaffSchema()) + if data is None: + return redirect(url_for('teams.list_teams')) + if not data['coach_id']: flash(_('Please select a coach.'), 'danger') return redirect(url_for('teams.list_teams')) - coach = User.query.get_or_404(int(coach_id)) - if not isinstance(coach, Coach): + 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')) @@ -346,13 +384,15 @@ def add_manager(team_id): flash(_('Permission denied.'), 'danger') return redirect(url_for('teams.list_teams')) - manager_id = request.form.get('manager_id') - if not manager_id: + data = _posted(TeamStaffSchema()) + if data is None: + return redirect(url_for('teams.list_teams')) + if not data['manager_id']: flash(_('Please select a manager.'), 'danger') return redirect(url_for('teams.list_teams')) - manager = User.query.get_or_404(int(manager_id)) - if not isinstance(manager, Manager): + 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')) @@ -387,9 +427,12 @@ def remove_coach(team_id): flash(_('Permission denied.'), 'danger') return redirect(url_for('teams.list_teams')) - coach_id = request.form.get('coach_id') - if coach_id: - coach = User.query.get(int(coach_id)) + data = _posted(TeamStaffSchema()) + if data is None: + return redirect(url_for('teams.list_teams')) + + if data['coach_id']: + coach = db.session.get(User, data['coach_id']) if coach and team.coaches.filter_by(id=coach.id).first(): team.coaches.remove(coach) if team.coach_id == coach.id: @@ -412,9 +455,12 @@ def remove_manager(team_id): flash(_('Permission denied.'), 'danger') return redirect(url_for('teams.list_teams')) - manager_id = request.form.get('manager_id') - if manager_id: - manager = User.query.get(int(manager_id)) + data = _posted(TeamStaffSchema()) + if data is None: + return redirect(url_for('teams.list_teams')) + + if data['manager_id']: + manager = db.session.get(User, data['manager_id']) if manager and team.managers.filter_by(id=manager.id).first(): team.managers.remove(manager) if team.manager_id == manager.id: @@ -437,14 +483,16 @@ def add_player(team_id): flash(_('Permission denied.'), 'danger') return redirect(url_for('teams.list_teams')) - player_id = request.form.get('player_id') - status = request.form.get('status', 'starter') - if not player_id: + data = _posted(TeamPlayerSchema()) + if data is None: + return redirect(url_for('teams.list_teams')) + if not data['player_id']: flash(_('Please select a player.'), 'danger') return redirect(url_for('teams.list_teams')) - player = User.query.get_or_404(int(player_id)) - if not isinstance(player, Player): + 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')) diff --git a/app/routes/tryouts.py b/app/routes/tryouts.py index df213be..4d375e9 100644 --- a/app/routes/tryouts.py +++ b/app/routes/tryouts.py @@ -303,7 +303,14 @@ def view_tryout(tryout_id): all_players = None if can_edit: - all_players = User.query.filter_by(role='player').order_by(User.username).all() + # is_active_account, like the manager and coach queries in this same + # module. Offering a deactivated account in a roster select + # contradicts the one control that says the person has left. + all_players = ( + User.query.filter_by(role='player', is_active_account=True) + .order_by(User.username) + .all() + ) matches = ( Match.query.filter_by(tryout_id=tryout_id).order_by(Match.date, Match.start_time).all() diff --git a/app/translations/en/LC_MESSAGES/messages.mo b/app/translations/en/LC_MESSAGES/messages.mo index 4ecf618..23c3123 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 987f8c3..e4301be 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 20:15-0400\n" +"POT-Creation-Date: 2026-08-11 20:39-0400\n" "PO-Revision-Date: 2026-08-07 20:22-0400\n" "Last-Translator: FULL NAME \n" "Language: en\n" @@ -90,127 +90,135 @@ msgstr "Player must be selected." msgid "Notes must be 2000 characters or less." msgstr "Notes must be 2000 characters or less." -#: app/validators.py:482 -msgid "Date must be in YYYY-MM-DD format." -msgstr "Date must be in YYYY-MM-DD format." - -#: app/validators.py:483 -msgid "A date is required." -msgstr "A date is required." - -#: app/validators.py:489 app/validators.py:554 -msgid "Start time must be in HH:MM format." -msgstr "Start time must be in HH:MM format." - -#: app/validators.py:490 app/validators.py:555 -msgid "A start time is required." -msgstr "A start time is required." - -#: app/validators.py:496 -msgid "End time must be in HH:MM format." -msgstr "End time must be in HH:MM format." - -#: app/validators.py:497 -msgid "An end time is required." -msgstr "An end time is required." - -#: app/validators.py:501 -msgid "Points must be 2000 characters or less." -msgstr "Points must be 2000 characters or less." - -#: app/validators.py:516 -msgid "End time must be after start time." -msgstr "End time must be after start time." - -#: app/validators.py:545 app/validators.py:547 -msgid "Day must be 0 (Monday) to 6 (Sunday)." -msgstr "Day must be 0 (Monday) to 6 (Sunday)." - -#: app/validators.py:548 -msgid "A day is required." -msgstr "A day is required." - -#: app/validators.py:583 -msgid "Player selection is malformed." -msgstr "Player selection is malformed." - -#: app/validators.py:609 app/validators.py:719 -msgid "A title is required." -msgstr "A title is required." - -#: app/validators.py:618 -msgid "Invalid date format." -msgstr "Invalid date format." - -#: app/validators.py:623 app/validators.py:630 -msgid "Invalid time format." -msgstr "Invalid time format." - -#: app/validators.py:624 -msgid "Start time is required. Please select a time slot." -msgstr "Start time is required. Please select a time slot." - -#: app/validators.py:638 -msgid "Unknown match status." -msgstr "Unknown match status." - -#: app/validators.py:654 -msgid "The end time must come after the start time." -msgstr "The end time must come after the start time." - -#: app/validators.py:668 -msgid "Unknown match type." -msgstr "Unknown match type." - -#: app/validators.py:680 -msgid "A team cannot play against itself." -msgstr "A team cannot play against itself." - -#: app/validators.py:728 -msgid "Unknown game." -msgstr "Unknown game." - -#: app/validators.py:733 -msgid "Invalid start date format." -msgstr "Invalid start date format." - -#: app/validators.py:734 -msgid "A start date is required." -msgstr "A start date is required." - -#: app/validators.py:740 -msgid "Invalid end date format." -msgstr "Invalid end date format." - -#: app/validators.py:748 -msgid "A tryout must allow at least one player." -msgstr "A tryout must allow at least one player." - -#: app/validators.py:751 -msgid "The player limit must be a whole number." -msgstr "The player limit must be a whole number." - -#: app/validators.py:763 -msgid "End date cannot be before start date." -msgstr "End date cannot be before start date." - -#: app/validators.py:790 app/validators.py:791 -msgid "Team name is required." -msgstr "Team name is required." - -#: app/validators.py:797 +#: app/validators.py:494 app/validators.py:853 msgid "Invalid coach selection." msgstr "Invalid coach selection." -#: app/validators.py:803 +#: app/validators.py:500 app/validators.py:859 msgid "Invalid manager selection." msgstr "Invalid manager selection." -#: app/validators.py:815 +#: app/validators.py:511 +msgid "Invalid player selection." +msgstr "Invalid player selection." + +#: app/validators.py:515 +msgid "Unknown roster status." +msgstr "Unknown roster status." + +#: app/validators.py:538 +msgid "Date must be in YYYY-MM-DD format." +msgstr "Date must be in YYYY-MM-DD format." + +#: app/validators.py:539 +msgid "A date is required." +msgstr "A date is required." + +#: app/validators.py:545 app/validators.py:610 +msgid "Start time must be in HH:MM format." +msgstr "Start time must be in HH:MM format." + +#: app/validators.py:546 app/validators.py:611 +msgid "A start time is required." +msgstr "A start time is required." + +#: app/validators.py:552 +msgid "End time must be in HH:MM format." +msgstr "End time must be in HH:MM format." + +#: app/validators.py:553 +msgid "An end time is required." +msgstr "An end time is required." + +#: app/validators.py:557 +msgid "Points must be 2000 characters or less." +msgstr "Points must be 2000 characters or less." + +#: app/validators.py:572 +msgid "End time must be after start time." +msgstr "End time must be after start time." + +#: app/validators.py:601 app/validators.py:603 +msgid "Day must be 0 (Monday) to 6 (Sunday)." +msgstr "Day must be 0 (Monday) to 6 (Sunday)." + +#: app/validators.py:604 +msgid "A day is required." +msgstr "A day is required." + +#: app/validators.py:639 +msgid "Player selection is malformed." +msgstr "Player selection is malformed." + +#: app/validators.py:665 app/validators.py:775 +msgid "A title is required." +msgstr "A title is required." + +#: app/validators.py:674 +msgid "Invalid date format." +msgstr "Invalid date format." + +#: app/validators.py:679 app/validators.py:686 +msgid "Invalid time format." +msgstr "Invalid time format." + +#: app/validators.py:680 +msgid "Start time is required. Please select a time slot." +msgstr "Start time is required. Please select a time slot." + +#: app/validators.py:694 +msgid "Unknown match status." +msgstr "Unknown match status." + +#: app/validators.py:710 +msgid "The end time must come after the start time." +msgstr "The end time must come after the start time." + +#: app/validators.py:724 +msgid "Unknown match type." +msgstr "Unknown match type." + +#: app/validators.py:736 +msgid "A team cannot play against itself." +msgstr "A team cannot play against itself." + +#: app/validators.py:784 +msgid "Unknown game." +msgstr "Unknown game." + +#: app/validators.py:789 +msgid "Invalid start date format." +msgstr "Invalid start date format." + +#: app/validators.py:790 +msgid "A start date is required." +msgstr "A start date is required." + +#: app/validators.py:796 +msgid "Invalid end date format." +msgstr "Invalid end date format." + +#: app/validators.py:804 +msgid "A tryout must allow at least one player." +msgstr "A tryout must allow at least one player." + +#: app/validators.py:807 +msgid "The player limit must be a whole number." +msgstr "The player limit must be a whole number." + +#: app/validators.py:819 +msgid "End date cannot be before start date." +msgstr "End date cannot be before start date." + +#: app/validators.py:846 app/validators.py:847 +msgid "Team name is required." +msgstr "Team name is required." + +#: app/validators.py:871 msgid "Scores run from 1 to 10." msgstr "Scores run from 1 to 10." -#: app/validators.py:816 +#: app/validators.py:872 msgid "A score must be a whole number from 1 to 10." msgstr "A score must be a whole number from 1 to 10." @@ -311,12 +319,12 @@ msgstr "Evaluation submitted successfully!" msgid "Evaluation updated!" msgstr "Evaluation updated!" -#: app/routes/evaluations.py:210 app/routes/teams.py:305 -#: app/routes/teams.py:346 app/routes/teams.py:387 app/routes/teams.py:412 -#: app/routes/teams.py:437 app/routes/teams.py:472 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 +#: app/routes/evaluations.py:210 app/routes/teams.py:341 +#: app/routes/teams.py:384 app/routes/teams.py:427 app/routes/teams.py:455 +#: app/routes/teams.py:483 app/routes/teams.py:520 app/routes/tryouts.py:444 +#: app/routes/tryouts.py:460 app/routes/tryouts.py:480 +#: app/routes/tryouts.py:519 app/routes/tryouts.py:555 +#: app/routes/tryouts.py:574 msgid "Permission denied." msgstr "Permission denied." @@ -373,130 +381,130 @@ msgstr "Use My Team(s) to view your teams." msgid "You do not have permission to view teams." msgstr "You do not have permission to view teams." -#: app/routes/teams.py:74 +#: app/routes/teams.py:79 msgid "This page is for players." msgstr "This page is for players." -#: app/routes/teams.py:150 +#: app/routes/teams.py:186 msgid "You do not have permission to create teams." msgstr "You do not have permission to create teams." -#: app/routes/teams.py:161 app/routes/teams.py:203 +#: app/routes/teams.py:197 app/routes/teams.py:239 #, python-format msgid "Team \"%(name)s\" already exists." msgstr "Team \"%(name)s\" already exists." -#: app/routes/teams.py:182 +#: app/routes/teams.py:218 #, python-format msgid "Team \"%(name)s\" created successfully!" msgstr "Team \"%(name)s\" created successfully!" -#: app/routes/teams.py:192 +#: app/routes/teams.py:228 msgid "You do not have permission to edit this team." msgstr "You do not have permission to edit this team." -#: app/routes/teams.py:236 +#: app/routes/teams.py:272 #, python-format msgid "Team \"%(name)s\" updated successfully!" msgstr "Team \"%(name)s\" updated successfully!" -#: app/routes/teams.py:264 +#: app/routes/teams.py:300 msgid "You do not have permission to delete teams." msgstr "You do not have permission to delete teams." -#: app/routes/teams.py:295 +#: app/routes/teams.py:331 #, python-format msgid "Team \"%(name)s\" deleted successfully." msgstr "Team \"%(name)s\" deleted successfully." -#: app/routes/teams.py:310 +#: app/routes/teams.py:348 msgid "Please select a coach." msgstr "Please select a coach." -#: app/routes/teams.py:315 +#: app/routes/teams.py:353 msgid "Only coaches can be assigned as coach." msgstr "Only coaches can be assigned as coach." -#: app/routes/teams.py:321 +#: app/routes/teams.py:359 #, python-format msgid "%(username)s is already a coach of %(name)s." msgstr "%(username)s is already a coach of %(name)s." -#: app/routes/teams.py:334 +#: app/routes/teams.py:372 #, python-format msgid "%(username)s added as coach of %(name)s." msgstr "%(username)s added as coach of %(name)s." -#: app/routes/teams.py:351 +#: app/routes/teams.py:391 msgid "Please select a manager." msgstr "Please select a manager." -#: app/routes/teams.py:356 +#: app/routes/teams.py:396 msgid "Only managers can be assigned as manager." msgstr "Only managers can be assigned as manager." -#: app/routes/teams.py:362 +#: app/routes/teams.py:402 #, python-format msgid "%(username)s is already a manager of %(name)s." msgstr "%(username)s is already a manager of %(name)s." -#: app/routes/teams.py:375 +#: app/routes/teams.py:415 #, python-format msgid "%(username)s added as manager of %(name)s." msgstr "%(username)s added as manager of %(name)s." -#: app/routes/teams.py:402 +#: app/routes/teams.py:445 #, python-format msgid "Coach removed from %(name)s." msgstr "Coach removed from %(name)s." -#: app/routes/teams.py:427 +#: app/routes/teams.py:473 #, python-format msgid "Manager removed from %(name)s." msgstr "Manager removed from %(name)s." -#: app/routes/teams.py:443 app/routes/tryouts.py:477 app/routes/tryouts.py:578 +#: app/routes/teams.py:490 app/routes/tryouts.py:484 app/routes/tryouts.py:585 msgid "Please select a player." msgstr "Please select a player." -#: app/routes/teams.py:448 +#: app/routes/teams.py:496 msgid "Can only assign players to teams." msgstr "Can only assign players to teams." -#: app/routes/teams.py:454 +#: app/routes/teams.py:502 #, python-format msgid "%(username)s is already on %(name)s." msgstr "%(username)s is already on %(name)s." -#: app/routes/teams.py:462 +#: app/routes/teams.py:510 #, python-format msgid "%(username)s added to %(name)s!" msgstr "%(username)s added to %(name)s!" -#: app/routes/teams.py:479 app/routes/teams.py:553 +#: app/routes/teams.py:527 app/routes/teams.py:601 #, python-format msgid "%(username)s is not on %(name)s." msgstr "%(username)s is not on %(name)s." -#: app/routes/teams.py:487 +#: app/routes/teams.py:535 #, python-format msgid "%(username)s removed from %(name)s." msgstr "%(username)s removed from %(name)s." -#: app/routes/teams.py:524 app/routes/teams.py:542 +#: app/routes/teams.py:572 app/routes/teams.py:590 msgid "You do not have permission to add notes to this team." msgstr "You do not have permission to add notes to this team." -#: app/routes/teams.py:532 +#: app/routes/teams.py:580 msgid "Team notes added successfully!" msgstr "Team notes added successfully!" -#: app/routes/teams.py:547 app/routes/users/notes.py:207 +#: app/routes/teams.py:595 app/routes/users/notes.py:207 #: app/routes/users/notes.py:250 msgid "Can only add notes for players." msgstr "Can only add notes for players." -#: app/routes/teams.py:563 +#: app/routes/teams.py:611 #, python-format msgid "Note added for %(username)s!" msgstr "Note added for %(username)s!" @@ -525,76 +533,76 @@ msgstr "Tryout updated successfully!" msgid "You do not have permission to view this tryout." msgstr "You do not have permission to view this tryout." -#: app/routes/tryouts.py:404 +#: app/routes/tryouts.py:411 msgid "Only players can register for tryouts." msgstr "Only players can register for tryouts." -#: app/routes/tryouts.py:408 +#: app/routes/tryouts.py:415 msgid "This tryout is not accepting registrations." msgstr "This tryout is not accepting registrations." -#: app/routes/tryouts.py:415 +#: app/routes/tryouts.py:422 msgid "You are already registered for this tryout." msgstr "You are already registered for this tryout." -#: app/routes/tryouts.py:421 app/routes/tryouts.py:496 +#: app/routes/tryouts.py:428 app/routes/tryouts.py:503 msgid "This tryout is full." msgstr "This tryout is full." -#: app/routes/tryouts.py:427 +#: app/routes/tryouts.py:434 msgid "Successfully registered for tryout!" msgstr "Successfully registered for tryout!" -#: app/routes/tryouts.py:443 +#: app/routes/tryouts.py:450 #, python-format msgid "Tryout status updated to %(new_status)s." msgstr "Tryout status updated to %(new_status)s." -#: app/routes/tryouts.py:463 +#: app/routes/tryouts.py:470 msgid "Registration status updated." msgstr "Registration status updated." -#: app/routes/tryouts.py:482 +#: app/routes/tryouts.py:489 msgid "Can only register players." msgstr "Can only register players." -#: app/routes/tryouts.py:488 +#: app/routes/tryouts.py:495 #, python-format msgid "%(username)s is already registered for this tryout." msgstr "%(username)s is already registered for this tryout." -#: app/routes/tryouts.py:502 +#: app/routes/tryouts.py:509 #, python-format msgid "%(username)s registered for tryout!" msgstr "%(username)s registered for tryout!" -#: app/routes/tryouts.py:538 +#: app/routes/tryouts.py:545 #, python-format msgid "%(username)s removed from tryout." msgstr "%(username)s removed from tryout." -#: app/routes/tryouts.py:556 +#: app/routes/tryouts.py:563 #, python-format msgid "Team \"%(team_name)s\" created!" msgstr "Team \"%(team_name)s\" created!" -#: app/routes/tryouts.py:587 +#: app/routes/tryouts.py:594 msgid "That player is not registered for this tryout." msgstr "That player is not registered for this tryout." -#: app/routes/tryouts.py:593 +#: app/routes/tryouts.py:600 msgid "Player is already on this team." msgstr "Player is already on this team." -#: app/routes/tryouts.py:598 +#: app/routes/tryouts.py:605 msgid "Player added to team!" msgstr "Player added to team!" -#: app/routes/tryouts.py:608 +#: app/routes/tryouts.py:615 msgid "You do not have permission to delete this tryout." msgstr "You do not have permission to delete this tryout." -#: app/routes/tryouts.py:644 +#: app/routes/tryouts.py:651 msgid "Tryout deleted successfully." msgstr "Tryout deleted successfully." diff --git a/app/translations/fr/LC_MESSAGES/messages.mo b/app/translations/fr/LC_MESSAGES/messages.mo index 0d14709..b9f3180 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 4a33801..690e0cc 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 20:15-0400\n" +"POT-Creation-Date: 2026-08-11 20:39-0400\n" "PO-Revision-Date: 2026-08-07 20:22-0400\n" "Last-Translator: FULL NAME \n" "Language: fr\n" @@ -92,127 +92,135 @@ msgstr "Vous devez choisir un joueur." msgid "Notes must be 2000 characters or less." msgstr "Les notes ne doivent pas dépasser 2000 caractères." -#: app/validators.py:482 -msgid "Date must be in YYYY-MM-DD format." -msgstr "La date doit être au format AAAA-MM-JJ." - -#: app/validators.py:483 -msgid "A date is required." -msgstr "Une date est requise." - -#: app/validators.py:489 app/validators.py:554 -msgid "Start time must be in HH:MM format." -msgstr "L’heure de début doit être au format HH:MM." - -#: app/validators.py:490 app/validators.py:555 -msgid "A start time is required." -msgstr "Une heure de début est requise." - -#: app/validators.py:496 -msgid "End time must be in HH:MM format." -msgstr "L’heure de fin doit être au format HH:MM." - -#: app/validators.py:497 -msgid "An end time is required." -msgstr "Une heure de fin est requise." - -#: app/validators.py:501 -msgid "Points must be 2000 characters or less." -msgstr "Les points ne doivent pas dépasser 2000 caractères." - -#: app/validators.py:516 -msgid "End time must be after start time." -msgstr "L'heure de fin doit être postérieure à l'heure de début." - -#: app/validators.py:545 app/validators.py:547 -msgid "Day must be 0 (Monday) to 6 (Sunday)." -msgstr "Le jour doit aller de 0 (lundi) à 6 (dimanche)." - -#: app/validators.py:548 -msgid "A day is required." -msgstr "Un jour est requis." - -#: app/validators.py:583 -msgid "Player selection is malformed." -msgstr "La sélection de joueurs est mal formée." - -#: app/validators.py:609 app/validators.py:719 -msgid "A title is required." -msgstr "Un titre est requis." - -#: app/validators.py:618 -msgid "Invalid date format." -msgstr "Format de date invalide." - -#: app/validators.py:623 app/validators.py:630 -msgid "Invalid time format." -msgstr "Format d’heure invalide." - -#: app/validators.py:624 -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:638 -msgid "Unknown match status." -msgstr "Statut de match inconnu." - -#: app/validators.py:654 -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:668 -msgid "Unknown match type." -msgstr "Type de match inconnu." - -#: app/validators.py:680 -msgid "A team cannot play against itself." -msgstr "Une équipe ne peut pas jouer contre elle-même." - -#: app/validators.py:728 -msgid "Unknown game." -msgstr "Jeu inconnu." - -#: app/validators.py:733 -msgid "Invalid start date format." -msgstr "Format de date de début invalide." - -#: app/validators.py:734 -msgid "A start date is required." -msgstr "Une date de début est requise." - -#: app/validators.py:740 -msgid "Invalid end date format." -msgstr "Format de date de fin invalide." - -#: app/validators.py:748 -msgid "A tryout must allow at least one player." -msgstr "Une sélection doit accepter au moins un joueur." - -#: app/validators.py:751 -msgid "The player limit must be a whole number." -msgstr "La limite de joueurs doit être un nombre entier." - -#: app/validators.py:763 -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:790 app/validators.py:791 -msgid "Team name is required." -msgstr "Le nom de l’équipe est obligatoire." - -#: app/validators.py:797 +#: app/validators.py:494 app/validators.py:853 msgid "Invalid coach selection." msgstr "Sélection de coach invalide." -#: app/validators.py:803 +#: app/validators.py:500 app/validators.py:859 msgid "Invalid manager selection." msgstr "Sélection de gérant invalide." -#: app/validators.py:815 +#: app/validators.py:511 +msgid "Invalid player selection." +msgstr "Sélection de joueur invalide." + +#: app/validators.py:515 +msgid "Unknown roster status." +msgstr "Statut d'effectif inconnu." + +#: app/validators.py:538 +msgid "Date must be in YYYY-MM-DD format." +msgstr "La date doit être au format AAAA-MM-JJ." + +#: app/validators.py:539 +msgid "A date is required." +msgstr "Une date est requise." + +#: app/validators.py:545 app/validators.py:610 +msgid "Start time must be in HH:MM format." +msgstr "L’heure de début doit être au format HH:MM." + +#: app/validators.py:546 app/validators.py:611 +msgid "A start time is required." +msgstr "Une heure de début est requise." + +#: app/validators.py:552 +msgid "End time must be in HH:MM format." +msgstr "L’heure de fin doit être au format HH:MM." + +#: app/validators.py:553 +msgid "An end time is required." +msgstr "Une heure de fin est requise." + +#: app/validators.py:557 +msgid "Points must be 2000 characters or less." +msgstr "Les points ne doivent pas dépasser 2000 caractères." + +#: app/validators.py:572 +msgid "End time must be after start time." +msgstr "L'heure de fin doit être postérieure à l'heure de début." + +#: app/validators.py:601 app/validators.py:603 +msgid "Day must be 0 (Monday) to 6 (Sunday)." +msgstr "Le jour doit aller de 0 (lundi) à 6 (dimanche)." + +#: app/validators.py:604 +msgid "A day is required." +msgstr "Un jour est requis." + +#: app/validators.py:639 +msgid "Player selection is malformed." +msgstr "La sélection de joueurs est mal formée." + +#: app/validators.py:665 app/validators.py:775 +msgid "A title is required." +msgstr "Un titre est requis." + +#: app/validators.py:674 +msgid "Invalid date format." +msgstr "Format de date invalide." + +#: app/validators.py:679 app/validators.py:686 +msgid "Invalid time format." +msgstr "Format d’heure invalide." + +#: app/validators.py:680 +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:694 +msgid "Unknown match status." +msgstr "Statut de match inconnu." + +#: app/validators.py:710 +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:724 +msgid "Unknown match type." +msgstr "Type de match inconnu." + +#: app/validators.py:736 +msgid "A team cannot play against itself." +msgstr "Une équipe ne peut pas jouer contre elle-même." + +#: app/validators.py:784 +msgid "Unknown game." +msgstr "Jeu inconnu." + +#: app/validators.py:789 +msgid "Invalid start date format." +msgstr "Format de date de début invalide." + +#: app/validators.py:790 +msgid "A start date is required." +msgstr "Une date de début est requise." + +#: app/validators.py:796 +msgid "Invalid end date format." +msgstr "Format de date de fin invalide." + +#: app/validators.py:804 +msgid "A tryout must allow at least one player." +msgstr "Une sélection doit accepter au moins un joueur." + +#: app/validators.py:807 +msgid "The player limit must be a whole number." +msgstr "La limite de joueurs doit être un nombre entier." + +#: app/validators.py:819 +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:846 app/validators.py:847 +msgid "Team name is required." +msgstr "Le nom de l’équipe est obligatoire." + +#: app/validators.py:871 msgid "Scores run from 1 to 10." msgstr "Les notes vont de 1 à 10." -#: app/validators.py:816 +#: app/validators.py:872 msgid "A score must be a whole number from 1 to 10." msgstr "Une note doit être un nombre entier de 1 à 10." @@ -313,12 +321,12 @@ msgstr "Évaluation enregistrée." msgid "Evaluation updated!" msgstr "Évaluation mise à jour." -#: app/routes/evaluations.py:210 app/routes/teams.py:305 -#: app/routes/teams.py:346 app/routes/teams.py:387 app/routes/teams.py:412 -#: app/routes/teams.py:437 app/routes/teams.py:472 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 +#: app/routes/evaluations.py:210 app/routes/teams.py:341 +#: app/routes/teams.py:384 app/routes/teams.py:427 app/routes/teams.py:455 +#: app/routes/teams.py:483 app/routes/teams.py:520 app/routes/tryouts.py:444 +#: app/routes/tryouts.py:460 app/routes/tryouts.py:480 +#: app/routes/tryouts.py:519 app/routes/tryouts.py:555 +#: app/routes/tryouts.py:574 msgid "Permission denied." msgstr "Accès refusé." @@ -377,130 +385,130 @@ msgstr "Utilisez « Mon ou mes équipes » pour consulter vos équipes." msgid "You do not have permission to view teams." msgstr "Vous n’avez pas les droits pour consulter les équipes." -#: app/routes/teams.py:74 +#: app/routes/teams.py:79 msgid "This page is for players." msgstr "Cette page est réservée aux joueurs." -#: app/routes/teams.py:150 +#: app/routes/teams.py:186 msgid "You do not have permission to create teams." msgstr "Vous n’avez pas les droits pour créer une équipe." -#: app/routes/teams.py:161 app/routes/teams.py:203 +#: app/routes/teams.py:197 app/routes/teams.py:239 #, python-format msgid "Team \"%(name)s\" already exists." msgstr "L’équipe « %(name)s » existe déjà." -#: app/routes/teams.py:182 +#: app/routes/teams.py:218 #, python-format msgid "Team \"%(name)s\" created successfully!" msgstr "Équipe « %(name)s » créée." -#: app/routes/teams.py:192 +#: app/routes/teams.py:228 msgid "You do not have permission to edit this team." msgstr "Vous n’avez pas les droits pour modifier cette équipe." -#: app/routes/teams.py:236 +#: app/routes/teams.py:272 #, python-format msgid "Team \"%(name)s\" updated successfully!" msgstr "Équipe « %(name)s » mise à jour." -#: app/routes/teams.py:264 +#: app/routes/teams.py:300 msgid "You do not have permission to delete teams." msgstr "Vous n’avez pas les droits pour supprimer une équipe." -#: app/routes/teams.py:295 +#: app/routes/teams.py:331 #, python-format msgid "Team \"%(name)s\" deleted successfully." msgstr "Équipe « %(name)s » supprimée." -#: app/routes/teams.py:310 +#: app/routes/teams.py:348 msgid "Please select a coach." msgstr "Veuillez choisir un coach." -#: app/routes/teams.py:315 +#: app/routes/teams.py:353 msgid "Only coaches can be assigned as coach." msgstr "Seuls les coachs peuvent être assignés comme coach." -#: app/routes/teams.py:321 +#: app/routes/teams.py:359 #, python-format msgid "%(username)s is already a coach of %(name)s." msgstr "%(username)s est déjà coach de %(name)s." -#: app/routes/teams.py:334 +#: app/routes/teams.py:372 #, python-format msgid "%(username)s added as coach of %(name)s." msgstr "%(username)s a été ajouté comme coach de %(name)s." -#: app/routes/teams.py:351 +#: app/routes/teams.py:391 msgid "Please select a manager." msgstr "Veuillez choisir un gérant." -#: app/routes/teams.py:356 +#: app/routes/teams.py:396 msgid "Only managers can be assigned as manager." msgstr "Seuls les gérants peuvent être assignés comme gérant." -#: app/routes/teams.py:362 +#: app/routes/teams.py:402 #, python-format msgid "%(username)s is already a manager of %(name)s." msgstr "%(username)s est déjà gérant de %(name)s." -#: app/routes/teams.py:375 +#: app/routes/teams.py:415 #, python-format msgid "%(username)s added as manager of %(name)s." msgstr "%(username)s a été ajouté comme gérant de %(name)s." -#: app/routes/teams.py:402 +#: app/routes/teams.py:445 #, python-format msgid "Coach removed from %(name)s." msgstr "Coach retiré de %(name)s." -#: app/routes/teams.py:427 +#: app/routes/teams.py:473 #, python-format msgid "Manager removed from %(name)s." msgstr "Gérant retiré de %(name)s." -#: app/routes/teams.py:443 app/routes/tryouts.py:477 app/routes/tryouts.py:578 +#: app/routes/teams.py:490 app/routes/tryouts.py:484 app/routes/tryouts.py:585 msgid "Please select a player." msgstr "Veuillez choisir un joueur." -#: app/routes/teams.py:448 +#: app/routes/teams.py:496 msgid "Can only assign players to teams." msgstr "Seuls des joueurs peuvent être assignés à une équipe." -#: app/routes/teams.py:454 +#: app/routes/teams.py:502 #, python-format msgid "%(username)s is already on %(name)s." msgstr "%(username)s fait déjà partie de %(name)s." -#: app/routes/teams.py:462 +#: app/routes/teams.py:510 #, python-format msgid "%(username)s added to %(name)s!" msgstr "%(username)s a été ajouté à %(name)s." -#: app/routes/teams.py:479 app/routes/teams.py:553 +#: app/routes/teams.py:527 app/routes/teams.py:601 #, python-format msgid "%(username)s is not on %(name)s." msgstr "%(username)s ne fait pas partie de %(name)s." -#: app/routes/teams.py:487 +#: app/routes/teams.py:535 #, python-format msgid "%(username)s removed from %(name)s." msgstr "%(username)s a été retiré de %(name)s." -#: app/routes/teams.py:524 app/routes/teams.py:542 +#: app/routes/teams.py:572 app/routes/teams.py:590 msgid "You do not have permission to add notes to this team." msgstr "Vous n’avez pas les droits pour ajouter des notes à cette équipe." -#: app/routes/teams.py:532 +#: app/routes/teams.py:580 msgid "Team notes added successfully!" msgstr "Notes d’équipe ajoutées." -#: app/routes/teams.py:547 app/routes/users/notes.py:207 +#: app/routes/teams.py:595 app/routes/users/notes.py:207 #: app/routes/users/notes.py:250 msgid "Can only add notes for players." msgstr "Il n’est possible d’ajouter des notes que pour des joueurs." -#: app/routes/teams.py:563 +#: app/routes/teams.py:611 #, python-format msgid "Note added for %(username)s!" msgstr "Note ajoutée pour %(username)s." @@ -529,76 +537,76 @@ msgstr "Sélection mise à jour." 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:404 +#: app/routes/tryouts.py:411 msgid "Only players can register for tryouts." msgstr "Seuls les joueurs peuvent s’inscrire à une sélection." -#: app/routes/tryouts.py:408 +#: app/routes/tryouts.py:415 msgid "This tryout is not accepting registrations." msgstr "Cette sélection n’accepte pas d’inscriptions." -#: app/routes/tryouts.py:415 +#: app/routes/tryouts.py:422 msgid "You are already registered for this tryout." msgstr "Vous êtes déjà inscrit à cette sélection." -#: app/routes/tryouts.py:421 app/routes/tryouts.py:496 +#: app/routes/tryouts.py:428 app/routes/tryouts.py:503 msgid "This tryout is full." msgstr "Cette sélection est complète." -#: app/routes/tryouts.py:427 +#: app/routes/tryouts.py:434 msgid "Successfully registered for tryout!" msgstr "Inscription à la sélection réussie." -#: app/routes/tryouts.py:443 +#: app/routes/tryouts.py:450 #, 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:463 +#: app/routes/tryouts.py:470 msgid "Registration status updated." msgstr "Statut d’inscription mis à jour." -#: app/routes/tryouts.py:482 +#: app/routes/tryouts.py:489 msgid "Can only register players." msgstr "Seuls des joueurs peuvent être inscrits." -#: app/routes/tryouts.py:488 +#: app/routes/tryouts.py:495 #, 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:502 +#: app/routes/tryouts.py:509 #, python-format msgid "%(username)s registered for tryout!" msgstr "%(username)s est inscrit à la sélection." -#: app/routes/tryouts.py:538 +#: app/routes/tryouts.py:545 #, python-format msgid "%(username)s removed from tryout." msgstr "%(username)s a été retiré de la sélection." -#: app/routes/tryouts.py:556 +#: app/routes/tryouts.py:563 #, python-format msgid "Team \"%(team_name)s\" created!" msgstr "Équipe « %(team_name)s » créée." -#: app/routes/tryouts.py:587 +#: app/routes/tryouts.py:594 msgid "That player is not registered for this tryout." msgstr "Ce joueur n’est pas inscrit à cette sélection." -#: app/routes/tryouts.py:593 +#: app/routes/tryouts.py:600 msgid "Player is already on this team." msgstr "Ce joueur est déjà dans cette équipe." -#: app/routes/tryouts.py:598 +#: app/routes/tryouts.py:605 msgid "Player added to team!" msgstr "Joueur ajouté à l’équipe." -#: app/routes/tryouts.py:608 +#: app/routes/tryouts.py:615 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:644 +#: app/routes/tryouts.py:651 msgid "Tryout deleted successfully." msgstr "Sélection supprimée." diff --git a/app/validators.py b/app/validators.py index 2b6738f..230773b 100644 --- a/app/validators.py +++ b/app/validators.py @@ -460,6 +460,62 @@ class UploadContractSchema(StripMixin): ) +#: Where a player stands on a team roster. +#: +#: `TeamPlayer.status` is a NOT NULL String(20) that `add_player` filled from +#: `request.form.get('status', 'starter')` with no check at all, so a forged +#: submission stored any string it liked. It then survived until someone +#: pressed the toggle, which reads `'substitute' if status == 'starter' else +#: 'starter'` — so an unknown value silently became `starter`, i.e. promoted +#: whoever held it (SEC-16). +TEAM_PLAYER_STATUSES = ('starter', 'substitute') + + +class TeamStaffSchema(StripMixin): + """One staff id, posted by the add/remove coach and manager forms. + + Both ids are optional here even though each route needs exactly one: + `remove_coach` treats an absent id as "remove every coach", and the add + routes already carry their own translated "Please select a coach." + message. Making the field required would replace that message with a + generic one for no gain. + + What this schema is for is the conversion. Wave K closed `create_team` + and `edit_team` and left five sibling routes reading `int(...)` straight + off the form — a non-numeric id was a 500 in each. Fixing a pattern in + one place and not its neighbours is the mistake this project keeps + making; this is the same mistake, made by the fix for it. + """ + + coach_id = fields.Integer( + allow_none=True, + load_default=None, + validate=validate.Range(min=1), + error_messages={'invalid': _l('Invalid coach selection.')}, + ) + manager_id = fields.Integer( + allow_none=True, + load_default=None, + validate=validate.Range(min=1), + error_messages={'invalid': _l('Invalid manager selection.')}, + ) + + +class TeamPlayerSchema(StripMixin): + """A player being put on a team roster, and where they stand on it.""" + + player_id = fields.Integer( + allow_none=True, + load_default=None, + validate=validate.Range(min=1), + error_messages={'invalid': _l('Invalid player selection.')}, + ) + status = fields.String( + load_default='starter', + validate=validate.OneOf(TEAM_PLAYER_STATUSES, error=_l('Unknown roster status.')), + ) + + class OneOnOneRequestSchema(StripMixin): """A player asking their coach for a session (MNT-12). diff --git a/tests/test_authorization.py b/tests/test_authorization.py index 3b9fcd3..af2cade 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -583,3 +583,148 @@ class TestTeamStaffAssignment: assert response.status_code < 500 with app.app_context(): assert OrgTeam.query.count() == 0 + + +class TestTeamRosterAssignment: + """SEC-16, the half wave K missed. + + Wave K put a schema on `create_team` and `edit_team` and left five + sibling routes reading `int(request.form.get(...))` — `add_coach`, + `add_manager`, `remove_coach`, `remove_manager` and `add_player`. Each + was a 500 on a non-numeric id. `add_player` also accepted any `status` + string into a NOT NULL column, and none of them checked whether the + account had been deactivated. + + Fixing a pattern in one place and not its neighbours is the mistake this + project keeps making. Here it was made by the fix for it. + """ + + @pytest.fixture + def team(self, app, make_user): + from app.models import OrgTeam + + admin_id = make_user('admin') + 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 + + @pytest.mark.parametrize( + ('path', 'field'), + [ + ('add_coach', 'coach_id'), + ('add_manager', 'manager_id'), + ('remove_coach', 'coach_id'), + ('remove_manager', 'manager_id'), + ('add_player', 'player_id'), + ], + ) + def test_a_non_numeric_id_is_not_a_500(self, app, client, team, as_role, path, field): + as_role('admin') + + response = client.post(f'/teams/{team}/{path}', data={field: 'not-a-number'}) + + assert response.status_code < 500 + + def test_a_real_coach_is_added(self, app, client, team, as_role, make_user): + """The premise for the two tests below.""" + from app.models import OrgTeam + + coach_id = make_user('coach') + as_role('admin') + + client.post(f'/teams/{team}/add_coach', data={'coach_id': str(coach_id)}) + + with app.app_context(): + org_team = db.session.get(OrgTeam, team) + assert [c.id for c in org_team.coaches.all()] == [coach_id] + + def test_a_player_cannot_be_added_as_coach(self, app, client, team, as_role, make_user): + from app.models import OrgTeam + + player_id = make_user('player') + as_role('admin') + + client.post(f'/teams/{team}/add_coach', data={'coach_id': str(player_id)}) + + with app.app_context(): + assert db.session.get(OrgTeam, team).coaches.all() == [] + + def test_a_deactivated_coach_cannot_be_added(self, app, client, team, as_role, make_user): + """`is_active_account` is what says the person has left the club. + Putting them back on a roster contradicts it.""" + from app.models import OrgTeam, User + + coach_id = make_user('coach') + with app.app_context(): + db.session.get(User, coach_id).is_active_account = False + db.session.commit() + as_role('admin') + + client.post(f'/teams/{team}/add_coach', data={'coach_id': str(coach_id)}) + + with app.app_context(): + assert db.session.get(OrgTeam, team).coaches.all() == [] + + def test_a_deactivated_player_cannot_be_added(self, app, client, team, as_role, make_user): + from app.models import TeamPlayer, User + + player_id = make_user('player') + with app.app_context(): + db.session.get(User, player_id).is_active_account = False + db.session.commit() + as_role('admin') + + client.post(f'/teams/{team}/add_player', data={'player_id': str(player_id)}) + + with app.app_context(): + assert TeamPlayer.query.filter_by(org_team_id=team).count() == 0 + + def test_a_deactivated_player_is_not_offered(self, app, client, as_role, make_user): + """The select and the route agreed on nothing: the query had no + is_active_account filter while the two beside it did.""" + from app.models import User + + player_id = make_user('player', username='ghostplayer') + with app.app_context(): + db.session.get(User, player_id).is_active_account = False + db.session.commit() + as_role('admin') + + body = client.get('/teams').get_data(as_text=True) + + assert 'ghostplayer' not in body + + def test_an_unknown_roster_status_is_refused(self, app, client, team, as_role, make_user): + """`status` went into a NOT NULL String(20) unchecked, and the toggle + reads anything that is not 'starter' as substitute — so an unknown + value silently promoted its holder on the next press.""" + from app.models import TeamPlayer + + player_id = make_user('player') + as_role('admin') + + client.post( + f'/teams/{team}/add_player', + data={'player_id': str(player_id), 'status': 'captain-for-life'}, + ) + + with app.app_context(): + rows = TeamPlayer.query.filter_by(org_team_id=team).all() + assert [r.status for r in rows] != ['captain-for-life'] + + def test_a_known_status_is_kept(self, app, client, team, as_role, make_user): + from app.models import TeamPlayer + + player_id = make_user('player') + as_role('admin') + + client.post( + f'/teams/{team}/add_player', + data={'player_id': str(player_id), 'status': 'substitute'}, + ) + + with app.app_context(): + row = TeamPlayer.query.filter_by(org_team_id=team).one() + assert row.status == 'substitute'