3 Commits
19 changed files with 906 additions and 381 deletions
+5 -2
View File
@@ -101,8 +101,11 @@ Ce qui **n'est pas** fait, pour que personne ne s'y fie :
- **Les comptes créés par le formulaire d'inscription sont actifs
immédiatement** : il n'y a pas d'étape de validation par le staff. Décision
de produit en attente, voir `docs/roles-and-permissions.md`.
- **L'identité Discord** transite encore par un champ caché du formulaire
d'inscription : elle n'est pas prouvée par le passage OAuth2.
- **L'unicité Discord n'est pas encore garantie par PostgreSQL.** L'identité
OAuth reste désormais côté serveur, le profil ne peut plus réécrire le
snowflake et l'application refuse les nouvelles collisions. Les doublons
historiques doivent être relevés puis corrigés avant la contrainte
`UNIQUE` (`schema_report.py --check-discord-identities`).
`docs/security-checklist.md` détaille la liste avant mise en production.
+93 -15
View File
@@ -21,10 +21,14 @@ from app.extensions import check_password, db, hash_password, limiter
from app.i18n import LOCALE_SESSION_KEY
from app.logging_config import log_auth_event
from app.models import ESPORT_GAMES, Player, User
from app.validators import LoginSchema, RegisterSchema
from app.validators import LoginSchema, RegisterSchema, validate_discord_user_id
#: Session key holding the pending OAuth2 anti-forgery token.
DISCORD_STATE_KEY = 'discord_oauth_state'
#: Whether the OAuth result should create a registration draft or relink the
#: signed-in account. Kept server-side and covered by the same signed session
#: as the anti-forgery state.
DISCORD_PURPOSE_KEY = 'discord_oauth_purpose'
# Failed-attempt tracking. The tally is kept for the audit trail and for the
# cool-off marker below; it no longer refuses a correct password (SEC-018).
@@ -354,6 +358,15 @@ def register():
form_data = dict(request.form)
form_data['games'] = request.form.getlist('games')
# Once Discord has authenticated the identity, neither its display
# name nor its snowflake is input data anymore. Remove any client
# copies before validation as well as before persistence: otherwise a
# forged, malformed hidden value can still make the verified flow fail.
discord_oauth = session.get('discord_oauth') or {}
if discord_oauth.get('id'):
form_data.pop('discord_username', None)
form_data.pop('discord_user_id', None)
refusal = check_registration_challenge(request.form)
if refusal is not None:
# Logged, because this is the only place abuse of the sign-up
@@ -380,8 +393,18 @@ def register():
full_name = validated['full_name']
phone = validated.get('phone')
selected_games = validated.get('games', [])
discord_username = validated.get('discord_username')
discord_user_id = validated.get('discord_user_id')
# The OAuth identity is server-side state. It used to be copied into
# hidden inputs and read back from request.form, which let anyone
# replace the verified Discord account before submitting (SEC-AUTH-005).
# A manual registration may still provide a display name, but never a
# Discord snowflake: that identifier is an authentication factor for
# bot reactions and must come from Discord itself.
discord_user_id = discord_oauth.get('id')
if discord_user_id:
discord_user_id = str(discord_user_id)
discord_username = (
discord_oauth.get('username') if discord_user_id else validated.get('discord_username')
)
league_os_profile = validated.get('league_os_profile')
if User.query.filter_by(username=username).first():
@@ -392,6 +415,13 @@ def register():
flash(_('Email already registered.'), 'danger')
return _rerender_registration(form_data)
# The database constraint belongs to DB-002, after production has
# been backed up and deduplicated. Refuse new duplicates now instead
# of leaving the critical impersonation path open until then.
if discord_user_id and User.query.filter_by(discord_user_id=discord_user_id).first():
flash(_('This Discord account is already linked to another account.'), 'danger')
return _rerender_registration(form_data)
hashed_password = hash_password(password)
user = Player(
username=username,
@@ -451,11 +481,15 @@ def discord_login():
Returns:
Response: Redirect to Discord authorization URL.
"""
purpose = 'profile' if current_user.is_authenticated else 'registration'
session[DISCORD_PURPOSE_KEY] = purpose
return_endpoint = 'users.edit_profile' if purpose == 'profile' else 'auth.register'
# DISCORD_REDIRECT_URI is checked too: quoting it when unset used to
# raise inside the query builder rather than report a configuration error.
if not DISCORD_CLIENT_ID or not DISCORD_REDIRECT_URI:
flash(_('Discord OAuth2 is not configured.'), 'danger')
return redirect(url_for('auth.register'))
return redirect(url_for(return_endpoint))
# Anti-forgery token, required by RFC 6749 §10.12. Without it, an
# attacker could have the victim's browser consume an authorization code
@@ -479,16 +513,24 @@ def discord_login():
def discord_callback():
"""Handle the OAuth2 callback from Discord.
Exchanges the authorization code for an access token, then fetches
the user's profile (/users/@me) and connections (/users/@me/connections).
Results are stored in the session and the user is redirected back to
the registration form where fields will be pre-filled.
Exchanges the authorization code for an access token, then fetches the
user's profile. During registration, connected game accounts are also
loaded into server-side draft state. For a signed-in profile relink, the
verified identity is written directly without passing through a form.
Returns:
Response: Redirect to registration page.
Response: Redirect to the registration form or profile editor.
"""
# The state is consumed whatever happens next: a token is single-use, and
# leaving it in the session would allow a replay.
purpose = session.pop(DISCORD_PURPOSE_KEY, 'registration')
if purpose == 'profile' and current_user.is_authenticated:
return_endpoint = 'users.edit_profile'
elif purpose == 'profile':
return_endpoint = 'auth.login'
else:
return_endpoint = 'auth.register'
expected_state = session.pop(DISCORD_STATE_KEY, None)
received_state = request.args.get('state', '')
@@ -500,12 +542,12 @@ def discord_callback():
),
'danger',
)
return redirect(url_for('auth.register'))
return redirect(url_for(return_endpoint))
code = request.args.get('code')
if not code:
flash(_('Discord authorization failed. No code received.'), 'danger')
return redirect(url_for('auth.register'))
return redirect(url_for(return_endpoint))
# Exchange the authorization code for an access token
token_data = {
@@ -529,11 +571,11 @@ def discord_callback():
access_token = token_json.get('access_token')
except requests.RequestException:
flash(_('Failed to connect to Discord. Please try again.'), 'danger')
return redirect(url_for('auth.register'))
return redirect(url_for(return_endpoint))
if not access_token:
flash(_('Failed to obtain Discord access token.'), 'danger')
return redirect(url_for('auth.register'))
return redirect(url_for(return_endpoint))
auth_headers = {'Authorization': f'Bearer {access_token}'}
@@ -548,7 +590,43 @@ def discord_callback():
user_data = user_response.json()
except requests.RequestException:
flash(_('Failed to fetch Discord user profile.'), 'danger')
return redirect(url_for('auth.register'))
return redirect(url_for(return_endpoint))
discord_user_id = user_data.get('id')
try:
if not discord_user_id:
raise ValidationError('missing Discord user id')
discord_user_id = str(discord_user_id)
validate_discord_user_id(discord_user_id)
except ValidationError:
flash(_('Failed to fetch Discord user profile.'), 'danger')
return redirect(url_for(return_endpoint))
if purpose == 'profile':
# If the session expired while Discord was open, do not turn a profile
# relink into registration state for an anonymous browser.
if not current_user.is_authenticated:
flash(_('Please log in to connect your Discord account.'), 'danger')
return redirect(url_for('auth.login'))
clash = User.query.filter(
User.discord_user_id == discord_user_id,
User.id != current_user.id,
).first()
if clash:
flash(_('This Discord account is already linked to another account.'), 'danger')
return redirect(url_for('users.edit_profile'))
current_user.discord_user_id = discord_user_id
current_user.discord_username = user_data.get('username') or None
db.session.commit()
log_auth_event(
'account.discord_linked',
username=current_user.username,
user_id=current_user.id,
)
flash(_('Discord account connected!'), 'success')
return redirect(url_for('users.edit_profile'))
# Fetch the user's connected gaming accounts
connections = []
@@ -587,7 +665,7 @@ def discord_callback():
# Store in session for the registration form to use
session['discord_oauth'] = {
'id': user_data.get('id'),
'id': discord_user_id,
'username': user_data.get('username'),
'avatar': user_data.get('avatar'),
'gamertag_suggestions': gamertag_suggestions,
+10
View File
@@ -111,6 +111,16 @@ def edit_user(user_id):
flash(_('Email already in use by another account.'), 'danger')
return _rerender()
discord_clash = None
if discord_user_id:
discord_clash = User.query.filter(
User.discord_user_id == discord_user_id,
User.id != user.id,
).first()
if discord_clash:
flash(_('This Discord account is already linked to another account.'), 'danger')
return _rerender()
role_changed = user.role != role
previous_role = user.role
+25 -25
View File
@@ -141,41 +141,41 @@ def add_disponibility():
@json_endpoint
@login_required
def add_disponibilities_bulk():
"""Add multiple disponibility blocks at once."""
"""Replace the current player's disponibility blocks atomically."""
data = request.get_json(silent=True) or {}
accepted, rejected = _load_slots(data.get('slots'))
if rejected:
return jsonify(
{
'error': 'Invalid slots; nothing was changed.',
'rejected': rejected,
}
), 400
PlayerDisponibility.query.filter_by(player_id=current_user.id).delete()
created = []
for slot in accepted:
start_time = slot['start_time']
existing = PlayerDisponibility.query.filter_by(
disponibility = PlayerDisponibility(
player_id=current_user.id,
day_of_week=slot['day_of_week'],
start_time=start_time,
).first()
if not existing:
disponibility = PlayerDisponibility(
player_id=current_user.id,
day_of_week=slot['day_of_week'],
start_time=start_time,
end_time=slot_end(start_time),
)
db.session.add(disponibility)
db.session.flush()
created.append(
{
'id': disponibility.id,
'day_of_week': disponibility.day_of_week,
'day_name': day_name(disponibility.day_of_week),
'start_time': disponibility.start_time.strftime('%H:%M'),
}
)
end_time=slot_end(start_time),
)
db.session.add(disponibility)
db.session.flush()
created.append(
{
'id': disponibility.id,
'day_of_week': disponibility.day_of_week,
'day_name': day_name(disponibility.day_of_week),
'start_time': disponibility.start_time.strftime('%H:%M'),
}
)
db.session.commit()
# `rejected` is reported rather than swallowed. What was accepted is
# still saved — dropping a whole batch because one cell was malformed
# would be its own kind of surprise — but the client can now tell the
# difference between "nine slots saved" and "ten sent, nine saved".
return jsonify({'success': True, 'created': created, 'rejected': rejected})
return jsonify({'success': True, 'created': created, 'rejected': []})
@users_bp.route('/disponibilities/clear', methods=['POST'])
-2
View File
@@ -76,7 +76,6 @@ def edit_profile():
phone = validated.get('phone')
selected_games = validated.get('games', [])
discord_username = validated.get('discord_username')
discord_user_id = validated.get('discord_user_id')
league_os_profile = validated.get('league_os_profile')
if username != current_user.username and User.query.filter_by(username=username).first():
@@ -105,7 +104,6 @@ def edit_profile():
current_user.phone = phone
current_user.games = ','.join(selected_games) if selected_games else None
current_user.discord_username = discord_username or None
current_user.discord_user_id = discord_user_id or None
current_user.league_os_profile = league_os_profile or None
update_user_gamertags(current_user, selected_games)
+61 -2
View File
@@ -29,10 +29,13 @@ Usage
# Also look for the seeded admin/password account (SEC-003)
python app/supporting_scripts/schema_report.py --check-seed-accounts
# Find Discord identities that must be reconciled before UNIQUE (SEC-012)
python app/supporting_scripts/schema_report.py --check-discord-identities
Exit codes
----------
0 the live schema matches the models
1 drift found — the report says what
1 drift or requested data risk found — the report says what
2 could not connect or read the catalogue
Reading the output
@@ -315,6 +318,42 @@ def find_seed_accounts(engine):
return results
def find_duplicate_discord_identities(engine):
"""Discord snowflakes claimed by more than one account (SEC-012).
New links are now refused in application code, but existing production
rows predate that guard. These groups must be reconciled before Alembic
can add the database-level UNIQUE constraint.
Returns:
list[tuple]: (discord_user_id, comma-separated usernames, count).
"""
from sqlalchemy import text
with engine.connect() as connection:
rows = connection.execute(
text(
'SELECT discord_user_id, COUNT(*) AS account_count '
'FROM users '
"WHERE discord_user_id IS NOT NULL AND discord_user_id <> '' "
'GROUP BY discord_user_id HAVING COUNT(*) > 1 '
'ORDER BY discord_user_id'
)
).fetchall()
duplicates = []
for discord_user_id, account_count in rows:
usernames = connection.execute(
text(
'SELECT username FROM users '
'WHERE discord_user_id = :discord_user_id ORDER BY username'
),
{'discord_user_id': discord_user_id},
).scalars()
duplicates.append((discord_user_id, ', '.join(usernames), account_count))
return duplicates
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.split('\n')[0])
parser.add_argument(
@@ -327,6 +366,11 @@ def main(argv=None):
action='store_true',
help='Also look for the admin/password account seeded by clear_db.py (SEC-003).',
)
parser.add_argument(
'--check-discord-identities',
action='store_true',
help='Find duplicate Discord IDs that block the SEC-012 UNIQUE constraint.',
)
args = parser.parse_args(argv)
if not args.url:
@@ -375,9 +419,24 @@ def main(argv=None):
)
print(f' {username} ({role}): {verdict}')
duplicate_discord_identities = []
if args.check_discord_identities:
print('\n' + '=' * 78)
print('Duplicate Discord identities (SEC-012)')
print('=' * 78)
try:
duplicate_discord_identities = find_duplicate_discord_identities(engine)
except SQLAlchemyError as exc:
print(f'Could not check: {exc}')
else:
if not duplicate_discord_identities:
print('No Discord identity is shared by multiple accounts.')
for discord_user_id, usernames, account_count in duplicate_discord_identities:
print(f' {discord_user_id}: {account_count} accounts ({usernames})')
blocking = sum(1 for f in findings if f.severity == BLOCKING)
print(f'\n{len(findings)} finding(s), {blocking} blocking.')
return 1 if findings else 0
return 1 if findings or duplicate_discord_identities else 0
if __name__ == '__main__': # pragma: no cover
+5 -6
View File
@@ -79,14 +79,13 @@
</div>
<div class="form-row">
<div class="form-group col-6">
<div class="form-group col-12">
<label for="discord_username"><i class="fab fa-discord"></i> {{ _('Discord Username') }}</label>
<input type="text" id="discord_username" name="discord_username" value="{{ user.discord_username or '' }}" placeholder="{{ _('e.g. Name#1234') }}">
</div>
<div class="form-group col-6">
<label for="discord_user_id"><i class="fab fa-discord"></i> Discord User ID <small>{{ _('(for DMs)') }}</small></label>
<input type="text" id="discord_user_id" name="discord_user_id" value="{{ user.discord_user_id or '' }}" placeholder="{{ _('Numeric ID (e.g. 123456789012345678)') }}">
<small class="text-muted">{{ _('Enable Developer Mode in Discord → Right-click profile → Copy ID') }}</small>
<a href="{{ url_for('auth.discord_login') }}" class="btn btn-secondary mt-2">
<i class="fab fa-discord"></i>
{% if user.discord_user_id %}{{ _('Reconnect') }}{% else %}{{ _('Connect Discord Account') }}{% endif %}
</a>
</div>
</div>
<div class="form-row">
+2 -6
View File
@@ -193,9 +193,6 @@
<p class="text-muted">{{ _('Loading...') }}</p>
</div>
<div class="form-actions">
<button type="button" class="btn btn-primary" data-action="save-disponibilities">
<i class="fas fa-save"></i> {{ _('Save Disponibilities') }}
</button>
<button type="button" class="btn btn-secondary" data-action="clear-disponibilities">
<i class="fas fa-trash"></i> {{ _('Clear All') }}
</button>
@@ -369,7 +366,7 @@ function saveCoachAvailability() {
const msg = document.createElement('div');
msg.className = 'alert alert-success';
msg.style.marginTop = '10px';
msg.innerHTML = '<i class="fas fa-check"></i> Availability saved!';
msg.innerHTML = '<span><i class="fas fa-check"></i> Availability saved!</span>';
document.getElementById('availability-grid').appendChild(msg);
setTimeout(() => msg.remove(), 3000);
}
@@ -571,7 +568,7 @@ function saveDisponibilities() {
var msg = document.createElement('div');
msg.className = 'alert alert-success';
msg.style.marginTop = '10px';
msg.innerHTML = '<i class="fas fa-check"></i> Disponibilities saved successfully!';
msg.innerHTML = '<span><i class="fas fa-check"></i> Disponibilities saved successfully!</span>';
document.getElementById('disponibilities-grid').appendChild(msg);
setTimeout(function() { msg.remove(); }, 3000);
}
@@ -610,7 +607,6 @@ document.addEventListener('DOMContentLoaded', function() {
// dispatched by the delegated listener in main.js. This replaces inline
// onclick attributes, which no CSP nonce is able to authorise.
registerActions({
'save-disponibilities': saveDisponibilities,
'clear-disponibilities': clearDisponibilities,
'clear-availability': clearAllAvailability,
});
-2
View File
@@ -57,8 +57,6 @@
<i class="fas fa-sync-alt"></i> {{ _('Reconnect') }}
</a>
</div>
<input type="hidden" name="discord_username" value="{{ discord_data.username }}">
<input type="hidden" name="discord_user_id" value="{{ discord_data.id }}">
<small class="form-text text-success">
<i class="fas fa-check-circle"></i> {{ _('Discord connected. Game connections have been used to pre-fill your profile below.') }}
</small>
Binary file not shown.
+164 -147
View File
@@ -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:39-0400\n"
"POT-Creation-Date: 2026-08-16 23:22-0400\n"
"PO-Revision-Date: 2026-08-07 20:22-0400\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: en\n"
@@ -23,8 +23,8 @@ msgstr ""
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/routes/users/contracts.py:96
#: app/forms.py:37 app/routes/auth.py:228 app/routes/auth.py:387
#: app/routes/users/contracts.py:98
#, python-format
msgid "%(field)s: %(msg)s"
msgstr "%(field)s: %(msg)s"
@@ -61,7 +61,7 @@ msgstr "Username is required."
msgid "Password is required."
msgstr "Password is required."
#: app/validators.py:227 app/validators.py:299
#: app/validators.py:227 app/validators.py:294
msgid "Username must be 3-80 characters."
msgstr "Username must be 3-80 characters."
@@ -69,169 +69,169 @@ msgstr "Username must be 3-80 characters."
msgid "Email must be 120 characters or less."
msgstr "Email must be 120 characters or less."
#: app/validators.py:246 app/validators.py:314 app/validators.py:345
#: app/validators.py:409
#: app/validators.py:246 app/validators.py:309 app/validators.py:340
#: app/validators.py:403
msgid "Full name is required."
msgstr "Full name is required."
#: app/validators.py:281
#: app/validators.py:276
msgid "Passwords do not match."
msgstr "Passwords do not match."
#: app/validators.py:318 app/validators.py:353
#: app/validators.py:313 app/validators.py:348
msgid "Invalid role selected."
msgstr "Invalid role selected."
#: app/validators.py:454
#: app/validators.py:443
msgid "Player must be selected."
msgstr "Player must be selected."
#: app/validators.py:457
#: app/validators.py:446
msgid "Notes must be 2000 characters or less."
msgstr "Notes must be 2000 characters or less."
#: app/validators.py:494 app/validators.py:853
#: app/validators.py:483 app/validators.py:854
msgid "Invalid coach selection."
msgstr "Invalid coach selection."
#: app/validators.py:500 app/validators.py:859
#: app/validators.py:489 app/validators.py:860
msgid "Invalid manager selection."
msgstr "Invalid manager selection."
#: app/validators.py:511
#: app/validators.py:507
msgid "Invalid player selection."
msgstr "Invalid player selection."
#: app/validators.py:515
#: app/validators.py:516
msgid "Unknown roster status."
msgstr "Unknown roster status."
#: app/validators.py:538
#: app/validators.py:539
msgid "Date must be in YYYY-MM-DD format."
msgstr "Date must be in YYYY-MM-DD format."
#: app/validators.py:539
#: app/validators.py:540
msgid "A date is required."
msgstr "A date is required."
#: app/validators.py:545 app/validators.py:610
#: app/validators.py:546 app/validators.py:611
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
#: app/validators.py:547 app/validators.py:612
msgid "A start time is required."
msgstr "A start time is required."
#: app/validators.py:552
#: app/validators.py:553
msgid "End time must be in HH:MM format."
msgstr "End time must be in HH:MM format."
#: app/validators.py:553
#: app/validators.py:554
msgid "An end time is required."
msgstr "An end time is required."
#: app/validators.py:557
#: app/validators.py:558
msgid "Points must be 2000 characters or less."
msgstr "Points must be 2000 characters or less."
#: app/validators.py:572
#: app/validators.py:573
msgid "End time must be after start time."
msgstr "End time must be after start time."
#: app/validators.py:601 app/validators.py:603
#: app/validators.py:602 app/validators.py:604
msgid "Day must be 0 (Monday) to 6 (Sunday)."
msgstr "Day must be 0 (Monday) to 6 (Sunday)."
#: app/validators.py:604
#: app/validators.py:605
msgid "A day is required."
msgstr "A day is required."
#: app/validators.py:639
#: app/validators.py:640
msgid "Player selection is malformed."
msgstr "Player selection is malformed."
#: app/validators.py:665 app/validators.py:775
#: app/validators.py:666 app/validators.py:776
msgid "A title is required."
msgstr "A title is required."
#: app/validators.py:674
#: app/validators.py:675
msgid "Invalid date format."
msgstr "Invalid date format."
#: app/validators.py:679 app/validators.py:686
#: app/validators.py:680 app/validators.py:687
msgid "Invalid time format."
msgstr "Invalid time format."
#: app/validators.py:680
#: app/validators.py:681
msgid "Start time is required. Please select a time slot."
msgstr "Start time is required. Please select a time slot."
#: app/validators.py:694
#: app/validators.py:695
msgid "Unknown match status."
msgstr "Unknown match status."
#: app/validators.py:710
#: app/validators.py:711
msgid "The end time must come after the start time."
msgstr "The end time must come after the start time."
#: app/validators.py:724
#: app/validators.py:725
msgid "Unknown match type."
msgstr "Unknown match type."
#: app/validators.py:736
#: app/validators.py:737
msgid "A team cannot play against itself."
msgstr "A team cannot play against itself."
#: app/validators.py:784
#: app/validators.py:785
msgid "Unknown game."
msgstr "Unknown game."
#: app/validators.py:789
#: app/validators.py:790
msgid "Invalid start date format."
msgstr "Invalid start date format."
#: app/validators.py:790
#: app/validators.py:791
msgid "A start date is required."
msgstr "A start date is required."
#: app/validators.py:796
#: app/validators.py:797
msgid "Invalid end date format."
msgstr "Invalid end date format."
#: app/validators.py:804
#: app/validators.py:805
msgid "A tryout must allow at least one player."
msgstr "A tryout must allow at least one player."
#: app/validators.py:807
#: app/validators.py:808
msgid "The player limit must be a whole number."
msgstr "The player limit must be a whole number."
#: app/validators.py:819
#: app/validators.py:820
msgid "End date cannot be before start date."
msgstr "End date cannot be before start date."
#: app/validators.py:846 app/validators.py:847
#: app/validators.py:847 app/validators.py:848
msgid "Team name is required."
msgstr "Team name is required."
#: app/validators.py:871
#: app/validators.py:872
msgid "Scores run from 1 to 10."
msgstr "Scores run from 1 to 10."
#: app/validators.py:872
#: app/validators.py:873
msgid "A score must be a whole number from 1 to 10."
msgstr "A score must be a whole number from 1 to 10."
#: app/routes/auth.py:241
#: app/routes/auth.py:245
msgid "This account has been deactivated."
msgstr "This account has been deactivated."
#: app/routes/auth.py:276
#: app/routes/auth.py:280
#, python-format
msgid "Welcome back, %(username)s!"
msgstr "Welcome back, %(username)s!"
#: app/routes/auth.py:306
#: app/routes/auth.py:310
msgid ""
"Login unsuccessful. Please check your username and password, or ask a "
"president for help."
@@ -239,27 +239,32 @@ msgstr ""
"Login unsuccessful. Please check your username and password, or ask a "
"president for help."
#: app/routes/auth.py:363
#: app/routes/auth.py:376
msgid "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:329
#: app/routes/auth.py:411 app/routes/users/accounts.py:339
msgid "Username already exists."
msgstr "Username already exists."
#: app/routes/auth.py:392 app/routes/users/accounts.py:333
#: app/routes/auth.py:415 app/routes/users/accounts.py:343
msgid "Email already registered."
msgstr "Email already registered."
#: app/routes/auth.py:436
#: app/routes/auth.py:422 app/routes/auth.py:617
#: app/routes/users/accounts.py:121
msgid "This Discord account is already linked to another account."
msgstr "This Discord account is already linked to another account."
#: app/routes/auth.py:466
msgid "Your account has been created! You can now log in."
msgstr "Your account has been created! You can now log in."
#: app/routes/auth.py:457
#: app/routes/auth.py:491
msgid "Discord OAuth2 is not configured."
msgstr "Discord OAuth2 is not configured."
#: app/routes/auth.py:498
#: app/routes/auth.py:540
msgid ""
"Discord authorization could not be verified. Please start the connection "
"again from this page."
@@ -267,27 +272,35 @@ msgstr ""
"Discord authorization could not be verified. Please start the connection "
"again from this page."
#: app/routes/auth.py:507
#: app/routes/auth.py:549
msgid "Discord authorization failed. No code received."
msgstr "Discord authorization failed. No code received."
#: app/routes/auth.py:531
#: app/routes/auth.py:573
msgid "Failed to connect to Discord. Please try again."
msgstr "Failed to connect to Discord. Please try again."
#: app/routes/auth.py:535
#: app/routes/auth.py:577
msgid "Failed to obtain Discord access token."
msgstr "Failed to obtain Discord access token."
#: app/routes/auth.py:550
#: app/routes/auth.py:592 app/routes/auth.py:602
msgid "Failed to fetch Discord user profile."
msgstr "Failed to fetch Discord user profile."
#: app/routes/auth.py:597
#: app/routes/auth.py:609
msgid "Please log in to connect your Discord account."
msgstr "Please log in to connect your Discord account."
#: app/routes/auth.py:628
msgid "Discord account connected!"
msgstr "Discord account connected!"
#: app/routes/auth.py:675
msgid "Discord account connected! Your profile has been pre-filled."
msgstr "Discord account connected! Your profile has been pre-filled."
#: app/routes/auth.py:625
#: app/routes/auth.py:703
msgid "You have been logged out."
msgstr "You have been logged out."
@@ -323,8 +336,8 @@ msgstr "Evaluation updated!"
#: 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
#: app/routes/tryouts.py:527 app/routes/tryouts.py:563
#: app/routes/tryouts.py:582
msgid "Permission denied."
msgstr "Permission denied."
@@ -463,7 +476,7 @@ msgstr "Coach removed from %(name)s."
msgid "Manager removed from %(name)s."
msgstr "Manager removed from %(name)s."
#: app/routes/teams.py:490 app/routes/tryouts.py:484 app/routes/tryouts.py:585
#: app/routes/teams.py:490 app/routes/tryouts.py:489 app/routes/tryouts.py:593
msgid "Please select a player."
msgstr "Please select a player."
@@ -545,7 +558,7 @@ msgstr "This tryout is not accepting registrations."
msgid "You are already registered for this tryout."
msgstr "You are already registered for this tryout."
#: app/routes/tryouts.py:428 app/routes/tryouts.py:503
#: app/routes/tryouts.py:428 app/routes/tryouts.py:511
msgid "This tryout is full."
msgstr "This tryout is full."
@@ -562,47 +575,47 @@ msgstr "Tryout status updated to %(new_status)s."
msgid "Registration status updated."
msgstr "Registration status updated."
#: app/routes/tryouts.py:489
#: app/routes/tryouts.py:497
msgid "Can only register players."
msgstr "Can only register players."
#: app/routes/tryouts.py:495
#: app/routes/tryouts.py:503
#, python-format
msgid "%(username)s is already registered for this tryout."
msgstr "%(username)s is already registered for this tryout."
#: app/routes/tryouts.py:509
#: app/routes/tryouts.py:517
#, python-format
msgid "%(username)s registered for tryout!"
msgstr "%(username)s registered for tryout!"
#: app/routes/tryouts.py:545
#: app/routes/tryouts.py:553
#, python-format
msgid "%(username)s removed from tryout."
msgstr "%(username)s removed from tryout."
#: app/routes/tryouts.py:563
#: app/routes/tryouts.py:571
#, python-format
msgid "Team \"%(team_name)s\" created!"
msgstr "Team \"%(team_name)s\" created!"
#: app/routes/tryouts.py:594
#: app/routes/tryouts.py:602
msgid "That player is not registered for this tryout."
msgstr "That player is not registered for this tryout."
#: app/routes/tryouts.py:600
#: app/routes/tryouts.py:608
msgid "Player is already on this team."
msgstr "Player is already on this team."
#: app/routes/tryouts.py:605
#: app/routes/tryouts.py:613
msgid "Player added to team!"
msgstr "Player added to team!"
#: app/routes/tryouts.py:615
#: app/routes/tryouts.py:623
msgid "You do not have permission to delete this tryout."
msgstr "You do not have permission to delete this tryout."
#: app/routes/tryouts.py:651
#: app/routes/tryouts.py:659
msgid "Tryout deleted successfully."
msgstr "Tryout deleted successfully."
@@ -630,11 +643,11 @@ msgstr "Only the president can edit users."
msgid "Email already in use by another account."
msgstr "Email already in use by another account."
#: app/routes/users/accounts.py:122
#: app/routes/users/accounts.py:132
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."
#: app/routes/users/accounts.py:135
#: app/routes/users/accounts.py:145
msgid ""
"This is the last active president. Promote another account before "
"changing this one."
@@ -642,29 +655,29 @@ msgstr ""
"This is the last active president. Promote another account before "
"changing this one."
#: app/routes/users/accounts.py:214
#: app/routes/users/accounts.py:224
#, python-format
msgid "User %(username)s updated successfully!"
msgstr "User %(username)s updated successfully!"
#: app/routes/users/accounts.py:235
#: app/routes/users/accounts.py:245
msgid "Only the president can delete users."
msgstr "Only the president can delete users."
#: app/routes/users/accounts.py:239
#: app/routes/users/accounts.py:249
msgid "You cannot delete your own account."
msgstr "You cannot delete your own account."
#: app/routes/users/accounts.py:298
#: app/routes/users/accounts.py:308
#, python-format
msgid "User %(deleted_username)s has been removed."
msgstr "User %(deleted_username)s has been removed."
#: app/routes/users/accounts.py:309
#: app/routes/users/accounts.py:319
msgid "Only the president can create users."
msgstr "Only the president can create users."
#: app/routes/users/accounts.py:357
#: app/routes/users/accounts.py:367
#, python-format
msgid "User %(full_name)s created as %(role)s!"
msgstr "User %(full_name)s created as %(role)s!"
@@ -673,32 +686,32 @@ msgstr "User %(full_name)s created as %(role)s!"
msgid "Only coaches can manage availability."
msgstr "Only coaches can manage availability."
#: app/routes/users/contracts.py:84
#: app/routes/users/contracts.py:86
msgid "Only presidents, managers, and coaches can upload contracts."
msgstr "Only presidents, managers, and coaches can upload contracts."
#: app/routes/users/contracts.py:103
#: app/routes/users/contracts.py:105
msgid "You do not have permission to upload a contract for this player."
msgstr "You do not have permission to upload a contract for this player."
#: app/routes/users/contracts.py:143
#: app/routes/users/contracts.py:145
#, python-format
msgid "Contract uploaded successfully for %(username)s!"
msgstr "Contract uploaded successfully for %(username)s!"
#: app/routes/users/contracts.py:157
#: app/routes/users/contracts.py:159
msgid "Only the player can upload their signed contract."
msgstr "Only the player can upload their signed contract."
#: app/routes/users/contracts.py:175
#: app/routes/users/contracts.py:177
msgid "Signed contract uploaded successfully!"
msgstr "Signed contract uploaded successfully!"
#: app/routes/users/contracts.py:185 app/routes/users/contracts.py:200
#: app/routes/users/contracts.py:187 app/routes/users/contracts.py:202
msgid "You do not have permission to download this contract."
msgstr "You do not have permission to download this contract."
#: app/routes/users/contracts.py:203
#: app/routes/users/contracts.py:205
msgid "No signed contract available."
msgstr "No signed contract available."
@@ -796,15 +809,15 @@ msgstr "Only coaches can reject One on One requests."
msgid "One on One request from %(player)s has been rejected."
msgstr "One on One request from %(player)s has been rejected."
#: app/routes/users/profile.py:83
#: app/routes/users/profile.py:82
msgid "Username already taken."
msgstr "Username already taken."
#: app/routes/users/profile.py:93
#: app/routes/users/profile.py:92
msgid "Email already in use."
msgstr "Email already in use."
#: app/routes/users/profile.py:123
#: app/routes/users/profile.py:121
msgid "Profile updated successfully!"
msgstr "Profile updated successfully!"
@@ -953,7 +966,7 @@ msgstr "%(total)s in total"
msgid "Next"
msgstr "Next"
#: app/templates/layouts/base.html:48 app/templates/layouts/base.html:154
#: app/templates/layouts/base.html:48 app/templates/layouts/base.html:148
#: app/templates/pages/dashboard.html:2 app/templates/pages/dashboard.html:3
msgid "Dashboard"
msgstr "Dashboard"
@@ -995,38 +1008,34 @@ msgid "My Notes"
msgstr "My Notes"
#: app/templates/layouts/base.html:106
msgid "Availability"
msgstr "Availability"
#: app/templates/layouts/base.html:112
msgid "Notes & One on One"
msgstr "Notes & One on One"
#: app/templates/layouts/base.html:119 app/templates/pages/contracts.html:2
#: app/templates/layouts/base.html:113 app/templates/pages/contracts.html:2
#: app/templates/pages/contracts.html:3 app/templates/pages/profile.html:9
msgid "Contracts"
msgstr "Contracts"
#: app/templates/layouts/base.html:126 app/templates/pages/profile.html:2
#: app/templates/layouts/base.html:120 app/templates/pages/profile.html:2
#: app/templates/pages/profile.html:3
msgid "My Profile"
msgstr "My Profile"
#: app/templates/layouts/base.html:137
#: app/templates/layouts/base.html:131
msgid "Logout"
msgstr "Logout"
#: app/templates/layouts/base.html:158
#: app/templates/layouts/base.html:152
msgid "Toggle dark mode"
msgstr "Toggle dark mode"
#: app/templates/layouts/base.html:170 app/templates/layouts/base.html:189
#: app/templates/layouts/base.html:164 app/templates/layouts/base.html:183
msgid "Dismiss"
msgstr "Dismiss"
#: app/templates/layouts/base.html:199
msgid "Team Tryout Management System"
msgstr "Team Tryout Management System"
#: app/templates/layouts/base.html:193
msgid "UdeS team manager"
msgstr "UdeS team manager"
#: app/templates/layouts/macros.html:116
msgid "Close"
@@ -1209,6 +1218,10 @@ msgid "Loading availability grid..."
msgstr "Loading availability grid..."
#: app/templates/pages/coach_availability.html:19
msgid "Save Availability"
msgstr "Save Availability"
#: app/templates/pages/coach_availability.html:22
#: app/templates/pages/profile.html:200 app/templates/pages/profile.html:220
msgid "Clear All"
msgstr "Clear All"
@@ -1372,7 +1385,7 @@ msgid "Role"
msgstr "Role"
#: app/templates/pages/create_user.html:41 app/templates/pages/login.html:11
#: app/templates/pages/register.html:139
#: app/templates/pages/register.html:137
msgid "Password"
msgstr "Password"
@@ -1535,7 +1548,7 @@ msgstr "Edit Profile"
#: app/templates/pages/edit_profile.html:33
#: app/templates/pages/edit_user.html:43 app/templates/pages/profile.html:63
#: app/templates/pages/register.html:83
#: app/templates/pages/register.html:81
msgid "E-Sports Profile"
msgstr "E-Sports Profile"
@@ -1544,12 +1557,12 @@ msgid "Update your competitive gaming profile for tryouts."
msgstr "Update your competitive gaming profile for tryouts."
#: app/templates/pages/edit_profile.html:37
#: app/templates/pages/register.html:87
#: app/templates/pages/register.html:85
msgid "Games You Play"
msgstr "Games You Play"
#: app/templates/pages/edit_profile.html:47
#: app/templates/pages/register.html:101
#: app/templates/pages/register.html:99
msgid "Select all games you're signing in for."
msgstr "Select all games you're signing in for."
@@ -1593,48 +1606,43 @@ msgid "e.g. Name#1234"
msgstr "e.g. Name#1234"
#: app/templates/pages/edit_profile.html:87
#: app/templates/pages/edit_user.html:95
msgid "(for DMs)"
msgstr "(for DMs)"
#: app/templates/pages/register.html:57
msgid "Reconnect"
msgstr "Reconnect"
#: app/templates/pages/edit_profile.html:88
#: app/templates/pages/edit_user.html:96
msgid "Numeric ID (e.g. 123456789012345678)"
msgstr "Numeric ID (e.g. 123456789012345678)"
#: app/templates/pages/edit_profile.html:87
#: app/templates/pages/register.html:67
msgid "Connect Discord Account"
msgstr "Connect Discord Account"
#: app/templates/pages/edit_profile.html:89
#: app/templates/pages/edit_user.html:97
msgid "Enable Developer Mode in Discord → Right-click profile → Copy ID"
msgstr "Enable Developer Mode in Discord → Right-click profile → Copy ID"
#: app/templates/pages/edit_profile.html:94
#: app/templates/pages/edit_profile.html:93
#: app/templates/pages/edit_user.html:100
msgid "League OS Connection"
msgstr "League OS Connection"
#: app/templates/pages/edit_profile.html:95
#: app/templates/pages/edit_user.html:101 app/templates/pages/register.html:131
#: app/templates/pages/edit_profile.html:94
#: app/templates/pages/edit_user.html:101 app/templates/pages/register.html:129
msgid "League OS profile link or ID"
msgstr "League OS profile link or ID"
#: app/templates/pages/edit_profile.html:100
#: app/templates/pages/edit_profile.html:99
msgid "Change Password"
msgstr "Change Password"
#: app/templates/pages/edit_profile.html:101
#: app/templates/pages/edit_profile.html:100
msgid "Leave blank to keep your current password."
msgstr "Leave blank to keep your current password."
#: app/templates/pages/edit_profile.html:103
#: app/templates/pages/edit_profile.html:102
msgid "New Password"
msgstr "New Password"
#: app/templates/pages/edit_profile.html:104
#: app/templates/pages/edit_profile.html:103
#: app/templates/pages/edit_user.html:107
msgid "Enter new password"
msgstr "Enter new password"
#: app/templates/pages/edit_profile.html:109 app/templates/pages/teams.html:296
#: app/templates/pages/edit_profile.html:108 app/templates/pages/teams.html:296
msgid "Save Changes"
msgstr "Save Changes"
@@ -1650,6 +1658,18 @@ msgstr "Games"
msgid "Enter gamertag for each selected game to link to Tracker Network."
msgstr "Enter gamertag for each selected game to link to Tracker Network."
#: app/templates/pages/edit_user.html:95
msgid "(for DMs)"
msgstr "(for DMs)"
#: app/templates/pages/edit_user.html:96
msgid "Numeric ID (e.g. 123456789012345678)"
msgstr "Numeric ID (e.g. 123456789012345678)"
#: app/templates/pages/edit_user.html:97
msgid "Enable Developer Mode in Discord → Right-click profile → Copy ID"
msgstr "Enable Developer Mode in Discord → Right-click profile → Copy ID"
#: app/templates/pages/edit_user.html:106
msgid "(leave blank to keep current)"
msgstr "(leave blank to keep current)"
@@ -2365,7 +2385,7 @@ msgstr ""
"Select time slots when you're available for One on One sessions (8am to "
"10pm)."
#: app/templates/pages/profile.html:454
#: app/templates/pages/profile.html:460
msgid "Click or click-and-drag to select your available hours"
msgstr "Click or click-and-drag to select your available hours"
@@ -2413,11 +2433,7 @@ msgstr ""
msgid "Connected"
msgstr "Connected"
#: app/templates/pages/register.html:57
msgid "Reconnect"
msgstr "Reconnect"
#: app/templates/pages/register.html:63
#: app/templates/pages/register.html:61
msgid ""
"Discord connected. Game connections have been used to pre-fill your "
"profile below."
@@ -2426,50 +2442,46 @@ msgstr ""
"profile below."
#: app/templates/pages/register.html:69
msgid "Connect Discord Account"
msgstr "Connect Discord Account"
#: app/templates/pages/register.html:71
msgid "Connect to pre-fill your gamertags from Steam, Battle.net, Xbox, etc."
msgstr "Connect to pre-fill your gamertags from Steam, Battle.net, Xbox, etc."
#: app/templates/pages/register.html:74
#: app/templates/pages/register.html:72
msgid "Discord Username (Manual)"
msgstr "Discord Username (Manual)"
#: app/templates/pages/register.html:75
#: app/templates/pages/register.html:73
msgid "e.g. YourName"
msgstr "e.g. YourName"
#: app/templates/pages/register.html:84
#: app/templates/pages/register.html:82
msgid "Set up your competitive gaming profile for tryouts."
msgstr "Set up your competitive gaming profile for tryouts."
#: app/templates/pages/register.html:129
#: app/templates/pages/register.html:127
msgid "League OS Connection (Optional)"
msgstr "League OS Connection (Optional)"
#: app/templates/pages/register.html:133
#: app/templates/pages/register.html:131
msgid "Connect your League OS profile for organized play."
msgstr "Connect your League OS profile for organized play."
#: app/templates/pages/register.html:137
#: app/templates/pages/register.html:135
msgid "Security"
msgstr "Security"
#: app/templates/pages/register.html:140
#: app/templates/pages/register.html:138
msgid "Create a password"
msgstr "Create a password"
#: app/templates/pages/register.html:144
#: app/templates/pages/register.html:142
msgid "Confirm Password"
msgstr "Confirm Password"
#: app/templates/pages/register.html:145
#: app/templates/pages/register.html:143
msgid "Confirm your password"
msgstr "Confirm your password"
#: app/templates/pages/register.html:159
#: app/templates/pages/register.html:157
msgid "Create Account"
msgstr "Create Account"
@@ -2984,3 +2996,8 @@ msgstr "View Profile"
#~ msgid "Invalid date or time format."
#~ msgstr "Invalid date or time format."
#~ msgid "Availability"
#~ msgstr "Availability"
#~ msgid "Team Tryout Management System"
#~ msgstr "Team Tryout Management System"
Binary file not shown.
+166 -149
View File
@@ -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:39-0400\n"
"POT-Creation-Date: 2026-08-16 23:22-0400\n"
"PO-Revision-Date: 2026-08-07 20:22-0400\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: fr\n"
@@ -23,8 +23,8 @@ msgstr ""
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/routes/users/contracts.py:96
#: app/forms.py:37 app/routes/auth.py:228 app/routes/auth.py:387
#: app/routes/users/contracts.py:98
#, python-format
msgid "%(field)s: %(msg)s"
msgstr "%(field)s : %(msg)s"
@@ -63,7 +63,7 @@ msgstr "Le nom dutilisateur est obligatoire."
msgid "Password is required."
msgstr "Le mot de passe est obligatoire."
#: app/validators.py:227 app/validators.py:299
#: app/validators.py:227 app/validators.py:294
msgid "Username must be 3-80 characters."
msgstr "Le nom dutilisateur doit compter de 3 à 80 caractères."
@@ -71,169 +71,169 @@ msgstr "Le nom dutilisateur doit compter de 3 à 80 caractères."
msgid "Email must be 120 characters or less."
msgstr "Ladresse courriel ne doit pas dépasser 120 caractères."
#: app/validators.py:246 app/validators.py:314 app/validators.py:345
#: app/validators.py:409
#: app/validators.py:246 app/validators.py:309 app/validators.py:340
#: app/validators.py:403
msgid "Full name is required."
msgstr "Le nom complet est obligatoire."
#: app/validators.py:281
#: app/validators.py:276
msgid "Passwords do not match."
msgstr "Les mots de passe ne concordent pas."
#: app/validators.py:318 app/validators.py:353
#: app/validators.py:313 app/validators.py:348
msgid "Invalid role selected."
msgstr "Rôle sélectionné invalide."
#: app/validators.py:454
#: app/validators.py:443
msgid "Player must be selected."
msgstr "Vous devez choisir un joueur."
#: app/validators.py:457
#: app/validators.py:446
msgid "Notes must be 2000 characters or less."
msgstr "Les notes ne doivent pas dépasser 2000 caractères."
#: app/validators.py:494 app/validators.py:853
#: app/validators.py:483 app/validators.py:854
msgid "Invalid coach selection."
msgstr "Sélection de coach invalide."
#: app/validators.py:500 app/validators.py:859
#: app/validators.py:489 app/validators.py:860
msgid "Invalid manager selection."
msgstr "Sélection de gérant invalide."
#: app/validators.py:511
#: app/validators.py:507
msgid "Invalid player selection."
msgstr "Sélection de joueur invalide."
#: app/validators.py:515
#: app/validators.py:516
msgid "Unknown roster status."
msgstr "Statut d'effectif inconnu."
#: app/validators.py:538
#: app/validators.py:539
msgid "Date must be in YYYY-MM-DD format."
msgstr "La date doit être au format AAAA-MM-JJ."
#: app/validators.py:539
#: app/validators.py:540
msgid "A date is required."
msgstr "Une date est requise."
#: app/validators.py:545 app/validators.py:610
#: app/validators.py:546 app/validators.py:611
msgid "Start time must be in HH:MM format."
msgstr "Lheure de début doit être au format HH:MM."
#: app/validators.py:546 app/validators.py:611
#: app/validators.py:547 app/validators.py:612
msgid "A start time is required."
msgstr "Une heure de début est requise."
#: app/validators.py:552
#: app/validators.py:553
msgid "End time must be in HH:MM format."
msgstr "Lheure de fin doit être au format HH:MM."
#: app/validators.py:553
#: app/validators.py:554
msgid "An end time is required."
msgstr "Une heure de fin est requise."
#: app/validators.py:557
#: app/validators.py:558
msgid "Points must be 2000 characters or less."
msgstr "Les points ne doivent pas dépasser 2000 caractères."
#: app/validators.py:572
#: app/validators.py:573
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
#: app/validators.py:602 app/validators.py:604
msgid "Day must be 0 (Monday) to 6 (Sunday)."
msgstr "Le jour doit aller de 0 (lundi) à 6 (dimanche)."
#: app/validators.py:604
#: app/validators.py:605
msgid "A day is required."
msgstr "Un jour est requis."
#: app/validators.py:639
#: app/validators.py:640
msgid "Player selection is malformed."
msgstr "La sélection de joueurs est mal formée."
#: app/validators.py:665 app/validators.py:775
#: app/validators.py:666 app/validators.py:776
msgid "A title is required."
msgstr "Un titre est requis."
#: app/validators.py:674
#: app/validators.py:675
msgid "Invalid date format."
msgstr "Format de date invalide."
#: app/validators.py:679 app/validators.py:686
#: app/validators.py:680 app/validators.py:687
msgid "Invalid time format."
msgstr "Format dheure invalide."
#: app/validators.py:680
#: app/validators.py:681
msgid "Start time is required. Please select a time slot."
msgstr "Lheure de début est obligatoire. Choisissez une plage horaire."
#: app/validators.py:694
#: app/validators.py:695
msgid "Unknown match status."
msgstr "Statut de match inconnu."
#: app/validators.py:710
#: app/validators.py:711
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
#: app/validators.py:725
msgid "Unknown match type."
msgstr "Type de match inconnu."
#: app/validators.py:736
#: app/validators.py:737
msgid "A team cannot play against itself."
msgstr "Une équipe ne peut pas jouer contre elle-même."
#: app/validators.py:784
#: app/validators.py:785
msgid "Unknown game."
msgstr "Jeu inconnu."
#: app/validators.py:789
#: app/validators.py:790
msgid "Invalid start date format."
msgstr "Format de date de début invalide."
#: app/validators.py:790
#: app/validators.py:791
msgid "A start date is required."
msgstr "Une date de début est requise."
#: app/validators.py:796
#: app/validators.py:797
msgid "Invalid end date format."
msgstr "Format de date de fin invalide."
#: app/validators.py:804
#: app/validators.py:805
msgid "A tryout must allow at least one player."
msgstr "Une sélection doit accepter au moins un joueur."
#: app/validators.py:807
#: app/validators.py:808
msgid "The player limit must be a whole number."
msgstr "La limite de joueurs doit être un nombre entier."
#: app/validators.py:819
#: app/validators.py:820
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
#: app/validators.py:847 app/validators.py:848
msgid "Team name is required."
msgstr "Le nom de l’équipe est obligatoire."
#: app/validators.py:871
#: app/validators.py:872
msgid "Scores run from 1 to 10."
msgstr "Les notes vont de 1 à 10."
#: app/validators.py:872
#: app/validators.py:873
msgid "A score must be a whole number from 1 to 10."
msgstr "Une note doit être un nombre entier de 1 à 10."
#: app/routes/auth.py:241
#: app/routes/auth.py:245
msgid "This account has been deactivated."
msgstr "Ce compte a été désactivé."
#: app/routes/auth.py:276
#: app/routes/auth.py:280
#, python-format
msgid "Welcome back, %(username)s!"
msgstr "Bon retour, %(username)s !"
#: app/routes/auth.py:306
#: app/routes/auth.py:310
msgid ""
"Login unsuccessful. Please check your username and password, or ask a "
"president for help."
@@ -241,27 +241,32 @@ msgstr ""
"Échec de la connexion. Vérifiez le nom dutilisateur et le mot de passe, "
"ou demandez de laide à un président."
#: app/routes/auth.py:363
#: app/routes/auth.py:376
msgid "Your registration could not be processed. Please try again."
msgstr "Votre inscription n'a pas pu être traitée. Veuillez réessayer."
#: app/routes/auth.py:388 app/routes/users/accounts.py:329
#: app/routes/auth.py:411 app/routes/users/accounts.py:339
msgid "Username already exists."
msgstr "Ce nom dutilisateur est déjà pris."
#: app/routes/auth.py:392 app/routes/users/accounts.py:333
#: app/routes/auth.py:415 app/routes/users/accounts.py:343
msgid "Email already registered."
msgstr "Cette adresse courriel est déjà enregistrée."
#: app/routes/auth.py:436
#: app/routes/auth.py:422 app/routes/auth.py:617
#: app/routes/users/accounts.py:121
msgid "This Discord account is already linked to another account."
msgstr "Ce compte Discord est déjà lié à un autre compte."
#: app/routes/auth.py:466
msgid "Your account has been created! You can now log in."
msgstr "Votre compte a été créé. Vous pouvez maintenant vous connecter."
#: app/routes/auth.py:457
#: app/routes/auth.py:491
msgid "Discord OAuth2 is not configured."
msgstr "La connexion Discord nest pas configurée."
#: app/routes/auth.py:498
#: app/routes/auth.py:540
msgid ""
"Discord authorization could not be verified. Please start the connection "
"again from this page."
@@ -269,27 +274,35 @@ msgstr ""
"Lautorisation Discord na pas pu être vérifiée. Relancez la connexion "
"depuis cette page."
#: app/routes/auth.py:507
#: app/routes/auth.py:549
msgid "Discord authorization failed. No code received."
msgstr "Lautorisation Discord a échoué : aucun code reçu."
#: app/routes/auth.py:531
#: app/routes/auth.py:573
msgid "Failed to connect to Discord. Please try again."
msgstr "Impossible de joindre Discord. Veuillez réessayer."
#: app/routes/auth.py:535
#: app/routes/auth.py:577
msgid "Failed to obtain Discord access token."
msgstr "Impossible dobtenir le jeton daccès Discord."
#: app/routes/auth.py:550
#: app/routes/auth.py:592 app/routes/auth.py:602
msgid "Failed to fetch Discord user profile."
msgstr "Impossible de récupérer le profil Discord."
#: app/routes/auth.py:597
#: app/routes/auth.py:609
msgid "Please log in to connect your Discord account."
msgstr "Veuillez vous connecter pour lier votre compte Discord."
#: app/routes/auth.py:628
msgid "Discord account connected!"
msgstr "Compte Discord connecté !"
#: app/routes/auth.py:675
msgid "Discord account connected! Your profile has been pre-filled."
msgstr "Compte Discord connecté. Votre profil a été pré-rempli."
#: app/routes/auth.py:625
#: app/routes/auth.py:703
msgid "You have been logged out."
msgstr "Vous avez été déconnecté."
@@ -325,8 +338,8 @@ msgstr "Évaluation mise à jour."
#: 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
#: app/routes/tryouts.py:527 app/routes/tryouts.py:563
#: app/routes/tryouts.py:582
msgid "Permission denied."
msgstr "Accès refusé."
@@ -467,7 +480,7 @@ msgstr "Coach retiré de %(name)s."
msgid "Manager removed from %(name)s."
msgstr "Gérant retiré de %(name)s."
#: app/routes/teams.py:490 app/routes/tryouts.py:484 app/routes/tryouts.py:585
#: app/routes/teams.py:490 app/routes/tryouts.py:489 app/routes/tryouts.py:593
msgid "Please select a player."
msgstr "Veuillez choisir un joueur."
@@ -549,7 +562,7 @@ msgstr "Cette sélection naccepte pas dinscriptions."
msgid "You are already registered for this tryout."
msgstr "Vous êtes déjà inscrit à cette sélection."
#: app/routes/tryouts.py:428 app/routes/tryouts.py:503
#: app/routes/tryouts.py:428 app/routes/tryouts.py:511
msgid "This tryout is full."
msgstr "Cette sélection est complète."
@@ -566,47 +579,47 @@ msgstr "Statut de la sélection mis à jour : %(new_status)s."
msgid "Registration status updated."
msgstr "Statut dinscription mis à jour."
#: app/routes/tryouts.py:489
#: app/routes/tryouts.py:497
msgid "Can only register players."
msgstr "Seuls des joueurs peuvent être inscrits."
#: app/routes/tryouts.py:495
#: app/routes/tryouts.py:503
#, 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:509
#: app/routes/tryouts.py:517
#, python-format
msgid "%(username)s registered for tryout!"
msgstr "%(username)s est inscrit à la sélection."
#: app/routes/tryouts.py:545
#: app/routes/tryouts.py:553
#, python-format
msgid "%(username)s removed from tryout."
msgstr "%(username)s a été retiré de la sélection."
#: app/routes/tryouts.py:563
#: app/routes/tryouts.py:571
#, python-format
msgid "Team \"%(team_name)s\" created!"
msgstr "Équipe « %(team_name)s » créée."
#: app/routes/tryouts.py:594
#: app/routes/tryouts.py:602
msgid "That player is not registered for this tryout."
msgstr "Ce joueur nest pas inscrit à cette sélection."
#: app/routes/tryouts.py:600
#: app/routes/tryouts.py:608
msgid "Player is already on this team."
msgstr "Ce joueur est déjà dans cette équipe."
#: app/routes/tryouts.py:605
#: app/routes/tryouts.py:613
msgid "Player added to team!"
msgstr "Joueur ajouté à l’équipe."
#: app/routes/tryouts.py:615
#: app/routes/tryouts.py:623
msgid "You do not have permission to delete this tryout."
msgstr "Vous navez pas les droits pour supprimer cette sélection."
#: app/routes/tryouts.py:651
#: app/routes/tryouts.py:659
msgid "Tryout deleted successfully."
msgstr "Sélection supprimée."
@@ -634,13 +647,13 @@ msgstr "Seul le président peut modifier des utilisateurs."
msgid "Email already in use by another account."
msgstr "Cette adresse courriel est déjà utilisée par un autre compte."
#: app/routes/users/accounts.py:122
#: app/routes/users/accounts.py:132
msgid "You cannot change your own role. Ask another president to do it."
msgstr ""
"Vous ne pouvez pas modifier votre propre rôle. Demandez à un autre "
"président de le faire."
#: app/routes/users/accounts.py:135
#: app/routes/users/accounts.py:145
msgid ""
"This is the last active president. Promote another account before "
"changing this one."
@@ -648,29 +661,29 @@ msgstr ""
"Cest le dernier président actif. Promouvez un autre compte avant de "
"modifier celui-ci."
#: app/routes/users/accounts.py:214
#: app/routes/users/accounts.py:224
#, python-format
msgid "User %(username)s updated successfully!"
msgstr "Utilisateur %(username)s mis à jour."
#: app/routes/users/accounts.py:235
#: app/routes/users/accounts.py:245
msgid "Only the president can delete users."
msgstr "Seul le président peut supprimer des utilisateurs."
#: app/routes/users/accounts.py:239
#: app/routes/users/accounts.py:249
msgid "You cannot delete your own account."
msgstr "Vous ne pouvez pas supprimer votre propre compte."
#: app/routes/users/accounts.py:298
#: app/routes/users/accounts.py:308
#, python-format
msgid "User %(deleted_username)s has been removed."
msgstr "Lutilisateur %(deleted_username)s a été supprimé."
#: app/routes/users/accounts.py:309
#: app/routes/users/accounts.py:319
msgid "Only the president can create users."
msgstr "Seul le président peut créer des utilisateurs."
#: app/routes/users/accounts.py:357
#: app/routes/users/accounts.py:367
#, python-format
msgid "User %(full_name)s created as %(role)s!"
msgstr "Utilisateur %(full_name)s créé avec le rôle %(role)s."
@@ -679,32 +692,32 @@ msgstr "Utilisateur %(full_name)s créé avec le rôle %(role)s."
msgid "Only coaches can manage availability."
msgstr "Seuls les coachs peuvent gérer leurs disponibilités."
#: app/routes/users/contracts.py:84
#: app/routes/users/contracts.py:86
msgid "Only presidents, managers, and coaches can upload contracts."
msgstr "Seuls les présidents, gérants et coachs peuvent téléverser un contrat."
#: app/routes/users/contracts.py:103
#: app/routes/users/contracts.py:105
msgid "You do not have permission to upload a contract for this player."
msgstr "Vous navez pas les droits pour téléverser un contrat pour ce joueur."
#: app/routes/users/contracts.py:143
#: app/routes/users/contracts.py:145
#, python-format
msgid "Contract uploaded successfully for %(username)s!"
msgstr "Contrat téléversé pour %(username)s."
#: app/routes/users/contracts.py:157
#: app/routes/users/contracts.py:159
msgid "Only the player can upload their signed contract."
msgstr "Seul le joueur peut téléverser son contrat signé."
#: app/routes/users/contracts.py:175
#: app/routes/users/contracts.py:177
msgid "Signed contract uploaded successfully!"
msgstr "Contrat signé téléversé."
#: app/routes/users/contracts.py:185 app/routes/users/contracts.py:200
#: app/routes/users/contracts.py:187 app/routes/users/contracts.py:202
msgid "You do not have permission to download this contract."
msgstr "Vous navez pas les droits pour télécharger ce contrat."
#: app/routes/users/contracts.py:203
#: app/routes/users/contracts.py:205
msgid "No signed contract available."
msgstr "Aucun contrat signé disponible."
@@ -804,15 +817,15 @@ msgstr "Seuls les coachs peuvent refuser une demande de rencontre."
msgid "One on One request from %(player)s has been rejected."
msgstr "La demande de rencontre de %(player)s a été refusée."
#: app/routes/users/profile.py:83
#: app/routes/users/profile.py:82
msgid "Username already taken."
msgstr "Ce nom dutilisateur est déjà pris."
#: app/routes/users/profile.py:93
#: app/routes/users/profile.py:92
msgid "Email already in use."
msgstr "Cette adresse courriel est déjà utilisée."
#: app/routes/users/profile.py:123
#: app/routes/users/profile.py:121
msgid "Profile updated successfully!"
msgstr "Profil mis à jour."
@@ -959,7 +972,7 @@ msgstr "%(total)s au total"
msgid "Next"
msgstr "Suivant"
#: app/templates/layouts/base.html:48 app/templates/layouts/base.html:154
#: app/templates/layouts/base.html:48 app/templates/layouts/base.html:148
#: app/templates/pages/dashboard.html:2 app/templates/pages/dashboard.html:3
msgid "Dashboard"
msgstr "Tableau de bord"
@@ -1001,38 +1014,34 @@ msgid "My Notes"
msgstr "Mes notes"
#: app/templates/layouts/base.html:106
msgid "Availability"
msgstr "Disponibilités"
#: app/templates/layouts/base.html:112
msgid "Notes & One on One"
msgstr "Notes et rencontres individuelles"
#: app/templates/layouts/base.html:119 app/templates/pages/contracts.html:2
#: app/templates/layouts/base.html:113 app/templates/pages/contracts.html:2
#: app/templates/pages/contracts.html:3 app/templates/pages/profile.html:9
msgid "Contracts"
msgstr "Contrats"
#: app/templates/layouts/base.html:126 app/templates/pages/profile.html:2
#: app/templates/layouts/base.html:120 app/templates/pages/profile.html:2
#: app/templates/pages/profile.html:3
msgid "My Profile"
msgstr "Mon profil"
#: app/templates/layouts/base.html:137
#: app/templates/layouts/base.html:131
msgid "Logout"
msgstr "Déconnexion"
#: app/templates/layouts/base.html:158
#: app/templates/layouts/base.html:152
msgid "Toggle dark mode"
msgstr "Basculer le mode sombre"
#: app/templates/layouts/base.html:170 app/templates/layouts/base.html:189
#: app/templates/layouts/base.html:164 app/templates/layouts/base.html:183
msgid "Dismiss"
msgstr "Fermer"
#: app/templates/layouts/base.html:199
msgid "Team Tryout Management System"
msgstr "Système de gestion des sélections d’équipe"
#: app/templates/layouts/base.html:193
msgid "UdeS team manager"
msgstr "UdeS team manager"
#: app/templates/layouts/macros.html:116
msgid "Close"
@@ -1217,6 +1226,10 @@ msgid "Loading availability grid..."
msgstr "Chargement de la grille de disponibilités..."
#: app/templates/pages/coach_availability.html:19
msgid "Save Availability"
msgstr "Enregistrer les disponibilités"
#: app/templates/pages/coach_availability.html:22
#: app/templates/pages/profile.html:200 app/templates/pages/profile.html:220
msgid "Clear All"
msgstr "Tout effacer"
@@ -1378,7 +1391,7 @@ msgid "Role"
msgstr "Rôle"
#: app/templates/pages/create_user.html:41 app/templates/pages/login.html:11
#: app/templates/pages/register.html:139
#: app/templates/pages/register.html:137
msgid "Password"
msgstr "Mot de passe"
@@ -1541,7 +1554,7 @@ msgstr "Modifier le profil"
#: app/templates/pages/edit_profile.html:33
#: app/templates/pages/edit_user.html:43 app/templates/pages/profile.html:63
#: app/templates/pages/register.html:83
#: app/templates/pages/register.html:81
msgid "E-Sports Profile"
msgstr "Profil e-sport"
@@ -1550,12 +1563,12 @@ msgid "Update your competitive gaming profile for tryouts."
msgstr "Mettez à jour votre profil de joueur compétitif pour les sélections."
#: app/templates/pages/edit_profile.html:37
#: app/templates/pages/register.html:87
#: app/templates/pages/register.html:85
msgid "Games You Play"
msgstr "Jeux auxquels vous jouez"
#: app/templates/pages/edit_profile.html:47
#: app/templates/pages/register.html:101
#: app/templates/pages/register.html:99
msgid "Select all games you're signing in for."
msgstr "Sélectionnez tous les jeux pour lesquels vous vous inscrivez."
@@ -1599,50 +1612,43 @@ msgid "e.g. Name#1234"
msgstr "ex. : Nom#1234"
#: app/templates/pages/edit_profile.html:87
#: app/templates/pages/edit_user.html:95
msgid "(for DMs)"
msgstr "(pour les messages privés)"
#: app/templates/pages/register.html:57
msgid "Reconnect"
msgstr "Reconnecter"
#: app/templates/pages/edit_profile.html:88
#: app/templates/pages/edit_user.html:96
msgid "Numeric ID (e.g. 123456789012345678)"
msgstr "Identifiant numérique (ex. : 123456789012345678)"
#: app/templates/pages/edit_profile.html:87
#: app/templates/pages/register.html:67
msgid "Connect Discord Account"
msgstr "Connecter un compte Discord"
#: app/templates/pages/edit_profile.html:89
#: app/templates/pages/edit_user.html:97
msgid "Enable Developer Mode in Discord → Right-click profile → Copy ID"
msgstr ""
"Activez le mode développeur dans Discord → clic droit sur le profil → "
"Copier lidentifiant"
#: app/templates/pages/edit_profile.html:94
#: app/templates/pages/edit_profile.html:93
#: app/templates/pages/edit_user.html:100
msgid "League OS Connection"
msgstr "Connexion League OS"
#: app/templates/pages/edit_profile.html:95
#: app/templates/pages/edit_user.html:101 app/templates/pages/register.html:131
#: app/templates/pages/edit_profile.html:94
#: app/templates/pages/edit_user.html:101 app/templates/pages/register.html:129
msgid "League OS profile link or ID"
msgstr "Lien ou identifiant du profil League OS"
#: app/templates/pages/edit_profile.html:100
#: app/templates/pages/edit_profile.html:99
msgid "Change Password"
msgstr "Changer le mot de passe"
#: app/templates/pages/edit_profile.html:101
#: app/templates/pages/edit_profile.html:100
msgid "Leave blank to keep your current password."
msgstr "Laissez vide pour conserver votre mot de passe actuel."
#: app/templates/pages/edit_profile.html:103
#: app/templates/pages/edit_profile.html:102
msgid "New Password"
msgstr "Nouveau mot de passe"
#: app/templates/pages/edit_profile.html:104
#: app/templates/pages/edit_profile.html:103
#: app/templates/pages/edit_user.html:107
msgid "Enter new password"
msgstr "Saisir le nouveau mot de passe"
#: app/templates/pages/edit_profile.html:109 app/templates/pages/teams.html:296
#: app/templates/pages/edit_profile.html:108 app/templates/pages/teams.html:296
msgid "Save Changes"
msgstr "Enregistrer les modifications"
@@ -1658,6 +1664,20 @@ msgstr "Jeux"
msgid "Enter gamertag for each selected game to link to Tracker Network."
msgstr "Saisissez un pseudo par jeu sélectionné pour le lier à Tracker Network."
#: app/templates/pages/edit_user.html:95
msgid "(for DMs)"
msgstr "(pour les messages privés)"
#: app/templates/pages/edit_user.html:96
msgid "Numeric ID (e.g. 123456789012345678)"
msgstr "Identifiant numérique (ex. : 123456789012345678)"
#: app/templates/pages/edit_user.html:97
msgid "Enable Developer Mode in Discord → Right-click profile → Copy ID"
msgstr ""
"Activez le mode développeur dans Discord → clic droit sur le profil → "
"Copier lidentifiant"
#: app/templates/pages/edit_user.html:106
msgid "(leave blank to keep current)"
msgstr "(laisser vide pour conserver lactuel)"
@@ -2378,7 +2398,7 @@ msgstr ""
"Choisissez les plages où vous êtes disponible pour des rencontres "
"individuelles (8 h à 22 h)."
#: app/templates/pages/profile.html:454
#: app/templates/pages/profile.html:460
msgid "Click or click-and-drag to select your available hours"
msgstr "Cliquez ou faites glisser pour choisir vos heures de disponibilité"
@@ -2426,11 +2446,7 @@ msgstr ""
msgid "Connected"
msgstr "Connecté"
#: app/templates/pages/register.html:57
msgid "Reconnect"
msgstr "Reconnecter"
#: app/templates/pages/register.html:63
#: app/templates/pages/register.html:61
msgid ""
"Discord connected. Game connections have been used to pre-fill your "
"profile below."
@@ -2439,52 +2455,48 @@ msgstr ""
"ci-dessous."
#: app/templates/pages/register.html:69
msgid "Connect Discord Account"
msgstr "Connecter un compte Discord"
#: app/templates/pages/register.html:71
msgid "Connect to pre-fill your gamertags from Steam, Battle.net, Xbox, etc."
msgstr ""
"Connectez-vous pour pré-remplir vos pseudos depuis Steam, Battle.net, "
"Xbox, etc."
#: app/templates/pages/register.html:74
#: app/templates/pages/register.html:72
msgid "Discord Username (Manual)"
msgstr "Nom dutilisateur Discord (saisie manuelle)"
#: app/templates/pages/register.html:75
#: app/templates/pages/register.html:73
msgid "e.g. YourName"
msgstr "ex. : VotrePseudo"
#: app/templates/pages/register.html:84
#: app/templates/pages/register.html:82
msgid "Set up your competitive gaming profile for tryouts."
msgstr "Configurez votre profil de joueur compétitif pour les sélections."
#: app/templates/pages/register.html:129
#: app/templates/pages/register.html:127
msgid "League OS Connection (Optional)"
msgstr "Connexion League OS (facultative)"
#: app/templates/pages/register.html:133
#: app/templates/pages/register.html:131
msgid "Connect your League OS profile for organized play."
msgstr "Liez votre profil League OS pour le jeu organisé."
#: app/templates/pages/register.html:137
#: app/templates/pages/register.html:135
msgid "Security"
msgstr "Sécurité"
#: app/templates/pages/register.html:140
#: app/templates/pages/register.html:138
msgid "Create a password"
msgstr "Créez un mot de passe"
#: app/templates/pages/register.html:144
#: app/templates/pages/register.html:142
msgid "Confirm Password"
msgstr "Confirmer le mot de passe"
#: app/templates/pages/register.html:145
#: app/templates/pages/register.html:143
msgid "Confirm your password"
msgstr "Confirmez votre mot de passe"
#: app/templates/pages/register.html:159
#: app/templates/pages/register.html:157
msgid "Create Account"
msgstr "Créer le compte"
@@ -3008,3 +3020,8 @@ msgstr "Voir le profil"
#~ msgid "Invalid date or time format."
#~ msgstr "Format de date ou dheure invalide."
#~ msgid "Availability"
#~ msgstr "Disponibilités"
#~ msgid "Team Tryout Management System"
#~ msgstr "Système de gestion des sélections d’équipe"
-11
View File
@@ -256,11 +256,6 @@ class RegisterSchema(StripMixin):
allow_none=True,
load_default=None,
)
discord_user_id = fields.String(
validate=validate_discord_user_id,
allow_none=True,
load_default=None,
)
league_os_profile = fields.String(
validate=validate.Length(max=256),
allow_none=True,
@@ -392,7 +387,6 @@ class EditProfileSchema(StripMixin):
phone: Optional.
password: Optional (only if changing).
discord_username: Optional.
discord_user_id: Optional.
league_os_profile: Optional.
games: Optional list.
"""
@@ -428,11 +422,6 @@ class EditProfileSchema(StripMixin):
allow_none=True,
load_default=None,
)
discord_user_id = fields.String(
validate=validate_discord_user_id,
allow_none=True,
load_default=None,
)
league_os_profile = fields.String(
validate=validate.Length(max=256),
allow_none=True,
+13 -4
View File
@@ -1,8 +1,9 @@
# Le schéma réel, et comment sortir de `create_all()`
> **État au 2026-08-11** : l'outil de relevé existe et est testé. Le relevé
> lui-même n'a pas été exécuté — il demande un accès à la base de production,
> qui ne peut pas venir du dépôt. Tout ce qui suit attend cette exécution.
> **État au 2026-08-16** : l'outil de relevé existe et est testé, y compris
> pour les collisions d'identité Discord. Le relevé lui-même n'a pas été
> exécuté — il demande un accès à la base de production, qui ne peut pas venir
> du dépôt. Tout changement de schéma ci-dessous attend cette exécution.
## Pourquoi c'est le nœud
@@ -60,6 +61,14 @@ existe-t-il encore ? (`SEC-003`) :
python app/supporting_scripts/schema_report.py --check-seed-accounts
```
L'identité Discord est désormais conservée côté serveur et toute nouvelle
collision est refusée par l'application. Les doublons historiques restent à
identifier avant d'ajouter la contrainte `UNIQUE` de `SEC-012` :
```bash
python app/supporting_scripts/schema_report.py --check-discord-identities
```
## Étape 3 — Alembic, décrivant le schéma **réel** (`DB-002`)
Le piège de cette étape tient en une phrase : **la migration initiale doit
@@ -111,7 +120,7 @@ Dans cet ordre, parce qu'ils dépendent tous de `DB-002` :
| `DB-008` | Trancher `attendance_confirmed` côté tryout | `discord_bot.py` écrit un attribut fantôme ; aujourd'hui journalisé en avertissement |
| `DB-009` | Horodatages avec fuseau | `datetime.utcnow` partout, déprécié en 3.12 |
| `ARCH-001` | Fusionner coach/équipe sur la relation m2m | Migration de données ; `app/permissions.py` rend la duplication inoffensive **en lecture** seulement, l'écriture crée toujours les deux |
| `SEC-012` | Identité Discord côté serveur, `unique=True` | La colonne doit être unique, donc dédoublonnée d'abord |
| `SEC-012` | Ajouter `unique=True` sur l'identité Discord | La valeur OAuth reste côté serveur et les nouvelles collisions sont refusées ; les lignes historiques doivent être dédoublonnées d'abord |
## Ce qu'on ne fait pas
+222
View File
@@ -0,0 +1,222 @@
"""The Discord snowflake is a verified identity, not profile text.
SEC-AUTH-005. Discord OAuth used to put its result in two hidden inputs;
registration then trusted those client-controlled values, and edit_profile
let the account owner replace the snowflake later. The bot uses that value to
route private messages and authorize reaction-driven writes.
"""
import time
from urllib.parse import parse_qs, urlparse
from app.routes import auth as auth_module
from app.routes.auth import (
MIN_REGISTRATION_SECONDS,
REGISTRATION_ISSUED_KEY,
)
FORM = {
'username': 'brandnew',
'email': '[email protected]',
'password': 'Password123',
'confirm_password': 'Password123',
'full_name': 'Brand New',
}
def _allow_registration(client):
with client.session_transaction() as session:
session[REGISTRATION_ISSUED_KEY] = time.time() - MIN_REGISTRATION_SECONDS - 1
def _user(app, username='brandnew'):
from app.models import User
with app.app_context():
return User.query.filter_by(username=username).first()
class _DiscordResponse:
def __init__(self, payload):
self.payload = payload
def raise_for_status(self):
return None
def json(self):
return self.payload
def _complete_profile_oauth(client, monkeypatch, discord_user_id):
monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_ID', 'client-id')
monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_SECRET', 'client-secret')
monkeypatch.setattr(
auth_module,
'DISCORD_REDIRECT_URI',
'https://example.test/auth/discord/callback',
)
start = client.get('/auth/discord/login')
state = parse_qs(urlparse(start.headers['Location']).query)['state'][0]
monkeypatch.setattr(
auth_module.requests,
'post',
lambda *args, **kwargs: _DiscordResponse({'access_token': 'token'}),
)
monkeypatch.setattr(
auth_module.requests,
'get',
lambda *args, **kwargs: _DiscordResponse(
{'id': discord_user_id, 'username': 'verified-user'}
),
)
return client.get(f'/auth/discord/callback?code=code&state={state}')
class TestRegistrationIdentity:
def test_a_posted_snowflake_without_oauth_is_ignored(self, app, client):
_allow_registration(client)
client.post(
'/auth/register',
data=dict(FORM, discord_user_id='111111111111111111'),
)
assert _user(app).discord_user_id is None
def test_the_server_side_oauth_identity_wins_over_the_form(self, app, client):
verified = '222222222222222222'
_allow_registration(client)
with client.session_transaction() as session:
session['discord_oauth'] = {
'id': verified,
'username': 'verified-user',
}
client.post(
'/auth/register',
data=dict(
FORM,
discord_user_id='111111111111111111',
discord_username='forged-user',
),
)
created = _user(app)
assert created.discord_user_id == verified
assert created.discord_username == 'verified-user'
def test_an_oauth_identity_already_in_use_is_refused(self, app, client, make_user):
verified = '222222222222222222'
make_user('player', discord_user_id=verified)
_allow_registration(client)
with client.session_transaction() as session:
session['discord_oauth'] = {
'id': verified,
'username': 'verified-user',
}
client.post('/auth/register', data=FORM)
assert _user(app) is None
def test_the_registration_page_has_no_client_identity_field(self, client):
with client.session_transaction() as session:
session['discord_oauth'] = {
'id': '222222222222222222',
'username': 'verified-user',
}
page = client.get('/auth/register').get_data(as_text=True)
assert 'name="discord_user_id"' not in page
class TestProfileIdentity:
def test_a_profile_post_cannot_replace_the_verified_snowflake(self, app, client, as_role):
original = '222222222222222222'
user_id = as_role('player', discord_user_id=original)
client.post(
'/users/profile/edit',
data={
'username': 'player1',
'full_name': 'Player 1',
'email': '[email protected]',
'discord_user_id': '111111111111111111',
},
)
from app.models import User
with app.app_context():
assert (
app.extensions['sqlalchemy'].session.get(User, user_id).discord_user_id == original
)
def test_the_profile_form_does_not_offer_the_snowflake(self, client, as_role):
as_role('player', discord_user_id='222222222222222222')
page = client.get('/users/profile/edit').get_data(as_text=True)
assert 'name="discord_user_id"' not in page
assert '/auth/discord/login' in page
def test_a_signed_in_user_can_relink_only_through_oauth(
self, app, client, as_role, monkeypatch
):
user_id = as_role('player', discord_user_id='111111111111111111')
response = _complete_profile_oauth(
client,
monkeypatch,
discord_user_id='222222222222222222',
)
from app.models import User
assert '/users/profile/edit' in response.headers['Location']
with app.app_context():
user = app.extensions['sqlalchemy'].session.get(User, user_id)
assert user.discord_user_id == '222222222222222222'
assert user.discord_username == 'verified-user'
def test_profile_oauth_refuses_an_identity_owned_by_someone_else(
self, app, client, as_role, make_user, monkeypatch
):
taken = '222222222222222222'
make_user('player', discord_user_id=taken)
user_id = as_role('player', discord_user_id='111111111111111111')
_complete_profile_oauth(client, monkeypatch, discord_user_id=taken)
from app.models import User
with app.app_context():
assert (
app.extensions['sqlalchemy'].session.get(User, user_id).discord_user_id
== '111111111111111111'
)
class TestAdministrativeFallback:
def test_an_admin_cannot_assign_a_snowflake_twice(self, app, client, as_role, make_user):
taken = '222222222222222222'
make_user('player', discord_user_id=taken)
target_id = make_user('player')
as_role('admin')
client.post(
f'/users/{target_id}/edit',
data={
'full_name': 'Target Player',
'email': '[email protected]',
'role': 'player',
'discord_user_id': taken,
},
)
from app.models import User
with app.app_context():
assert app.extensions['sqlalchemy'].session.get(User, target_id).discord_user_id is None
+61
View File
@@ -202,6 +202,67 @@ class TestCatalogueIntegrity:
f'{len(untranslated)} untranslated string(s) in {locale}: {untranslated[:5]}'
)
# Babel keeps its guessed translation when it marks an entry fuzzy,
# but gettext deliberately ignores that guess at runtime. Merely
# checking m.string therefore let five English fallbacks through after
# the branding merge, including a dangerously wrong French label.
fuzzy = [m.id for m in catalog if m.id and 'fuzzy' in m.flags]
assert not fuzzy, f'{len(fuzzy)} fuzzy string(s) in {locale}: {fuzzy[:5]}'
def test_the_catalogue_contains_every_message_in_the_source(self, tmp_path):
"""A translated PO can still be stale.
The previous guard only inspected entries already in the catalogue.
A new `_()` in Python or Jinja therefore stayed English without any
failure until somebody happened to run extraction by hand.
"""
import os
import subprocess
import sys
from babel.messages.pofile import read_po
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
extracted_path = tmp_path / 'messages.pot'
subprocess.run(
[
sys.executable,
'-m',
'babel.messages.frontend',
'extract',
'-F',
'babel.cfg',
'-k',
'_l',
'-o',
str(extracted_path),
'.',
],
cwd=root,
check=True,
capture_output=True,
text=True,
)
with open(extracted_path, encoding='utf-8') as handle:
source_ids = {message.id for message in read_po(handle) if message.id}
for locale in SUPPORTED_LOCALES:
path = os.path.join(
root,
'app',
'translations',
locale,
'LC_MESSAGES',
'messages.po',
)
with open(path, encoding='utf-8') as handle:
catalogue_ids = {message.id for message in read_po(handle) if message.id}
missing = source_ids - catalogue_ids
assert not missing, (
f'{len(missing)} source string(s) absent from {locale}: '
f'{sorted(missing, key=str)[:5]}'
)
class TestSelectorUnit:
def test_select_locale_returns_a_supported_code(self, app):
+33
View File
@@ -257,3 +257,36 @@ class TestSeedAccountCheck:
output = capsys.readouterr().out
assert 'password has been changed' in output
assert 'PASSWORD IS STILL' not in output
class TestDiscordIdentityCheck:
def test_it_finds_identity_collisions_before_the_unique_migration(self, live_db, app, capsys):
engine, _tamper = live_db
path = str(engine.url).replace('sqlite:///', '')
connection = sqlite3.connect(path)
for username in ('alice', 'bob'):
connection.execute(
'INSERT INTO users '
'(username, password_hash, role, full_name, email, '
'is_active_account, discord_user_id) '
"VALUES (?, 'hash', 'player', ?, ?, 1, '222222222222222222')",
(username, username.title(), f'{username}@example.test'),
)
connection.commit()
connection.close()
with app.app_context():
result = main(['--url', str(engine.url), '--check-discord-identities'])
output = capsys.readouterr().out
assert result == 1
assert '222222222222222222: 2 accounts (alice, bob)' in output
def test_a_clean_identity_set_does_not_change_the_exit_code(self, live_db, app, capsys):
engine, _tamper = live_db
with app.app_context():
result = main(['--url', str(engine.url), '--check-discord-identities'])
assert result == 0
assert 'No Discord identity is shared' in capsys.readouterr().out
+46 -10
View File
@@ -92,7 +92,7 @@ class TestPlayerAvailability:
assert PlayerDisponibility.query.count() == 0
class TestBulkAvailabilityReportsWhatItDropped:
class TestBulkAvailabilityReplacementIsAtomic:
def test_valid_slots_are_saved(self, app, client, as_role):
from app.models import PlayerDisponibility
@@ -107,28 +107,64 @@ class TestBulkAvailabilityReportsWhatItDropped:
with app.app_context():
assert PlayerDisponibility.query.count() == 1
def test_a_dropped_slot_is_named(self, app, client, as_role):
"""It used to `continue` and answer success, so the client could not
tell nine saved from ten sent."""
def test_a_good_payload_replaces_the_slots(self, app, client, as_role):
from app.models import PlayerDisponibility
as_role('player')
player_id = as_role('player')
client.post(
'/users/disponibilities/add_bulk',
json={'slots': [{'day_of_week': 1, 'start_time': '09:00'}]},
)
response = client.post(
'/users/disponibilities/add_bulk',
json={'slots': [{'day_of_week': 3, 'start_time': '18:00'}]},
)
assert response.status_code == 200
with app.app_context():
slots = PlayerDisponibility.query.filter_by(player_id=player_id).all()
assert [(s.day_of_week, s.start_time) for s in slots] == [(3, time(18, 0))]
def test_an_empty_payload_clears_the_slots(self, app, client, as_role):
from app.models import PlayerDisponibility
player_id = as_role('player')
client.post(
'/users/disponibilities/add_bulk',
json={'slots': [{'day_of_week': 1, 'start_time': '09:00'}]},
)
response = client.post('/users/disponibilities/add_bulk', json={'slots': []})
assert response.status_code == 200
with app.app_context():
assert PlayerDisponibility.query.filter_by(player_id=player_id).count() == 0
def test_a_malformed_slot_changes_nothing(self, app, client, as_role):
from app.models import PlayerDisponibility
player_id = as_role('player')
client.post(
'/users/disponibilities/add_bulk',
json={'slots': [{'day_of_week': 1, 'start_time': '09:00'}]},
)
response = client.post(
'/users/disponibilities/add_bulk',
json={
'slots': [
{'day_of_week': 1, 'start_time': '09:00'},
{'day_of_week': 3, 'start_time': '18:00'},
{'day_of_week': 1, 'start_time': 'nope'},
]
},
)
body = response.get_json()
assert len(body['created']) == 1
assert len(body['rejected']) == 1, 'the caller must learn a slot was dropped'
assert response.status_code == 400
assert len(response.get_json()['rejected']) == 1
with app.app_context():
assert PlayerDisponibility.query.count() == 1
slots = PlayerDisponibility.query.filter_by(player_id=player_id).all()
assert [(s.day_of_week, s.start_time) for s in slots] == [(1, time(9, 0))]
class TestCoachAvailabilityIsNotWipedByABadPayload: