fix(auth): garder l identite Discord du cote verifie
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
@@ -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.
@@ -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 d’utilisateur 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 d’utilisateur doit compter de 3 à 80 caractères."
|
||||
|
||||
@@ -71,169 +71,169 @@ msgstr "Le nom d’utilisateur doit compter de 3 à 80 caractères."
|
||||
msgid "Email must be 120 characters or less."
|
||||
msgstr "L’adresse 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 "L’heure 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 "L’heure 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 d’heure invalide."
|
||||
|
||||
#: app/validators.py:680
|
||||
#: app/validators.py:681
|
||||
msgid "Start time is required. Please select a time slot."
|
||||
msgstr "L’heure de début est obligatoire. Choisissez une plage horaire."
|
||||
|
||||
#: app/validators.py:694
|
||||
#: 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 d’utilisateur et le mot de passe, "
|
||||
"ou demandez de l’aide à 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 d’utilisateur 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 n’est 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 ""
|
||||
"L’autorisation Discord n’a 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 "L’autorisation 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 d’obtenir le jeton d’accè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 n’accepte pas d’inscriptions."
|
||||
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 d’inscription 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 n’est 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 n’avez 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 ""
|
||||
"C’est 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 "L’utilisateur %(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 n’avez 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 n’avez 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 d’utilisateur 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 l’identifiant"
|
||||
|
||||
#: 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 l’identifiant"
|
||||
|
||||
#: app/templates/pages/edit_user.html:106
|
||||
msgid "(leave blank to keep current)"
|
||||
msgstr "(laisser vide pour conserver l’actuel)"
|
||||
@@ -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 d’utilisateur 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 d’heure invalide."
|
||||
|
||||
#~ msgid "Availability"
|
||||
#~ msgstr "Disponibilités"
|
||||
|
||||
#~ msgid "Team Tryout Management System"
|
||||
#~ msgstr "Système de gestion des sélections d’équipe"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user