fix(web): une erreur sur un point JSON ne renvoie plus une page HTML
STD-09, trouve en recroisant l'audit anterieur -- celui mene sur le miroir GitHub, jamais repasse depuis qu'on a decouvert que ce n'etait pas la bonne source. Sept gestionnaires d'erreur portaient chacun leur copie d'une liste de prefixes d'URL decidant "JSON ou page HTML". Les copies avaient derive -- trois testaient /users/coach-availability, quatre non -- et toutes manquaient les memes points. Un fetch() qui recoit une page d'erreur HTML leve en la parsant : sur le calendrier, les listes de selections et d'equipes restaient vides, sans message dans la page et sans rien dans le journal. Deux choses apprises en ecrivant le test, aucune n'etait dans le constat. L'approche par prefixe ne pouvait pas etre reparee. Trois des seize vues JSON sont a des chemins qu'aucun prefixe ne distingue des pages HTML voisines -- /matches/<id>/toggle-presence/<id> et ses deux cousins, que les gabarits appellent justement en fetch(). Les vues se declarent donc elles-memes (@json_endpoint, app/api.py), et un test parcourt la carte des URL pour verifier qu'aucune vue appelant jsonify n'a ete oubliee. Et surtout : @login_required n'atteint jamais le gestionnaire 401. Flask-Login intercepte avant et redirige. Les seize points JSON repondaient donc a une session expiree par une 302 vers un formulaire HTML, quoi que dise la liste de prefixes. Reecrire la liste seule aurait eu l'air d'un correctif sans rien changer. Au passage, le message flash de ce gestionnaire etait la seule chaine de l'application qui n'avait jamais ete traduite. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+47
@@ -0,0 +1,47 @@
|
|||||||
|
"""Marking the views whose answers — including their failures — are JSON.
|
||||||
|
|
||||||
|
STD-09. Deciding "JSON or HTML page" from the URL path could not work here,
|
||||||
|
and the audit's own recommendation ("gestion d'erreurs API par préfixe d'URL
|
||||||
|
codé en dur", fix the prefixes) would not have fixed it either. Three of the
|
||||||
|
sixteen JSON views sit at paths no prefix can single out:
|
||||||
|
|
||||||
|
/matches/<int:match_id>/toggle-presence/<int:participant_id>
|
||||||
|
/team-matches/<int:match_id>/toggle-presence/<int:participant_id>
|
||||||
|
/teams/<int:team_id>/toggle_status/<int:player_id>
|
||||||
|
|
||||||
|
They are interleaved with the HTML routes of the same blueprints, and the
|
||||||
|
templates fetch them. Any prefix wide enough to catch them catches every
|
||||||
|
page of the section with them.
|
||||||
|
|
||||||
|
So the view says so itself. `wants_json_response()` in app.py reads the mark
|
||||||
|
off the registered view function, and `tests/test_api_error_format.py` walks
|
||||||
|
the URL map to prove that every view calling `jsonify` carries it — the
|
||||||
|
mechanism that was missing before was not a better list, it was anything at
|
||||||
|
all that checked the list.
|
||||||
|
|
||||||
|
Usage — directly under the route decorator, above `login_required`, so the
|
||||||
|
mark lands on the object the route registers::
|
||||||
|
|
||||||
|
@matches_bp.route('/api/events')
|
||||||
|
@json_endpoint
|
||||||
|
@login_required
|
||||||
|
def api_events():
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def json_endpoint(view):
|
||||||
|
"""Mark a view as answering in JSON, errors included.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
view: The view function, already wrapped by any decorator below this
|
||||||
|
one (`login_required` in every current case).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The same object, with the mark set. Nothing is wrapped: an extra
|
||||||
|
wrapper here would be one more thing between Flask and the view for
|
||||||
|
no gain, and `functools.wraps` copying `__dict__` is exactly the
|
||||||
|
detail that would make this fragile.
|
||||||
|
"""
|
||||||
|
view.returns_json = True
|
||||||
|
return view
|
||||||
+87
-35
@@ -9,7 +9,18 @@ import secrets
|
|||||||
|
|
||||||
import markupsafe
|
import markupsafe
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from flask import Flask, g, jsonify, redirect, render_template, request, url_for
|
from flask import (
|
||||||
|
Flask,
|
||||||
|
current_app,
|
||||||
|
flash,
|
||||||
|
g,
|
||||||
|
jsonify,
|
||||||
|
redirect,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
|
url_for,
|
||||||
|
)
|
||||||
|
from flask_babel import gettext as _
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from werkzeug.exceptions import HTTPException
|
from werkzeug.exceptions import HTTPException
|
||||||
@@ -19,6 +30,38 @@ from app.extensions import babel, csrf, db, limiter, login_manager
|
|||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
#: Fallback for requests that never reached a view: a 404 on an unrouted
|
||||||
|
#: path has no endpoint to read a mark off, and `/users/api/typo` should
|
||||||
|
#: still answer a fetch() in JSON.
|
||||||
|
#:
|
||||||
|
#: Not the primary mechanism. Seven error handlers each carried their own
|
||||||
|
#: copy of a list like this (STD-09); the copies had drifted, and all seven
|
||||||
|
#: were missing the same endpoints. Views now mark themselves — see
|
||||||
|
#: `app/api.py` for why a prefix list could not have been made correct.
|
||||||
|
JSON_URL_PREFIXES = (
|
||||||
|
'/users/disponibilities',
|
||||||
|
'/users/coach-availability',
|
||||||
|
'/users/api/',
|
||||||
|
'/matches/api/',
|
||||||
|
'/team-matches/api/',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def wants_json_response():
|
||||||
|
"""Whether this request must be answered with JSON rather than an HTML page.
|
||||||
|
|
||||||
|
Three signals, in order of authority: the view said so (`@json_endpoint`),
|
||||||
|
the path is under a JSON prefix (for requests that matched no view at
|
||||||
|
all), or the caller asked for JSON and nothing else.
|
||||||
|
"""
|
||||||
|
view = current_app.view_functions.get(request.endpoint) if request.endpoint else None
|
||||||
|
if getattr(view, 'returns_json', False):
|
||||||
|
return True
|
||||||
|
if request.path.startswith(JSON_URL_PREFIXES):
|
||||||
|
return True
|
||||||
|
accept = request.accept_mimetypes
|
||||||
|
return accept.best == 'application/json' and not accept.accept_html
|
||||||
|
|
||||||
|
|
||||||
def nl2br(value):
|
def nl2br(value):
|
||||||
"""Convert newlines to HTML line breaks.
|
"""Convert newlines to HTML line breaks.
|
||||||
@@ -482,6 +525,36 @@ def create_app(config=None):
|
|||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Custom Error Handlers
|
# Custom Error Handlers
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
#
|
||||||
|
# STD-09. The seven handlers below each carried their own copy of a list
|
||||||
|
# of URL prefixes, and the copies had drifted: three of them checked
|
||||||
|
# `/users/coach-availability`, four did not. Worse, every copy was
|
||||||
|
# missing the same five endpoints — `/matches/api/…` and
|
||||||
|
# `/team-matches/api/…` — so an error on any of those answered a
|
||||||
|
# `fetch()` with an HTML error page. The browser then failed to parse it
|
||||||
|
# as JSON and the page simply did nothing: on the calendar, the tryout
|
||||||
|
# and team selects stayed empty with no message anywhere. A session that
|
||||||
|
# expired mid-page produced exactly that, because the 401 handler
|
||||||
|
# redirects to an HTML login form.
|
||||||
|
#
|
||||||
|
# The mechanism now lives in wants_json_response() / app/api.py, and a
|
||||||
|
# test walks the URL map to prove no jsonify-returning view is missed.
|
||||||
|
#
|
||||||
|
# The handler below is the one that actually mattered. `@login_required`
|
||||||
|
# never reaches the 401 handler: Flask-Login intercepts first and calls
|
||||||
|
# its own unauthorized callback, which redirects. So every one of the
|
||||||
|
# sixteen JSON endpoints answered an expired session with a 302 to an
|
||||||
|
# HTML login form, whatever the prefix list said — and the page's
|
||||||
|
# `fetch()` threw parsing it. Rewriting the prefix list alone would have
|
||||||
|
# left this untouched and looked like a fix.
|
||||||
|
@login_manager.unauthorized_handler
|
||||||
|
def handle_unauthorized():
|
||||||
|
"""What an unauthenticated request gets: a redirect, or a 401 in JSON."""
|
||||||
|
if wants_json_response():
|
||||||
|
return jsonify({'error': 'Unauthorized', 'message': 'Your session has expired.'}), 401
|
||||||
|
flash(_('Please log in to access this page.'), 'warning')
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
@app.errorhandler(400)
|
@app.errorhandler(400)
|
||||||
def bad_request(error):
|
def bad_request(error):
|
||||||
"""Handle 400 Bad Request errors.
|
"""Handle 400 Bad Request errors.
|
||||||
@@ -492,32 +565,21 @@ def create_app(config=None):
|
|||||||
Returns:
|
Returns:
|
||||||
Response: Rendered error page or JSON for API requests.
|
Response: Rendered error page or JSON for API requests.
|
||||||
"""
|
"""
|
||||||
if (
|
if wants_json_response():
|
||||||
request.path.startswith('/users/disponibilities')
|
|
||||||
or request.path.startswith('/users/coach-availability')
|
|
||||||
or request.path.startswith('/users/api/')
|
|
||||||
):
|
|
||||||
return jsonify({'error': 'Bad request', 'message': str(error)}), 400
|
return jsonify({'error': 'Bad request', 'message': str(error)}), 400
|
||||||
return render_template('errors/400.html', error=error), 400
|
return render_template('errors/400.html', error=error), 400
|
||||||
|
|
||||||
@app.errorhandler(401)
|
@app.errorhandler(401)
|
||||||
def unauthorized(error):
|
def unauthorized(error):
|
||||||
"""Handle 401 Unauthorized errors.
|
"""Handle an explicit abort(401).
|
||||||
|
|
||||||
Args:
|
Rarely reached: `@login_required` is intercepted by Flask-Login
|
||||||
error: The error object.
|
before Flask's error handling, and answered by handle_unauthorized
|
||||||
|
above. This covers code that aborts with 401 itself, and gives the
|
||||||
Returns:
|
same answer — the two used to differ, and the flash message here was
|
||||||
Response: Redirect to login for pages, JSON for API.
|
the one string in the application that had never been translated.
|
||||||
"""
|
"""
|
||||||
if request.path.startswith('/users/disponibilities') or request.path.startswith(
|
return handle_unauthorized()
|
||||||
'/users/api/'
|
|
||||||
):
|
|
||||||
return jsonify({'error': 'Unauthorized'}), 401
|
|
||||||
from flask import flash as _flash
|
|
||||||
|
|
||||||
_flash('Please log in to access this page.', 'warning')
|
|
||||||
return redirect(url_for('auth.login'))
|
|
||||||
|
|
||||||
@app.errorhandler(403)
|
@app.errorhandler(403)
|
||||||
def forbidden(error):
|
def forbidden(error):
|
||||||
@@ -529,9 +591,7 @@ def create_app(config=None):
|
|||||||
Returns:
|
Returns:
|
||||||
Response: Rendered error page or JSON for API requests.
|
Response: Rendered error page or JSON for API requests.
|
||||||
"""
|
"""
|
||||||
if request.path.startswith('/users/disponibilities') or request.path.startswith(
|
if wants_json_response():
|
||||||
'/users/api/'
|
|
||||||
):
|
|
||||||
return jsonify({'error': 'Forbidden', 'message': str(error)}), 403
|
return jsonify({'error': 'Forbidden', 'message': str(error)}), 403
|
||||||
return render_template('errors/403.html', error=error), 403
|
return render_template('errors/403.html', error=error), 403
|
||||||
|
|
||||||
@@ -545,9 +605,7 @@ def create_app(config=None):
|
|||||||
Returns:
|
Returns:
|
||||||
Response: Rendered error page or JSON for API requests.
|
Response: Rendered error page or JSON for API requests.
|
||||||
"""
|
"""
|
||||||
if request.path.startswith('/users/disponibilities') or request.path.startswith(
|
if wants_json_response():
|
||||||
'/users/api/'
|
|
||||||
):
|
|
||||||
return jsonify({'error': 'Not found'}), 404
|
return jsonify({'error': 'Not found'}), 404
|
||||||
return render_template('errors/404.html', error=error), 404
|
return render_template('errors/404.html', error=error), 404
|
||||||
|
|
||||||
@@ -561,9 +619,7 @@ def create_app(config=None):
|
|||||||
Returns:
|
Returns:
|
||||||
Response: JSON error for API or rendered page.
|
Response: JSON error for API or rendered page.
|
||||||
"""
|
"""
|
||||||
if request.path.startswith('/users/disponibilities') or request.path.startswith(
|
if wants_json_response():
|
||||||
'/users/api/'
|
|
||||||
):
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{'error': 'Too many requests', 'message': 'Please try again later.'}
|
{'error': 'Too many requests', 'message': 'Please try again later.'}
|
||||||
), 429
|
), 429
|
||||||
@@ -593,9 +649,7 @@ def create_app(config=None):
|
|||||||
# attacker can use — so showing it costs nothing (OBS-005).
|
# attacker can use — so showing it costs nothing (OBS-005).
|
||||||
request_id = g.get('request_id', '-')
|
request_id = g.get('request_id', '-')
|
||||||
|
|
||||||
if request.path.startswith('/users/disponibilities') or request.path.startswith(
|
if wants_json_response():
|
||||||
'/users/api/'
|
|
||||||
):
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
'error': 'Internal server error',
|
'error': 'Internal server error',
|
||||||
@@ -615,9 +669,7 @@ def create_app(config=None):
|
|||||||
Returns:
|
Returns:
|
||||||
Response: JSON error for API, re-raises for others.
|
Response: JSON error for API, re-raises for others.
|
||||||
"""
|
"""
|
||||||
if request.path.startswith('/users/disponibilities') or request.path.startswith(
|
if wants_json_response():
|
||||||
'/users/api/'
|
|
||||||
):
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{'error': error.name, 'message': error.description, 'code': error.code}
|
{'error': error.name, 'message': error.description, 'code': error.code}
|
||||||
), error.code
|
), error.code
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from flask_login import current_user, login_required
|
|||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
from app.api import json_endpoint
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.forms import flash_validation_errors, form_payload
|
from app.forms import flash_validation_errors, form_payload
|
||||||
from app.models import (
|
from app.models import (
|
||||||
@@ -135,6 +136,7 @@ def calendar_window(args):
|
|||||||
|
|
||||||
|
|
||||||
@matches_bp.route('/api/events')
|
@matches_bp.route('/api/events')
|
||||||
|
@json_endpoint
|
||||||
@login_required
|
@login_required
|
||||||
def api_events():
|
def api_events():
|
||||||
"""Calendar events for FullCalendar.
|
"""Calendar events for FullCalendar.
|
||||||
@@ -264,6 +266,7 @@ def api_events():
|
|||||||
|
|
||||||
|
|
||||||
@matches_bp.route('/api/events/<int:tryout_id>')
|
@matches_bp.route('/api/events/<int:tryout_id>')
|
||||||
|
@json_endpoint
|
||||||
@login_required
|
@login_required
|
||||||
def api_events_for_tryout(tryout_id):
|
def api_events_for_tryout(tryout_id):
|
||||||
"""API endpoint returning calendar events for a specific tryout."""
|
"""API endpoint returning calendar events for a specific tryout."""
|
||||||
@@ -540,6 +543,7 @@ def edit_match(match_id):
|
|||||||
|
|
||||||
|
|
||||||
@matches_bp.route('/api/manageable-tryouts')
|
@matches_bp.route('/api/manageable-tryouts')
|
||||||
|
@json_endpoint
|
||||||
@login_required
|
@login_required
|
||||||
def api_manageable_tryouts():
|
def api_manageable_tryouts():
|
||||||
"""API endpoint returning tryouts the current user can manage."""
|
"""API endpoint returning tryouts the current user can manage."""
|
||||||
@@ -637,6 +641,7 @@ def get_players_available_at_time(date_str, time_str):
|
|||||||
|
|
||||||
|
|
||||||
@matches_bp.route('/api/available_players/<date>/<time>')
|
@matches_bp.route('/api/available_players/<date>/<time>')
|
||||||
|
@json_endpoint
|
||||||
@login_required
|
@login_required
|
||||||
def api_available_players(date, time):
|
def api_available_players(date, time):
|
||||||
"""API endpoint to get players available at a specific date/time slot."""
|
"""API endpoint to get players available at a specific date/time slot."""
|
||||||
@@ -647,6 +652,7 @@ def api_available_players(date, time):
|
|||||||
|
|
||||||
|
|
||||||
@matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
|
@matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
|
||||||
|
@json_endpoint
|
||||||
@login_required
|
@login_required
|
||||||
def toggle_presence(match_id, participant_id):
|
def toggle_presence(match_id, participant_id):
|
||||||
"""Toggle attendance_confirmed for a match participant."""
|
"""Toggle attendance_confirmed for a match participant."""
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from flask_babel import gettext as _
|
|||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
|
|
||||||
|
from app.api import json_endpoint
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.forms import flash_validation_errors, form_payload
|
from app.forms import flash_validation_errors, form_payload
|
||||||
from app.models import (
|
from app.models import (
|
||||||
@@ -262,6 +263,7 @@ def delete_match(match_id):
|
|||||||
|
|
||||||
|
|
||||||
@team_matches_bp.route('/api/manageable-teams')
|
@team_matches_bp.route('/api/manageable-teams')
|
||||||
|
@json_endpoint
|
||||||
@login_required
|
@login_required
|
||||||
def api_manageable_teams():
|
def api_manageable_teams():
|
||||||
"""API endpoint returning teams the current user can schedule matches for."""
|
"""API endpoint returning teams the current user can schedule matches for."""
|
||||||
@@ -279,6 +281,7 @@ def api_manageable_teams():
|
|||||||
|
|
||||||
|
|
||||||
@team_matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
|
@team_matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
|
||||||
|
@json_endpoint
|
||||||
@login_required
|
@login_required
|
||||||
def toggle_presence(match_id, participant_id):
|
def toggle_presence(match_id, participant_id):
|
||||||
"""Toggle is_confirmed for a team match participant."""
|
"""Toggle is_confirmed for a team match participant."""
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from flask import Blueprint, flash, jsonify, redirect, render_template, request,
|
|||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
|
|
||||||
|
from app.api import json_endpoint
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Admin,
|
Admin,
|
||||||
@@ -474,6 +475,7 @@ def remove_player(team_id, player_id):
|
|||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/toggle_status/<int:player_id>', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/toggle_status/<int:player_id>', methods=['POST'])
|
||||||
|
@json_endpoint
|
||||||
@login_required
|
@login_required
|
||||||
def toggle_player_status(team_id, player_id):
|
def toggle_player_status(team_id, player_id):
|
||||||
"""Toggle a player's status between starter and substitute."""
|
"""Toggle a player's status between starter and substitute."""
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from flask import flash, jsonify, redirect, render_template, request, url_for
|
|||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
|
|
||||||
|
from app.api import json_endpoint
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models import Coach, CoachAvailability, PlayerDisponibility, User
|
from app.models import Coach, CoachAvailability, PlayerDisponibility, User
|
||||||
from app.routes.users.blueprint import users_bp
|
from app.routes.users.blueprint import users_bp
|
||||||
@@ -23,6 +24,7 @@ def add_30_minutes(t):
|
|||||||
|
|
||||||
|
|
||||||
@users_bp.route('/disponibilities')
|
@users_bp.route('/disponibilities')
|
||||||
|
@json_endpoint
|
||||||
@login_required
|
@login_required
|
||||||
def get_disponibilities():
|
def get_disponibilities():
|
||||||
"""API endpoint to get all player disponibilities for scheduling."""
|
"""API endpoint to get all player disponibilities for scheduling."""
|
||||||
@@ -52,6 +54,7 @@ def get_disponibilities():
|
|||||||
|
|
||||||
|
|
||||||
@users_bp.route('/disponibilities/my')
|
@users_bp.route('/disponibilities/my')
|
||||||
|
@json_endpoint
|
||||||
@login_required
|
@login_required
|
||||||
def get_my_disponibilities():
|
def get_my_disponibilities():
|
||||||
"""API endpoint for players to get their own disponibilities."""
|
"""API endpoint for players to get their own disponibilities."""
|
||||||
@@ -74,6 +77,7 @@ def get_my_disponibilities():
|
|||||||
|
|
||||||
|
|
||||||
@users_bp.route('/disponibilities/add', methods=['POST'])
|
@users_bp.route('/disponibilities/add', methods=['POST'])
|
||||||
|
@json_endpoint
|
||||||
@login_required
|
@login_required
|
||||||
def add_disponibility():
|
def add_disponibility():
|
||||||
"""Add a disponibility block for the current player."""
|
"""Add a disponibility block for the current player."""
|
||||||
@@ -107,6 +111,7 @@ def add_disponibility():
|
|||||||
|
|
||||||
|
|
||||||
@users_bp.route('/disponibilities/add_bulk', methods=['POST'])
|
@users_bp.route('/disponibilities/add_bulk', methods=['POST'])
|
||||||
|
@json_endpoint
|
||||||
@login_required
|
@login_required
|
||||||
def add_disponibilities_bulk():
|
def add_disponibilities_bulk():
|
||||||
"""Add multiple disponibility blocks at once."""
|
"""Add multiple disponibility blocks at once."""
|
||||||
@@ -151,6 +156,7 @@ def add_disponibilities_bulk():
|
|||||||
|
|
||||||
|
|
||||||
@users_bp.route('/disponibilities/clear', methods=['POST'])
|
@users_bp.route('/disponibilities/clear', methods=['POST'])
|
||||||
|
@json_endpoint
|
||||||
@login_required
|
@login_required
|
||||||
def clear_disponibilities():
|
def clear_disponibilities():
|
||||||
"""Clear all disponibilities for the current player."""
|
"""Clear all disponibilities for the current player."""
|
||||||
@@ -160,6 +166,7 @@ def clear_disponibilities():
|
|||||||
|
|
||||||
|
|
||||||
@users_bp.route('/disponibilities/<int:disponibility_id>/delete', methods=['POST'])
|
@users_bp.route('/disponibilities/<int:disponibility_id>/delete', methods=['POST'])
|
||||||
|
@json_endpoint
|
||||||
@login_required
|
@login_required
|
||||||
def delete_disponibility(disponibility_id):
|
def delete_disponibility(disponibility_id):
|
||||||
"""Delete a disponibility block."""
|
"""Delete a disponibility block."""
|
||||||
@@ -172,6 +179,7 @@ def delete_disponibility(disponibility_id):
|
|||||||
|
|
||||||
|
|
||||||
@users_bp.route('/coach-availability', methods=['GET', 'POST'])
|
@users_bp.route('/coach-availability', methods=['GET', 'POST'])
|
||||||
|
@json_endpoint
|
||||||
@login_required
|
@login_required
|
||||||
def manage_coach_availability():
|
def manage_coach_availability():
|
||||||
"""Manage coach availability for One on One sessions."""
|
"""Manage coach availability for One on One sessions."""
|
||||||
@@ -221,6 +229,7 @@ def manage_coach_availability():
|
|||||||
|
|
||||||
|
|
||||||
@users_bp.route('/coach-availability/clear', methods=['POST'])
|
@users_bp.route('/coach-availability/clear', methods=['POST'])
|
||||||
|
@json_endpoint
|
||||||
@login_required
|
@login_required
|
||||||
def clear_coach_availability():
|
def clear_coach_availability():
|
||||||
"""Clear all coach availability slots."""
|
"""Clear all coach availability slots."""
|
||||||
|
|||||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: team-tryouts VERSION\n"
|
"Project-Id-Version: team-tryouts VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2026-08-11 15:25-0400\n"
|
"POT-Creation-Date: 2026-08-11 18:57-0400\n"
|
||||||
"PO-Revision-Date: 2026-08-07 20:22-0400\n"
|
"PO-Revision-Date: 2026-08-07 20:22-0400\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language: en\n"
|
"Language: en\n"
|
||||||
@@ -19,6 +19,10 @@ msgstr ""
|
|||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Generated-By: Babel 2.18.0\n"
|
"Generated-By: Babel 2.18.0\n"
|
||||||
|
|
||||||
|
#: app/app.py:555
|
||||||
|
msgid "Please log in to access this page."
|
||||||
|
msgstr "Please log in to access this page."
|
||||||
|
|
||||||
#: app/forms.py:37 app/routes/auth.py:224 app/routes/auth.py:374
|
#: app/forms.py:37 app/routes/auth.py:224 app/routes/auth.py:374
|
||||||
#: app/routes/users/contracts.py:96
|
#: app/routes/users/contracts.py:96
|
||||||
#, python-format
|
#, python-format
|
||||||
@@ -199,11 +203,11 @@ msgstr ""
|
|||||||
msgid "Your registration could not be processed. Please try again."
|
msgid "Your registration could not be processed. Please try again."
|
||||||
msgstr "Your registration could not be processed. Please try again."
|
msgstr "Your registration could not be processed. Please try again."
|
||||||
|
|
||||||
#: app/routes/auth.py:388 app/routes/users/accounts.py:308
|
#: app/routes/auth.py:388 app/routes/users/accounts.py:325
|
||||||
msgid "Username already exists."
|
msgid "Username already exists."
|
||||||
msgstr "Username already exists."
|
msgstr "Username already exists."
|
||||||
|
|
||||||
#: app/routes/auth.py:392 app/routes/users/accounts.py:312
|
#: app/routes/auth.py:392 app/routes/users/accounts.py:329
|
||||||
msgid "Email already registered."
|
msgid "Email already registered."
|
||||||
msgstr "Email already registered."
|
msgstr "Email already registered."
|
||||||
|
|
||||||
@@ -275,9 +279,9 @@ msgstr "Evaluation submitted successfully!"
|
|||||||
msgid "Evaluation updated!"
|
msgid "Evaluation updated!"
|
||||||
msgstr "Evaluation updated!"
|
msgstr "Evaluation updated!"
|
||||||
|
|
||||||
#: app/routes/evaluations.py:210 app/routes/teams.py:270
|
#: app/routes/evaluations.py:210 app/routes/teams.py:289
|
||||||
#: app/routes/teams.py:311 app/routes/teams.py:352 app/routes/teams.py:377
|
#: app/routes/teams.py:330 app/routes/teams.py:371 app/routes/teams.py:396
|
||||||
#: app/routes/teams.py:402 app/routes/teams.py:437 app/routes/tryouts.py:437
|
#: app/routes/teams.py:421 app/routes/teams.py:456 app/routes/tryouts.py:437
|
||||||
#: app/routes/tryouts.py:453 app/routes/tryouts.py:473
|
#: app/routes/tryouts.py:453 app/routes/tryouts.py:473
|
||||||
#: app/routes/tryouts.py:512 app/routes/tryouts.py:548
|
#: app/routes/tryouts.py:512 app/routes/tryouts.py:548
|
||||||
#: app/routes/tryouts.py:567
|
#: app/routes/tryouts.py:567
|
||||||
@@ -288,183 +292,183 @@ msgstr "Permission denied."
|
|||||||
msgid "That language is not available."
|
msgid "That language is not available."
|
||||||
msgstr "That language is not available."
|
msgstr "That language is not available."
|
||||||
|
|
||||||
#: app/routes/matches.py:361
|
#: app/routes/matches.py:364
|
||||||
msgid "You do not have permission to schedule matches for this tryout."
|
msgid "You do not have permission to schedule matches for this tryout."
|
||||||
msgstr "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:365 app/routes/matches.py:445
|
#: app/routes/matches.py:368 app/routes/matches.py:448
|
||||||
msgid "This tryout has ended. Matches can no longer be created or modified."
|
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."
|
msgstr "This tryout has ended. Matches can no longer be created or modified."
|
||||||
|
|
||||||
#: app/routes/matches.py:427
|
#: app/routes/matches.py:430
|
||||||
msgid "Match scheduled successfully!"
|
msgid "Match scheduled successfully!"
|
||||||
msgstr "Match scheduled successfully!"
|
msgstr "Match scheduled successfully!"
|
||||||
|
|
||||||
#: app/routes/matches.py:441 app/routes/team_matches.py:211
|
#: app/routes/matches.py:444 app/routes/team_matches.py:212
|
||||||
msgid "You do not have permission to edit this match."
|
msgid "You do not have permission to edit this match."
|
||||||
msgstr "You do not have permission to edit this match."
|
msgstr "You do not have permission to edit this match."
|
||||||
|
|
||||||
#: app/routes/matches.py:536 app/routes/team_matches.py:241
|
#: app/routes/matches.py:539 app/routes/team_matches.py:242
|
||||||
msgid "Match updated successfully!"
|
msgid "Match updated successfully!"
|
||||||
msgstr "Match updated successfully!"
|
msgstr "Match updated successfully!"
|
||||||
|
|
||||||
#: app/routes/matches.py:571 app/routes/team_matches.py:256
|
#: app/routes/matches.py:575 app/routes/team_matches.py:257
|
||||||
msgid "You do not have permission to delete this match."
|
msgid "You do not have permission to delete this match."
|
||||||
msgstr "You do not have permission to delete this match."
|
msgstr "You do not have permission to delete this match."
|
||||||
|
|
||||||
#: app/routes/matches.py:574
|
#: app/routes/matches.py:578
|
||||||
msgid "This tryout has ended. Matches can no longer be deleted."
|
msgid "This tryout has ended. Matches can no longer be deleted."
|
||||||
msgstr "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:587 app/routes/team_matches.py:260
|
#: app/routes/matches.py:591 app/routes/team_matches.py:261
|
||||||
msgid "Match deleted successfully."
|
msgid "Match deleted successfully."
|
||||||
msgstr "Match deleted successfully."
|
msgstr "Match deleted successfully."
|
||||||
|
|
||||||
#: app/routes/team_matches.py:104
|
#: app/routes/team_matches.py:105
|
||||||
msgid "You do not have permission to schedule matches for this team."
|
msgid "You do not have permission to schedule matches for this team."
|
||||||
msgstr "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:196
|
#: app/routes/team_matches.py:197
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Team match \"%(title)s\" scheduled successfully!"
|
msgid "Team match \"%(title)s\" scheduled successfully!"
|
||||||
msgstr "Team match \"%(title)s\" scheduled successfully!"
|
msgstr "Team match \"%(title)s\" scheduled successfully!"
|
||||||
|
|
||||||
#: app/routes/teams.py:40
|
#: app/routes/teams.py:41
|
||||||
msgid "Use My Team(s) to view your teams."
|
msgid "Use My Team(s) to view your teams."
|
||||||
msgstr "Use My Team(s) to view your teams."
|
msgstr "Use My Team(s) to view your teams."
|
||||||
|
|
||||||
#: app/routes/teams.py:43
|
#: app/routes/teams.py:44
|
||||||
msgid "You do not have permission to view teams."
|
msgid "You do not have permission to view teams."
|
||||||
msgstr "You do not have permission to view teams."
|
msgstr "You do not have permission to view teams."
|
||||||
|
|
||||||
#: app/routes/teams.py:70
|
#: app/routes/teams.py:71
|
||||||
msgid "This page is for players."
|
msgid "This page is for players."
|
||||||
msgstr "This page is for players."
|
msgstr "This page is for players."
|
||||||
|
|
||||||
#: app/routes/teams.py:123
|
#: app/routes/teams.py:124
|
||||||
msgid "You do not have permission to create teams."
|
msgid "You do not have permission to create teams."
|
||||||
msgstr "You do not have permission to create teams."
|
msgstr "You do not have permission to create teams."
|
||||||
|
|
||||||
#: app/routes/teams.py:131 app/routes/teams.py:176
|
#: app/routes/teams.py:132 app/routes/teams.py:177
|
||||||
msgid "Team name is required."
|
msgid "Team name is required."
|
||||||
msgstr "Team name is required."
|
msgstr "Team name is required."
|
||||||
|
|
||||||
#: app/routes/teams.py:136 app/routes/teams.py:181
|
#: app/routes/teams.py:137 app/routes/teams.py:182
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Team \"%(name)s\" already exists."
|
msgid "Team \"%(name)s\" already exists."
|
||||||
msgstr "Team \"%(name)s\" already exists."
|
msgstr "Team \"%(name)s\" already exists."
|
||||||
|
|
||||||
#: app/routes/teams.py:158
|
#: app/routes/teams.py:159
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Team \"%(name)s\" created successfully!"
|
msgid "Team \"%(name)s\" created successfully!"
|
||||||
msgstr "Team \"%(name)s\" created successfully!"
|
msgstr "Team \"%(name)s\" created successfully!"
|
||||||
|
|
||||||
#: app/routes/teams.py:168
|
#: app/routes/teams.py:169
|
||||||
msgid "You do not have permission to edit this team."
|
msgid "You do not have permission to edit this team."
|
||||||
msgstr "You do not have permission to edit this team."
|
msgstr "You do not have permission to edit this team."
|
||||||
|
|
||||||
#: app/routes/teams.py:219
|
#: app/routes/teams.py:220
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Team \"%(name)s\" updated successfully!"
|
msgid "Team \"%(name)s\" updated successfully!"
|
||||||
msgstr "Team \"%(name)s\" updated successfully!"
|
msgstr "Team \"%(name)s\" updated successfully!"
|
||||||
|
|
||||||
#: app/routes/teams.py:228
|
#: app/routes/teams.py:248
|
||||||
msgid "You do not have permission to delete teams."
|
msgid "You do not have permission to delete teams."
|
||||||
msgstr "You do not have permission to delete teams."
|
msgstr "You do not have permission to delete teams."
|
||||||
|
|
||||||
#: app/routes/teams.py:260
|
#: app/routes/teams.py:279
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Team \"%(name)s\" deleted successfully."
|
msgid "Team \"%(name)s\" deleted successfully."
|
||||||
msgstr "Team \"%(name)s\" deleted successfully."
|
msgstr "Team \"%(name)s\" deleted successfully."
|
||||||
|
|
||||||
#: app/routes/teams.py:275
|
#: app/routes/teams.py:294
|
||||||
msgid "Please select a coach."
|
msgid "Please select a coach."
|
||||||
msgstr "Please select a coach."
|
msgstr "Please select a coach."
|
||||||
|
|
||||||
#: app/routes/teams.py:280
|
#: app/routes/teams.py:299
|
||||||
msgid "Only coaches can be assigned as coach."
|
msgid "Only coaches can be assigned as coach."
|
||||||
msgstr "Only coaches can be assigned as coach."
|
msgstr "Only coaches can be assigned as coach."
|
||||||
|
|
||||||
#: app/routes/teams.py:286
|
#: app/routes/teams.py:305
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(username)s is already a coach of %(name)s."
|
msgid "%(username)s is already a coach of %(name)s."
|
||||||
msgstr "%(username)s is already a coach of %(name)s."
|
msgstr "%(username)s is already a coach of %(name)s."
|
||||||
|
|
||||||
#: app/routes/teams.py:299
|
#: app/routes/teams.py:318
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(username)s added as coach of %(name)s."
|
msgid "%(username)s added as coach of %(name)s."
|
||||||
msgstr "%(username)s added as coach of %(name)s."
|
msgstr "%(username)s added as coach of %(name)s."
|
||||||
|
|
||||||
#: app/routes/teams.py:316
|
#: app/routes/teams.py:335
|
||||||
msgid "Please select a manager."
|
msgid "Please select a manager."
|
||||||
msgstr "Please select a manager."
|
msgstr "Please select a manager."
|
||||||
|
|
||||||
#: app/routes/teams.py:321
|
#: app/routes/teams.py:340
|
||||||
msgid "Only managers can be assigned as manager."
|
msgid "Only managers can be assigned as manager."
|
||||||
msgstr "Only managers can be assigned as manager."
|
msgstr "Only managers can be assigned as manager."
|
||||||
|
|
||||||
#: app/routes/teams.py:327
|
#: app/routes/teams.py:346
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(username)s is already a manager of %(name)s."
|
msgid "%(username)s is already a manager of %(name)s."
|
||||||
msgstr "%(username)s is already a manager of %(name)s."
|
msgstr "%(username)s is already a manager of %(name)s."
|
||||||
|
|
||||||
#: app/routes/teams.py:340
|
#: app/routes/teams.py:359
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(username)s added as manager of %(name)s."
|
msgid "%(username)s added as manager of %(name)s."
|
||||||
msgstr "%(username)s added as manager of %(name)s."
|
msgstr "%(username)s added as manager of %(name)s."
|
||||||
|
|
||||||
#: app/routes/teams.py:367
|
#: app/routes/teams.py:386
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Coach removed from %(name)s."
|
msgid "Coach removed from %(name)s."
|
||||||
msgstr "Coach removed from %(name)s."
|
msgstr "Coach removed from %(name)s."
|
||||||
|
|
||||||
#: app/routes/teams.py:392
|
#: app/routes/teams.py:411
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Manager removed from %(name)s."
|
msgid "Manager removed from %(name)s."
|
||||||
msgstr "Manager removed from %(name)s."
|
msgstr "Manager removed from %(name)s."
|
||||||
|
|
||||||
#: app/routes/teams.py:408 app/routes/tryouts.py:477 app/routes/tryouts.py:578
|
#: app/routes/teams.py:427 app/routes/tryouts.py:477 app/routes/tryouts.py:578
|
||||||
msgid "Please select a player."
|
msgid "Please select a player."
|
||||||
msgstr "Please select a player."
|
msgstr "Please select a player."
|
||||||
|
|
||||||
#: app/routes/teams.py:413
|
#: app/routes/teams.py:432
|
||||||
msgid "Can only assign players to teams."
|
msgid "Can only assign players to teams."
|
||||||
msgstr "Can only assign players to teams."
|
msgstr "Can only assign players to teams."
|
||||||
|
|
||||||
#: app/routes/teams.py:419
|
#: app/routes/teams.py:438
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(username)s is already on %(name)s."
|
msgid "%(username)s is already on %(name)s."
|
||||||
msgstr "%(username)s is already on %(name)s."
|
msgstr "%(username)s is already on %(name)s."
|
||||||
|
|
||||||
#: app/routes/teams.py:427
|
#: app/routes/teams.py:446
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(username)s added to %(name)s!"
|
msgid "%(username)s added to %(name)s!"
|
||||||
msgstr "%(username)s added to %(name)s!"
|
msgstr "%(username)s added to %(name)s!"
|
||||||
|
|
||||||
#: app/routes/teams.py:444 app/routes/teams.py:517
|
#: app/routes/teams.py:463 app/routes/teams.py:537
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(username)s is not on %(name)s."
|
msgid "%(username)s is not on %(name)s."
|
||||||
msgstr "%(username)s is not on %(name)s."
|
msgstr "%(username)s is not on %(name)s."
|
||||||
|
|
||||||
#: app/routes/teams.py:452
|
#: app/routes/teams.py:471
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(username)s removed from %(name)s."
|
msgid "%(username)s removed from %(name)s."
|
||||||
msgstr "%(username)s removed from %(name)s."
|
msgstr "%(username)s removed from %(name)s."
|
||||||
|
|
||||||
#: app/routes/teams.py:488 app/routes/teams.py:506
|
#: app/routes/teams.py:508 app/routes/teams.py:526
|
||||||
msgid "You do not have permission to add notes to this team."
|
msgid "You do not have permission to add notes to this team."
|
||||||
msgstr "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:496
|
#: app/routes/teams.py:516
|
||||||
msgid "Team notes added successfully!"
|
msgid "Team notes added successfully!"
|
||||||
msgstr "Team notes added successfully!"
|
msgstr "Team notes added successfully!"
|
||||||
|
|
||||||
#: app/routes/teams.py:511 app/routes/users/notes.py:207
|
#: app/routes/teams.py:531 app/routes/users/notes.py:207
|
||||||
#: app/routes/users/notes.py:250
|
#: app/routes/users/notes.py:250
|
||||||
msgid "Can only add notes for players."
|
msgid "Can only add notes for players."
|
||||||
msgstr "Can only add notes for players."
|
msgstr "Can only add notes for players."
|
||||||
|
|
||||||
#: app/routes/teams.py:527
|
#: app/routes/teams.py:547
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Note added for %(username)s!"
|
msgid "Note added for %(username)s!"
|
||||||
msgstr "Note added for %(username)s!"
|
msgstr "Note added for %(username)s!"
|
||||||
@@ -578,23 +582,23 @@ msgstr "Only PDF files are allowed for contracts."
|
|||||||
msgid "That file is not a PDF, whatever its name says."
|
msgid "That file is not a PDF, whatever its name says."
|
||||||
msgstr "That file is not a PDF, whatever its name says."
|
msgstr "That file is not a PDF, whatever its name says."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:53
|
#: app/routes/users/accounts.py:54
|
||||||
msgid "Only the president can manage users."
|
msgid "Only the president can manage users."
|
||||||
msgstr "Only the president can manage users."
|
msgstr "Only the president can manage users."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:65
|
#: app/routes/users/accounts.py:66
|
||||||
msgid "Only the president can edit users."
|
msgid "Only the president can edit users."
|
||||||
msgstr "Only the president can edit users."
|
msgstr "Only the president can edit users."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:106
|
#: app/routes/users/accounts.py:107
|
||||||
msgid "Email already in use by another account."
|
msgid "Email already in use by another account."
|
||||||
msgstr "Email already in use by another account."
|
msgstr "Email already in use by another account."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:117
|
#: app/routes/users/accounts.py:118
|
||||||
msgid "You cannot change your own role. Ask another president to do it."
|
msgid "You cannot change your own role. Ask another president to do it."
|
||||||
msgstr "You cannot change your own role. Ask another president to do it."
|
msgstr "You cannot change your own role. Ask another president to do it."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:130
|
#: app/routes/users/accounts.py:131
|
||||||
msgid ""
|
msgid ""
|
||||||
"This is the last active president. Promote another account before "
|
"This is the last active president. Promote another account before "
|
||||||
"changing this one."
|
"changing this one."
|
||||||
@@ -602,34 +606,34 @@ msgstr ""
|
|||||||
"This is the last active president. Promote another account before "
|
"This is the last active president. Promote another account before "
|
||||||
"changing this one."
|
"changing this one."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:209
|
#: app/routes/users/accounts.py:210
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "User %(username)s updated successfully!"
|
msgid "User %(username)s updated successfully!"
|
||||||
msgstr "User %(username)s updated successfully!"
|
msgstr "User %(username)s updated successfully!"
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:230
|
#: app/routes/users/accounts.py:231
|
||||||
msgid "Only the president can delete users."
|
msgid "Only the president can delete users."
|
||||||
msgstr "Only the president can delete users."
|
msgstr "Only the president can delete users."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:234
|
#: app/routes/users/accounts.py:235
|
||||||
msgid "You cannot delete your own account."
|
msgid "You cannot delete your own account."
|
||||||
msgstr "You cannot delete your own account."
|
msgstr "You cannot delete your own account."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:277
|
#: app/routes/users/accounts.py:294
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "User %(deleted_username)s has been removed."
|
msgid "User %(deleted_username)s has been removed."
|
||||||
msgstr "User %(deleted_username)s has been removed."
|
msgstr "User %(deleted_username)s has been removed."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:288
|
#: app/routes/users/accounts.py:305
|
||||||
msgid "Only the president can create users."
|
msgid "Only the president can create users."
|
||||||
msgstr "Only the president can create users."
|
msgstr "Only the president can create users."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:336
|
#: app/routes/users/accounts.py:353
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "User %(full_name)s created as %(role)s!"
|
msgid "User %(full_name)s created as %(role)s!"
|
||||||
msgstr "User %(full_name)s created as %(role)s!"
|
msgstr "User %(full_name)s created as %(role)s!"
|
||||||
|
|
||||||
#: app/routes/users/availability.py:179
|
#: app/routes/users/availability.py:187
|
||||||
msgid "Only coaches can manage availability."
|
msgid "Only coaches can manage availability."
|
||||||
msgstr "Only coaches can manage availability."
|
msgstr "Only coaches can manage availability."
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: team-tryouts VERSION\n"
|
"Project-Id-Version: team-tryouts VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2026-08-11 15:25-0400\n"
|
"POT-Creation-Date: 2026-08-11 18:57-0400\n"
|
||||||
"PO-Revision-Date: 2026-08-07 20:22-0400\n"
|
"PO-Revision-Date: 2026-08-07 20:22-0400\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language: fr\n"
|
"Language: fr\n"
|
||||||
@@ -19,6 +19,10 @@ msgstr ""
|
|||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Generated-By: Babel 2.18.0\n"
|
"Generated-By: Babel 2.18.0\n"
|
||||||
|
|
||||||
|
#: app/app.py:555
|
||||||
|
msgid "Please log in to access this page."
|
||||||
|
msgstr "Veuillez vous connecter pour accéder à cette page."
|
||||||
|
|
||||||
#: app/forms.py:37 app/routes/auth.py:224 app/routes/auth.py:374
|
#: app/forms.py:37 app/routes/auth.py:224 app/routes/auth.py:374
|
||||||
#: app/routes/users/contracts.py:96
|
#: app/routes/users/contracts.py:96
|
||||||
#, python-format
|
#, python-format
|
||||||
@@ -201,11 +205,11 @@ msgstr ""
|
|||||||
msgid "Your registration could not be processed. Please try again."
|
msgid "Your registration could not be processed. Please try again."
|
||||||
msgstr "Votre inscription n'a pas pu être traitée. Veuillez réessayer."
|
msgstr "Votre inscription n'a pas pu être traitée. Veuillez réessayer."
|
||||||
|
|
||||||
#: app/routes/auth.py:388 app/routes/users/accounts.py:308
|
#: app/routes/auth.py:388 app/routes/users/accounts.py:325
|
||||||
msgid "Username already exists."
|
msgid "Username already exists."
|
||||||
msgstr "Ce nom d’utilisateur est déjà pris."
|
msgstr "Ce nom d’utilisateur est déjà pris."
|
||||||
|
|
||||||
#: app/routes/auth.py:392 app/routes/users/accounts.py:312
|
#: app/routes/auth.py:392 app/routes/users/accounts.py:329
|
||||||
msgid "Email already registered."
|
msgid "Email already registered."
|
||||||
msgstr "Cette adresse courriel est déjà enregistrée."
|
msgstr "Cette adresse courriel est déjà enregistrée."
|
||||||
|
|
||||||
@@ -277,9 +281,9 @@ msgstr "Évaluation enregistrée."
|
|||||||
msgid "Evaluation updated!"
|
msgid "Evaluation updated!"
|
||||||
msgstr "Évaluation mise à jour."
|
msgstr "Évaluation mise à jour."
|
||||||
|
|
||||||
#: app/routes/evaluations.py:210 app/routes/teams.py:270
|
#: app/routes/evaluations.py:210 app/routes/teams.py:289
|
||||||
#: app/routes/teams.py:311 app/routes/teams.py:352 app/routes/teams.py:377
|
#: app/routes/teams.py:330 app/routes/teams.py:371 app/routes/teams.py:396
|
||||||
#: app/routes/teams.py:402 app/routes/teams.py:437 app/routes/tryouts.py:437
|
#: app/routes/teams.py:421 app/routes/teams.py:456 app/routes/tryouts.py:437
|
||||||
#: app/routes/tryouts.py:453 app/routes/tryouts.py:473
|
#: app/routes/tryouts.py:453 app/routes/tryouts.py:473
|
||||||
#: app/routes/tryouts.py:512 app/routes/tryouts.py:548
|
#: app/routes/tryouts.py:512 app/routes/tryouts.py:548
|
||||||
#: app/routes/tryouts.py:567
|
#: app/routes/tryouts.py:567
|
||||||
@@ -290,185 +294,185 @@ msgstr "Accès refusé."
|
|||||||
msgid "That language is not available."
|
msgid "That language is not available."
|
||||||
msgstr "Cette langue n’est pas disponible."
|
msgstr "Cette langue n’est pas disponible."
|
||||||
|
|
||||||
#: app/routes/matches.py:361
|
#: app/routes/matches.py:364
|
||||||
msgid "You do not have permission to schedule matches for this tryout."
|
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."
|
msgstr "Vous n’avez pas les droits pour planifier des matchs pour cette sélection."
|
||||||
|
|
||||||
#: app/routes/matches.py:365 app/routes/matches.py:445
|
#: app/routes/matches.py:368 app/routes/matches.py:448
|
||||||
msgid "This tryout has ended. Matches can no longer be created or modified."
|
msgid "This tryout has ended. Matches can no longer be created or modified."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Cette sélection est terminée. Les matchs ne peuvent plus être créés ni "
|
"Cette sélection est terminée. Les matchs ne peuvent plus être créés ni "
|
||||||
"modifiés."
|
"modifiés."
|
||||||
|
|
||||||
#: app/routes/matches.py:427
|
#: app/routes/matches.py:430
|
||||||
msgid "Match scheduled successfully!"
|
msgid "Match scheduled successfully!"
|
||||||
msgstr "Match planifié."
|
msgstr "Match planifié."
|
||||||
|
|
||||||
#: app/routes/matches.py:441 app/routes/team_matches.py:211
|
#: app/routes/matches.py:444 app/routes/team_matches.py:212
|
||||||
msgid "You do not have permission to edit this match."
|
msgid "You do not have permission to edit this match."
|
||||||
msgstr "Vous n’avez pas les droits pour modifier ce match."
|
msgstr "Vous n’avez pas les droits pour modifier ce match."
|
||||||
|
|
||||||
#: app/routes/matches.py:536 app/routes/team_matches.py:241
|
#: app/routes/matches.py:539 app/routes/team_matches.py:242
|
||||||
msgid "Match updated successfully!"
|
msgid "Match updated successfully!"
|
||||||
msgstr "Match mis à jour."
|
msgstr "Match mis à jour."
|
||||||
|
|
||||||
#: app/routes/matches.py:571 app/routes/team_matches.py:256
|
#: app/routes/matches.py:575 app/routes/team_matches.py:257
|
||||||
msgid "You do not have permission to delete this match."
|
msgid "You do not have permission to delete this match."
|
||||||
msgstr "Vous n’avez pas les droits pour supprimer ce match."
|
msgstr "Vous n’avez pas les droits pour supprimer ce match."
|
||||||
|
|
||||||
#: app/routes/matches.py:574
|
#: app/routes/matches.py:578
|
||||||
msgid "This tryout has ended. Matches can no longer be deleted."
|
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."
|
msgstr "Cette sélection est terminée. Les matchs ne peuvent plus être supprimés."
|
||||||
|
|
||||||
#: app/routes/matches.py:587 app/routes/team_matches.py:260
|
#: app/routes/matches.py:591 app/routes/team_matches.py:261
|
||||||
msgid "Match deleted successfully."
|
msgid "Match deleted successfully."
|
||||||
msgstr "Match supprimé."
|
msgstr "Match supprimé."
|
||||||
|
|
||||||
#: app/routes/team_matches.py:104
|
#: app/routes/team_matches.py:105
|
||||||
msgid "You do not have permission to schedule matches for this team."
|
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."
|
msgstr "Vous n’avez pas les droits pour planifier des matchs pour cette équipe."
|
||||||
|
|
||||||
#: app/routes/team_matches.py:196
|
#: app/routes/team_matches.py:197
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Team match \"%(title)s\" scheduled successfully!"
|
msgid "Team match \"%(title)s\" scheduled successfully!"
|
||||||
msgstr "Match d’équipe « %(title)s » planifié."
|
msgstr "Match d’équipe « %(title)s » planifié."
|
||||||
|
|
||||||
#: app/routes/teams.py:40
|
#: app/routes/teams.py:41
|
||||||
msgid "Use My Team(s) to view your teams."
|
msgid "Use My Team(s) to view your teams."
|
||||||
msgstr "Utilisez « Mon ou mes équipes » pour consulter vos équipes."
|
msgstr "Utilisez « Mon ou mes équipes » pour consulter vos équipes."
|
||||||
|
|
||||||
#: app/routes/teams.py:43
|
#: app/routes/teams.py:44
|
||||||
msgid "You do not have permission to view teams."
|
msgid "You do not have permission to view teams."
|
||||||
msgstr "Vous n’avez pas les droits pour consulter les équipes."
|
msgstr "Vous n’avez pas les droits pour consulter les équipes."
|
||||||
|
|
||||||
#: app/routes/teams.py:70
|
#: app/routes/teams.py:71
|
||||||
msgid "This page is for players."
|
msgid "This page is for players."
|
||||||
msgstr "Cette page est réservée aux joueurs."
|
msgstr "Cette page est réservée aux joueurs."
|
||||||
|
|
||||||
#: app/routes/teams.py:123
|
#: app/routes/teams.py:124
|
||||||
msgid "You do not have permission to create teams."
|
msgid "You do not have permission to create teams."
|
||||||
msgstr "Vous n’avez pas les droits pour créer une équipe."
|
msgstr "Vous n’avez pas les droits pour créer une équipe."
|
||||||
|
|
||||||
#: app/routes/teams.py:131 app/routes/teams.py:176
|
#: app/routes/teams.py:132 app/routes/teams.py:177
|
||||||
msgid "Team name is required."
|
msgid "Team name is required."
|
||||||
msgstr "Le nom de l’équipe est obligatoire."
|
msgstr "Le nom de l’équipe est obligatoire."
|
||||||
|
|
||||||
#: app/routes/teams.py:136 app/routes/teams.py:181
|
#: app/routes/teams.py:137 app/routes/teams.py:182
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Team \"%(name)s\" already exists."
|
msgid "Team \"%(name)s\" already exists."
|
||||||
msgstr "L’équipe « %(name)s » existe déjà."
|
msgstr "L’équipe « %(name)s » existe déjà."
|
||||||
|
|
||||||
#: app/routes/teams.py:158
|
#: app/routes/teams.py:159
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Team \"%(name)s\" created successfully!"
|
msgid "Team \"%(name)s\" created successfully!"
|
||||||
msgstr "Équipe « %(name)s » créée."
|
msgstr "Équipe « %(name)s » créée."
|
||||||
|
|
||||||
#: app/routes/teams.py:168
|
#: app/routes/teams.py:169
|
||||||
msgid "You do not have permission to edit this team."
|
msgid "You do not have permission to edit this team."
|
||||||
msgstr "Vous n’avez pas les droits pour modifier cette équipe."
|
msgstr "Vous n’avez pas les droits pour modifier cette équipe."
|
||||||
|
|
||||||
#: app/routes/teams.py:219
|
#: app/routes/teams.py:220
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Team \"%(name)s\" updated successfully!"
|
msgid "Team \"%(name)s\" updated successfully!"
|
||||||
msgstr "Équipe « %(name)s » mise à jour."
|
msgstr "Équipe « %(name)s » mise à jour."
|
||||||
|
|
||||||
#: app/routes/teams.py:228
|
#: app/routes/teams.py:248
|
||||||
msgid "You do not have permission to delete teams."
|
msgid "You do not have permission to delete teams."
|
||||||
msgstr "Vous n’avez pas les droits pour supprimer une équipe."
|
msgstr "Vous n’avez pas les droits pour supprimer une équipe."
|
||||||
|
|
||||||
#: app/routes/teams.py:260
|
#: app/routes/teams.py:279
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Team \"%(name)s\" deleted successfully."
|
msgid "Team \"%(name)s\" deleted successfully."
|
||||||
msgstr "Équipe « %(name)s » supprimée."
|
msgstr "Équipe « %(name)s » supprimée."
|
||||||
|
|
||||||
#: app/routes/teams.py:275
|
#: app/routes/teams.py:294
|
||||||
msgid "Please select a coach."
|
msgid "Please select a coach."
|
||||||
msgstr "Veuillez choisir un coach."
|
msgstr "Veuillez choisir un coach."
|
||||||
|
|
||||||
#: app/routes/teams.py:280
|
#: app/routes/teams.py:299
|
||||||
msgid "Only coaches can be assigned as coach."
|
msgid "Only coaches can be assigned as coach."
|
||||||
msgstr "Seuls les coachs peuvent être assignés comme coach."
|
msgstr "Seuls les coachs peuvent être assignés comme coach."
|
||||||
|
|
||||||
#: app/routes/teams.py:286
|
#: app/routes/teams.py:305
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(username)s is already a coach of %(name)s."
|
msgid "%(username)s is already a coach of %(name)s."
|
||||||
msgstr "%(username)s est déjà coach de %(name)s."
|
msgstr "%(username)s est déjà coach de %(name)s."
|
||||||
|
|
||||||
#: app/routes/teams.py:299
|
#: app/routes/teams.py:318
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(username)s added as coach of %(name)s."
|
msgid "%(username)s added as coach of %(name)s."
|
||||||
msgstr "%(username)s a été ajouté comme coach de %(name)s."
|
msgstr "%(username)s a été ajouté comme coach de %(name)s."
|
||||||
|
|
||||||
#: app/routes/teams.py:316
|
#: app/routes/teams.py:335
|
||||||
msgid "Please select a manager."
|
msgid "Please select a manager."
|
||||||
msgstr "Veuillez choisir un gérant."
|
msgstr "Veuillez choisir un gérant."
|
||||||
|
|
||||||
#: app/routes/teams.py:321
|
#: app/routes/teams.py:340
|
||||||
msgid "Only managers can be assigned as manager."
|
msgid "Only managers can be assigned as manager."
|
||||||
msgstr "Seuls les gérants peuvent être assignés comme gérant."
|
msgstr "Seuls les gérants peuvent être assignés comme gérant."
|
||||||
|
|
||||||
#: app/routes/teams.py:327
|
#: app/routes/teams.py:346
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(username)s is already a manager of %(name)s."
|
msgid "%(username)s is already a manager of %(name)s."
|
||||||
msgstr "%(username)s est déjà gérant de %(name)s."
|
msgstr "%(username)s est déjà gérant de %(name)s."
|
||||||
|
|
||||||
#: app/routes/teams.py:340
|
#: app/routes/teams.py:359
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(username)s added as manager of %(name)s."
|
msgid "%(username)s added as manager of %(name)s."
|
||||||
msgstr "%(username)s a été ajouté comme gérant de %(name)s."
|
msgstr "%(username)s a été ajouté comme gérant de %(name)s."
|
||||||
|
|
||||||
#: app/routes/teams.py:367
|
#: app/routes/teams.py:386
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Coach removed from %(name)s."
|
msgid "Coach removed from %(name)s."
|
||||||
msgstr "Coach retiré de %(name)s."
|
msgstr "Coach retiré de %(name)s."
|
||||||
|
|
||||||
#: app/routes/teams.py:392
|
#: app/routes/teams.py:411
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Manager removed from %(name)s."
|
msgid "Manager removed from %(name)s."
|
||||||
msgstr "Gérant retiré de %(name)s."
|
msgstr "Gérant retiré de %(name)s."
|
||||||
|
|
||||||
#: app/routes/teams.py:408 app/routes/tryouts.py:477 app/routes/tryouts.py:578
|
#: app/routes/teams.py:427 app/routes/tryouts.py:477 app/routes/tryouts.py:578
|
||||||
msgid "Please select a player."
|
msgid "Please select a player."
|
||||||
msgstr "Veuillez choisir un joueur."
|
msgstr "Veuillez choisir un joueur."
|
||||||
|
|
||||||
#: app/routes/teams.py:413
|
#: app/routes/teams.py:432
|
||||||
msgid "Can only assign players to teams."
|
msgid "Can only assign players to teams."
|
||||||
msgstr "Seuls des joueurs peuvent être assignés à une équipe."
|
msgstr "Seuls des joueurs peuvent être assignés à une équipe."
|
||||||
|
|
||||||
#: app/routes/teams.py:419
|
#: app/routes/teams.py:438
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(username)s is already on %(name)s."
|
msgid "%(username)s is already on %(name)s."
|
||||||
msgstr "%(username)s fait déjà partie de %(name)s."
|
msgstr "%(username)s fait déjà partie de %(name)s."
|
||||||
|
|
||||||
#: app/routes/teams.py:427
|
#: app/routes/teams.py:446
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(username)s added to %(name)s!"
|
msgid "%(username)s added to %(name)s!"
|
||||||
msgstr "%(username)s a été ajouté à %(name)s."
|
msgstr "%(username)s a été ajouté à %(name)s."
|
||||||
|
|
||||||
#: app/routes/teams.py:444 app/routes/teams.py:517
|
#: app/routes/teams.py:463 app/routes/teams.py:537
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(username)s is not on %(name)s."
|
msgid "%(username)s is not on %(name)s."
|
||||||
msgstr "%(username)s ne fait pas partie de %(name)s."
|
msgstr "%(username)s ne fait pas partie de %(name)s."
|
||||||
|
|
||||||
#: app/routes/teams.py:452
|
#: app/routes/teams.py:471
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(username)s removed from %(name)s."
|
msgid "%(username)s removed from %(name)s."
|
||||||
msgstr "%(username)s a été retiré de %(name)s."
|
msgstr "%(username)s a été retiré de %(name)s."
|
||||||
|
|
||||||
#: app/routes/teams.py:488 app/routes/teams.py:506
|
#: app/routes/teams.py:508 app/routes/teams.py:526
|
||||||
msgid "You do not have permission to add notes to this team."
|
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."
|
msgstr "Vous n’avez pas les droits pour ajouter des notes à cette équipe."
|
||||||
|
|
||||||
#: app/routes/teams.py:496
|
#: app/routes/teams.py:516
|
||||||
msgid "Team notes added successfully!"
|
msgid "Team notes added successfully!"
|
||||||
msgstr "Notes d’équipe ajoutées."
|
msgstr "Notes d’équipe ajoutées."
|
||||||
|
|
||||||
#: app/routes/teams.py:511 app/routes/users/notes.py:207
|
#: app/routes/teams.py:531 app/routes/users/notes.py:207
|
||||||
#: app/routes/users/notes.py:250
|
#: app/routes/users/notes.py:250
|
||||||
msgid "Can only add notes for players."
|
msgid "Can only add notes for players."
|
||||||
msgstr "Il n’est possible d’ajouter des notes que pour des joueurs."
|
msgstr "Il n’est possible d’ajouter des notes que pour des joueurs."
|
||||||
|
|
||||||
#: app/routes/teams.py:527
|
#: app/routes/teams.py:547
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Note added for %(username)s!"
|
msgid "Note added for %(username)s!"
|
||||||
msgstr "Note ajoutée pour %(username)s."
|
msgstr "Note ajoutée pour %(username)s."
|
||||||
@@ -582,25 +586,25 @@ msgstr "Seuls les fichiers PDF sont acceptés pour les contrats."
|
|||||||
msgid "That file is not a PDF, whatever its name says."
|
msgid "That file is not a PDF, whatever its name says."
|
||||||
msgstr "Ce fichier n’est pas un PDF, quel que soit son nom."
|
msgstr "Ce fichier n’est pas un PDF, quel que soit son nom."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:53
|
#: app/routes/users/accounts.py:54
|
||||||
msgid "Only the president can manage users."
|
msgid "Only the president can manage users."
|
||||||
msgstr "Seul le président peut gérer les utilisateurs."
|
msgstr "Seul le président peut gérer les utilisateurs."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:65
|
#: app/routes/users/accounts.py:66
|
||||||
msgid "Only the president can edit users."
|
msgid "Only the president can edit users."
|
||||||
msgstr "Seul le président peut modifier des utilisateurs."
|
msgstr "Seul le président peut modifier des utilisateurs."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:106
|
#: app/routes/users/accounts.py:107
|
||||||
msgid "Email already in use by another account."
|
msgid "Email already in use by another account."
|
||||||
msgstr "Cette adresse courriel est déjà utilisée par un autre compte."
|
msgstr "Cette adresse courriel est déjà utilisée par un autre compte."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:117
|
#: app/routes/users/accounts.py:118
|
||||||
msgid "You cannot change your own role. Ask another president to do it."
|
msgid "You cannot change your own role. Ask another president to do it."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Vous ne pouvez pas modifier votre propre rôle. Demandez à un autre "
|
"Vous ne pouvez pas modifier votre propre rôle. Demandez à un autre "
|
||||||
"président de le faire."
|
"président de le faire."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:130
|
#: app/routes/users/accounts.py:131
|
||||||
msgid ""
|
msgid ""
|
||||||
"This is the last active president. Promote another account before "
|
"This is the last active president. Promote another account before "
|
||||||
"changing this one."
|
"changing this one."
|
||||||
@@ -608,34 +612,34 @@ msgstr ""
|
|||||||
"C’est le dernier président actif. Promouvez un autre compte avant de "
|
"C’est le dernier président actif. Promouvez un autre compte avant de "
|
||||||
"modifier celui-ci."
|
"modifier celui-ci."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:209
|
#: app/routes/users/accounts.py:210
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "User %(username)s updated successfully!"
|
msgid "User %(username)s updated successfully!"
|
||||||
msgstr "Utilisateur %(username)s mis à jour."
|
msgstr "Utilisateur %(username)s mis à jour."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:230
|
#: app/routes/users/accounts.py:231
|
||||||
msgid "Only the president can delete users."
|
msgid "Only the president can delete users."
|
||||||
msgstr "Seul le président peut supprimer des utilisateurs."
|
msgstr "Seul le président peut supprimer des utilisateurs."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:234
|
#: app/routes/users/accounts.py:235
|
||||||
msgid "You cannot delete your own account."
|
msgid "You cannot delete your own account."
|
||||||
msgstr "Vous ne pouvez pas supprimer votre propre compte."
|
msgstr "Vous ne pouvez pas supprimer votre propre compte."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:277
|
#: app/routes/users/accounts.py:294
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "User %(deleted_username)s has been removed."
|
msgid "User %(deleted_username)s has been removed."
|
||||||
msgstr "L’utilisateur %(deleted_username)s a été supprimé."
|
msgstr "L’utilisateur %(deleted_username)s a été supprimé."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:288
|
#: app/routes/users/accounts.py:305
|
||||||
msgid "Only the president can create users."
|
msgid "Only the president can create users."
|
||||||
msgstr "Seul le président peut créer des utilisateurs."
|
msgstr "Seul le président peut créer des utilisateurs."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:336
|
#: app/routes/users/accounts.py:353
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "User %(full_name)s created as %(role)s!"
|
msgid "User %(full_name)s created as %(role)s!"
|
||||||
msgstr "Utilisateur %(full_name)s créé avec le rôle %(role)s."
|
msgstr "Utilisateur %(full_name)s créé avec le rôle %(role)s."
|
||||||
|
|
||||||
#: app/routes/users/availability.py:179
|
#: app/routes/users/availability.py:187
|
||||||
msgid "Only coaches can manage availability."
|
msgid "Only coaches can manage availability."
|
||||||
msgstr "Seuls les coachs peuvent gérer leurs disponibilités."
|
msgstr "Seuls les coachs peuvent gérer leurs disponibilités."
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""What a JSON endpoint answers when it fails.
|
||||||
|
|
||||||
|
STD-09. The seven error handlers each carried their own copy of a list of
|
||||||
|
URL prefixes deciding "JSON or HTML page". The copies had drifted — three
|
||||||
|
checked `/users/coach-availability`, four did not — and every one of them
|
||||||
|
was missing the same five endpoints, the ones under `/matches/api/` and
|
||||||
|
`/team-matches/api/`.
|
||||||
|
|
||||||
|
The failure was invisible from the server. A `fetch()` that receives an HTML
|
||||||
|
error page throws while parsing it, so on the calendar the tryout and team
|
||||||
|
selects simply stayed empty: no message on the page, nothing in the log, and
|
||||||
|
the only trace a SyntaxError in a console nobody had open. An expired
|
||||||
|
session produced exactly this, because the 401 handler answers a browser
|
||||||
|
with a redirect to an HTML login form.
|
||||||
|
|
||||||
|
The first test here is the one that matters: it derives the list of JSON
|
||||||
|
views from the code rather than from a second hand-written list, because a
|
||||||
|
hand-written list is what failed. Writing it is what showed that the prefix
|
||||||
|
approach could not be repaired — three of the sixteen JSON views sit at
|
||||||
|
paths (`/matches/<id>/toggle-presence/<id>`) that no prefix can single out
|
||||||
|
from the HTML pages beside them. Views mark themselves now (`app/api.py`).
|
||||||
|
|
||||||
|
It also showed the deeper half. `@login_required` never reaches Flask's 401
|
||||||
|
handler: Flask-Login intercepts first and redirects. So every JSON endpoint
|
||||||
|
answered an expired session with an HTML login page no matter what the
|
||||||
|
prefix list said, and rewriting the list alone would have looked like a fix
|
||||||
|
while changing nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROUTES_DIR = pathlib.Path(__file__).resolve().parent.parent / 'app' / 'routes'
|
||||||
|
|
||||||
|
|
||||||
|
def _view_functions_returning_json():
|
||||||
|
"""Every route handler whose body calls jsonify(), found by AST.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
set[str]: Function names. Matching them back to URL rules is the
|
||||||
|
caller's job — the URL map knows the endpoint names.
|
||||||
|
"""
|
||||||
|
found = set()
|
||||||
|
for path in ROUTES_DIR.rglob('*.py'):
|
||||||
|
tree = ast.parse(path.read_text(encoding='utf-8'))
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, ast.FunctionDef):
|
||||||
|
continue
|
||||||
|
decorated = any(
|
||||||
|
isinstance(dec, ast.Call)
|
||||||
|
and isinstance(dec.func, ast.Attribute)
|
||||||
|
and dec.func.attr == 'route'
|
||||||
|
for dec in node.decorator_list
|
||||||
|
)
|
||||||
|
if not decorated:
|
||||||
|
continue
|
||||||
|
for inner in ast.walk(node):
|
||||||
|
if (
|
||||||
|
isinstance(inner, ast.Call)
|
||||||
|
and isinstance(inner.func, ast.Name)
|
||||||
|
and inner.func.id == 'jsonify'
|
||||||
|
):
|
||||||
|
found.add(node.name)
|
||||||
|
break
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
class TestEveryJsonViewIsCovered:
|
||||||
|
def test_the_scan_finds_something(self):
|
||||||
|
"""Premise. An AST walk that matches nothing would make the test
|
||||||
|
below pass against any prefix list at all, including an empty one."""
|
||||||
|
assert len(_view_functions_returning_json()) >= 5
|
||||||
|
|
||||||
|
def test_every_json_view_carries_the_mark(self, app):
|
||||||
|
"""The regression. Derived from the code, not from a second list.
|
||||||
|
|
||||||
|
A view that calls jsonify and forgets `@json_endpoint` fails here
|
||||||
|
rather than in a browser, silently, months later.
|
||||||
|
"""
|
||||||
|
json_views = _view_functions_returning_json()
|
||||||
|
|
||||||
|
unmarked = []
|
||||||
|
for rule in app.url_map.iter_rules():
|
||||||
|
if rule.endpoint.rsplit('.', 1)[-1] not in json_views:
|
||||||
|
continue
|
||||||
|
view = app.view_functions[rule.endpoint]
|
||||||
|
if not getattr(view, 'returns_json', False):
|
||||||
|
unmarked.append(f'{rule.endpoint} → {rule}')
|
||||||
|
|
||||||
|
assert unmarked == [], (
|
||||||
|
'these views answer with jsonify but are not marked @json_endpoint, '
|
||||||
|
'so their errors would be an HTML page a fetch() cannot parse: '
|
||||||
|
+ ', '.join(sorted(unmarked))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'path',
|
||||||
|
[
|
||||||
|
'/matches/api/events',
|
||||||
|
'/matches/api/manageable-tryouts',
|
||||||
|
'/team-matches/api/manageable-teams',
|
||||||
|
'/users/disponibilities',
|
||||||
|
'/users/api/nothing-here',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class TestTheApiPathsThemselves:
|
||||||
|
def test_an_unauthenticated_call_is_answered_in_json(self, client, path):
|
||||||
|
"""These are exactly the calls the calendar and the availability
|
||||||
|
pages make. Before, an expired session answered every one of them
|
||||||
|
with a 302 to an HTML login page."""
|
||||||
|
response = client.get(path, follow_redirects=False)
|
||||||
|
|
||||||
|
assert response.status_code != 200
|
||||||
|
assert response.is_json, (
|
||||||
|
f'{path} answered {response.content_type}; a fetch() would throw '
|
||||||
|
f'while parsing it and the page would fail silently'
|
||||||
|
)
|
||||||
|
assert 'error' in response.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
class TestHtmlPagesAreUnaffected:
|
||||||
|
def test_a_missing_page_still_renders_html(self, client):
|
||||||
|
response = client.get('/no-such-page')
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert 'text/html' in response.content_type
|
||||||
|
|
||||||
|
def test_a_page_needing_login_still_redirects(self, client):
|
||||||
|
"""The behaviour a browser needs, and the one worth not breaking
|
||||||
|
while fixing the JSON side."""
|
||||||
|
response = client.get('/users/profile', follow_redirects=False)
|
||||||
|
|
||||||
|
assert response.status_code in (301, 302)
|
||||||
|
assert '/auth/login' in response.headers['Location']
|
||||||
|
|
||||||
|
|
||||||
|
class TestExplicitContentNegotiation:
|
||||||
|
def test_a_caller_that_asks_for_json_gets_json(self, client):
|
||||||
|
"""Belt and braces for a path the prefix list does not know about."""
|
||||||
|
response = client.get('/no-such-page', headers={'Accept': 'application/json'})
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.is_json
|
||||||
|
|
||||||
|
def test_a_browser_accept_header_still_gets_html(self, client):
|
||||||
|
"""Browsers send `*/*` alongside text/html; that must not read as a
|
||||||
|
request for JSON."""
|
||||||
|
response = client.get(
|
||||||
|
'/no-such-page',
|
||||||
|
headers={'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert 'text/html' in response.content_type
|
||||||
Reference in New Issue
Block a user