2 Commits
Author SHA1 Message Date
cedrick2711 e4a271cfd8 changer gestion des team intra tryout
CI - Security, Lint & Tests / validate (push) Failing after 1m4s
2026-09-05 14:38:31 -04:00
cedrick2711 742995d3cd fix probleme de match dans les calendriers et ajout de boutton delete pour les matchs
CI - Security, Lint & Tests / validate (push) Failing after 1m0s
2026-09-05 12:50:48 -04:00
6 changed files with 597 additions and 105 deletions
+76 -12
View File
@@ -26,11 +26,13 @@ from app.models import (
PlayerDisponibility,
Scout,
Team,
TeamMatch,
TeamMember,
Tryout,
TryoutRegistration,
User,
)
from app.permissions import visible_org_teams
from app.services.scheduling import notify_participants, zip_participants
from app.validators import MatchEditSchema, MatchSchema
@@ -74,6 +76,25 @@ def default_end_time(date, start_time):
return (datetime.combine(date, start_time) + timedelta(minutes=DEFAULT_MATCH_MINUTES)).time()
def calendar_event_time_fields(date, start_time, end_time):
"""FullCalendar date/time fields for a match.
A date-only event renders in the all-day row; a ``start``/``end`` with a
time places it on the clock for the same calendar day. Matches, team
matches and one-on-one sessions are stored as a date plus optional time
columns, so the feed translates that into the shape FullCalendar expects.
"""
if not start_time:
return {'date': date.strftime('%Y-%m-%d'), 'allDay': True}
fields = {
'start': f'{date.isoformat()}T{start_time.strftime("%H:%M:%S")}',
'allDay': False,
}
if end_time:
fields['end'] = f'{date.isoformat()}T{end_time.strftime("%H:%M:%S")}'
return fields
def create_participants(match, data):
"""Attach participants to a match, per its type.
@@ -165,13 +186,17 @@ def api_events():
president — and then issue one MatchParticipant query per match to find
out whether the viewer was in it. The calendar's cost grew with the
whole history, on every navigation.
Tryout matches, regular-season team matches and approved one-on-one
sessions share this feed. Timed rows carry `start`/`end` so
FullCalendar places them on the clock instead of the all-day row.
"""
events = []
tryouts = get_visible_tryouts_for_user()
window_start, window_end = calendar_window(request.args)
tryouts_by_id = {tryout.id: tryout for tryout in tryouts}
if tryouts_by_id:
window_start, window_end = calendar_window(request.args)
query = Match.query.filter(Match.tryout_id.in_(tryouts_by_id))
if window_start:
query = query.filter(Match.date >= window_start)
@@ -225,11 +250,9 @@ def api_events():
user_participant = mine_by_match.get(match.id)
events.append(
{
event = {
'id': f'match_{match.id}',
'title': match.title,
'date': match.date.strftime('%Y-%m-%d'),
'type': 'match',
'color': match_color,
'extendedProps': {
@@ -248,7 +271,50 @@ def api_events():
else False,
},
}
event.update(calendar_event_time_fields(match.date, match.start_time, match.end_time))
events.append(event)
# Regular-season team matches: a separate table from tryout matches, but
# the same calendar. Visible to the roles that can see the org team.
if isinstance(current_user, (Admin, Manager)):
team_query = TeamMatch.query
elif isinstance(current_user, (Coach, Player)):
team_ids = [team.id for team in visible_org_teams(current_user)]
team_query = (
TeamMatch.query.filter(TeamMatch.org_team_id.in_(team_ids))
if team_ids
else TeamMatch.query.filter(TeamMatch.id == -1)
)
else:
team_query = TeamMatch.query.filter(TeamMatch.id == -1)
if window_start:
team_query = team_query.filter(TeamMatch.date >= window_start)
if window_end:
team_query = team_query.filter(TeamMatch.date <= window_end)
for team_match in team_query.options(joinedload(TeamMatch.org_team)).all():
start_time_str = team_match.start_time.strftime('%H:%M') if team_match.start_time else None
end_time_str = team_match.end_time.strftime('%H:%M') if team_match.end_time else None
event = {
'id': f'team_match_{team_match.id}',
'title': team_match.title,
'type': 'team_match',
'color': '#3b82f6',
'extendedProps': {
'location': team_match.location or 'TBD',
'status': team_match.status,
'description': team_match.description or '',
'org_team': team_match.org_team.name if team_match.org_team else '',
'opponent': team_match.opponent or '',
'start_time': start_time_str,
'end_time': end_time_str,
},
}
event.update(
calendar_event_time_fields(team_match.date, team_match.start_time, team_match.end_time)
)
events.append(event)
# Add approved One on One sessions for the current user (player or coach)
if isinstance(current_user, Player):
@@ -263,11 +329,9 @@ def api_events():
one_on_ones = []
for ooo in one_on_ones:
events.append(
{
event = {
'id': f'one_on_one_{ooo.id}',
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
'date': ooo.date.strftime('%Y-%m-%d'),
'type': 'one_on_one',
'color': '#8b5cf6',
'extendedProps': {
@@ -279,7 +343,8 @@ def api_events():
'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}",
},
}
)
event.update(calendar_event_time_fields(ooo.date, ooo.start_time, ooo.end_time))
events.append(event)
return jsonify(events)
@@ -351,11 +416,9 @@ def api_events_for_tryout(tryout_id):
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
events.append(
{
event = {
'id': f'match_{match.id}',
'title': match.title,
'date': match.date.strftime('%Y-%m-%d'),
'type': 'match',
'color': match_color,
'extendedProps': {
@@ -369,7 +432,8 @@ def api_events_for_tryout(tryout_id):
'end_time': end_time_str,
},
}
)
event.update(calendar_event_time_fields(match.date, match.start_time, match.end_time))
events.append(event)
return jsonify(events)
+191 -14
View File
@@ -16,6 +16,7 @@ from app.models import (
ESPORT_GAMES,
GAME_POSITIONS,
Admin,
AppSettings,
Coach,
Evaluation,
Manager,
@@ -30,7 +31,6 @@ from app.models import (
Tryout,
TryoutRegistration,
User,
AppSettings,
)
from app.time_utils import utc_now_naive
from app.validators import (
@@ -38,6 +38,7 @@ from app.validators import (
TryoutRegistrationStatusSchema,
TryoutSchema,
TryoutStatusSchema,
TryoutTeamMemberPositionSchema,
TryoutTeamMemberSchema,
TryoutTeamSchema,
)
@@ -140,7 +141,10 @@ def list_tryouts():
def create_tryout():
"""Create a new tryout event. Requires Admin or Manager."""
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return redirect(url_for('tryouts.list_tryouts'))
if not can_manage():
@@ -204,7 +208,10 @@ def edit_tryout(tryout_id):
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
if not current_user.can_manage_this_tryout(tryout):
@@ -273,9 +280,7 @@ def view_tryout(tryout_id):
can_view = False
if isinstance(current_user, Admin):
can_view = True
elif isinstance(current_user, Manager):
can_view = current_user.can_manage_this_tryout(tryout)
elif isinstance(current_user, Coach):
elif isinstance(current_user, (Manager, Coach)):
can_view = current_user.can_manage_this_tryout(tryout)
elif isinstance(current_user, Player):
is_registered = (
@@ -341,7 +346,12 @@ def view_tryout(tryout_id):
member_players = _users_by_id([m.player_id for m in member_rows if m.player_id])
for row in member_rows:
members_by_team.setdefault(row.team_id, []).append(
{'player': member_players.get(row.player_id), 'position': row.position}
{
'player': member_players.get(row.player_id),
'position': row.position,
'member_id': row.id,
'player_id': row.player_id,
}
)
team_data = [{'team': team, 'members': members_by_team.get(team.id, [])} for team in teams]
@@ -500,7 +510,10 @@ def update_status(tryout_id):
"""Update the status of a tryout."""
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
@@ -524,7 +537,10 @@ def update_registration_status(tryout_id, player_id):
"""Update a registration's attendance status."""
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
@@ -552,7 +568,10 @@ def register_player(tryout_id):
"""Manually register a player for a tryout (by managers/coaches)."""
tryout = locked_tryout_or_404(tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
@@ -603,7 +622,10 @@ def remove_player(tryout_id, player_id):
"""Remove a registered player from a tryout (cascades to teams/matches)."""
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
@@ -643,7 +665,10 @@ def create_team(tryout_id):
"""Create a tryout-specific team."""
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
@@ -670,7 +695,10 @@ def add_to_team(tryout_id, team_id):
team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
@@ -710,13 +738,162 @@ def add_to_team(tryout_id, team_id):
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/edit', methods=['POST'])
@login_required
def edit_team(tryout_id, team_id):
"""Rename a tryout-specific team."""
team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if team.tryout_id != tryout_id:
abort(404)
try:
data = TryoutTeamSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
team.name = data['team_name']
db.session.commit()
flash(_('Team renamed to "%(team_name)s".', team_name=data['team_name']), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/delete', methods=['POST'])
@login_required
def delete_team(tryout_id, team_id):
"""Delete a tryout-specific team and its memberships."""
team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if team.tryout_id != tryout_id:
abort(404)
name = team.name
# Personal note references outlive the team.
PersonalNote.query.filter_by(team_id=team_id).update(
{'team_id': None}, synchronize_session=False
)
# Matches referencing this team: null the FK so the match survives.
Match.query.filter_by(team1_id=team_id).update({'team1_id': None}, synchronize_session=False)
Match.query.filter_by(team2_id=team_id).update({'team2_id': None}, synchronize_session=False)
# Clean participants for those matches (they are no longer in that team).
for match in Match.query.filter(
db.or_(Match.team1_id == team_id, Match.team2_id == team_id)
).all():
MatchParticipant.query.filter_by(match_id=match.id).delete(synchronize_session=False)
TeamMember.query.filter_by(team_id=team_id).delete(synchronize_session=False)
db.session.delete(team)
db.session.commit()
flash(_('Team "%(name)s" deleted.', name=name), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/remove/<int:player_id>', methods=['POST'])
@login_required
def remove_from_team(tryout_id, team_id, player_id):
"""Remove a player from a tryout team."""
team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if team.tryout_id != tryout_id:
abort(404)
TeamMember.query.filter_by(team_id=team_id, player_id=player_id).delete(
synchronize_session=False
)
# Also remove the player from any match participants that were tied to this team.
match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()]
if match_ids:
MatchParticipant.query.filter(
MatchParticipant.match_id.in_(match_ids),
MatchParticipant.player_id == player_id,
).delete(synchronize_session=False)
db.session.commit()
flash(_('Player removed from team.'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route(
'/<int:tryout_id>/team/<int:team_id>/member/<int:member_id>/position', methods=['POST']
)
@login_required
def update_member_position(tryout_id, team_id, member_id):
"""Set or clear a team member's in-game position (role)."""
team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id)
member = db.get_or_404(TeamMember, member_id)
if tryouts_locked():
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if team.tryout_id != tryout_id or member.team_id != team_id:
abort(404)
try:
data = TryoutTeamMemberPositionSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
member.position = data['position'] or None
db.session.commit()
flash(_('Position updated.'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/delete', methods=['POST'])
@login_required
def delete_tryout(tryout_id):
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked():
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
flash(
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
'danger',
)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout):
+8
View File
@@ -346,6 +346,14 @@ function showEventModal(event) {
grid.appendChild(detailItem('Teams', buildTeamsNode(props), true));
}
// Team match details
if (type === 'team_match') {
grid.appendChild(detailItem('Team', document.createTextNode(props.org_team || 'Team')));
if (props.opponent) {
grid.appendChild(detailItem('Opponent', document.createTextNode(props.opponent)));
}
}
if (props.description) {
grid.appendChild(detailItem('Description', multilineNode(props.description), true));
}
+135 -4
View File
@@ -249,7 +249,14 @@
{% for team in team_data %}
<div class="team-card mb-3">
<div class="team-card-header">
<h4 class="team-name">{{ team.team.name }}</h4>
{% if can_edit and not tryout.is_ended %}
<button class="btn btn-sm btn-outline" data-action="show-modal" data-modal-id="editTeamModal{{ team.team.id }}">
<i class="fas fa-edit"></i> {{ _('Edit') }}
</button>
{% endif %}
</div>
<ul class="team-members">
{% for member in team.members %}
<li>
@@ -263,11 +270,63 @@
<li class="text-muted">{{ _('No players assigned yet.') }}</li>
{% endfor %}
</ul>
{% if can_edit and not tryout.is_ended and registered_players %}
</div>
{% if can_edit and not tryout.is_ended %}
{% set positions = game_positions.get(tryout.game, []) %}
<form method="POST" action="{{ url_for('tryouts.add_to_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="form-inline mt-2">
<!-- Edit Team Modal -->
<div id="editTeamModal{{ team.team.id }}" class="modal hidden">
<div class="modal-backdrop" data-action="hide-modal" data-modal-id="editTeamModal{{ team.team.id }}"></div>
<div class="modal-content">
<div class="modal-header">
<h3><i class="fas fa-users-cog"></i> {{ _('Edit Team: %(name)s', name=team.team.name) }}</h3>
<button class="modal-close" data-action="hide-modal" data-modal-id="editTeamModal{{ team.team.id }}">&times;</button>
</div>
<div class="modal-body">
<!-- Rename -->
<form method="POST" action="{{ url_for('tryouts.edit_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="form-inline mb-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="player_id" class="form-select" required>
<label for="renameInput{{ team.team.id }}" class="mr-2" style="white-space:nowrap;">{{ _('Team name') }}</label>
<input type="text" id="renameInput{{ team.team.id }}" name="team_name" value="{{ team.team.name }}" required class="form-input" style="flex:1;">
<button type="submit" class="btn btn-sm btn-primary ml-2">{{ _('Rename') }}</button>
</form>
<hr class="section-divider">
<!-- Roster -->
<h5 class="mb-2"><i class="fas fa-users"></i> {{ _('Roster') }}</h5>
<div class="roster-rows">
{% for member in team.members %}
<div class="roster-row">
<div class="avatar-sm">{{ member.player.username[:2] | upper }}</div>
<span class="roster-player-name">{{ member.player.username }}</span>
<form method="POST" action="{{ url_for('tryouts.update_member_position', tryout_id=tryout.id, team_id=team.team.id, member_id=member.member_id) }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="position" class="form-select roster-role-select" data-submit-on-change>
<option value="">{{ _('No role') }}</option>
{% for pos in positions %}
<option value="{{ pos }}" {% if member.position == pos %}selected{% endif %}>{{ pos }}</option>
{% endfor %}
</select>
</form>
<form method="POST" action="{{ url_for('tryouts.remove_from_team', tryout_id=tryout.id, team_id=team.team.id, player_id=member.player_id) }}" class="inline-form" data-confirm="{{ _('Remove %(player)s from this team?', player=member.player.username) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger roster-remove-btn" title="{{ _('Remove from team') }}">
<i class="fas fa-user-minus"></i>
</button>
</form>
</div>
{% else %}
<p class="text-muted text-center">{{ _('No players assigned yet.') }}</p>
{% endfor %}
</div>
{% if registered_players %}
<hr class="section-divider">
<h5 class="mb-2"><i class="fas fa-user-plus"></i> {{ _('Add Player') }}</h5>
<form method="POST" action="{{ url_for('tryouts.add_to_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="form-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="player_id" class="form-select" required style="flex:1;">
<option value="">{{ _('Select player...') }}</option>
{% for p in registered_players %}
{% if p.id not in team.members | map(attribute='player') | map(attribute='id') | list %}
@@ -283,12 +342,24 @@
{% endfor %}
</select>
{% else %}
<input type="text" name="position" placeholder="{{ _('Position') }}" class="form-input mx-2">
<input type="text" name="position" placeholder="{{ _('Position') }}" class="form-input mx-2" style="max-width:140px;">
{% endif %}
<button type="submit" class="btn btn-sm btn-primary">{{ _('Add') }}</button>
</form>
{% endif %}
<hr class="section-divider" style="border-color: var(--danger);">
<div class="text-center">
<form method="POST" action="{{ url_for('tryouts.delete_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="inline-form" data-confirm="{{ _('Delete this team? Players already in the team will be removed and matches referencing it will be unlinked.') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-danger">{{ _('Delete Team') }}</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{% else %}
<p class="text-muted text-center">{{ _('No teams created yet.') }}</p>
{% endfor %}
@@ -458,6 +529,12 @@
<a href="{{ url_for('matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline">
<i class="fas fa-edit"></i> {{ _('Edit') }}
</a>
<form method="POST" action="{{ url_for('matches.delete_match', match_id=m.id) }}" class="inline-form" data-confirm="{{ _('Delete this match?') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger" title="{{ _('Delete Match') }}">
<i class="fas fa-trash"></i> {{ _('Delete') }}
</button>
</form>
{% endif %}
<a href="{{ url_for('users.add_note_from_match', match_id=m.id) }}" class="btn btn-sm btn-primary" title="{{ _('Add Note for this Match') }}">
<i class="fas fa-sticky-note"></i> {{ _('Note') }}
@@ -568,8 +645,56 @@
transform: scale(1.08);
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
}
/* Team card header — edit button aligned right */
.team-card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.team-card-header .team-name {
margin-bottom: 0;
}
/* Roster rows inside modal */
.roster-rows {
display: flex;
flex-direction: column;
gap: 6px;
}
.roster-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--gray-50);
border-radius: var(--radius-sm);
}
.roster-player-name {
flex: 1;
font-size: 0.9rem;
font-weight: 500;
}
.roster-role-select {
width: auto;
font-size: 0.75rem;
padding: 1px 4px;
}
.roster-remove-btn {
padding: 1px 5px;
font-size: 0.7rem;
}
[data-theme="dark"] .roster-row {
background: var(--bg-tertiary);
}
</style>
<script nonce="{{ csp_nonce }}">
function showModal(modalId) {
document.getElementById(modalId).classList.remove('hidden');
}
function hideModal(modalId) {
document.getElementById(modalId).classList.add('hidden');
}
function showCreateTeam() { document.getElementById('createTeamForm').classList.remove('hidden'); }
function hideCreateTeam() { document.getElementById('createTeamForm').classList.add('hidden'); }
@@ -627,6 +752,12 @@ document.addEventListener('DOMContentLoaded', function() {
registerActions({
'show-create-team': showCreateTeam,
'hide-create-team': hideCreateTeam,
'show-modal': function (element) {
showModal(element.getAttribute('data-modal-id'));
},
'hide-modal': function (element) {
hideModal(element.getAttribute('data-modal-id'));
},
'toggle-tryout-presence': function (element) {
toggleTryoutPresence(
element.getAttribute('data-match-id'),
+12
View File
@@ -605,6 +605,18 @@ class TryoutTeamMemberSchema(StripMixin):
)
class TryoutTeamMemberPositionSchema(StripMixin):
"""Update a team member's in-game position (role)."""
position = fields.String(
load_default='',
validate=validate.Length(
max=50,
error=_l('Position must be 50 characters or less.'),
),
)
class NoteContentSchema(StripMixin):
"""Bounded text stored as a team or personal coaching note."""
+103 -3
View File
@@ -168,7 +168,7 @@ class TestTheWindow:
'/matches/api/events?start=2030-05-01T00:00:00-04:00&end=2030-05-31T00:00:00-04:00'
).get_json()
assert _match_event(payload)['date'] == '2030-05-02'
assert _match_event(payload)['start'] == '2030-05-02T18:00:00'
def test_a_match_outside_the_window_is_not(
self, app, client, as_role, make_user, match_factory
@@ -192,7 +192,7 @@ class TestTheWindow:
payload = client.get('/matches/api/events').get_json()
assert _match_event(payload)['date'] == '2030-05-02'
assert _match_event(payload)['start'] == '2030-05-02T18:00:00'
def test_unparseable_bounds_are_ignored_rather_than_rejected(
self, app, client, as_role, make_user, match_factory
@@ -206,7 +206,7 @@ class TestTheWindow:
response = client.get('/matches/api/events?start=whenever&end=')
assert response.status_code == 200
assert _match_event(response.get_json())['date'] == '2030-05-02'
assert _match_event(response.get_json())['start'] == '2030-05-02T18:00:00'
def test_the_viewers_own_participation_still_comes_back(
self, app, client, as_role, make_user, match_factory
@@ -258,3 +258,103 @@ class TestTheWindowParser:
from flask import request
assert calendar_window(request.args) == (None, None)
class TestTimedEvents:
"""Matches with time fields render on the clock, not as all-day."""
def test_timed_match_has_start_and_end(self, app, client, as_role, make_user, match_factory):
player_id = make_user('player')
admin_id = as_role('admin')
match_factory(admin_id, player_id)
ev = _match_event(client.get('/matches/api/events').get_json())
assert ev['start'] == '2030-05-02T18:00:00'
assert ev['end'] == '2030-05-02T19:00:00'
assert ev['allDay'] is False
def test_allday_match_has_date_when_no_time(
self, app, client, as_role, make_user, match_factory
):
from datetime import date
from app.extensions import db
from app.models import Match, MatchParticipant, Tryout, TryoutRegistration
player_id = make_user('player')
admin_id = as_role('admin')
with app.app_context():
tryout = Tryout(
title='All-day tryout',
game='Valorant',
date=date(2030, 5, 1),
created_by=admin_id,
status='upcoming',
)
db.session.add(tryout)
db.session.flush()
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id))
match = Match(
tryout_id=tryout.id,
title='All-day scrim',
description=None,
date=date(2030, 5, 2),
start_time=None,
end_time=None,
match_type='player_scrim',
created_by=admin_id,
)
db.session.add(match)
db.session.flush()
db.session.add(MatchParticipant(match_id=match.id, player_id=player_id))
db.session.commit()
payload = client.get('/matches/api/events').get_json()
ev = next(e for e in payload if e['title'] == 'All-day scrim')
assert ev['date'] == '2030-05-02'
assert ev['allDay'] is True
assert 'start' not in ev
class TestTeamMatchCalendarEvents:
"""Regular-season team matches appear on the main calendar."""
def test_team_matches_appear_for_admin(self, app, client, as_role, make_user):
from datetime import date, time
from app.extensions import db
from app.models import OrgTeam, TeamMatch
admin_id = as_role('admin')
with app.app_context():
team = OrgTeam(name='Test Org', created_by=admin_id)
db.session.add(team)
db.session.flush()
tm = TeamMatch(
org_team_id=team.id,
title='Semifinal',
opponent='Enemy Team',
date=date(2030, 6, 15),
start_time=time(20, 0),
end_time=time(21, 30),
location='Main arena',
status='scheduled',
created_by=admin_id,
)
db.session.add(tm)
db.session.commit()
payload = client.get('/matches/api/events').get_json()
team_events = [e for e in payload if e['type'] == 'team_match']
assert len(team_events) == 1
ev = team_events[0]
assert ev['title'] == 'Semifinal'
assert ev['start'] == '2030-06-15T20:00:00'
assert ev['allDay'] is False
assert ev['extendedProps']['org_team'] == 'Test Org'
assert ev['extendedProps']['opponent'] == 'Enemy Team'