Compare commits
3
Commits
32c112ceea
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e5903b131 | ||
|
|
bd9ed4bdc7 | ||
|
|
a9b33b30f5 |
+18
-4
@@ -324,8 +324,9 @@ def _create_database():
|
||||
def _stop(*_args):
|
||||
"""Stop the PostgreSQL server with a fast shutdown.
|
||||
|
||||
Registered as ``atexit`` and signal handler so it fires on Ctrl+C,
|
||||
normal exit, and terminal close.
|
||||
Registered as an ``atexit`` handler so it fires on normal exit. Signal
|
||||
handling goes through ``_handle_stop_signal``, which also stops the
|
||||
server but then re-raises so the process actually exits.
|
||||
"""
|
||||
global _started
|
||||
global _stopped_system_service_name
|
||||
@@ -349,6 +350,19 @@ def _stop(*_args):
|
||||
_start_system_service(srv)
|
||||
|
||||
|
||||
def _handle_stop_signal(signum, frame):
|
||||
"""Stop PostgreSQL, then let the interpreter shut down normally.
|
||||
|
||||
``_stop`` alone would swallow Ctrl+C: it stops the server and restores the
|
||||
system service, then returns without exiting, leaving the dev server
|
||||
running against a stopped database and the terminal frozen. Re-raising
|
||||
``KeyboardInterrupt`` hands control back to the framework, which exits;
|
||||
the ``atexit`` registration then runs ``_stop`` again (idempotent).
|
||||
"""
|
||||
_stop()
|
||||
raise KeyboardInterrupt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -420,9 +434,9 @@ def start() -> str:
|
||||
_started = True
|
||||
atexit.register(_stop)
|
||||
with contextlib.suppress(ValueError, OSError):
|
||||
signal.signal(signal.SIGINT, _stop)
|
||||
signal.signal(signal.SIGINT, _handle_stop_signal)
|
||||
with contextlib.suppress(AttributeError, ValueError, OSError):
|
||||
signal.signal(signal.SIGBREAK, _stop) # type: ignore[attr-defined]
|
||||
signal.signal(signal.SIGBREAK, _handle_stop_signal) # type: ignore[attr-defined]
|
||||
|
||||
logger.info('Local PostgreSQL ready — DATABASE_URL=%s', DATABASE_URL)
|
||||
return DATABASE_URL
|
||||
|
||||
@@ -61,7 +61,14 @@ class OrgTeam(db.Model):
|
||||
return [tp.player for tp in self.team_players]
|
||||
|
||||
def get_players_with_status(self):
|
||||
return [
|
||||
{'player': tp.player, 'status': tp.status, 'position': tp.position}
|
||||
rows = [
|
||||
{
|
||||
'player': tp.player,
|
||||
'status': tp.status,
|
||||
'position': tp.position,
|
||||
'is_team_representative': tp.is_team_representative,
|
||||
}
|
||||
for tp in self.team_players
|
||||
]
|
||||
# The responsible heads the roster; everyone else keeps roster order.
|
||||
return sorted(rows, key=lambda row: not row['is_team_representative'])
|
||||
|
||||
@@ -12,6 +12,10 @@ class TeamPlayer(db.Model):
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
||||
status = db.Column(db.String(20), nullable=False, default='starter')
|
||||
#: A player an admin named as their team's responsible ("joueur
|
||||
#: responsable") — see app/permissions.py. Grants match-management
|
||||
#: rights for that one team and nothing else.
|
||||
is_team_representative = db.Column(db.Boolean, nullable=False, default=False)
|
||||
position = db.Column(db.String(50), nullable=True)
|
||||
added_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ class Admin(User):
|
||||
def can_manage_this_org_team(self, org_team):
|
||||
return True
|
||||
|
||||
def can_designate_team_representative(self):
|
||||
return True
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
from app.models.tryout.tryout import Tryout
|
||||
|
||||
|
||||
@@ -103,5 +103,20 @@ class User(UserMixin, db.Model):
|
||||
def can_manage_this_org_team(self, org_team):
|
||||
return False
|
||||
|
||||
def can_manage_team_matches(self, org_team):
|
||||
"""Whether the user may manage this team's matches and practices.
|
||||
|
||||
Broader than :meth:`can_manage_this_org_team`: a player named as the
|
||||
team's responsible joins whoever administers it. The rule itself
|
||||
lives in ``app.permissions``.
|
||||
"""
|
||||
from app.permissions import can_manage_team_matches
|
||||
|
||||
return can_manage_team_matches(self, org_team)
|
||||
|
||||
def can_designate_team_representative(self):
|
||||
"""Whether the user may name a team's responsible. President only."""
|
||||
return False
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
return []
|
||||
|
||||
@@ -158,6 +158,78 @@ def can_manage_org_team(user, org_team):
|
||||
return bool(org_team) and user.can_manage_this_org_team(org_team)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Team representatives ("joueurs responsables")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def is_team_representative(user, org_team):
|
||||
"""Whether the user is the named responsible of this organisation team.
|
||||
|
||||
Args:
|
||||
user: The acting user.
|
||||
org_team: The team concerned.
|
||||
|
||||
Returns:
|
||||
bool
|
||||
"""
|
||||
if not user or not org_team:
|
||||
return False
|
||||
|
||||
from app.models import TeamPlayer
|
||||
|
||||
return (
|
||||
TeamPlayer.query.filter_by(
|
||||
player_id=user.id,
|
||||
org_team_id=org_team.id,
|
||||
is_team_representative=True,
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def representative_org_teams(user):
|
||||
"""Organisation teams the user is the responsible of, ordered by name.
|
||||
|
||||
Args:
|
||||
user: The acting user.
|
||||
|
||||
Returns:
|
||||
list[OrgTeam]: Possibly empty.
|
||||
"""
|
||||
from app.models import OrgTeam, TeamPlayer
|
||||
|
||||
if not user:
|
||||
return []
|
||||
return (
|
||||
OrgTeam.query.join(TeamPlayer, TeamPlayer.org_team_id == OrgTeam.id)
|
||||
.filter(
|
||||
TeamPlayer.player_id == user.id,
|
||||
TeamPlayer.is_team_representative.is_(True),
|
||||
)
|
||||
.order_by(OrgTeam.name)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def can_manage_team_matches(user, org_team):
|
||||
"""Whether the user may manage this team's matches and practices.
|
||||
|
||||
The staff rule (see :func:`can_manage_org_team`) *plus* the player named
|
||||
as the team's responsible. Deliberately narrower than
|
||||
``can_manage_org_team``: a responsible schedules and edits matches, but
|
||||
does not administer the roster.
|
||||
|
||||
Args:
|
||||
user: The acting user.
|
||||
org_team: The team concerned.
|
||||
|
||||
Returns:
|
||||
bool
|
||||
"""
|
||||
return can_manage_org_team(user, org_team) or is_team_representative(user, org_team)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reach over players
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -224,7 +224,6 @@ def api_events():
|
||||
|
||||
for match in matches:
|
||||
tryout = tryouts_by_id[match.tryout_id]
|
||||
match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b'
|
||||
# 'description' used to be participants_str + '<br>' + description.
|
||||
# Building presentation markup inside a JSON field is what carried
|
||||
# the stored XSS: the browser dropped it straight into innerHTML,
|
||||
@@ -255,7 +254,6 @@ def api_events():
|
||||
'id': f'match_{match.id}',
|
||||
'title': match.title,
|
||||
'type': 'match',
|
||||
'color': match_color,
|
||||
'extendedProps': {
|
||||
'location': match.location or tryout.location or 'TBD',
|
||||
'status': match.status,
|
||||
@@ -301,7 +299,6 @@ def api_events():
|
||||
'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,
|
||||
@@ -334,7 +331,6 @@ def api_events():
|
||||
'id': f'one_on_one_{ooo.id}',
|
||||
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
|
||||
'type': 'one_on_one',
|
||||
'color': '#8b5cf6',
|
||||
'extendedProps': {
|
||||
'location': 'Discord / Voice Chat',
|
||||
'status': 'approved',
|
||||
|
||||
@@ -23,7 +23,7 @@ from app.models import (
|
||||
TeamPlayer,
|
||||
)
|
||||
from app.pagination import paginate
|
||||
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
|
||||
from app.permissions import coach_org_teams, representative_org_teams, visible_org_teams
|
||||
from app.responders import respond_after_change
|
||||
from app.routes.matches import default_end_time
|
||||
from app.services.scheduling import notify_participants, zip_participants
|
||||
@@ -36,10 +36,11 @@ team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches'
|
||||
def can_manage_team_match(team):
|
||||
"""Whether the current user can manage matches for this team.
|
||||
|
||||
Same rule as administering the team itself, so it is the same call.
|
||||
This function used to restate it, and the restatement drifted.
|
||||
Everyone who administers the team, plus the player named as its
|
||||
responsible. Narrower than administering the team itself: a responsible
|
||||
schedules and edits matches, but does not touch the roster.
|
||||
"""
|
||||
return can_manage_org_team(current_user, team)
|
||||
return current_user.can_manage_team_matches(team)
|
||||
|
||||
|
||||
def season_locked():
|
||||
@@ -300,13 +301,13 @@ def delete_match(match_id):
|
||||
@login_required
|
||||
def api_manageable_teams():
|
||||
"""API endpoint returning teams the current user can schedule matches for."""
|
||||
if not current_user.can_schedule_matches():
|
||||
return jsonify([])
|
||||
|
||||
if isinstance(current_user, (Admin, Manager)):
|
||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
elif isinstance(current_user, Coach):
|
||||
teams = coach_org_teams(current_user)
|
||||
elif isinstance(current_user, Player):
|
||||
# A responsible schedules matches for their own team(s) only.
|
||||
teams = representative_org_teams(current_user)
|
||||
else:
|
||||
return jsonify([])
|
||||
|
||||
|
||||
+35
-3
@@ -26,7 +26,7 @@ from app.models import (
|
||||
Tryout,
|
||||
User,
|
||||
)
|
||||
from app.permissions import visible_org_teams
|
||||
from app.permissions import representative_org_teams, visible_org_teams
|
||||
from app.responders import respond_after_change
|
||||
from app.time_utils import utc_now_naive
|
||||
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
|
||||
@@ -40,10 +40,12 @@ def list_teams():
|
||||
"""List all organization teams visible to the current user."""
|
||||
can_manage = current_user.can_manage_teams()
|
||||
|
||||
if isinstance(current_user, Player):
|
||||
# A player reaches this page only as a team's responsible; anyone else
|
||||
# is sent to their own view.
|
||||
if isinstance(current_user, Player) and not representative_org_teams(current_user):
|
||||
flash(_('Use My Team(s) to view your teams.'), 'info')
|
||||
return redirect(url_for('teams.my_teams'))
|
||||
if not isinstance(current_user, (Admin, Coach, Manager)):
|
||||
if not isinstance(current_user, (Admin, Coach, Manager, Player)):
|
||||
flash(_('You do not have permission to view teams.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
@@ -563,6 +565,36 @@ def toggle_player_status(team_id, player_id):
|
||||
)
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/toggle_representative/<int:player_id>', methods=['POST'])
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def toggle_player_representative(team_id, player_id):
|
||||
"""Name or un-name a player as their team's responsible (president only).
|
||||
|
||||
The responsible gains match-management rights for this one team. The
|
||||
flag itself stays a president's decision: this is deliberately the one
|
||||
roster action a responsible cannot perform on their own team.
|
||||
"""
|
||||
db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_designate_team_representative():
|
||||
return jsonify({'error': 'Permission denied'}), 403
|
||||
|
||||
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
||||
if not tp:
|
||||
return jsonify({'error': 'Player not found on this team'}), 404
|
||||
|
||||
tp.is_team_representative = not tp.is_team_representative
|
||||
db.session.commit()
|
||||
return jsonify(
|
||||
{
|
||||
'success': True,
|
||||
'player_id': player_id,
|
||||
'is_team_representative': tp.is_team_representative,
|
||||
'player_name': tp.player.username,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/add-team-note', methods=['POST'])
|
||||
@login_required
|
||||
def add_team_note(team_id):
|
||||
|
||||
+119
-15
@@ -1043,6 +1043,10 @@ a:hover { color: var(--primary-dark); }
|
||||
|
||||
.fc {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--fc-border-color: var(--gray-200);
|
||||
--fc-page-bg-color: transparent;
|
||||
--fc-neutral-bg-color: var(--gray-50);
|
||||
--fc-today-bg-color: var(--info-light);
|
||||
}
|
||||
|
||||
.fc-toolbar {
|
||||
@@ -1055,24 +1059,29 @@ a:hover { color: var(--primary-dark); }
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.fc-button {
|
||||
background: var(--primary) !important;
|
||||
border: none !important;
|
||||
.fc .fc-button {
|
||||
background: var(--gray-50) !important;
|
||||
border: 1px solid var(--gray-200) !important;
|
||||
color: var(--gray-700) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
padding: 6px 12px !important;
|
||||
font-size: 0.85rem !important;
|
||||
}
|
||||
|
||||
.fc-button:hover {
|
||||
background: var(--primary-dark) !important;
|
||||
.fc .fc-button:hover {
|
||||
background: var(--gray-100) !important;
|
||||
color: var(--gray-900) !important;
|
||||
}
|
||||
|
||||
.fc-button-primary:not(:disabled).fc-button-active,
|
||||
.fc-button-primary:not(:disabled):active {
|
||||
background: var(--primary-dark) !important;
|
||||
.fc .fc-button-primary:not(:disabled).fc-button-active,
|
||||
.fc .fc-button-primary:not(:disabled):active {
|
||||
background: var(--primary) !important;
|
||||
border-color: var(--primary) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.fc-daygrid-day {
|
||||
.fc-daygrid-day,
|
||||
.fc-timegrid-slot {
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
@@ -1080,16 +1089,111 @@ a:hover { color: var(--primary-dark); }
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.fc-event {
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 4px;
|
||||
font-size: 0.8rem;
|
||||
/* Event cards (scoped to the main calendar: the tryout mini-calendar keeps
|
||||
its inline event colors) */
|
||||
#calendar .fc-event {
|
||||
border: none !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
padding: 3px 6px !important;
|
||||
font-size: 0.8rem !important;
|
||||
background-color: var(--fc-event-bg, var(--primary)) !important;
|
||||
color: #fff !important;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.fc-event-title {
|
||||
font-weight: 600;
|
||||
#calendar .fc-event-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#calendar .fc-event-body i {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
#calendar .fc-event-text {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#calendar .fc-event-time {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#calendar .fc-event-title {
|
||||
font-weight: 600 !important;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Event type colors (semantic, light theme) */
|
||||
#calendar .fc-event--team { --fc-event-bg: #10b981; }
|
||||
#calendar .fc-event--scrim { --fc-event-bg: #f59e0b; }
|
||||
#calendar .fc-event--team-match { --fc-event-bg: #3b82f6; }
|
||||
#calendar .fc-event--one-on-one { --fc-event-bg: #8b5cf6; }
|
||||
|
||||
#calendar .fc-now-indicator {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
/* Dark theme: the calendar chrome reacts to the light/dark switch */
|
||||
[data-theme="dark"] .fc {
|
||||
--fc-border-color: var(--border-color);
|
||||
--fc-neutral-bg-color: var(--bg-secondary);
|
||||
--fc-today-bg-color: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc-toolbar-title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc .fc-button {
|
||||
background: var(--bg-tertiary) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc .fc-button:hover {
|
||||
background: var(--bg-secondary) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc .fc-button-primary:not(:disabled).fc-button-active,
|
||||
[data-theme="dark"] .fc .fc-button-primary:not(:disabled):active {
|
||||
background: var(--primary) !important;
|
||||
border-color: var(--primary) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc-daygrid-day:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc-daygrid-day-number,
|
||||
[data-theme="dark"] .fc-col-header-cell-cushion,
|
||||
[data-theme="dark"] .fc-timegrid-axis-cushion,
|
||||
[data-theme="dark"] .fc-timegrid-slot-label-cushion {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Pastel event chips with dark text keep contrast on a dark background. */
|
||||
[data-theme="dark"] #calendar .fc-event {
|
||||
color: var(--gray-900) !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] #calendar .fc-event--team { --fc-event-bg: #d1fae5; }
|
||||
[data-theme="dark"] #calendar .fc-event--scrim { --fc-event-bg: #fef3c7; }
|
||||
[data-theme="dark"] #calendar .fc-event--team-match { --fc-event-bg: #dbeafe; }
|
||||
[data-theme="dark"] #calendar .fc-event--one-on-one { --fc-event-bg: #ede9fe; }
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
@@ -137,12 +137,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek,timeGridDay,listMonth'
|
||||
right: ''
|
||||
},
|
||||
events: '/matches/api/events',
|
||||
eventClick: function(info) {
|
||||
showEventModal(info.event);
|
||||
},
|
||||
eventClassNames: function(arg) {
|
||||
return [eventClassName(arg.event)];
|
||||
},
|
||||
eventContent: function(arg) {
|
||||
return renderEventContent(arg);
|
||||
},
|
||||
dateClick: function(info) {
|
||||
if (canScheduleMatches) {
|
||||
var dateStr = info.dateStr;
|
||||
@@ -158,7 +164,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
},
|
||||
slotMinTime: '12:00:00',
|
||||
slotMaxTime: '24:00:00'
|
||||
slotMaxTime: '24:00:00',
|
||||
slotEventOverlap: true,
|
||||
dayMaxEvents: false,
|
||||
nowIndicator: true,
|
||||
height: 'auto'
|
||||
});
|
||||
calendar.render();
|
||||
|
||||
@@ -177,6 +187,41 @@ function changeView(viewName) {
|
||||
}
|
||||
}
|
||||
|
||||
function eventClassName(event) {
|
||||
var props = event.extendedProps || {};
|
||||
if (props.type === 'team_match') { return 'fc-event--team-match'; }
|
||||
if (props.type === 'one_on_one') { return 'fc-event--one-on-one'; }
|
||||
return props.match_type === 'team_vs_team' ? 'fc-event--team' : 'fc-event--scrim';
|
||||
}
|
||||
|
||||
function eventIcon(type, matchType) {
|
||||
if (type === 'team_match') { return 'fa-trophy'; }
|
||||
if (type === 'one_on_one') { return 'fa-user-friends'; }
|
||||
if (matchType === 'team_vs_team') { return 'fa-shield-alt'; }
|
||||
if (matchType === 'player_vs_player') { return 'fa-users'; }
|
||||
return 'fa-gamepad';
|
||||
}
|
||||
|
||||
function renderEventContent(arg) {
|
||||
// Built with DOM nodes + textContent: the title comes from a JSON endpoint
|
||||
// and has never been HTML-escaped, so it must not reach innerHTML.
|
||||
var props = arg.event.extendedProps || {};
|
||||
var wrap = makeEl('div', 'fc-event-body');
|
||||
|
||||
var icon = makeEl('i', 'fas ' + eventIcon(props.type, props.match_type));
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
wrap.appendChild(icon);
|
||||
|
||||
var text = makeEl('span', 'fc-event-text');
|
||||
if (!arg.event.allDay && arg.timeText && arg.view.type.indexOf('dayGrid') === 0) {
|
||||
text.appendChild(makeEl('span', 'fc-event-time', arg.timeText));
|
||||
}
|
||||
text.appendChild(makeEl('span', 'fc-event-title', arg.event.title));
|
||||
wrap.appendChild(text);
|
||||
|
||||
return { domNodes: [wrap] };
|
||||
}
|
||||
|
||||
// --- Create Event Modal ---
|
||||
function showCreateEventModal(dateStr) {
|
||||
document.getElementById('createEventDate').textContent = dateStr;
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
{{ entry.player.username }}
|
||||
</a>
|
||||
</td>
|
||||
<td><span class="score">{{ entry.avg if entry.avg is not none else '-' }}</span></td>
|
||||
<td><span class="score">{{ entry.avg | round(1) if entry.avg is not none else '-' }}</span></td>
|
||||
<td>{{ entry.count }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('evaluations.player_evaluations', player_id=entry.player.id) }}" class="btn btn-sm btn-outline" title="{{ _('Open player view') }}">
|
||||
@@ -90,7 +90,7 @@
|
||||
{% for score in ev.scores %}
|
||||
<td>{{ score if score is not none else '-' }}</td>
|
||||
{% endfor %}
|
||||
<td><span class="score">{{ ev.overall if ev.overall is not none else '-' }}</span></td>
|
||||
<td><span class="score">{{ ev.overall | round(1) if ev.overall is not none else '-' }}</span></td>
|
||||
<td>{{ ev.position or '-' }}</td>
|
||||
<td>{{ ev.date.strftime('%m/%d/%Y') if ev.date else '-' }}</td>
|
||||
</tr>
|
||||
|
||||
@@ -10,6 +10,16 @@
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-users"></i> {{ team.name }}</h3>
|
||||
<div class="card-actions">
|
||||
{% if current_user.can_manage_team_matches(team) %}
|
||||
<a href="{{ url_for('team_matches.create_match', team_id=team.id) }}" class="btn btn-sm btn-success" title="{{ _('Schedule Team Match') }}">
|
||||
<i class="fas fa-plus"></i> {{ _('Match') }}
|
||||
</a>
|
||||
<a href="{{ url_for('team_matches.create_match', team_id=team.id, type='practice') }}" class="btn btn-sm btn-info" title="{{ _('Schedule Practice') }}">
|
||||
<i class="fas fa-dumbbell"></i> {{ _('Practice') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Staff bar -->
|
||||
<div class="team-staff-bar">
|
||||
@@ -61,6 +71,9 @@
|
||||
<div class="user-mini">
|
||||
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
|
||||
<a href="{{ url_for('users.view_user', user_id=entry.player.id) }}">{{ entry.player.username }}</a>
|
||||
{% if entry.is_team_representative %}
|
||||
<span class="badge badge-info" title="{{ _('Team representative') }}">{{ _('Representative') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-info"><i class="fas fa-star"></i></div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ combined_average if combined_average is not none else '-' }}</h3>
|
||||
<h3>{{ combined_average | round(1) if combined_average is not none else '-' }}</h3>
|
||||
<p>{{ _('Combined average') }} · {{ evaluation_count }} {{ _('evaluations') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,7 +46,7 @@
|
||||
<div class="stat-card stat-card-sm">
|
||||
<div class="stat-icon bg-info"><i class="fas fa-chart-bar"></i></div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ avg if avg is not none else '-' }}</h3>
|
||||
<h3>{{ avg | round(1) if avg is not none else '-' }}</h3>
|
||||
<p>{{ label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,7 +75,7 @@
|
||||
{% for score in ev.scores %}
|
||||
<td>{{ score if score is not none else '-' }}</td>
|
||||
{% endfor %}
|
||||
<td><span class="score">{{ ev.overall if ev.overall is not none else '-' }}</span></td>
|
||||
<td><span class="score">{{ ev.overall | round(1) if ev.overall is not none else '-' }}</span></td>
|
||||
<td>{{ ev.position or '-' }}</td>
|
||||
<td>{{ ev.date.strftime('%m/%d/%Y') if ev.date else '-' }}</td>
|
||||
</tr>
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
<span class="badge badge-{{ m.status }}">{{ m.status }}</span>
|
||||
</td>
|
||||
<td class="eval-actions">
|
||||
{% set can_manage_this = (current_user.role in ['admin', 'manager']) or (current_user.role == 'coach' and m.org_team.coaches.filter_by(id=current_user.id).first()) or (current_user.role == 'coach' and m.org_team.coach_id == current_user.id) %}
|
||||
{% set can_manage_this = current_user.can_manage_team_matches(m.org_team) %}
|
||||
{% if can_manage_this %}
|
||||
<a href="{{ url_for('team_matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline" title="{{ _('Edit Match') }}">
|
||||
<i class="fas fa-edit"></i>
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
<a href="{{ url_for('team_matches.list_matches') }}?team_id={{ team.id }}" class="btn btn-sm btn-outline" title="{{ _('View Team Matches') }}">
|
||||
<i class="fas fa-futbol"></i> {{ _('Matches') }}
|
||||
</a>
|
||||
{% if current_user.can_schedule_matches() and (current_user.role in ['admin', 'manager'] or (current_user.role == 'coach' and team.coaches.filter_by(id=current_user.id).first()) or (current_user.role == 'coach' and team.coach_id == current_user.id)) %}
|
||||
{% if current_user.can_manage_team_matches(team) %}
|
||||
<a href="{{ url_for('team_matches.create_match', team_id=team.id) }}" class="btn btn-sm btn-success" title="{{ _('Schedule Team Match') }}">
|
||||
<i class="fas fa-plus"></i> {{ _('Match') }}
|
||||
</a>
|
||||
@@ -159,6 +159,9 @@
|
||||
<div class="user-mini">
|
||||
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
|
||||
<a href="{{ url_for('users.view_user', user_id=entry.player.id) }}">{{ entry.player.username }}</a>
|
||||
{% if entry.is_team_representative %}
|
||||
<span class="badge badge-info" title="{{ _('Team representative') }}">{{ _('Representative') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@@ -180,6 +183,16 @@
|
||||
<td>{{ entry.player.phone or '-' }}</td>
|
||||
{% if can_manage_team %}
|
||||
<td>
|
||||
{% if current_user.can_designate_team_representative() %}
|
||||
<button type="button"
|
||||
class="btn btn-sm representative-toggle-btn {% if entry.is_team_representative %}btn-warning{% else %}btn-outline{% endif %}"
|
||||
data-team-id="{{ team.id }}"
|
||||
data-player-id="{{ entry.player.id }}"
|
||||
data-action="toggle-representative"
|
||||
title="{{ _('Name as team representative') }}">
|
||||
<i class="fas fa-star"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
<form method="POST" data-ajax action="{{ url_for('teams.remove_player', team_id=team.id, player_id=entry.player.id) }}" class="inline-form" data-confirm="{{ _('Remove %(player)s from %(team)s?', player=entry.player.username, team=team.name) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit" class="btn btn-sm btn-danger">
|
||||
@@ -341,6 +354,35 @@ function toggleStatus(btn) {
|
||||
});
|
||||
}
|
||||
|
||||
function toggleRepresentative(btn) {
|
||||
var teamId = btn.getAttribute('data-team-id');
|
||||
var playerId = btn.getAttribute('data-player-id');
|
||||
|
||||
fetch('/teams/' + teamId + '/toggle_representative/' + playerId, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': '{{ csrf_token() }}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(function(response) { return response.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
if (data.is_team_representative) {
|
||||
btn.classList.remove('btn-outline');
|
||||
btn.classList.add('btn-warning');
|
||||
} else {
|
||||
btn.classList.remove('btn-warning');
|
||||
btn.classList.add('btn-outline');
|
||||
}
|
||||
window.TT.refreshPage(window.location.pathname);
|
||||
}
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('Error:', error);
|
||||
});
|
||||
}
|
||||
|
||||
var currentEditTeamId = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@@ -416,6 +458,7 @@ registerActions({
|
||||
'hide-create-form': hideCreateForm,
|
||||
'hide-edit-form': hideEditForm,
|
||||
'toggle-status': toggleStatus,
|
||||
'toggle-representative': toggleRepresentative,
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
|
||||
Binary file not shown.
@@ -3047,6 +3047,18 @@ msgstr "View Profile"
|
||||
msgid "Availability"
|
||||
msgstr "Availability"
|
||||
|
||||
#: app/templates/pages/my_teams.html:65 app/templates/pages/teams.html:163
|
||||
msgid "Representative"
|
||||
msgstr "Representative"
|
||||
|
||||
#: app/templates/pages/my_teams.html:65 app/templates/pages/teams.html:163
|
||||
msgid "Team representative"
|
||||
msgstr "Team representative"
|
||||
|
||||
#: app/templates/pages/teams.html:192
|
||||
msgid "Name as team representative"
|
||||
msgstr "Name as team representative"
|
||||
|
||||
#~ msgid "×"
|
||||
#~ msgstr "×"
|
||||
|
||||
|
||||
Binary file not shown.
@@ -3070,6 +3070,18 @@ msgstr "Voir le profil"
|
||||
msgid "Availability"
|
||||
msgstr "Disponibilités"
|
||||
|
||||
#: app/templates/pages/my_teams.html:65 app/templates/pages/teams.html:163
|
||||
msgid "Representative"
|
||||
msgstr "Responsable"
|
||||
|
||||
#: app/templates/pages/my_teams.html:65 app/templates/pages/teams.html:163
|
||||
msgid "Team representative"
|
||||
msgstr "Responsable d’équipe"
|
||||
|
||||
#: app/templates/pages/teams.html:192
|
||||
msgid "Name as team representative"
|
||||
msgstr "Nommer comme responsable d’équipe"
|
||||
|
||||
#~ msgid "×"
|
||||
#~ msgstr "×"
|
||||
|
||||
|
||||
@@ -128,3 +128,18 @@ Générer la migration initiale depuis les modèles « pour avancer en
|
||||
attendant ». Ça produit un dépôt qui a l'air d'avoir des migrations, une
|
||||
production estampillée sur un état qu'elle n'a pas, et la dérive préservée
|
||||
sous une couche de plus.
|
||||
|
||||
## Ajouts de colonnes faits à la main
|
||||
|
||||
En attendant Alembic, une colonne ajoutée à un modèle doit être rattrapée sur
|
||||
les bases existantes par un script, en plus du modèle (pour les installations
|
||||
neuves, via `create_all()`). `migrations/add_tryout_coaches.py` a ouvert la
|
||||
voie ; `migrations/add_team_representative.py` ajoute
|
||||
`team_players.is_team_representative` de la même façon : lecture du catalogue,
|
||||
`ALTER TABLE ... ADD COLUMN` idempotent, rien si la colonne est déjà là.
|
||||
|
||||
Le script trouve sa base tout seul : `DATABASE_URL` s'il est défini (production,
|
||||
CI), sinon les valeurs `LOCAL_PG*` de `app/.env` — le serveur de développement
|
||||
est réutilisé s'il répond, sinon (re)démarré puis arrêté à la fin du script :
|
||||
|
||||
python migrations/add_team_representative.py
|
||||
|
||||
@@ -64,6 +64,31 @@ voulue, elle n'est écrite nulle part ailleurs qu'ici.
|
||||
| Coach | rattaché à l'équipe, par la relation m2m **ou** par la colonne héritée |
|
||||
| Recruteur, Joueur | toujours faux |
|
||||
|
||||
### `can_manage_team_matches(team)`
|
||||
|
||||
Plus étroite que `can_manage_this_org_team`, et c'est voulu : elle répond à
|
||||
« peut-on gérer les matchs et les entraînements de cette équipe ? ».
|
||||
|
||||
| Rôle | Règle |
|
||||
|---|---|
|
||||
| Président, Gérant | toujours vrai |
|
||||
| Coach | rattaché à l'équipe (m2m **ou** colonne héritée) |
|
||||
| Joueur responsable | vrai pour **sa seule** équipe |
|
||||
| Recruteur, Joueur | faux |
|
||||
|
||||
### Le joueur responsable
|
||||
|
||||
Un **joueur responsable** est un joueur qu'un **président** désigne, depuis la
|
||||
page Équipes, sur la ligne de son équipe (étoile à côté du bouton de retrait).
|
||||
Il peut planifier, modifier et supprimer les **matchs et entraînements** de
|
||||
cette équipe, et rien d'autre : ni l'effectif, ni les notes, ni le statut
|
||||
partant/remplaçant, qui restent réservés au personnel.
|
||||
|
||||
Le drapeau vit sur `team_players.is_team_representative` (voir
|
||||
`docs/database-schema.md`). La désignation est la décision du président —
|
||||
`can_designate_team_representative()`, vrai seulement pour `Admin` — pour
|
||||
qu'un responsable ne puisse pas se reproduire lui-même.
|
||||
|
||||
## Le piège : deux façons d'être rattaché
|
||||
|
||||
C'est le point le plus coûteux de tout le modèle d'autorisation, et il est
|
||||
@@ -117,6 +142,9 @@ contexte de requête.
|
||||
| `attached_org_teams(user)` | Les équipes auxquelles cette personne est rattachée, quel que soit son rôle |
|
||||
| `visible_org_teams(user)` | Ce qu'elle a le droit de voir |
|
||||
| `can_manage_org_team(user, team)` | Peut-elle administrer cette équipe |
|
||||
| `can_manage_team_matches(user, team)` | Peut-elle gérer les matchs et entraînements de cette équipe |
|
||||
| `is_team_representative(user, team)` | Est-elle le responsable désigné de cette équipe |
|
||||
| `representative_org_teams(user)` | Les équipes dont elle est le responsable |
|
||||
| `coach_player_ids(coach)` | Les joueurs qu'il encadre |
|
||||
| `coach_can_access_player(coach, id)` | Peut-il lire la fiche de ce joueur |
|
||||
| `can_manage_player_contract(user, id)` | Peut-elle déposer un contrat pour lui |
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Migration: add the `is_team_representative` flag to `team_players`.
|
||||
|
||||
A team's responsible ("joueur responsable") is a player an admin names to
|
||||
manage that team's matches and practices. The flag lives on the roster row,
|
||||
beside `status` and `position`.
|
||||
|
||||
`db.create_all()` creates missing tables and never alters an existing one, so
|
||||
a database that already has `team_players` needs this script. Fresh installs
|
||||
get the column from the model.
|
||||
|
||||
Where it connects
|
||||
-----------------
|
||||
The script finds its own database, so there is no URL to remember:
|
||||
|
||||
1. If `DATABASE_URL` is already set, it is used and nothing else is touched.
|
||||
That is the production and CI path.
|
||||
2. Otherwise the local values from `app/.env` are used. If the development
|
||||
server in `instance/pgdata/` answers a connection it is reused as-is; if it
|
||||
does not, it is (re)started and stopped again when this script exits. This
|
||||
is the `LOCAL_PG=true` block of `app/.env.example`.
|
||||
3. If neither is available the script explains what to set and stops.
|
||||
|
||||
So a laptop with the `LOCAL_PG=true` block runs the migration with a single
|
||||
command:
|
||||
|
||||
python migrations/add_team_representative.py
|
||||
|
||||
Usage: python migrations/add_team_representative.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
# Loaded before anything else reads the environment: app.local_postgres reads
|
||||
# LOCAL_PG_PORT / LOCAL_PG_USER / LOCAL_PG_DATABASE at import time, and without
|
||||
# this they would fall back to that module's defaults instead of app/.env.
|
||||
from dotenv import load_dotenv
|
||||
|
||||
_ENV_FILE = os.path.join(PROJECT_ROOT, 'app', '.env')
|
||||
load_dotenv(_ENV_FILE if os.path.exists(_ENV_FILE) else None)
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
def _local_database():
|
||||
"""The development URL and port, read from the LOCAL_PG_* variables."""
|
||||
from app.local_postgres import DATABASE_URL, PORT
|
||||
|
||||
return DATABASE_URL, PORT
|
||||
|
||||
|
||||
def _postgres_answers(url):
|
||||
"""Whether `url` is a working PostgreSQL connection right now.
|
||||
|
||||
A bare TCP probe is not enough here: a server killed part-way through its
|
||||
startup can leave the port listening while every connection is refused,
|
||||
and trusting that evidence fails the migration for a reason that looks
|
||||
nothing like its cause.
|
||||
"""
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
from app.app import normalise_database_url
|
||||
|
||||
try:
|
||||
engine = create_engine(
|
||||
normalise_database_url(url), connect_args={'connect_timeout': 2}
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
try:
|
||||
with engine.connect():
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _stop_local_server():
|
||||
"""Fast-stop the local development server, if a postmaster is alive.
|
||||
|
||||
Called only before (re)starting it: `pg_ctl status` calls a half-dead
|
||||
postmaster "running", so `start()` would leave the broken one in place.
|
||||
"""
|
||||
from app.local_postgres import DATADIR, _find_binary
|
||||
|
||||
pg_ctl = _find_binary('pg_ctl')
|
||||
if not pg_ctl:
|
||||
return
|
||||
subprocess.run(
|
||||
[str(pg_ctl), 'stop', '-m', 'fast', '-D', str(DATADIR)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def resolve_database_url():
|
||||
"""Pick the database to migrate.
|
||||
|
||||
Returns:
|
||||
tuple[str, str]: the connection URL and a note saying where it came
|
||||
from, for the log line.
|
||||
|
||||
Raises:
|
||||
SystemExit: when neither a URL nor a usable local server is available.
|
||||
"""
|
||||
explicit = os.environ.get('DATABASE_URL')
|
||||
if explicit:
|
||||
return explicit, 'DATABASE_URL'
|
||||
|
||||
url, port = _local_database()
|
||||
if _postgres_answers(url):
|
||||
return url, f'local PostgreSQL already running on port {port}'
|
||||
|
||||
if os.getenv('LOCAL_PG', '').strip().lower() == 'true':
|
||||
from app.local_postgres import start
|
||||
|
||||
_stop_local_server()
|
||||
return start(), f'local PostgreSQL started on port {port}'
|
||||
|
||||
raise SystemExit(
|
||||
'No database to migrate.\n'
|
||||
' Set DATABASE_URL to a connection string, or set LOCAL_PG=true in\n'
|
||||
' app/.env to use the local development server (see app/.env.example,\n'
|
||||
' DEVELOPMENT block).'
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
# A migration must never start the Discord bot: two instances fight over
|
||||
# the same gateway session, and neither belongs to a script whose only job
|
||||
# is to run an ALTER.
|
||||
os.environ['ENABLE_DISCORD_BOT'] = 'false'
|
||||
|
||||
url, source = resolve_database_url()
|
||||
os.environ['DATABASE_URL'] = url
|
||||
|
||||
# Imported here, after the environment is settled, so create_app() reads
|
||||
# the URL this script resolved rather than a missing one.
|
||||
from app.app import create_app
|
||||
|
||||
print(f'[INFO] Database: {source}.')
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
already_there = db.session.execute(
|
||||
text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = 'team_players' "
|
||||
"AND column_name = 'is_team_representative'"
|
||||
)
|
||||
).scalar()
|
||||
|
||||
if already_there:
|
||||
print("Column 'team_players.is_team_representative' already exists. Skipping.")
|
||||
return
|
||||
|
||||
db.session.execute(
|
||||
text(
|
||||
"ALTER TABLE team_players "
|
||||
"ADD COLUMN is_team_representative BOOLEAN NOT NULL DEFAULT FALSE"
|
||||
)
|
||||
)
|
||||
db.session.commit()
|
||||
print("Added 'team_players.is_team_representative'.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Team representative ("joueur responsable") — the rule and the routes.
|
||||
|
||||
A player an admin names as their team's responsible may create, edit and
|
||||
delete that team's matches and practices, and nothing else. Designation
|
||||
itself is the president's decision, never the responsible's.
|
||||
"""
|
||||
|
||||
from datetime import date, time
|
||||
|
||||
import pytest
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import AppSettings, OrgTeam, TeamMatch, TeamPlayer, User
|
||||
from app.permissions import (
|
||||
can_manage_team_matches,
|
||||
is_team_representative,
|
||||
representative_org_teams,
|
||||
)
|
||||
|
||||
|
||||
def _redirected(response):
|
||||
return response.status_code in (301, 302)
|
||||
|
||||
|
||||
def _build_world(app, make_user):
|
||||
"""Two teams, a responsible on the first, a plain player on it too."""
|
||||
with app.app_context():
|
||||
admin_id = make_user('admin')
|
||||
rep_id = make_user('player')
|
||||
plain_id = make_user('player')
|
||||
|
||||
team = OrgTeam(name='Varsity', created_by=admin_id)
|
||||
other = OrgTeam(name='JV', created_by=admin_id)
|
||||
db.session.add_all([team, other])
|
||||
db.session.flush()
|
||||
|
||||
# Added plain-first on purpose: the roster must still show the
|
||||
# responsible at the top, not last, and a test whose world added the
|
||||
# responsible first would pass without the ordering ever running.
|
||||
db.session.add_all(
|
||||
[
|
||||
TeamPlayer(player_id=plain_id, org_team_id=team.id),
|
||||
TeamPlayer(player_id=rep_id, org_team_id=team.id, is_team_representative=True),
|
||||
]
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
# create/delete matches are gated on the season for non-presidents,
|
||||
# and the responsible is one of them.
|
||||
AppSettings.set_bool('season_active', True)
|
||||
|
||||
return {
|
||||
'admin': admin_id,
|
||||
'rep': rep_id,
|
||||
'plain': plain_id,
|
||||
'team': team.id,
|
||||
'other': other.id,
|
||||
'rep_username': db.session.get(User, rep_id).username,
|
||||
'plain_username': db.session.get(User, plain_id).username,
|
||||
}
|
||||
|
||||
|
||||
def _make_match(app, world, title, team_key='team'):
|
||||
with app.app_context():
|
||||
match = TeamMatch(
|
||||
org_team_id=world[team_key],
|
||||
title=title,
|
||||
date=date(2026, 10, 1),
|
||||
start_time=time(10, 0),
|
||||
end_time=time(11, 0),
|
||||
created_by=world['admin'],
|
||||
)
|
||||
db.session.add(match)
|
||||
db.session.commit()
|
||||
return match.id
|
||||
|
||||
|
||||
def _login_as(client, login, username):
|
||||
"""Switch the test client to another existing account."""
|
||||
client.post('/auth/logout', follow_redirects=False)
|
||||
response = login(username)
|
||||
assert _redirected(response), f'could not log in as {username}'
|
||||
|
||||
|
||||
class TestTheRule:
|
||||
"""The permission itself, without a request in the way."""
|
||||
|
||||
def test_a_rep_manages_their_team(self, app, make_user):
|
||||
world = _build_world(app, make_user)
|
||||
with app.app_context():
|
||||
rep = db.session.get(User, world['rep'])
|
||||
team = db.session.get(OrgTeam, world['team'])
|
||||
|
||||
assert is_team_representative(rep, team) is True
|
||||
assert can_manage_team_matches(rep, team) is True
|
||||
assert rep.can_manage_team_matches(team) is True
|
||||
|
||||
def test_a_rep_manages_no_other_team(self, app, make_user):
|
||||
world = _build_world(app, make_user)
|
||||
with app.app_context():
|
||||
rep = db.session.get(User, world['rep'])
|
||||
other = db.session.get(OrgTeam, world['other'])
|
||||
|
||||
assert is_team_representative(rep, other) is False
|
||||
assert can_manage_team_matches(rep, other) is False
|
||||
|
||||
def test_a_rep_is_not_a_roster_manager(self, app, make_user):
|
||||
"""Match rights only: the roster stays staff-only."""
|
||||
world = _build_world(app, make_user)
|
||||
with app.app_context():
|
||||
rep = db.session.get(User, world['rep'])
|
||||
team = db.session.get(OrgTeam, world['team'])
|
||||
|
||||
assert rep.can_manage_this_org_team(team) is False
|
||||
assert rep.can_manage_teams() is False
|
||||
assert rep.can_schedule_matches() is False
|
||||
|
||||
def test_a_plain_player_manages_nothing(self, app, make_user):
|
||||
world = _build_world(app, make_user)
|
||||
with app.app_context():
|
||||
plain = db.session.get(User, world['plain'])
|
||||
team = db.session.get(OrgTeam, world['team'])
|
||||
|
||||
assert can_manage_team_matches(plain, team) is False
|
||||
|
||||
def test_representative_org_teams_is_scoped(self, app, make_user):
|
||||
world = _build_world(app, make_user)
|
||||
with app.app_context():
|
||||
rep = db.session.get(User, world['rep'])
|
||||
assert [team.id for team in representative_org_teams(rep)] == [world['team']]
|
||||
|
||||
@pytest.mark.parametrize('role', ['admin', 'manager', 'coach', 'player', 'scout'])
|
||||
def test_only_the_president_may_designate(self, app, make_user, role):
|
||||
with app.app_context():
|
||||
user = db.session.get(User, make_user(role))
|
||||
assert user.can_designate_team_representative() == (role == 'admin')
|
||||
|
||||
|
||||
class TestMatchManagement:
|
||||
"""The routes the feature exists for: matches and practices."""
|
||||
|
||||
def test_a_rep_schedules_a_match(self, app, client, login, make_user):
|
||||
world = _build_world(app, make_user)
|
||||
_login_as(client, login, world['rep_username'])
|
||||
|
||||
response = client.post(
|
||||
f'/team-matches/{world["team"]}/create',
|
||||
data={'title': 'Rep Match', 'date': '2026-10-01', 'start_time': '10:00'},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert _redirected(response)
|
||||
with app.app_context():
|
||||
assert TeamMatch.query.filter_by(title='Rep Match').count() == 1
|
||||
|
||||
def test_a_rep_schedules_a_practice(self, app, client, login, make_user):
|
||||
world = _build_world(app, make_user)
|
||||
_login_as(client, login, world['rep_username'])
|
||||
|
||||
response = client.post(
|
||||
f'/team-matches/{world["team"]}/create?type=practice',
|
||||
data={'title': 'Evening Scrim', 'date': '2026-10-02', 'start_time': '18:00'},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert _redirected(response)
|
||||
with app.app_context():
|
||||
match = TeamMatch.query.filter_by(title='Evening Scrim').first()
|
||||
assert match is not None
|
||||
assert match.opponent is None
|
||||
|
||||
def test_a_rep_edits_a_match(self, app, client, login, make_user):
|
||||
world = _build_world(app, make_user)
|
||||
match_id = _make_match(app, world, title='Before')
|
||||
_login_as(client, login, world['rep_username'])
|
||||
|
||||
response = client.post(
|
||||
f'/team-matches/{match_id}/edit',
|
||||
data={'title': 'After', 'date': '2026-10-03', 'start_time': '12:00'},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert _redirected(response)
|
||||
with app.app_context():
|
||||
assert db.session.get(TeamMatch, match_id).title == 'After'
|
||||
|
||||
def test_a_rep_deletes_a_match(self, app, client, login, make_user):
|
||||
world = _build_world(app, make_user)
|
||||
match_id = _make_match(app, world, title='Doomed')
|
||||
_login_as(client, login, world['rep_username'])
|
||||
|
||||
response = client.post(f'/team-matches/{match_id}/delete', data={}, follow_redirects=False)
|
||||
assert _redirected(response)
|
||||
with app.app_context():
|
||||
assert db.session.get(TeamMatch, match_id) is None
|
||||
|
||||
def test_a_rep_does_not_schedule_for_another_team(self, app, client, login, make_user):
|
||||
world = _build_world(app, make_user)
|
||||
_login_as(client, login, world['rep_username'])
|
||||
|
||||
client.post(
|
||||
f'/team-matches/{world["other"]}/create',
|
||||
data={'title': 'Sneaky', 'date': '2026-10-01', 'start_time': '10:00'},
|
||||
follow_redirects=False,
|
||||
)
|
||||
with app.app_context():
|
||||
assert TeamMatch.query.filter_by(title='Sneaky').count() == 0
|
||||
|
||||
def test_a_rep_does_not_delete_another_teams_match(self, app, client, login, make_user):
|
||||
world = _build_world(app, make_user)
|
||||
match_id = _make_match(app, world, title='Elsewhere', team_key='other')
|
||||
_login_as(client, login, world['rep_username'])
|
||||
|
||||
client.post(f'/team-matches/{match_id}/delete', data={}, follow_redirects=False)
|
||||
with app.app_context():
|
||||
assert db.session.get(TeamMatch, match_id) is not None
|
||||
|
||||
def test_a_plain_player_does_not_schedule(self, app, client, login, make_user):
|
||||
world = _build_world(app, make_user)
|
||||
_login_as(client, login, world['plain_username'])
|
||||
|
||||
client.post(
|
||||
f'/team-matches/{world["team"]}/create',
|
||||
data={'title': 'Nope', 'date': '2026-10-01', 'start_time': '10:00'},
|
||||
follow_redirects=False,
|
||||
)
|
||||
with app.app_context():
|
||||
assert TeamMatch.query.filter_by(title='Nope').count() == 0
|
||||
|
||||
def test_a_rep_does_not_touch_the_roster(self, app, client, login, make_user):
|
||||
"""Decision 3: matches only, never the roster."""
|
||||
world = _build_world(app, make_user)
|
||||
newcomer_id = make_user('player')
|
||||
_login_as(client, login, world['rep_username'])
|
||||
|
||||
client.post(
|
||||
f'/teams/{world["team"]}/add_player',
|
||||
data={'player_id': str(newcomer_id), 'status': 'starter'},
|
||||
follow_redirects=False,
|
||||
)
|
||||
with app.app_context():
|
||||
added = TeamPlayer.query.filter_by(
|
||||
player_id=newcomer_id, org_team_id=world['team']
|
||||
).first()
|
||||
assert added is None
|
||||
|
||||
def test_a_rep_does_not_toggle_a_roster_status(self, app, client, login, make_user):
|
||||
world = _build_world(app, make_user)
|
||||
_login_as(client, login, world['rep_username'])
|
||||
|
||||
response = client.post(f'/teams/{world["team"]}/toggle_status/{world["plain"]}', json={})
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestDesignation:
|
||||
"""Only the president names a responsible."""
|
||||
|
||||
@pytest.mark.parametrize('role', ['manager', 'coach', 'player', 'scout'])
|
||||
def test_a_non_president_cannot_designate(self, app, client, as_role, make_user, role):
|
||||
world = _build_world(app, make_user)
|
||||
target_id = make_user('player')
|
||||
as_role(role)
|
||||
|
||||
response = client.post(f'/teams/{world["team"]}/toggle_representative/{target_id}', json={})
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_the_president_designates_a_rep(self, app, client, as_role, make_user):
|
||||
world = _build_world(app, make_user)
|
||||
as_role('admin')
|
||||
|
||||
response = client.post(
|
||||
f'/teams/{world["team"]}/toggle_representative/{world["plain"]}', json={}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['is_team_representative'] is True
|
||||
|
||||
with app.app_context():
|
||||
row = TeamPlayer.query.filter_by(
|
||||
player_id=world['plain'], org_team_id=world['team']
|
||||
).one()
|
||||
assert row.is_team_representative is True
|
||||
|
||||
def test_a_rep_cannot_promote_another_player(self, app, client, login, make_user):
|
||||
world = _build_world(app, make_user)
|
||||
_login_as(client, login, world['rep_username'])
|
||||
|
||||
response = client.post(
|
||||
f'/teams/{world["team"]}/toggle_representative/{world["plain"]}', json={}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
with app.app_context():
|
||||
row = TeamPlayer.query.filter_by(
|
||||
player_id=world['plain'], org_team_id=world['team']
|
||||
).one()
|
||||
assert row.is_team_representative is False
|
||||
|
||||
|
||||
class TestReachingThePage:
|
||||
"""Reuse of the teams page, scoped (decision 4)."""
|
||||
|
||||
def test_a_plain_player_is_sent_to_their_own_view(self, client, login, app, make_user):
|
||||
world = _build_world(app, make_user)
|
||||
_login_as(client, login, world['plain_username'])
|
||||
|
||||
response = client.get('/teams', follow_redirects=False)
|
||||
assert _redirected(response)
|
||||
assert '/teams/my-teams' in response.headers['Location']
|
||||
|
||||
def test_a_rep_settles_on_the_teams_page(self, client, login, app, make_user):
|
||||
world = _build_world(app, make_user)
|
||||
_login_as(client, login, world['rep_username'])
|
||||
|
||||
assert client.get('/teams').status_code == 200
|
||||
|
||||
def test_the_page_offers_matches_but_not_the_roster(self, client, login, app, make_user):
|
||||
world = _build_world(app, make_user)
|
||||
_login_as(client, login, world['rep_username'])
|
||||
|
||||
html = client.get('/teams').get_data(as_text=True)
|
||||
assert f'/team-matches/{world["team"]}/create' in html
|
||||
assert f'/teams/{world["team"]}/add_player' not in html
|
||||
|
||||
def test_the_my_teams_page_offers_the_schedule_buttons(self, client, login, app, make_user):
|
||||
"""A player's nav only has "My Team(s)", so the rep schedules there."""
|
||||
world = _build_world(app, make_user)
|
||||
_login_as(client, login, world['rep_username'])
|
||||
|
||||
html = client.get('/teams/my-teams').get_data(as_text=True)
|
||||
assert f'/team-matches/{world["team"]}/create' in html
|
||||
assert 'create?type=practice' in html
|
||||
|
||||
def test_the_my_teams_page_is_silent_for_a_plain_player(self, client, login, app, make_user):
|
||||
world = _build_world(app, make_user)
|
||||
_login_as(client, login, world['plain_username'])
|
||||
|
||||
html = client.get('/teams/my-teams').get_data(as_text=True)
|
||||
assert f'/team-matches/{world["team"]}/create' not in html
|
||||
|
||||
|
||||
class TestRosterOrder:
|
||||
"""The responsible heads the roster, on both pages that render it."""
|
||||
|
||||
def test_the_responsible_is_listed_first(self, app, make_user):
|
||||
world = _build_world(app, make_user)
|
||||
with app.app_context():
|
||||
team = db.session.get(OrgTeam, world['team'])
|
||||
roster = team.get_players_with_status()
|
||||
|
||||
assert [row['player'].id for row in roster] == [world['rep'], world['plain']]
|
||||
assert roster[0]['is_team_representative'] is True
|
||||
assert roster[1]['is_team_representative'] is False
|
||||
Reference in New Issue
Block a user