Add billing-ending banner, events CRUD, RBIHF import, public API, team photos, and sponsors
A large batch of club-management features built up over one session: - Club dashboard banner warning admins 1 month before billing ends - Full Events/EventSeries CRUD (recurrence builder, occurrence lifecycle, per-team permissions), with match->game rename and game-specific fields (score, competition, live status, external game ID) - Django-admin competition dropdown, gated per-club by feature flag - Auto-import of RBIHF fixtures (scrape -> diff -> preview -> confirm), with location/opponent dropdowns suggested from existing club data - Feature-flag-gated Shop/Forms nav sections, reusing the same flag machinery for the RBIHF import button - Team roster now scoped to members active this season or next, sorted and grouped by position - Club sport type (ice hockey / other), shown in the control panel's club subtitle - Per-season team photo upload from the team page - New public read-only API (Django Ninja) at /api/v1/: news, team rosters, upcoming/live/per-team games, and sponsors -- auto-documented via Swagger UI, CORS-enabled for a club's own external website - Club sponsors: admin-only CRUD (logo, URL, active date window) plus a date-windowed, optionally randomized API endpoint - Assorted fixes: NullBooleanField dropdown rendering, cross-club event validation timing, searchable-select chip placement, btn-neutral -> default button style sweep, calendar-month chart windows Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1gj3J1QPfP38XWpnpbFpy
This commit is contained in:
13
.idea/pyLspTools.xml
generated
13
.idea/pyLspTools.xml
generated
@@ -1,5 +1,18 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
|
<component name="PyToolsState">
|
||||||
|
<option name="tools">
|
||||||
|
<map>
|
||||||
|
<entry key="ruff">
|
||||||
|
<value>
|
||||||
|
<ToolEntry>
|
||||||
|
<option name="enabled" value="true" />
|
||||||
|
</ToolEntry>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
<component name="RuffConfiguration">
|
<component name="RuffConfiguration">
|
||||||
<option name="enabled" value="true" />
|
<option name="enabled" value="true" />
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
0
api/__init__.py
Normal file
0
api/__init__.py
Normal file
6
api/apps.py
Normal file
6
api/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ApiConfig(AppConfig):
|
||||||
|
name = "api"
|
||||||
|
verbose_name = "Public API"
|
||||||
17
api/errors.py
Normal file
17
api/errors.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""Shared error helpers for the public API -- see api/urls.py.
|
||||||
|
|
||||||
|
Every endpoint is club-scoped via the same subdomain-based tenant resolution
|
||||||
|
the rest of the platform uses (club.tenancy.ClubTenantMiddleware sets
|
||||||
|
request.club before any view runs). A request with no club on the host --
|
||||||
|
the bare base domain, an unknown slug, an archived club -- has nothing to
|
||||||
|
serve, so it 404s the same way club.mixins.ClubStaffRequiredMixin already
|
||||||
|
404s the staff-facing app off the base domain.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
|
||||||
|
|
||||||
|
def require_club(request):
|
||||||
|
if request.club is None:
|
||||||
|
raise HttpError(404, "No club found for this host.")
|
||||||
|
return request.club
|
||||||
31
api/middleware.py
Normal file
31
api/middleware.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""CORS for the public API only.
|
||||||
|
|
||||||
|
Every route under /api/v1/ is public, read-only, and unauthenticated -- no
|
||||||
|
cookies or credentials are ever involved, so there's no CSRF/session risk in
|
||||||
|
answering any origin. That's the whole reason this is a few lines here
|
||||||
|
instead of pulling in django-cors-headers for a handful of GET routes: the
|
||||||
|
rest of the site keeps Django's ordinary same-origin behaviour untouched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.http import HttpResponse
|
||||||
|
|
||||||
|
API_PATH_PREFIX = "/api/v1/"
|
||||||
|
|
||||||
|
|
||||||
|
class PublicApiCorsMiddleware:
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
if not request.path.startswith(API_PATH_PREFIX):
|
||||||
|
return self.get_response(request)
|
||||||
|
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
response = HttpResponse(status=204)
|
||||||
|
else:
|
||||||
|
response = self.get_response(request)
|
||||||
|
|
||||||
|
response["Access-Control-Allow-Origin"] = "*"
|
||||||
|
response["Access-Control-Allow-Methods"] = "GET, OPTIONS"
|
||||||
|
response["Access-Control-Allow-Headers"] = "Content-Type"
|
||||||
|
return response
|
||||||
451
api/tests.py
Normal file
451
api/tests.py
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from club.models import Club, Season, Sponsor
|
||||||
|
from events.models import Event, Location, Opponent
|
||||||
|
from members.models import Member
|
||||||
|
from news.models import News, NewsPhoto
|
||||||
|
from teams.models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
|
||||||
|
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "rival-fc.rosterchief.app", "testserver"],
|
||||||
|
)
|
||||||
|
class ApiTestBase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||||
|
today = timezone.localdate()
|
||||||
|
self.season = Season.objects.create(club=self.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300))
|
||||||
|
self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
|
||||||
|
|
||||||
|
def api_get(self, path, **params):
|
||||||
|
return self.client.get(f"/api/v1{path}", params, HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
def api_get_base_domain(self, path, **params):
|
||||||
|
return self.client.get(f"/api/v1{path}", params, HTTP_HOST="rosterchief.app")
|
||||||
|
|
||||||
|
|
||||||
|
class NewsApiTests(ApiTestBase):
|
||||||
|
def make_news(self, **overrides):
|
||||||
|
defaults = {"club": self.club, "title": "News", "body": "body", "status": News.Status.PUBLISHED, "published_at": timezone.now() - datetime.timedelta(hours=1), "visibility": News.Visibility.EXTERNAL}
|
||||||
|
defaults.update(overrides)
|
||||||
|
return News.objects.create(**defaults)
|
||||||
|
|
||||||
|
def test_a_draft_is_excluded(self):
|
||||||
|
self.make_news(status=News.Status.DRAFT, published_at=None)
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/news/").json()["count"], 0)
|
||||||
|
|
||||||
|
def test_a_scheduled_but_not_yet_released_item_is_excluded(self):
|
||||||
|
self.make_news(published_at=timezone.now() + datetime.timedelta(days=1))
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/news/").json()["count"], 0)
|
||||||
|
|
||||||
|
def test_an_internal_only_item_is_excluded(self):
|
||||||
|
self.make_news(visibility=News.Visibility.INTERNAL)
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/news/").json()["count"], 0)
|
||||||
|
|
||||||
|
def test_an_external_item_is_included(self):
|
||||||
|
item = self.make_news(visibility=News.Visibility.EXTERNAL)
|
||||||
|
|
||||||
|
data = self.api_get("/news/").json()
|
||||||
|
|
||||||
|
self.assertEqual(data["count"], 1)
|
||||||
|
self.assertEqual(data["results"][0]["id"], str(item.pk))
|
||||||
|
|
||||||
|
def test_a_both_visibility_item_is_included(self):
|
||||||
|
self.make_news(visibility=News.Visibility.BOTH)
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/news/").json()["count"], 1)
|
||||||
|
|
||||||
|
def test_newest_first(self):
|
||||||
|
older = self.make_news(title="Older", published_at=timezone.now() - datetime.timedelta(days=2))
|
||||||
|
newer = self.make_news(title="Newer", published_at=timezone.now() - datetime.timedelta(hours=1))
|
||||||
|
|
||||||
|
results = self.api_get("/news/").json()["results"]
|
||||||
|
|
||||||
|
self.assertEqual([r["id"] for r in results], [str(newer.pk), str(older.pk)])
|
||||||
|
|
||||||
|
def test_photos_get_absolute_urls(self):
|
||||||
|
item = self.make_news()
|
||||||
|
NewsPhoto.objects.create(news_item=item, image="clubs/ajax-united/news/x/pic.jpg", is_main=True)
|
||||||
|
|
||||||
|
photo = self.api_get("/news/").json()["results"][0]["photos"][0]
|
||||||
|
|
||||||
|
self.assertTrue(photo["url"].startswith("http://ajax-united.rosterchief.app/media/"))
|
||||||
|
self.assertTrue(photo["is_main"])
|
||||||
|
|
||||||
|
def test_pagination_limit_and_offset(self):
|
||||||
|
for i in range(3):
|
||||||
|
self.make_news(title=f"Item {i}", published_at=timezone.now() - datetime.timedelta(hours=1, minutes=i))
|
||||||
|
|
||||||
|
data = self.api_get("/news/", limit=1, offset=1).json()
|
||||||
|
|
||||||
|
self.assertEqual(data["count"], 3)
|
||||||
|
self.assertEqual(len(data["results"]), 1)
|
||||||
|
|
||||||
|
def test_limit_is_capped(self):
|
||||||
|
self.assertEqual(self.api_get("/news/", limit=1000).json()["limit"], 100)
|
||||||
|
|
||||||
|
def test_no_news_is_an_empty_list_not_an_error(self):
|
||||||
|
response = self.api_get("/news/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["results"], [])
|
||||||
|
|
||||||
|
|
||||||
|
class TeamsApiTests(ApiTestBase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.forward = Position.objects.create(club=self.club, name="Forward", short_name="FW", ordering=1)
|
||||||
|
self.defense = Position.objects.create(club=self.club, name="Defense", short_name="DF", ordering=2)
|
||||||
|
self.coach_position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
|
||||||
|
|
||||||
|
def test_list_teams(self):
|
||||||
|
data = self.api_get("/teams/").json()
|
||||||
|
|
||||||
|
self.assertEqual(data, [{"id": str(self.team.pk), "name": "First Team", "short_name": "1st", "photo_url": None}])
|
||||||
|
|
||||||
|
def test_roster_groups_players_by_position(self):
|
||||||
|
alice = Member.objects.create(first_name="Alice", last_name="Ash")
|
||||||
|
bob = Member.objects.create(first_name="Bob", last_name="Birch")
|
||||||
|
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
|
||||||
|
# Two forwards (ordering 1) at different jersey numbers, one defense (ordering 2).
|
||||||
|
TeamMembership.objects.create(team=self.team, member=bob, season=self.season, position=self.forward, jersey_number=9)
|
||||||
|
TeamMembership.objects.create(team=self.team, member=alice, season=self.season, position=self.forward, jersey_number=2)
|
||||||
|
TeamMembership.objects.create(team=self.team, member=carol, season=self.season, position=self.defense, jersey_number=1)
|
||||||
|
|
||||||
|
groups = self.api_get(f"/teams/{self.team.pk}/roster/").json()["players"]
|
||||||
|
|
||||||
|
# Groups in Position.ordering order (Forward before Defense); within a
|
||||||
|
# group, sorted by jersey number.
|
||||||
|
self.assertEqual([g["position"] for g in groups], ["Forward", "Defense"])
|
||||||
|
self.assertEqual([p["first_name"] for p in groups[0]["players"]], ["Alice", "Bob"])
|
||||||
|
self.assertEqual([p["first_name"] for p in groups[1]["players"]], ["Carol"])
|
||||||
|
|
||||||
|
def test_a_player_entry_no_longer_repeats_its_position(self):
|
||||||
|
# The position is now the group key, not a per-player field.
|
||||||
|
alice = Member.objects.create(first_name="Alice", last_name="Ash")
|
||||||
|
TeamMembership.objects.create(team=self.team, member=alice, season=self.season, position=self.forward, jersey_number=2)
|
||||||
|
|
||||||
|
player = self.api_get(f"/teams/{self.team.pk}/roster/").json()["players"][0]["players"][0]
|
||||||
|
|
||||||
|
self.assertNotIn("position", player)
|
||||||
|
|
||||||
|
def test_roster_includes_staff(self):
|
||||||
|
dana = Member.objects.create(first_name="Dana", last_name="Dean")
|
||||||
|
StaffAssignment.objects.create(team=self.team, member=dana, season=self.season, position=self.coach_position)
|
||||||
|
|
||||||
|
staff = self.api_get(f"/teams/{self.team.pk}/roster/").json()["staff"]
|
||||||
|
|
||||||
|
self.assertEqual(staff, [{"id": str(dana.pk), "first_name": "Dana", "last_name": "Dean", "position": "Head Coach"}])
|
||||||
|
|
||||||
|
def test_roster_is_current_season_only(self):
|
||||||
|
other_season = Season.objects.create(club=self.club, start_date=datetime.date(2000, 1, 1), end_date=datetime.date(2000, 12, 31))
|
||||||
|
eve = Member.objects.create(first_name="Eve", last_name="Elm")
|
||||||
|
TeamMembership.objects.create(team=self.team, member=eve, season=other_season, position=self.forward, jersey_number=1)
|
||||||
|
|
||||||
|
players = self.api_get(f"/teams/{self.team.pk}/roster/").json()["players"]
|
||||||
|
|
||||||
|
self.assertEqual(players, [])
|
||||||
|
|
||||||
|
def test_roster_is_empty_with_no_current_season(self):
|
||||||
|
self.season.delete()
|
||||||
|
team_without_season = self.team
|
||||||
|
|
||||||
|
data = self.api_get(f"/teams/{team_without_season.pk}/roster/").json()
|
||||||
|
|
||||||
|
self.assertEqual(data["season"], None)
|
||||||
|
self.assertEqual(data["players"], [])
|
||||||
|
self.assertEqual(data["staff"], [])
|
||||||
|
|
||||||
|
def test_list_teams_includes_the_current_seasons_photo(self):
|
||||||
|
TeamPhoto.objects.create(team=self.team, season=self.season, image="clubs/ajax-united/teams/x/26-27/pic.jpg")
|
||||||
|
|
||||||
|
photo_url = self.api_get("/teams/").json()[0]["photo_url"]
|
||||||
|
|
||||||
|
self.assertTrue(photo_url.startswith("http://ajax-united.rosterchief.app/media/"))
|
||||||
|
|
||||||
|
def test_roster_includes_the_current_seasons_photo(self):
|
||||||
|
TeamPhoto.objects.create(team=self.team, season=self.season, image="clubs/ajax-united/teams/x/26-27/pic.jpg")
|
||||||
|
|
||||||
|
photo_url = self.api_get(f"/teams/{self.team.pk}/roster/").json()["team"]["photo_url"]
|
||||||
|
|
||||||
|
self.assertTrue(photo_url.startswith("http://ajax-united.rosterchief.app/media/"))
|
||||||
|
|
||||||
|
def test_a_photo_from_a_different_season_does_not_leak(self):
|
||||||
|
other_season = Season.objects.create(club=self.club, start_date=datetime.date(2000, 1, 1), end_date=datetime.date(2000, 12, 31))
|
||||||
|
TeamPhoto.objects.create(team=self.team, season=other_season, image="clubs/ajax-united/teams/x/00-00/pic.jpg")
|
||||||
|
|
||||||
|
self.assertIsNone(self.api_get("/teams/").json()[0]["photo_url"])
|
||||||
|
self.assertIsNone(self.api_get(f"/teams/{self.team.pk}/roster/").json()["team"]["photo_url"])
|
||||||
|
|
||||||
|
def test_a_team_from_another_club_404s(self):
|
||||||
|
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
|
||||||
|
other_team = Team.objects.create(club=other_club, name="Rival Team", short_name="RIV")
|
||||||
|
|
||||||
|
response = self.api_get(f"/teams/{other_team.pk}/roster/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
|
class GamesApiTests(ApiTestBase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.home_location = Location.objects.create(club=self.club, name="Home Arena", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
|
||||||
|
self.opponent = Opponent.objects.create(club=self.club, name="Rivals FC")
|
||||||
|
|
||||||
|
def make_game(self, **overrides):
|
||||||
|
defaults = {"club": self.club, "title": "Game", "kind": Event.EventKind.GAME, "start": timezone.now() + datetime.timedelta(days=1), "opponent": self.opponent}
|
||||||
|
defaults.update(overrides)
|
||||||
|
event = Event.objects.create(**defaults)
|
||||||
|
event.teams.add(self.team)
|
||||||
|
return event
|
||||||
|
|
||||||
|
def test_upcoming_games_are_listed_with_location_and_teams(self):
|
||||||
|
self.make_game(location=self.home_location)
|
||||||
|
|
||||||
|
games = self.api_get("/games/upcoming/").json()
|
||||||
|
|
||||||
|
self.assertEqual(len(games), 1)
|
||||||
|
self.assertEqual(games[0]["home_team"], "First Team")
|
||||||
|
self.assertEqual(games[0]["away_team"], "Rivals FC")
|
||||||
|
self.assertEqual(games[0]["location"]["name"], "Home Arena")
|
||||||
|
self.assertEqual(games[0]["status"], "upcoming")
|
||||||
|
|
||||||
|
def test_upcoming_excludes_cancelled_games(self):
|
||||||
|
self.make_game(cancelled=True)
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/games/upcoming/").json(), [])
|
||||||
|
|
||||||
|
def test_upcoming_excludes_past_games(self):
|
||||||
|
self.make_game(start=timezone.now() - datetime.timedelta(days=1))
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/games/upcoming/").json(), [])
|
||||||
|
|
||||||
|
def test_upcoming_includes_tournaments(self):
|
||||||
|
self.make_game(kind=Event.EventKind.TOURNAMENT)
|
||||||
|
|
||||||
|
self.assertEqual(len(self.api_get("/games/upcoming/").json()), 1)
|
||||||
|
|
||||||
|
def test_upcoming_excludes_other_kinds(self):
|
||||||
|
self.make_game(kind=Event.EventKind.TRAINING)
|
||||||
|
self.make_game(kind=Event.EventKind.SOCIAL)
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/games/upcoming/").json(), [])
|
||||||
|
|
||||||
|
def test_live_endpoint_stays_game_only(self):
|
||||||
|
# is_live/scores are game-specific -- a tournament wouldn't have
|
||||||
|
# anything meaningful to show here even if flagged live.
|
||||||
|
self.make_game(kind=Event.EventKind.TOURNAMENT, start=timezone.now() - datetime.timedelta(minutes=10), is_live=True)
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/games/live/").json(), [])
|
||||||
|
|
||||||
|
def test_count_is_respected(self):
|
||||||
|
for i in range(3):
|
||||||
|
self.make_game(start=timezone.now() + datetime.timedelta(days=i + 1))
|
||||||
|
|
||||||
|
self.assertEqual(len(self.api_get("/games/upcoming/", count=2).json()), 2)
|
||||||
|
|
||||||
|
def test_count_is_capped(self):
|
||||||
|
for i in range(3):
|
||||||
|
self.make_game(start=timezone.now() + datetime.timedelta(days=i + 1))
|
||||||
|
|
||||||
|
# Cap is 50, well above the 3 created -- just confirm an oversized
|
||||||
|
# request doesn't error and doesn't somehow exceed what exists.
|
||||||
|
response = self.api_get("/games/upcoming/", count=1000)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(len(response.json()), 3)
|
||||||
|
|
||||||
|
def test_live_games_are_listed_with_scores(self):
|
||||||
|
self.make_game(start=timezone.now() - datetime.timedelta(minutes=10), is_live=True, score_for=2, score_against=1, location=self.home_location)
|
||||||
|
|
||||||
|
games = self.api_get("/games/live/").json()
|
||||||
|
|
||||||
|
self.assertEqual(len(games), 1)
|
||||||
|
self.assertEqual(games[0]["status"], "live")
|
||||||
|
self.assertEqual(games[0]["home_score"], 2)
|
||||||
|
self.assertEqual(games[0]["away_score"], 1)
|
||||||
|
|
||||||
|
def test_live_excludes_cancelled_games(self):
|
||||||
|
self.make_game(is_live=True, cancelled=True)
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/games/live/").json(), [])
|
||||||
|
|
||||||
|
def test_non_live_games_are_excluded_from_live_endpoint(self):
|
||||||
|
self.make_game()
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/games/live/").json(), [])
|
||||||
|
|
||||||
|
def test_score_relabelling_for_an_away_game(self):
|
||||||
|
# No location (or a non-home one) -- is_home_game is False, so our
|
||||||
|
# team's score_for/score_against map to the away side.
|
||||||
|
self.make_game(start=timezone.now() - datetime.timedelta(days=1), score_for=4, score_against=3)
|
||||||
|
|
||||||
|
games = self.api_get(f"/teams/{self.team.pk}/games/").json()
|
||||||
|
|
||||||
|
self.assertEqual(games[0]["home_team"], "Rivals FC")
|
||||||
|
self.assertEqual(games[0]["away_team"], "First Team")
|
||||||
|
self.assertEqual(games[0]["home_score"], 3)
|
||||||
|
self.assertEqual(games[0]["away_score"], 4)
|
||||||
|
self.assertEqual(games[0]["status"], "finished")
|
||||||
|
|
||||||
|
def test_score_relabelling_for_a_home_game(self):
|
||||||
|
self.make_game(start=timezone.now() - datetime.timedelta(days=1), score_for=4, score_against=3, location=self.home_location)
|
||||||
|
|
||||||
|
games = self.api_get(f"/teams/{self.team.pk}/games/").json()
|
||||||
|
|
||||||
|
self.assertEqual(games[0]["home_team"], "First Team")
|
||||||
|
self.assertEqual(games[0]["away_team"], "Rivals FC")
|
||||||
|
self.assertEqual(games[0]["home_score"], 4)
|
||||||
|
self.assertEqual(games[0]["away_score"], 3)
|
||||||
|
|
||||||
|
def test_team_games_includes_past_and_upcoming_for_the_current_season(self):
|
||||||
|
self.make_game(title="Past", start=timezone.now() - datetime.timedelta(days=1), score_for=1, score_against=0)
|
||||||
|
self.make_game(title="Future", start=timezone.now() + datetime.timedelta(days=1))
|
||||||
|
|
||||||
|
games = self.api_get(f"/teams/{self.team.pk}/games/").json()
|
||||||
|
|
||||||
|
self.assertEqual(len(games), 2)
|
||||||
|
|
||||||
|
def test_team_games_excludes_a_different_season(self):
|
||||||
|
other_season = Season.objects.create(club=self.club, start_date=datetime.date(2000, 1, 1), end_date=datetime.date(2000, 12, 31))
|
||||||
|
self.make_game(start=datetime.datetime(2000, 6, 1, tzinfo=datetime.UTC), season=other_season)
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get(f"/teams/{self.team.pk}/games/").json(), [])
|
||||||
|
|
||||||
|
def test_team_games_excludes_cancelled(self):
|
||||||
|
self.make_game(cancelled=True)
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get(f"/teams/{self.team.pk}/games/").json(), [])
|
||||||
|
|
||||||
|
def test_team_games_is_empty_with_no_current_season(self):
|
||||||
|
self.make_game()
|
||||||
|
self.season.delete()
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get(f"/teams/{self.team.pk}/games/").json(), [])
|
||||||
|
|
||||||
|
def test_a_team_from_another_club_404s_on_games(self):
|
||||||
|
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
|
||||||
|
other_team = Team.objects.create(club=other_club, name="Rival Team", short_name="RIV")
|
||||||
|
|
||||||
|
response = self.api_get(f"/teams/{other_team.pk}/games/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
|
class TenancyAndCorsTests(ApiTestBase):
|
||||||
|
def test_the_base_domain_404s(self):
|
||||||
|
response = self.api_get_base_domain("/news/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_a_get_response_carries_the_cors_header(self):
|
||||||
|
response = self.api_get("/news/")
|
||||||
|
|
||||||
|
self.assertEqual(response["Access-Control-Allow-Origin"], "*")
|
||||||
|
|
||||||
|
def test_an_options_preflight_gets_a_204_with_cors_headers(self):
|
||||||
|
response = self.client.options("/api/v1/news/", HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
self.assertEqual(response["Access-Control-Allow-Origin"], "*")
|
||||||
|
self.assertIn("GET", response["Access-Control-Allow-Methods"])
|
||||||
|
|
||||||
|
def test_cors_headers_are_not_added_outside_the_api(self):
|
||||||
|
response = self.client.get("/", HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
self.assertNotIn("Access-Control-Allow-Origin", response)
|
||||||
|
|
||||||
|
def test_docs_page_resolves(self):
|
||||||
|
self.assertEqual(self.api_get("/docs").status_code, 200)
|
||||||
|
|
||||||
|
def test_openapi_schema_resolves(self):
|
||||||
|
self.assertEqual(self.api_get("/openapi.json").status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
class SponsorApiTests(ApiTestBase):
|
||||||
|
def make_sponsor(self, **overrides):
|
||||||
|
today = timezone.localdate()
|
||||||
|
defaults = {"club": self.club, "name": "Acme Corp", "start_date": today - datetime.timedelta(days=10), "end_date": today + datetime.timedelta(days=10)}
|
||||||
|
defaults.update(overrides)
|
||||||
|
return Sponsor.objects.create(**defaults)
|
||||||
|
|
||||||
|
def test_a_sponsor_covering_today_is_included(self):
|
||||||
|
self.make_sponsor()
|
||||||
|
|
||||||
|
data = self.api_get("/sponsors/").json()
|
||||||
|
|
||||||
|
self.assertEqual(len(data), 1)
|
||||||
|
self.assertEqual(data[0]["name"], "Acme Corp")
|
||||||
|
|
||||||
|
def test_a_sponsor_starting_in_the_future_is_excluded(self):
|
||||||
|
today = timezone.localdate()
|
||||||
|
self.make_sponsor(start_date=today + datetime.timedelta(days=1), end_date=None)
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/sponsors/").json(), [])
|
||||||
|
|
||||||
|
def test_a_sponsor_that_already_ended_is_excluded(self):
|
||||||
|
today = timezone.localdate()
|
||||||
|
self.make_sponsor(start_date=today - datetime.timedelta(days=20), end_date=today - datetime.timedelta(days=1))
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/sponsors/").json(), [])
|
||||||
|
|
||||||
|
def test_a_sponsor_with_no_end_date_and_a_past_start_is_included(self):
|
||||||
|
today = timezone.localdate()
|
||||||
|
self.make_sponsor(start_date=today - datetime.timedelta(days=100), end_date=None)
|
||||||
|
|
||||||
|
self.assertEqual(len(self.api_get("/sponsors/").json()), 1)
|
||||||
|
|
||||||
|
def test_a_sponsor_starting_today_is_included(self):
|
||||||
|
today = timezone.localdate()
|
||||||
|
self.make_sponsor(start_date=today, end_date=None)
|
||||||
|
|
||||||
|
self.assertEqual(len(self.api_get("/sponsors/").json()), 1)
|
||||||
|
|
||||||
|
def test_a_sponsor_ending_today_is_included(self):
|
||||||
|
today = timezone.localdate()
|
||||||
|
self.make_sponsor(start_date=today - datetime.timedelta(days=10), end_date=today)
|
||||||
|
|
||||||
|
self.assertEqual(len(self.api_get("/sponsors/").json()), 1)
|
||||||
|
|
||||||
|
def test_another_clubs_sponsor_never_leaks_in(self):
|
||||||
|
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
|
||||||
|
self.make_sponsor(club=other_club)
|
||||||
|
|
||||||
|
self.assertEqual(self.api_get("/sponsors/").json(), [])
|
||||||
|
|
||||||
|
def test_logo_url_is_absolute_when_set(self):
|
||||||
|
self.make_sponsor(logo="clubs/ajax-united/sponsors/x/logo.png")
|
||||||
|
|
||||||
|
logo_url = self.api_get("/sponsors/").json()[0]["logo_url"]
|
||||||
|
|
||||||
|
self.assertTrue(logo_url.startswith("http://ajax-united.rosterchief.app/media/"))
|
||||||
|
|
||||||
|
def test_logo_url_is_null_when_not_set(self):
|
||||||
|
self.make_sponsor()
|
||||||
|
|
||||||
|
self.assertIsNone(self.api_get("/sponsors/").json()[0]["logo_url"])
|
||||||
|
|
||||||
|
def test_randomize_returns_the_same_set_of_sponsors(self):
|
||||||
|
for i in range(5):
|
||||||
|
self.make_sponsor(name=f"Sponsor {i}")
|
||||||
|
|
||||||
|
stable = {s["id"] for s in self.api_get("/sponsors/").json()}
|
||||||
|
randomized = {s["id"] for s in self.api_get("/sponsors/", randomize="true").json()}
|
||||||
|
|
||||||
|
self.assertEqual(stable, randomized)
|
||||||
|
self.assertEqual(len(stable), 5)
|
||||||
|
|
||||||
|
def test_default_order_is_stable_and_alphabetical(self):
|
||||||
|
self.make_sponsor(name="Zulu Corp")
|
||||||
|
self.make_sponsor(name="Acme Corp")
|
||||||
|
|
||||||
|
names = [s["name"] for s in self.api_get("/sponsors/").json()]
|
||||||
|
|
||||||
|
self.assertEqual(names, ["Acme Corp", "Zulu Corp"])
|
||||||
28
api/urls.py
Normal file
28
api/urls.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""The public read-only API -- see ARCHITECTURE.md and the plan this shipped
|
||||||
|
under. Mounted at /api/v1/ (rosterchief/urls.py), club-scoped by the same
|
||||||
|
subdomain-based tenant resolution every other view uses
|
||||||
|
(club.tenancy.ClubTenantMiddleware sets request.club before this ever runs).
|
||||||
|
|
||||||
|
Each domain app owns its own router and schemas (news/api.py, teams/api.py,
|
||||||
|
events/api.py) -- this module only wires them together, same reasoning as
|
||||||
|
management/controlpanel never owning domain logic themselves.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from ninja import NinjaAPI
|
||||||
|
|
||||||
|
from club.api import router as club_router
|
||||||
|
from events.api import router as events_router
|
||||||
|
from news.api import router as news_router
|
||||||
|
from teams.api import router as teams_router
|
||||||
|
|
||||||
|
api = NinjaAPI(
|
||||||
|
title="RosterChief public API",
|
||||||
|
version="1.0.0",
|
||||||
|
description="Public, read-only data for a club's own external website: news, team rosters, fixtures, and sponsors.",
|
||||||
|
urls_namespace="api",
|
||||||
|
)
|
||||||
|
|
||||||
|
api.add_router("/news", news_router)
|
||||||
|
api.add_router("/teams", teams_router)
|
||||||
|
api.add_router("/", events_router)
|
||||||
|
api.add_router("/sponsors", club_router)
|
||||||
@@ -295,7 +295,7 @@ class TwoFactorPageTests(TestCase):
|
|||||||
self.assertNotContains(self.response, 'placeholder="Code"')
|
self.assertNotContains(self.response, 'placeholder="Code"')
|
||||||
|
|
||||||
def test_cancel_sits_beside_sign_in_and_is_not_primary(self):
|
def test_cancel_sits_beside_sign_in_and_is_not_primary(self):
|
||||||
self.assertContains(self.response, '<button class="btn btn-outline btn-neutral gap-2" type="submit" form="logout-from-stage">')
|
self.assertContains(self.response, '<button class="btn btn-outline gap-2" type="submit" form="logout-from-stage">')
|
||||||
self.assertContains(self.response, '<button class="btn btn-primary gap-2" type="submit">')
|
self.assertContains(self.response, '<button class="btn btn-primary gap-2" type="submit">')
|
||||||
|
|
||||||
def test_cancel_has_a_form_to_submit(self):
|
def test_cancel_has_a_form_to_submit(self):
|
||||||
@@ -388,7 +388,7 @@ class SignOutPageTests(TestCase):
|
|||||||
|
|
||||||
def test_sign_out_and_cancel_sit_side_by_side_with_icons(self):
|
def test_sign_out_and_cancel_sit_side_by_side_with_icons(self):
|
||||||
html = self.response.content.decode()
|
html = self.response.content.decode()
|
||||||
cancel = html[html.index('<a class="btn btn-outline btn-neutral gap-2" href="/">') :]
|
cancel = html[html.index('<a class="btn btn-outline gap-2" href="/">') :]
|
||||||
sign_out = html[html.index('<button class="btn btn-primary gap-2"') :]
|
sign_out = html[html.index('<button class="btn btn-primary gap-2"') :]
|
||||||
|
|
||||||
self.assertIn("<svg", cancel[: cancel.index("</a>")])
|
self.assertIn("<svg", cancel[: cancel.index("</a>")])
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from .models import Club, ClubMembership, ClubRole, FeePayment, Season
|
from .models import Club, ClubMembership, ClubRole, FeePayment, Season, Sponsor
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Club)
|
@admin.register(Club)
|
||||||
class ClubAdmin(admin.ModelAdmin):
|
class ClubAdmin(admin.ModelAdmin):
|
||||||
list_display = ["name", "slug"]
|
list_display = ["name", "slug", "sport_type"]
|
||||||
|
list_filter = ["sport_type"]
|
||||||
search_fields = ["name", "slug"]
|
search_fields = ["name", "slug"]
|
||||||
prepopulated_fields = {"slug": ["name"]}
|
prepopulated_fields = {"slug": ["name"]}
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Sponsor)
|
||||||
|
class SponsorAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["name", "club", "start_date", "end_date"]
|
||||||
|
list_filter = ["club"]
|
||||||
|
search_fields = ["name"]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Season)
|
@admin.register(Season)
|
||||||
class SeasonAdmin(admin.ModelAdmin):
|
class SeasonAdmin(admin.ModelAdmin):
|
||||||
list_display = ["__str__", "club", "start_date", "end_date"]
|
list_display = ["__str__", "club", "start_date", "end_date"]
|
||||||
|
|||||||
58
club/api.py
Normal file
58
club/api.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""Public read-only sponsors endpoint -- see api/urls.py for how this is
|
||||||
|
mounted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.utils import timezone
|
||||||
|
from ninja import Router, Schema
|
||||||
|
|
||||||
|
from api.errors import require_club
|
||||||
|
|
||||||
|
from .models import Sponsor
|
||||||
|
|
||||||
|
router = Router(tags=["sponsors"])
|
||||||
|
|
||||||
|
|
||||||
|
class SponsorOut(Schema):
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
logo_url: str | None
|
||||||
|
url: str | None
|
||||||
|
start_date: date
|
||||||
|
end_date: date | None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_sponsor_out(sponsor, request) -> SponsorOut:
|
||||||
|
return SponsorOut(
|
||||||
|
id=sponsor.pk,
|
||||||
|
name=sponsor.name,
|
||||||
|
logo_url=request.build_absolute_uri(sponsor.logo.url) if sponsor.logo else None,
|
||||||
|
url=sponsor.url or None,
|
||||||
|
start_date=sponsor.start_date,
|
||||||
|
end_date=sponsor.end_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response=list[SponsorOut], summary="Active sponsors")
|
||||||
|
def list_sponsors(request, randomize: bool = False):
|
||||||
|
"""Sponsors currently "live": start_date has passed and either there's no
|
||||||
|
end_date (runs indefinitely once started) or it hasn't passed yet. Both
|
||||||
|
bounds are inclusive of today.
|
||||||
|
|
||||||
|
`randomize=true` shuffles the result (e.g. for a sponsor strip that
|
||||||
|
shouldn't always lead with the same one) -- shuffled in Python after a
|
||||||
|
stable-ordered fetch rather than an ORDER BY RANDOM(), which sponsor
|
||||||
|
counts are far too small to need and which SQLite/Postgres don't even
|
||||||
|
express the same way."""
|
||||||
|
club = require_club(request)
|
||||||
|
today = timezone.localdate()
|
||||||
|
|
||||||
|
sponsors = list(Sponsor.objects.filter(club=club, start_date__lte=today).filter(Q(end_date__isnull=True) | Q(end_date__gte=today)).order_by("name"))
|
||||||
|
if randomize:
|
||||||
|
random.shuffle(sponsors)
|
||||||
|
|
||||||
|
return [_to_sponsor_out(sponsor, request) for sponsor in sponsors]
|
||||||
18
club/migrations/0018_club_sport_type.py
Normal file
18
club/migrations/0018_club_sport_type.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-08-04 22:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('club', '0017_club_season_duration_months_club_season_start'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='club',
|
||||||
|
name='sport_type',
|
||||||
|
field=models.CharField(choices=[('ice_hockey', 'Ice hockey'), ('other', 'Other')], default='other', help_text='Which sport this club plays -- determines which competitions and score fetchers are relevant to it.', max_length=20, verbose_name='sport'),
|
||||||
|
),
|
||||||
|
]
|
||||||
36
club/migrations/0019_sponsor.py
Normal file
36
club/migrations/0019_sponsor.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-08-06 15:02
|
||||||
|
|
||||||
|
import club.models
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('club', '0018_club_sport_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Sponsor',
|
||||||
|
fields=[
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||||
|
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||||
|
('logo', models.FileField(blank=True, upload_to=club.models.sponsor_logo_path, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'])], verbose_name='logo')),
|
||||||
|
('url', models.URLField(blank=True, help_text="The sponsor's own website, if they have one.", verbose_name='URL')),
|
||||||
|
('start_date', models.DateField(verbose_name='start date')),
|
||||||
|
('end_date', models.DateField(blank=True, help_text='Leave blank to keep this sponsor active indefinitely once it starts.', null=True, verbose_name='end date')),
|
||||||
|
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'sponsor',
|
||||||
|
'verbose_name_plural': 'sponsors',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
|
from waffle import flag_is_active
|
||||||
|
|
||||||
from .services.access import can_add_news, can_edit_news, can_publish_news, has_management_access, is_club_admin, is_coach_manager, teams_managed_by
|
from .services.access import can_add_news, can_edit_news, can_publish_news, has_management_access, is_club_admin, is_coach_manager, teams_managed_by
|
||||||
|
|
||||||
@@ -36,6 +37,27 @@ class ClubAdminRequiredMixin(ClubStaffRequiredMixin):
|
|||||||
return is_club_admin(self.request.user, self.request.club)
|
return is_club_admin(self.request.user, self.request.club)
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureRequiredMixin(ClubAdminRequiredMixin):
|
||||||
|
"""Gate for a whole management section (shop, forms, ...) this club doesn't
|
||||||
|
have at all unless its waffle Flag (see the ``features`` app, set per-club
|
||||||
|
from the control panel's Features page) is active for it. Checked before
|
||||||
|
the admin-only test below and as a plain 404 rather than folded into
|
||||||
|
``test_func``'s 403: a club with the feature off doesn't have a permissions
|
||||||
|
problem, the section just doesn't exist there, same reasoning as
|
||||||
|
``ClubStaffRequiredMixin`` 404ing the whole app off the base domain.
|
||||||
|
|
||||||
|
Subclasses set ``feature_flag`` to the Flag's name, e.g. ``"shop"``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
feature_flag: str = ""
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
club = getattr(request, "club", None)
|
||||||
|
if club is not None and not flag_is_active(request, self.feature_flag):
|
||||||
|
raise Http404(f"The “{self.feature_flag}” feature isn't enabled for this club.")
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class TeamManagerRequiredMixin(ClubStaffRequiredMixin):
|
class TeamManagerRequiredMixin(ClubStaffRequiredMixin):
|
||||||
"""A manager of *this* team, or a club ADMIN. ``self.get_team()`` must return the
|
"""A manager of *this* team, or a club ADMIN. ``self.get_team()`` must return the
|
||||||
``Team`` the view acts on (e.g. from the URL's ``pk``) before ``test_func`` runs.
|
``Team`` the view acts on (e.g. from the URL's ``pk``) before ``test_func`` runs.
|
||||||
@@ -51,6 +73,23 @@ class TeamManagerRequiredMixin(ClubStaffRequiredMixin):
|
|||||||
return teams_managed_by(user, club).filter(pk=self.get_team().pk).exists()
|
return teams_managed_by(user, club).filter(pk=self.get_team().pk).exists()
|
||||||
|
|
||||||
|
|
||||||
|
class EventManagerRequiredMixin(ClubStaffRequiredMixin):
|
||||||
|
"""Admin, or a manager of at least one of this event's/series' *current*
|
||||||
|
teams. ``self.get_teams()`` must return the Team queryset/iterable the
|
||||||
|
view acts on (e.g. ``self.get_object().teams.all()``) before ``test_func``
|
||||||
|
runs. Events/series aren't single-team like a roster entry -- ``teams`` is
|
||||||
|
M2M, so authority is "manages at least one", not "manages the one"."""
|
||||||
|
|
||||||
|
def get_teams(self):
|
||||||
|
raise NotImplementedError("Subclasses must return the Teams this view acts on.")
|
||||||
|
|
||||||
|
def test_func(self):
|
||||||
|
user, club = self.request.user, self.request.club
|
||||||
|
if is_club_admin(user, club):
|
||||||
|
return True
|
||||||
|
return teams_managed_by(user, club).filter(pk__in=self.get_teams().values_list("pk", flat=True)).exists()
|
||||||
|
|
||||||
|
|
||||||
class ManagementPositionRequiredMixin(ClubStaffRequiredMixin):
|
class ManagementPositionRequiredMixin(ClubStaffRequiredMixin):
|
||||||
"""ADMIN, or anyone with a current-season *management*-position
|
"""ADMIN, or anyone with a current-season *management*-position
|
||||||
StaffAssignment on any team -- unlike ``TeamManagerRequiredMixin``, the
|
StaffAssignment on any team -- unlike ``TeamManagerRequiredMixin``, the
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import datetime
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import FileExtensionValidator, MaxValueValidator, MinValueValidator, RegexValidator
|
from django.core.validators import FileExtensionValidator, MaxValueValidator, MinValueValidator, RegexValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -30,6 +31,13 @@ def club_logo_path(instance: Club, filename: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
class Club(UUIDModel):
|
class Club(UUIDModel):
|
||||||
|
class SportType(models.TextChoices):
|
||||||
|
"""Which sport this club plays. Only two options for now -- expand this as
|
||||||
|
more sport-specific competition fetchers (see events.competition) are added."""
|
||||||
|
|
||||||
|
ICE_HOCKEY = "ice_hockey", _("Ice hockey")
|
||||||
|
OTHER = "other", _("Other")
|
||||||
|
|
||||||
name = models.CharField(_("name"), max_length=255)
|
name = models.CharField(_("name"), max_length=255)
|
||||||
slug = models.SlugField(_("slug"), max_length=255, unique=True, blank=True, help_text=_("Drives subdomain / path resolution (e.g. ajax-united.rosterchief.app)."))
|
slug = models.SlugField(_("slug"), max_length=255, unique=True, blank=True, help_text=_("Drives subdomain / path resolution (e.g. ajax-united.rosterchief.app)."))
|
||||||
|
|
||||||
@@ -58,6 +66,14 @@ class Club(UUIDModel):
|
|||||||
help_text=_("Hex colour for highlights on the club's pages, e.g. avatar initials. Defaults to the theme's secondary colour."),
|
help_text=_("Hex colour for highlights on the club's pages, e.g. avatar initials. Defaults to the theme's secondary colour."),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
sport_type = models.CharField(
|
||||||
|
_("sport"),
|
||||||
|
max_length=20,
|
||||||
|
choices=SportType.choices,
|
||||||
|
default=SportType.OTHER,
|
||||||
|
help_text=_("Which sport this club plays -- determines which competitions and score fetchers are relevant to it."),
|
||||||
|
)
|
||||||
|
|
||||||
archived_at = models.DateTimeField(_("archived at"), null=True, blank=True, help_text=_("Archived clubs stop resolving on their subdomain, but their data is retained."))
|
archived_at = models.DateTimeField(_("archived at"), null=True, blank=True, help_text=_("Archived clubs stop resolving on their subdomain, but their data is retained."))
|
||||||
|
|
||||||
season_start = models.DateField(
|
season_start = models.DateField(
|
||||||
@@ -143,6 +159,39 @@ class Club(UUIDModel):
|
|||||||
self.save(update_fields=["archived_at"])
|
self.save(update_fields=["archived_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def sponsor_logo_path(instance: Sponsor, filename: str) -> str:
|
||||||
|
return f"clubs/{instance.club.slug}/sponsors/{instance.pk}/{filename}"
|
||||||
|
|
||||||
|
|
||||||
|
class Sponsor(ClubScopedModel):
|
||||||
|
name = models.CharField(_("name"), max_length=255)
|
||||||
|
logo = models.FileField(
|
||||||
|
_("logo"),
|
||||||
|
upload_to=sponsor_logo_path,
|
||||||
|
blank=True,
|
||||||
|
# A plain FileField, not ImageField: same reasoning as Club.logo -- a
|
||||||
|
# sponsor's own logo is just as commonly a vector file, and ImageField's
|
||||||
|
# Pillow validation can't read those.
|
||||||
|
validators=[FileExtensionValidator(allowed_extensions=["png", "jpg", "jpeg", "gif", "webp", "svg"])],
|
||||||
|
)
|
||||||
|
url = models.URLField(_("URL"), blank=True, help_text=_("The sponsor's own website, if they have one."))
|
||||||
|
|
||||||
|
start_date = models.DateField(_("start date"))
|
||||||
|
end_date = models.DateField(_("end date"), null=True, blank=True, help_text=_("Leave blank to keep this sponsor active indefinitely once it starts."))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("sponsor")
|
||||||
|
verbose_name_plural = _("sponsors")
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
if self.end_date is not None and self.start_date is not None and self.end_date < self.start_date:
|
||||||
|
raise ValidationError({"end_date": _("End date can't be before the start date.")})
|
||||||
|
|
||||||
|
|
||||||
class Season(ClubScopedModel):
|
class Season(ClubScopedModel):
|
||||||
start_date = models.DateField(_("start date"))
|
start_date = models.DateField(_("start date"))
|
||||||
end_date = models.DateField(_("end date"))
|
end_date = models.DateField(_("end date"))
|
||||||
@@ -175,6 +224,12 @@ class Season(ClubScopedModel):
|
|||||||
"""Return ``club``'s season covering ``date`` (no tenant context needed)."""
|
"""Return ``club``'s season covering ``date`` (no tenant context needed)."""
|
||||||
return cls.objects.filter(club=club, start_date__lte=date, end_date__gte=date).first()
|
return cls.objects.filter(club=club, start_date__lte=date, end_date__gte=date).first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def next_after(cls, club, date: datetime.date):
|
||||||
|
"""Return ``club``'s soonest season starting after ``date`` (no tenant
|
||||||
|
context needed) -- the season that follows the one covering ``date``."""
|
||||||
|
return cls.objects.filter(club=club, start_date__gt=date).order_by("start_date").first()
|
||||||
|
|
||||||
|
|
||||||
class ClubMembership(ClubScopedModel):
|
class ClubMembership(ClubScopedModel):
|
||||||
class StatusChoices(models.TextChoices):
|
class StatusChoices(models.TextChoices):
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from events.models import Event
|
|||||||
from members.models import Family, FamilyMembership, Member
|
from members.models import Family, FamilyMembership, Member
|
||||||
from teams.models import Position, StaffAssignment, Team, TeamMembership
|
from teams.models import Position, StaffAssignment, Team, TeamMembership
|
||||||
|
|
||||||
from .models import Club, ClubMembership, ClubRole, FeePayment, Season, club_logo_path
|
from .models import Club, ClubMembership, ClubRole, FeePayment, Season, Sponsor, club_logo_path
|
||||||
from .services.access import (
|
from .services.access import (
|
||||||
COACH_MANAGER,
|
COACH_MANAGER,
|
||||||
can_edit_event,
|
can_edit_event,
|
||||||
@@ -464,6 +464,56 @@ class SeasonGetCurrentTests(TestCase):
|
|||||||
Season.get_current(datetime.date(2026, 12, 25))
|
Season.get_current(datetime.date(2026, 12, 25))
|
||||||
|
|
||||||
|
|
||||||
|
class SeasonNextAfterTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||||
|
self.current = Season.objects.create(club=self.club, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31))
|
||||||
|
self.next_season = Season.objects.create(club=self.club, start_date=datetime.date(2027, 8, 1), end_date=datetime.date(2028, 5, 31))
|
||||||
|
|
||||||
|
def test_returns_the_soonest_season_starting_after_the_date(self):
|
||||||
|
self.assertEqual(Season.next_after(self.club, datetime.date(2026, 12, 25)), self.next_season)
|
||||||
|
|
||||||
|
def test_returns_none_when_there_is_no_later_season(self):
|
||||||
|
self.assertIsNone(Season.next_after(self.club, datetime.date(2027, 12, 25)))
|
||||||
|
|
||||||
|
def test_is_scoped_to_the_given_club(self):
|
||||||
|
other = Club.objects.create(name="Rival FC", slug="rival-fc")
|
||||||
|
Season.objects.create(club=other, start_date=datetime.date(2027, 8, 1), end_date=datetime.date(2028, 5, 31))
|
||||||
|
|
||||||
|
self.assertEqual(Season.next_after(other, datetime.date(2026, 12, 25)).club, other)
|
||||||
|
|
||||||
|
|
||||||
|
class SponsorModelTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||||
|
|
||||||
|
def test_str_returns_name(self):
|
||||||
|
sponsor = Sponsor.objects.create(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1))
|
||||||
|
|
||||||
|
self.assertEqual(str(sponsor), "Acme Corp")
|
||||||
|
|
||||||
|
def test_a_blank_end_date_is_valid(self):
|
||||||
|
sponsor = Sponsor(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1))
|
||||||
|
sponsor.full_clean()
|
||||||
|
|
||||||
|
def test_an_end_date_on_the_same_day_as_start_is_valid(self):
|
||||||
|
sponsor = Sponsor(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1), end_date=datetime.date(2026, 1, 1))
|
||||||
|
sponsor.full_clean()
|
||||||
|
|
||||||
|
def test_an_end_date_before_start_is_rejected(self):
|
||||||
|
sponsor = Sponsor(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 6, 1), end_date=datetime.date(2026, 1, 1))
|
||||||
|
|
||||||
|
with self.assertRaises(ValidationError) as ctx:
|
||||||
|
sponsor.full_clean()
|
||||||
|
self.assertIn("end_date", ctx.exception.error_dict)
|
||||||
|
|
||||||
|
def test_sponsors_are_ordered_by_name(self):
|
||||||
|
Sponsor.objects.create(club=self.club, name="Zulu Corp", start_date=datetime.date(2026, 1, 1))
|
||||||
|
Sponsor.objects.create(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1))
|
||||||
|
|
||||||
|
self.assertEqual(list(Sponsor.objects.values_list("name", flat=True)), ["Acme Corp", "Zulu Corp"])
|
||||||
|
|
||||||
|
|
||||||
class AdminRegistrationSmokeTests(TestCase):
|
class AdminRegistrationSmokeTests(TestCase):
|
||||||
"""Every registered model across all apps must have a working admin: load
|
"""Every registered model across all apps must have a working admin: load
|
||||||
each changelist and add page to catch bad list_display / search_fields /
|
each changelist and add page to catch bad list_display / search_fields /
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from .services.admins import find_member_by_email
|
|||||||
class ClubForm(forms.ModelForm):
|
class ClubForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Club
|
model = Club
|
||||||
fields = ["name", "slug", "logo", "primary_color", "secondary_color", "season_start", "season_duration_months"]
|
fields = ["name", "slug", "sport_type", "logo", "primary_color", "secondary_color", "season_start", "season_duration_months"]
|
||||||
help_texts = {"slug": _("Drives the club's subdomain. Left blank, it is derived from the name.")}
|
help_texts = {"slug": _("Drives the club's subdomain. Left blank, it is derived from the name.")}
|
||||||
# Deliberately a text input, not <input type="color">: a colour picker cannot
|
# Deliberately a text input, not <input type="color">: a colour picker cannot
|
||||||
# express "no colour" -- it would submit #000000 for every club that never
|
# express "no colour" -- it would submit #000000 for every club that never
|
||||||
|
|||||||
@@ -11,11 +11,11 @@
|
|||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Billing</h2>
|
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Billing</h2>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('subscription_modal').showModal()">
|
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('subscription_modal').showModal()">
|
||||||
{% lucide "layers" size=14 %} {% if subscription %}Change plan{% else %}Start billing{% endif %}
|
{% lucide "layers" size=14 %} {% if subscription %}Change plan{% else %}Start billing{% endif %}
|
||||||
</button>
|
</button>
|
||||||
{% if not subscription %}
|
{% if not subscription %}
|
||||||
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('trial_modal').showModal()">
|
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('trial_modal').showModal()">
|
||||||
{% lucide "hourglass" size=14 %} Start trial
|
{% lucide "hourglass" size=14 %} Start trial
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
{% if not due.payments.all %}
|
{% if not due.payments.all %}
|
||||||
<form class="inline" method="post" action="{% url 'controlpanel:due_waive' due.pk %}">
|
<form class="inline" method="post" action="{% url 'controlpanel:due_waive' due.pk %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button class="btn btn-outline btn-neutral btn-sm gap-1" type="submit">{% lucide "ban" size=14 %} Waive payment</button>
|
<button class="btn btn-outline btn-sm gap-1" type="submit">{% lucide "ban" size=14 %} Waive payment</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="card-title text-base">{% lucide "toggle-right" size=18 %} Features</h2>
|
<h2 class="card-title text-base">{% lucide "toggle-right" size=18 %} Features</h2>
|
||||||
<a class="btn btn-outline btn-neutral btn-sm gap-2" href="{% url 'controlpanel:features' %}">{% lucide "wrench" size=14 %} Manage features</a>
|
<a class="btn btn-outline btn-sm gap-2" href="{% url 'controlpanel:features' %}">{% lucide "wrench" size=14 %} Manage features</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<a class="link link-hover font-semibold tracking-wide" href="{% url "controlpanel:club_detail" club.pk %}">{{ club.name }}</a>
|
<a class="link link-hover font-semibold tracking-wide" href="{% url "controlpanel:club_detail" club.pk %}">{{ club.name }}</a>
|
||||||
<div class="text-xs opacity-60">{{ club.slug }}.rosterchief.app</div>
|
<div class="text-xs opacity-60">{{ club.slug }}.rosterchief.app · {{ club.get_sport_type_display }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<a class="btn btn-sm btn-outline btn-neutral gap-2" href="{% url "controlpanel:club_detail" club.pk %}">{% lucide "pencil" size=14 %} Edit</a>
|
<a class="btn btn-outline btn-sm gap-2" href="{% url "controlpanel:club_detail" club.pk %}">{% lucide "pencil" size=14 %} Edit</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<form method="dialog">
|
<form method="dialog">
|
||||||
<button class="btn btn-outline btn-neutral gap-2">{% lucide "x" size=16 %} {% trans "Cancel" %}</button>
|
<button class="btn btn-outline gap-2">{% lucide "x" size=16 %} {% trans "Cancel" %}</button>
|
||||||
</form>
|
</form>
|
||||||
<button class="btn btn-error gap-2" type="submit" form="{{ modal_id }}-form">{% lucide submit_icon|default:"trash-2" size=16 %} {{ submit_label|default:default_submit_label }}</button>
|
<button class="btn btn-error gap-2" type="submit" form="{{ modal_id }}-form">{% lucide submit_icon|default:"trash-2" size=16 %} {{ submit_label|default:default_submit_label }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<form method="dialog">
|
<form method="dialog">
|
||||||
<button class="btn btn-outline btn-neutral gap-2">{% lucide "x" size=16 %} {% trans "Cancel" %}</button>
|
<button class="btn btn-outline gap-2">{% lucide "x" size=16 %} {% trans "Cancel" %}</button>
|
||||||
</form>
|
</form>
|
||||||
<button class="btn btn-primary gap-2" type="submit" form="{{ modal_id }}-form">{% lucide submit_icon|default:"check" size=16 %} {{ submit_label|default:default_submit_label }}</button>
|
<button class="btn btn-primary gap-2" type="submit" form="{{ modal_id }}-form">{% lucide submit_icon|default:"check" size=16 %} {{ submit_label|default:default_submit_label }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="flex flex-row gap-2 justify-end">
|
<td class="flex flex-row gap-2 justify-end">
|
||||||
<button class="btn btn-primary btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ tier.pk|dom_id:"tier_price_modal" }}').showModal()">{% lucide "euro" size=14 %} New price</button>
|
<button class="btn btn-primary btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ tier.pk|dom_id:"tier_price_modal" }}').showModal()">{% lucide "euro" size=14 %} New price</button>
|
||||||
<button class="btn btn-sm btn-outline btn-neutral gap-1" type="button" onclick="document.getElementById('{{ tier.pk|dom_id:"tier_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
|
<button class="btn btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ tier.pk|dom_id:"tier_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
|||||||
@@ -27,14 +27,14 @@
|
|||||||
{% block heading %}{{ club.name }}{% endblock heading %}
|
{% block heading %}{{ club.name }}{% endblock heading %}
|
||||||
|
|
||||||
{% block subheading %}
|
{% block subheading %}
|
||||||
{{ club.slug }}.rosterchief.app
|
{{ club.slug }}.rosterchief.app · {{ club.get_sport_type_display }}
|
||||||
{% if club.is_archived %}
|
{% if club.is_archived %}
|
||||||
<span class="badge badge-warning badge-sm ml-2">Archived</span>
|
<span class="badge badge-warning badge-sm ml-2">Archived</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock subheading %}
|
{% endblock subheading %}
|
||||||
|
|
||||||
{% block actions %}
|
{% block actions %}
|
||||||
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'controlpanel:club_update' club.pk %}">{% lucide "pencil" size=16 %} Edit</a>
|
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:club_update' club.pk %}">{% lucide "pencil" size=16 %} Edit</a>
|
||||||
<a class="btn btn-primary gap-2" href="https://{{ club.slug }}.rosterchief.app">{% lucide "external-link" size=16 %} Open</a>
|
<a class="btn btn-primary gap-2" href="https://{{ club.slug }}.rosterchief.app">{% lucide "external-link" size=16 %} Open</a>
|
||||||
{% if club.is_archived %}
|
{% if club.is_archived %}
|
||||||
<form method="post" action="{% url 'controlpanel:club_restore' club.pk %}">
|
<form method="post" action="{% url 'controlpanel:club_restore' club.pk %}">
|
||||||
|
|||||||
@@ -15,9 +15,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{% form_field form.name %}
|
{% form_field form.name %}
|
||||||
{% form_field form.slug %}
|
{% form_field form.slug %}
|
||||||
|
{% form_field form.sport_type %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
@@ -36,7 +37,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-start pt-2 mt-2">
|
<div class="card-actions justify-start pt-2 mt-2">
|
||||||
<a class="btn btn-outline btn-neutral gap-2" href="{% if update_view %}{% url "controlpanel:club_detail" object.pk %}{% else %}{% url "controlpanel:club_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} Cancel</a>
|
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "controlpanel:club_detail" object.pk %}{% else %}{% url "controlpanel:club_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} Cancel</a>
|
||||||
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} Save</button>
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} Save</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
|
|
||||||
{% block actions %}
|
{% block actions %}
|
||||||
{% if show_archived %}
|
{% if show_archived %}
|
||||||
<a class="btn btn-outline btn-neutral" href="{% url 'controlpanel:club_list' %}">{% lucide "archive-x" size=16 %} Hide archived clubs</a>
|
<a class="btn btn-outline" href="{% url 'controlpanel:club_list' %}">{% lucide "archive-x" size=16 %} Hide archived clubs</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a class="btn btn-outline btn-neutral" href="{% url 'controlpanel:club_list' %}?archived=1">{% lucide "archive" size=16 %} Show archived clubs</a>
|
<a class="btn btn-outline" href="{% url 'controlpanel:club_list' %}?archived=1">{% lucide "archive" size=16 %} Show archived clubs</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} New club</a>
|
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} New club</a>
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
class="input input-bordered w-full max-w-xs">
|
class="input input-bordered w-full max-w-xs">
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button class="btn btn-outline btn-neutral gap-2" type="submit">{% lucide "search" size=16 %} Search</button>
|
<button class="btn btn-outline gap-2" type="submit">{% lucide "search" size=16 %} Search</button>
|
||||||
{% if search %}
|
{% if search %}
|
||||||
<a class="btn btn-primary gap-2" href="{% url "controlpanel:club_list" %}">{% lucide "x" size=16 %} Clear filter</a>
|
<a class="btn btn-primary gap-2" href="{% url "controlpanel:club_list" %}">{% lucide "x" size=16 %} Clear filter</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
<td>{{ flag.clubs.count }}</td>
|
<td>{{ flag.clubs.count }}</td>
|
||||||
<td class="max-w-xs truncate opacity-70">{{ flag.note|default:"-" }}</td>
|
<td class="max-w-xs truncate opacity-70">{{ flag.note|default:"-" }}</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<button class="btn btn-outline btn-neutral btn-sm gap-1" type="button" onclick="document.getElementById('{{ flag.pk|dom_id:"flag_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
|
<button class="btn btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ flag.pk|dom_id:"flag_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
|||||||
@@ -54,6 +54,15 @@
|
|||||||
>{{ field.value|default:"" }}</textarea>
|
>{{ field.value|default:"" }}</textarea>
|
||||||
|
|
||||||
{% elif field_type == "select" %}
|
{% elif field_type == "select" %}
|
||||||
|
{% comment %}
|
||||||
|
field.field.widget.choices, not field.field.choices: a plain ChoiceField
|
||||||
|
exposes both (kept in sync), but NullBooleanField (the "everyone" tri-state
|
||||||
|
Yes/No/Unknown field) only ever has them on the widget -- the field itself
|
||||||
|
has no .choices attribute at all. Reading from the field silently rendered
|
||||||
|
zero <option> tags for it, an empty dropdown, since a template attribute
|
||||||
|
lookup that raises AttributeError just resolves to nothing rather than
|
||||||
|
erroring.
|
||||||
|
{% endcomment %}
|
||||||
<select
|
<select
|
||||||
class="select w-full {% if size_modifier %}select-{{ size_modifier }}{% endif %} {% if field.errors %}select-error text-error{% endif %}"
|
class="select w-full {% if size_modifier %}select-{{ size_modifier }}{% endif %} {% if field.errors %}select-error text-error{% endif %}"
|
||||||
name="{{ field.html_name }}"
|
name="{{ field.html_name }}"
|
||||||
@@ -67,7 +76,7 @@
|
|||||||
<option selected disabled>{{ field.label|capfirst }}</option>
|
<option selected disabled>{{ field.label|capfirst }}</option>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for option_value, option_label in field.field.choices %}
|
{% for option_value, option_label in field.field.widget.choices %}
|
||||||
<option value="{{ option_value }}" {% if option_value in field.value %}selected="selected"{% endif %}>
|
<option value="{{ option_value }}" {% if option_value in field.value %}selected="selected"{% endif %}>
|
||||||
{{ option_label }}
|
{{ option_label }}
|
||||||
</option>
|
</option>
|
||||||
@@ -77,7 +86,7 @@
|
|||||||
<option selected disabled>{{ field.label|capfirst }}</option>
|
<option selected disabled>{{ field.label|capfirst }}</option>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for option_value, option_label in field.field.choices %}
|
{% for option_value, option_label in field.field.widget.choices %}
|
||||||
<option value="{{ option_value }}" {% if field.value|stringformat:"s" == option_value|stringformat:"s" %}selected="selected"{% endif %}>
|
<option value="{{ option_value }}" {% if field.value|stringformat:"s" == option_value|stringformat:"s" %}selected="selected"{% endif %}>
|
||||||
{{ option_label }}
|
{{ option_label }}
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@@ -116,8 +116,16 @@ class ClubManagementTests(ControlPanelTestBase):
|
|||||||
def test_dashboard_lists_clubs(self):
|
def test_dashboard_lists_clubs(self):
|
||||||
self.assertContains(self.client.get(reverse("controlpanel:dashboard")), "Ajax United")
|
self.assertContains(self.client.get(reverse("controlpanel:dashboard")), "Ajax United")
|
||||||
|
|
||||||
|
def test_the_club_rows_subtitle_shows_the_sport_type_behind_the_url(self):
|
||||||
|
self.club.sport_type = Club.SportType.ICE_HOCKEY
|
||||||
|
self.club.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse("controlpanel:dashboard"))
|
||||||
|
|
||||||
|
self.assertContains(response, f"{self.club.slug}.rosterchief.app · Ice hockey", html=False)
|
||||||
|
|
||||||
def test_create_club_derives_the_slug(self):
|
def test_create_club_derives_the_slug(self):
|
||||||
response = self.client.post(reverse("controlpanel:club_create"), {"name": "New Club", "slug": "", "season_start": "2000-08-01", "season_duration_months": "12"})
|
response = self.client.post(reverse("controlpanel:club_create"), {"name": "New Club", "slug": "", "sport_type": "other", "season_start": "2000-08-01", "season_duration_months": "12"})
|
||||||
|
|
||||||
club = Club.objects.get(name="New Club")
|
club = Club.objects.get(name="New Club")
|
||||||
self.assertEqual(club.slug, "new-club")
|
self.assertEqual(club.slug, "new-club")
|
||||||
@@ -126,12 +134,21 @@ class ClubManagementTests(ControlPanelTestBase):
|
|||||||
def test_update_club(self):
|
def test_update_club(self):
|
||||||
self.client.post(
|
self.client.post(
|
||||||
reverse("controlpanel:club_update", args=[self.club.pk]),
|
reverse("controlpanel:club_update", args=[self.club.pk]),
|
||||||
{"name": "Renamed", "slug": self.club.slug, "season_start": "2000-08-01", "season_duration_months": "12"},
|
{"name": "Renamed", "slug": self.club.slug, "sport_type": "other", "season_start": "2000-08-01", "season_duration_months": "12"},
|
||||||
)
|
)
|
||||||
|
|
||||||
self.club.refresh_from_db()
|
self.club.refresh_from_db()
|
||||||
self.assertEqual(self.club.name, "Renamed")
|
self.assertEqual(self.club.name, "Renamed")
|
||||||
|
|
||||||
|
def test_update_club_sport_type(self):
|
||||||
|
self.client.post(
|
||||||
|
reverse("controlpanel:club_update", args=[self.club.pk]),
|
||||||
|
{"name": self.club.name, "slug": self.club.slug, "sport_type": "ice_hockey", "season_start": "2000-08-01", "season_duration_months": "12"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.club.refresh_from_db()
|
||||||
|
self.assertEqual(self.club.sport_type, "ice_hockey")
|
||||||
|
|
||||||
def test_club_detail_shows_statistics(self):
|
def test_club_detail_shows_statistics(self):
|
||||||
response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk]))
|
response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk]))
|
||||||
|
|
||||||
@@ -139,6 +156,14 @@ class ClubManagementTests(ControlPanelTestBase):
|
|||||||
self.assertContains(response, "Teams & staff")
|
self.assertContains(response, "Teams & staff")
|
||||||
self.assertContains(response, "Shop")
|
self.assertContains(response, "Shop")
|
||||||
|
|
||||||
|
def test_club_detail_subheading_shows_the_sport_type_behind_the_url(self):
|
||||||
|
self.club.sport_type = Club.SportType.ICE_HOCKEY
|
||||||
|
self.club.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk]))
|
||||||
|
|
||||||
|
self.assertContains(response, f"{self.club.slug}.rosterchief.app · Ice hockey", html=False)
|
||||||
|
|
||||||
def test_archive_then_restore(self):
|
def test_archive_then_restore(self):
|
||||||
self.client.post(reverse("controlpanel:club_archive", args=[self.club.pk]))
|
self.client.post(reverse("controlpanel:club_archive", args=[self.club.pk]))
|
||||||
self.club.refresh_from_db()
|
self.club.refresh_from_db()
|
||||||
@@ -489,6 +514,17 @@ class FeatureViewTests(ControlPanelTestBase):
|
|||||||
self.assertContains(response, "shop")
|
self.assertContains(response, "shop")
|
||||||
self.assertContains(response, "maintenance")
|
self.assertContains(response, "maintenance")
|
||||||
|
|
||||||
|
def test_the_everyone_field_renders_as_a_populated_dropdown(self):
|
||||||
|
# Regression: NullBooleanField has no .choices on the field itself (only
|
||||||
|
# on field.widget.choices) -- the shared form_field templatetag read the
|
||||||
|
# wrong attribute and silently rendered the "everyone" tri-state dropdown
|
||||||
|
# with zero <option> tags. See controlpanel/templates/templatetags/field.html.
|
||||||
|
response = self.client.get(reverse("controlpanel:features"))
|
||||||
|
|
||||||
|
self.assertContains(response, '<option value="unknown">Unknown</option>', html=True)
|
||||||
|
self.assertContains(response, '<option value="true">Yes</option>', html=True)
|
||||||
|
self.assertContains(response, '<option value="false">No</option>', html=True)
|
||||||
|
|
||||||
def test_the_flag_forms_are_post_only(self):
|
def test_the_flag_forms_are_post_only(self):
|
||||||
# Reachable only through a modal on the features page: there is no standalone
|
# Reachable only through a modal on the features page: there is no standalone
|
||||||
# template to render on a GET.
|
# template to render on a GET.
|
||||||
@@ -824,16 +860,20 @@ class FlagAdoptionTests(TestCase):
|
|||||||
self.club = Club.objects.create(name="Ajax United")
|
self.club = Club.objects.create(name="Ajax United")
|
||||||
|
|
||||||
def test_clubs_are_counted_per_flag(self):
|
def test_clubs_are_counted_per_flag(self):
|
||||||
|
# Not an exact-list assertion: migration 0018 seeds real "CEHL"/"RBIHF"
|
||||||
|
# flags for the built-in competitions, so a fresh test database is never
|
||||||
|
# actually flag-free.
|
||||||
flag = Flag.objects.create(name="shop")
|
flag = Flag.objects.create(name="shop")
|
||||||
flag.clubs.add(self.club)
|
flag.clubs.add(self.club)
|
||||||
|
|
||||||
self.assertEqual(flag_adoption(), [{"name": "shop", "clubs": 1, "everyone": None, "overridden": False}])
|
self.assertIn({"name": "shop", "clubs": 1, "everyone": None, "overridden": False}, flag_adoption())
|
||||||
|
|
||||||
def test_an_everyone_flag_reports_itself_as_overridden(self):
|
def test_an_everyone_flag_reports_itself_as_overridden(self):
|
||||||
# `everyone` beats club targeting, so the club count would be a lie.
|
# `everyone` beats club targeting, so the club count would be a lie.
|
||||||
Flag.objects.create(name="shop", everyone=True)
|
Flag.objects.create(name="shop", everyone=True)
|
||||||
|
|
||||||
self.assertTrue(flag_adoption()[0]["overridden"])
|
shop_entry = next(entry for entry in flag_adoption() if entry["name"] == "shop")
|
||||||
|
self.assertTrue(shop_entry["overridden"])
|
||||||
|
|
||||||
|
|
||||||
class PlatformChartTests(TestCase):
|
class PlatformChartTests(TestCase):
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
from django import forms
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from .models import Attendance, Event, EventSeries, Location, Opponent
|
from .models import Attendance, Competition, Event, EventSeries, Location, Opponent
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Opponent)
|
@admin.register(Opponent)
|
||||||
@@ -39,8 +40,38 @@ class EventSeriesAdmin(admin.ModelAdmin):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class EventAdminForm(forms.ModelForm):
|
||||||
|
"""The `competition` field is a plain CharField on Event (it just stores a
|
||||||
|
name), but the admin should only ever offer a competition this club is
|
||||||
|
actually allowed to use -- one whose feature flag is active for it, set
|
||||||
|
from the control panel's Features page. A competition with no flag never
|
||||||
|
appears at all."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Event
|
||||||
|
fields = [
|
||||||
|
"title", "kind", "season", "series", "detached", "cancelled", "teams", "invited_members", "excluded_members",
|
||||||
|
"start", "end", "gathering", "deadline", "location", "opponent",
|
||||||
|
"competition", "external_game_id", "score_for", "score_against", "is_live",
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
club = self.instance.club if self.instance.club_id else None
|
||||||
|
competitions = Competition.objects.filter(flag__isnull=False).select_related("flag")
|
||||||
|
if club is not None:
|
||||||
|
competitions = [competition for competition in competitions if competition.flag.is_active_for_club(club)]
|
||||||
|
self.fields["competition"] = forms.ChoiceField(
|
||||||
|
choices=[("", "---------"), *[(competition.name, competition.name) for competition in competitions]],
|
||||||
|
required=False,
|
||||||
|
label=self.fields["competition"].label,
|
||||||
|
help_text=_("Only competitions whose feature flag is active for this club are offered here."),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Event)
|
@admin.register(Event)
|
||||||
class EventAdmin(admin.ModelAdmin):
|
class EventAdmin(admin.ModelAdmin):
|
||||||
|
form = EventAdminForm
|
||||||
list_display = ["title", "kind", "start", "season", "series", "detached", "club"]
|
list_display = ["title", "kind", "start", "season", "series", "detached", "club"]
|
||||||
list_filter = ["kind", "club", "teams", "detached", "cancelled"]
|
list_filter = ["kind", "club", "teams", "detached", "cancelled"]
|
||||||
search_fields = ["title"]
|
search_fields = ["title"]
|
||||||
@@ -53,9 +84,18 @@ class EventAdmin(admin.ModelAdmin):
|
|||||||
[_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}],
|
[_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}],
|
||||||
[_("When"), {"fields": ["start", "end", "gathering", "deadline"]}],
|
[_("When"), {"fields": ["start", "end", "gathering", "deadline"]}],
|
||||||
[_("Where"), {"fields": ["location", "opponent"]}],
|
[_("Where"), {"fields": ["location", "opponent"]}],
|
||||||
|
[_("Game"), {"fields": ["competition", "external_game_id", "score_for", "score_against", "is_live"]}],
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Competition)
|
||||||
|
class CompetitionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["name", "sport_type", "module", "flag"]
|
||||||
|
list_filter = ["sport_type"]
|
||||||
|
search_fields = ["name", "module"]
|
||||||
|
raw_id_fields = ["flag"]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Attendance)
|
@admin.register(Attendance)
|
||||||
class AttendanceAdmin(admin.ModelAdmin):
|
class AttendanceAdmin(admin.ModelAdmin):
|
||||||
list_display = ["event", "member", "status", "showed_up"]
|
list_display = ["event", "member", "status", "showed_up"]
|
||||||
|
|||||||
139
events/api.py
Normal file
139
events/api.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""Public read-only game endpoints -- see api/urls.py for how this is
|
||||||
|
mounted. Also owns GET /teams/{team_id}/games/: it's an Event query through
|
||||||
|
and through, so the query logic and GameOut schema live here rather than
|
||||||
|
being duplicated in teams/api.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.utils import timezone
|
||||||
|
from ninja import Router, Schema
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
|
||||||
|
from api.errors import require_club
|
||||||
|
from club.services.access import current_season
|
||||||
|
from teams.models import Team
|
||||||
|
|
||||||
|
from .models import Event
|
||||||
|
|
||||||
|
router = Router(tags=["games"])
|
||||||
|
|
||||||
|
DEFAULT_UPCOMING_COUNT = 10
|
||||||
|
MAX_UPCOMING_COUNT = 50
|
||||||
|
|
||||||
|
#: /games/upcoming/ covers anything worth putting on a public fixture list --
|
||||||
|
#: not just Game, but Tournament too. The other game endpoints (live,
|
||||||
|
#: per-team) stay Game-only: is_live/score_for/score_against are genuinely
|
||||||
|
#: game-specific (events/models.py), so a tournament wouldn't have anything
|
||||||
|
#: meaningful to show there anyway.
|
||||||
|
UPCOMING_KINDS = [Event.EventKind.GAME, Event.EventKind.TOURNAMENT]
|
||||||
|
|
||||||
|
|
||||||
|
class LocationOut(Schema):
|
||||||
|
name: str
|
||||||
|
address: str
|
||||||
|
city: str
|
||||||
|
zip_code: str
|
||||||
|
country: str
|
||||||
|
is_home: bool
|
||||||
|
|
||||||
|
|
||||||
|
class GameOut(Schema):
|
||||||
|
id: uuid.UUID
|
||||||
|
start: datetime
|
||||||
|
location: LocationOut | None
|
||||||
|
home_team: str | None
|
||||||
|
away_team: str | None
|
||||||
|
competition: str
|
||||||
|
is_live: bool
|
||||||
|
status: str # "upcoming" | "live" | "finished"
|
||||||
|
home_score: int | None
|
||||||
|
away_score: int | None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_game_out(event, team=None) -> GameOut:
|
||||||
|
if team is None:
|
||||||
|
# .first() would re-query even with teams prefetched; go through the
|
||||||
|
# prefetch cache instead.
|
||||||
|
related_teams = list(event.teams.all())
|
||||||
|
team = related_teams[0] if related_teams else None
|
||||||
|
|
||||||
|
team_name = team.name if team is not None else None
|
||||||
|
opponent_name = event.opponent.name if event.opponent_id else None
|
||||||
|
|
||||||
|
if event.is_home_game:
|
||||||
|
home_team, away_team = team_name, opponent_name
|
||||||
|
home_score, away_score = event.score_for, event.score_against
|
||||||
|
else:
|
||||||
|
home_team, away_team = opponent_name, team_name
|
||||||
|
home_score, away_score = event.score_against, event.score_for
|
||||||
|
|
||||||
|
if event.is_live:
|
||||||
|
status = "live"
|
||||||
|
elif event.start > timezone.now():
|
||||||
|
status = "upcoming"
|
||||||
|
else:
|
||||||
|
status = "finished"
|
||||||
|
|
||||||
|
location = None
|
||||||
|
if event.location_id:
|
||||||
|
location = LocationOut(name=event.location.name, address=event.location.address, city=event.location.city, zip_code=event.location.zip_code, country=str(event.location.country), is_home=event.location.is_home)
|
||||||
|
|
||||||
|
return GameOut(
|
||||||
|
id=event.pk,
|
||||||
|
start=event.start,
|
||||||
|
location=location,
|
||||||
|
home_team=home_team,
|
||||||
|
away_team=away_team,
|
||||||
|
competition=event.competition,
|
||||||
|
is_live=event.is_live,
|
||||||
|
status=status,
|
||||||
|
home_score=home_score,
|
||||||
|
away_score=away_score,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/games/upcoming/", response=list[GameOut], summary="Upcoming games")
|
||||||
|
def list_upcoming_games(request, count: int = DEFAULT_UPCOMING_COUNT):
|
||||||
|
"""The next `count` non-cancelled upcoming games and tournaments, club-wide."""
|
||||||
|
club = require_club(request)
|
||||||
|
count = max(1, min(count, MAX_UPCOMING_COUNT))
|
||||||
|
|
||||||
|
events = Event.objects.filter(club=club, kind__in=UPCOMING_KINDS, cancelled=False, start__gte=timezone.now()).select_related("opponent", "location").prefetch_related("teams").order_by("start")[:count]
|
||||||
|
|
||||||
|
return [_to_game_out(event) for event in events]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/games/live/", response=list[GameOut], summary="Live games")
|
||||||
|
def list_live_games(request):
|
||||||
|
club = require_club(request)
|
||||||
|
|
||||||
|
events = Event.objects.filter(club=club, kind=Event.EventKind.GAME, cancelled=False, is_live=True).select_related("opponent", "location").prefetch_related("teams").order_by("start")
|
||||||
|
|
||||||
|
return [_to_game_out(event) for event in events]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/teams/{team_id}/games/", response=list[GameOut], summary="A team's current-season games")
|
||||||
|
def list_team_games(request, team_id: uuid.UUID):
|
||||||
|
"""All of this team's current-season games, past and upcoming -- past ones
|
||||||
|
include both teams' scores. Same "explicit season, else derived from
|
||||||
|
start date" scoping management.views.EventListView already applies."""
|
||||||
|
club = require_club(request)
|
||||||
|
team = Team.objects.filter(club=club, pk=team_id).first()
|
||||||
|
if team is None:
|
||||||
|
raise HttpError(404, "No such team.")
|
||||||
|
|
||||||
|
season = current_season(club)
|
||||||
|
if season is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
events = (
|
||||||
|
Event.objects.filter(club=club, teams=team, kind=Event.EventKind.GAME, cancelled=False)
|
||||||
|
.filter(Q(season=season) | Q(season__isnull=True, start__date__gte=season.start_date, start__date__lte=season.end_date))
|
||||||
|
.select_related("opponent", "location")
|
||||||
|
.order_by("start")
|
||||||
|
)
|
||||||
|
|
||||||
|
return [_to_game_out(event, team=team) for event in events]
|
||||||
0
events/competition/__init__.py
Normal file
0
events/competition/__init__.py
Normal file
19
events/competition/base.py
Normal file
19
events/competition/base.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
from ..models import Event
|
||||||
|
|
||||||
|
|
||||||
|
class GameInformation(TypedDict):
|
||||||
|
live: bool
|
||||||
|
scoreA: int
|
||||||
|
scoreB: int
|
||||||
|
|
||||||
|
|
||||||
|
class CompetitionBaseClass:
|
||||||
|
url: str
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.url = "http://localhost"
|
||||||
|
|
||||||
|
def update_game_information(self, event: Event) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
90
events/competition/hockey.py
Normal file
90
events/competition/hockey.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from ..models import Event
|
||||||
|
from .base import CompetitionBaseClass
|
||||||
|
|
||||||
|
|
||||||
|
class RBIHF(CompetitionBaseClass):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.url = "https://rbihf.be/modules/league/ajax/time.php"
|
||||||
|
|
||||||
|
def update_game_information(self, event: Event) -> None:
|
||||||
|
season = "{start}{end}".format(start=event.season.start_date.strftime("%y"), end=event.season.end_date.strftime("%y"))
|
||||||
|
|
||||||
|
payload = {"gameNr": event.external_game_id, "season": season}
|
||||||
|
headers = {
|
||||||
|
"Cookie": "language=en",
|
||||||
|
"Postman-Token": "rosterchief",
|
||||||
|
"Host": "www.rbihf.be",
|
||||||
|
"User-Agent": "PostmanRuntime/7.37.0",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Accept-Encoding": "gzip,deflate,br",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"Referer": f"https://rbihf.be/game/{event.external_game_id}",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
}
|
||||||
|
|
||||||
|
req = requests.get(self.url, params=payload, headers=headers)
|
||||||
|
|
||||||
|
if req.status_code == 200:
|
||||||
|
game_data = req.json()
|
||||||
|
|
||||||
|
event.is_live = game_data["live"]
|
||||||
|
|
||||||
|
if event.is_home_game:
|
||||||
|
event.score_for = game_data["scoreA"]
|
||||||
|
event.score_against = game_data["scoreB"]
|
||||||
|
else:
|
||||||
|
event.score_for = game_data["scoreB"]
|
||||||
|
event.score_against = game_data["scoreA"]
|
||||||
|
|
||||||
|
event.save(update_fields=["is_live", "score_for", "score_against"])
|
||||||
|
|
||||||
|
|
||||||
|
class CEHL(CompetitionBaseClass):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.url = "https://www.cehl.eu/ajax/"
|
||||||
|
|
||||||
|
def update_game_information(self, event: Event) -> None:
|
||||||
|
season = "{start}{end}".format(start=event.season.start_date.strftime("%y"), end=event.season.end_date.strftime("%y"))
|
||||||
|
|
||||||
|
referer_url = urljoin("https://www.cehl.eu", f"game/{season}/{event.external_game_id}")
|
||||||
|
timeline_url = urljoin(self.url, "timeline.php")
|
||||||
|
score_url = urljoin(self.url, "score.php")
|
||||||
|
|
||||||
|
payload = {"nr": event.external_game_id, "season": season}
|
||||||
|
headers = {
|
||||||
|
"Cookie": "language=en",
|
||||||
|
"Postman-Token": "rosterchief",
|
||||||
|
"Host": "www.cehl.eu",
|
||||||
|
"User-Agent": "PostmanRuntime/7.37.0",
|
||||||
|
"Accept": "*/*",
|
||||||
|
"Accept-Encoding": "gzip,deflate,br",
|
||||||
|
"Connection": "close",
|
||||||
|
"Referer": referer_url,
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
}
|
||||||
|
|
||||||
|
timeline_req = requests.get(timeline_url, params=payload, headers=headers)
|
||||||
|
score_req = requests.get(score_url, params=payload, headers=headers)
|
||||||
|
|
||||||
|
if timeline_req.status_code == 200:
|
||||||
|
event.is_live = timeline_req.json()["live"] == 1
|
||||||
|
|
||||||
|
if score_req.status_code == 200:
|
||||||
|
game_data = score_req.json()
|
||||||
|
|
||||||
|
if event.is_home_game:
|
||||||
|
event.score_for = game_data["scoreA"]
|
||||||
|
event.score_against = game_data["scoreB"]
|
||||||
|
|
||||||
|
else:
|
||||||
|
event.score_for = game_data["scoreB"]
|
||||||
|
event.score_against = game_data["scoreA"]
|
||||||
|
|
||||||
|
if timeline_req.status_code == 200 or score_req.status_code == 200:
|
||||||
|
event.save(update_fields=["is_live", "score_for", "score_against"])
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-08-04 11:24
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('events', '0012_alter_location_country'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='event',
|
||||||
|
name='kind',
|
||||||
|
field=models.CharField(choices=[('training', 'Training'), ('match', 'Match'), ('tournament', 'Tournament'), ('meeting', 'Meeting'), ('social', 'Social'), ('other', 'Other')], default='other', max_length=10, verbose_name='kind'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='eventseries',
|
||||||
|
name='kind',
|
||||||
|
field=models.CharField(choices=[('training', 'Training'), ('match', 'Match'), ('tournament', 'Tournament'), ('meeting', 'Meeting'), ('social', 'Social'), ('other', 'Other')], default='other', max_length=10, verbose_name='kind'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-08-04 11:28
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def rename_match_to_game(apps, schema_editor):
|
||||||
|
Event = apps.get_model('events', 'Event')
|
||||||
|
EventSeries = apps.get_model('events', 'EventSeries')
|
||||||
|
Event.objects.filter(kind='match').update(kind='game')
|
||||||
|
EventSeries.objects.filter(kind='match').update(kind='game')
|
||||||
|
|
||||||
|
|
||||||
|
def rename_game_to_match(apps, schema_editor):
|
||||||
|
Event = apps.get_model('events', 'Event')
|
||||||
|
EventSeries = apps.get_model('events', 'EventSeries')
|
||||||
|
Event.objects.filter(kind='game').update(kind='match')
|
||||||
|
EventSeries.objects.filter(kind='game').update(kind='match')
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('events', '0013_alter_event_kind_alter_eventseries_kind'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='event',
|
||||||
|
name='competition',
|
||||||
|
field=models.CharField(blank=True, help_text='The league, cup or competition this game is part of.', max_length=255, verbose_name='competition'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='event',
|
||||||
|
name='external_game_id',
|
||||||
|
field=models.CharField(blank=True, help_text="This game's id in an external competition data source, for automatic score fetching later.", max_length=255, verbose_name='external game ID'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='event',
|
||||||
|
name='is_live',
|
||||||
|
field=models.BooleanField(default=False, help_text='The game is currently in progress.', verbose_name='live'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='event',
|
||||||
|
name='score_against',
|
||||||
|
field=models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='score (opponent)'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='event',
|
||||||
|
name='score_for',
|
||||||
|
field=models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='score (us)'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='event',
|
||||||
|
name='deadline',
|
||||||
|
field=models.DateTimeField(blank=True, null=True, verbose_name='registration deadline'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='event',
|
||||||
|
name='kind',
|
||||||
|
field=models.CharField(choices=[('training', 'Training'), ('game', 'Game'), ('tournament', 'Tournament'), ('meeting', 'Meeting'), ('social', 'Social'), ('other', 'Other')], default='other', max_length=10, verbose_name='kind'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='eventseries',
|
||||||
|
name='deadline_offset',
|
||||||
|
field=models.DurationField(blank=True, help_text="How long before the start each occurrence's registration deadline is.", null=True, verbose_name='registration deadline offset'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='eventseries',
|
||||||
|
name='kind',
|
||||||
|
field=models.CharField(choices=[('training', 'Training'), ('game', 'Game'), ('tournament', 'Tournament'), ('meeting', 'Meeting'), ('social', 'Social'), ('other', 'Other')], default='other', max_length=10, verbose_name='kind'),
|
||||||
|
),
|
||||||
|
migrations.RunPython(rename_match_to_game, rename_game_to_match),
|
||||||
|
]
|
||||||
18
events/migrations/0015_alter_attendance_status.py
Normal file
18
events/migrations/0015_alter_attendance_status.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-08-04 13:11
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('events', '0014_event_competition_event_external_game_id_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='attendance',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('present', 'Present'), ('absent', 'Absent'), ('excused', 'Excused'), ('selected', 'Selected'), ('not_selected', 'Not selected'), ('maybe', 'Maybe'), ('no_response', 'No response')], default='no_response', max_length=20, verbose_name='status'),
|
||||||
|
),
|
||||||
|
]
|
||||||
21
events/migrations/0016_competition.py
Normal file
21
events/migrations/0016_competition.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-08-04 21:22
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('events', '0015_alter_attendance_status'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Competition',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=250)),
|
||||||
|
('module', models.CharField(max_length=250)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
33
events/migrations/0017_auto_20260804_2322.py
Normal file
33
events/migrations/0017_auto_20260804_2322.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-08-04 21:22
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def create_competiton_group(apps, schema_editor):
|
||||||
|
Competition = apps.get_model("events", "Competition")
|
||||||
|
|
||||||
|
try:
|
||||||
|
Competition.objects.create(name="RBIHF", module="events.competition.hockey")
|
||||||
|
Competition.objects.create(name="CEHL", module="events.competition.hockey")
|
||||||
|
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def remove_competiton_group(apps, schema_editor):
|
||||||
|
Competition = apps.get_model("events", "Competition")
|
||||||
|
|
||||||
|
try:
|
||||||
|
Competition.objects.get(name="RBIHF", module="events.competition.hockey").delete()
|
||||||
|
Competition.objects.get(name="CEHL", module="events.competition.hockey")
|
||||||
|
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('events', '0016_competition'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [migrations.RunPython(create_competiton_group, remove_competiton_group)]
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-08-04 21:32
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def create_flags_for_seeded_competitions(apps, schema_editor):
|
||||||
|
"""RBIHF/CEHL (0017's data migration) predate the flag field -- give them one
|
||||||
|
each so they're actually selectable (unflagged competitions never show up
|
||||||
|
in the Event admin's dropdown) instead of silently invisible."""
|
||||||
|
Competition = apps.get_model("events", "Competition")
|
||||||
|
Flag = apps.get_model(*settings.WAFFLE_FLAG_MODEL.split("."))
|
||||||
|
|
||||||
|
for competition in Competition.objects.filter(flag__isnull=True):
|
||||||
|
flag, _created = Flag.objects.get_or_create(name=competition.name)
|
||||||
|
competition.flag = flag
|
||||||
|
competition.save(update_fields=["flag"])
|
||||||
|
|
||||||
|
|
||||||
|
def noop(apps, schema_editor):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('events', '0017_auto_20260804_2322'),
|
||||||
|
migrations.swappable_dependency(settings.WAFFLE_FLAG_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='competition',
|
||||||
|
options={'ordering': ['name'], 'verbose_name': 'competition', 'verbose_name_plural': 'competitions'},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='competition',
|
||||||
|
name='flag',
|
||||||
|
field=models.ForeignKey(blank=True, help_text="Which clubs this competition is offered to -- set (or leave blank to hide it everywhere) from the control panel's Features page. A competition with no flag never shows up on the Event admin's competition dropdown.", null=True, on_delete=django.db.models.deletion.PROTECT, related_name='competitions', to=settings.WAFFLE_FLAG_MODEL, verbose_name='feature flag'),
|
||||||
|
),
|
||||||
|
migrations.RunPython(create_flags_for_seeded_competitions, noop),
|
||||||
|
]
|
||||||
31
events/migrations/0019_competition_sport_type.py
Normal file
31
events/migrations/0019_competition_sport_type.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-08-04 22:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def mark_seeded_hockey_competitions(apps, schema_editor):
|
||||||
|
"""RBIHF/CEHL (0017's data migration) predate sport_type -- they're both ice
|
||||||
|
hockey leagues (see events/competition/hockey.py), so backfill them rather
|
||||||
|
than leaving them at the generic "other" default."""
|
||||||
|
Competition = apps.get_model("events", "Competition")
|
||||||
|
Competition.objects.filter(name__in=["RBIHF", "CEHL"]).update(sport_type="ice_hockey")
|
||||||
|
|
||||||
|
|
||||||
|
def noop(apps, schema_editor):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('events', '0018_alter_competition_options_competition_flag'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='competition',
|
||||||
|
name='sport_type',
|
||||||
|
field=models.CharField(choices=[('ice_hockey', 'Ice hockey'), ('other', 'Other')], default='other', help_text='Which sport this competition is for.', max_length=20, verbose_name='sport'),
|
||||||
|
),
|
||||||
|
migrations.RunPython(mark_seeded_hockey_competitions, noop),
|
||||||
|
]
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_countries.fields import CountryField
|
from django_countries.fields import CountryField
|
||||||
|
|
||||||
from club.models import Season
|
from club.models import Club, Season
|
||||||
from members.models import Member
|
from members.models import Member
|
||||||
from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
|
from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
|
||||||
from teams.models import Team
|
from teams.models import Team
|
||||||
@@ -48,12 +49,12 @@ class Location(ClubScopedModel):
|
|||||||
|
|
||||||
class Event(ClubScopedModel):
|
class Event(ClubScopedModel):
|
||||||
class EventKind(models.TextChoices):
|
class EventKind(models.TextChoices):
|
||||||
TRAINING = "training", _("training")
|
TRAINING = "training", _("Training")
|
||||||
MATCH = "match", _("match")
|
GAME = "game", _("Game")
|
||||||
TOURNAMENT = "tournament", _("tournament")
|
TOURNAMENT = "tournament", _("Tournament")
|
||||||
MEETING = "meeting", _("meeting")
|
MEETING = "meeting", _("Meeting")
|
||||||
SOCIAL = "social", _("social")
|
SOCIAL = "social", _("Social")
|
||||||
OTHER = "other", _("other")
|
OTHER = "other", _("Other")
|
||||||
|
|
||||||
series = models.ForeignKey("EventSeries", on_delete=models.CASCADE, related_name="occurrences", null=True, blank=True, verbose_name=_("series"), help_text=_("The recurring series this occurrence belongs to; blank for one-off events."))
|
series = models.ForeignKey("EventSeries", on_delete=models.CASCADE, related_name="occurrences", null=True, blank=True, verbose_name=_("series"), help_text=_("The recurring series this occurrence belongs to; blank for one-off events."))
|
||||||
detached = models.BooleanField(_("detached"), default=False, help_text=_("Edited independently; excluded from series-wide updates and regeneration."))
|
detached = models.BooleanField(_("detached"), default=False, help_text=_("Edited independently; excluded from series-wide updates and regeneration."))
|
||||||
@@ -70,12 +71,21 @@ class Event(ClubScopedModel):
|
|||||||
start = models.DateTimeField(_("start"))
|
start = models.DateTimeField(_("start"))
|
||||||
end = models.DateTimeField(_("end"), blank=True, null=True)
|
end = models.DateTimeField(_("end"), blank=True, null=True)
|
||||||
gathering = models.DateTimeField(_("gathering"), blank=True, null=True)
|
gathering = models.DateTimeField(_("gathering"), blank=True, null=True)
|
||||||
deadline = models.DateTimeField(_("deadline"), blank=True, null=True)
|
deadline = models.DateTimeField(_("registration deadline"), blank=True, null=True)
|
||||||
|
|
||||||
location = models.ForeignKey(Location, on_delete=models.SET_NULL, related_name="events", null=True, blank=True, verbose_name=_("location"))
|
location = models.ForeignKey(Location, on_delete=models.SET_NULL, related_name="events", null=True, blank=True, verbose_name=_("location"))
|
||||||
opponent = models.ForeignKey(Opponent, on_delete=models.SET_NULL, related_name="events", null=True, blank=True, verbose_name=_("opponent"))
|
opponent = models.ForeignKey(Opponent, on_delete=models.SET_NULL, related_name="events", null=True, blank=True, verbose_name=_("opponent"))
|
||||||
created_by = models.ForeignKey(Member, on_delete=models.SET_NULL, related_name="created_events", null=True, blank=True, verbose_name=_("created by"))
|
created_by = models.ForeignKey(Member, on_delete=models.SET_NULL, related_name="created_events", null=True, blank=True, verbose_name=_("created by"))
|
||||||
|
|
||||||
|
# Game-specific -- meaningless for other kinds, so all optional. external_game_id
|
||||||
|
# is this game's id in an external competition/fixture data source, for a later
|
||||||
|
# automatic score-fetcher to key off; nothing populates it yet.
|
||||||
|
competition = models.CharField(_("competition"), max_length=255, blank=True, help_text=_("The league, cup or competition this game is part of."))
|
||||||
|
external_game_id = models.CharField(_("external game ID"), max_length=255, blank=True, help_text=_("This game's id in an external competition data source, for automatic score fetching later."))
|
||||||
|
score_for = models.PositiveSmallIntegerField(_("score (us)"), null=True, blank=True)
|
||||||
|
score_against = models.PositiveSmallIntegerField(_("score (opponent)"), null=True, blank=True)
|
||||||
|
is_live = models.BooleanField(_("live"), default=False, help_text=_("The game is currently in progress."))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("event")
|
verbose_name = _("event")
|
||||||
verbose_name_plural = _("events")
|
verbose_name_plural = _("events")
|
||||||
@@ -87,6 +97,13 @@ class Event(ClubScopedModel):
|
|||||||
def clean(self):
|
def clean(self):
|
||||||
validate_club_scope(self, self.club_id, same_club_fields=("season", "location", "opponent"))
|
validate_club_scope(self, self.club_id, same_club_fields=("season", "location", "opponent"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_home_game(self) -> bool:
|
||||||
|
"""Whether this game is being played at the club's own ground
|
||||||
|
(Location.is_home) -- False for anything that isn't a game, or a game
|
||||||
|
with no location set, or one at an away/neutral location."""
|
||||||
|
return self.kind == self.EventKind.GAME and self.location_id is not None and self.location.is_home
|
||||||
|
|
||||||
|
|
||||||
class EventSeries(ClubScopedModel):
|
class EventSeries(ClubScopedModel):
|
||||||
"""A recurring event definition that materialises concrete Event rows."""
|
"""A recurring event definition that materialises concrete Event rows."""
|
||||||
@@ -96,7 +113,7 @@ class EventSeries(ClubScopedModel):
|
|||||||
until = models.DateTimeField(_("until"), null=True, blank=True, help_text=_("Series end: no occurrences are generated after this. Leave blank for open-ended (bounded by the rule's own COUNT/UNTIL, if any)."))
|
until = models.DateTimeField(_("until"), null=True, blank=True, help_text=_("Series end: no occurrences are generated after this. Leave blank for open-ended (bounded by the rule's own COUNT/UNTIL, if any)."))
|
||||||
duration = models.DurationField(_("duration"), null=True, blank=True, help_text=_("Length of each occurrence; sets each event's end."))
|
duration = models.DurationField(_("duration"), null=True, blank=True, help_text=_("Length of each occurrence; sets each event's end."))
|
||||||
gathering_offset = models.DurationField(_("gathering offset"), null=True, blank=True, help_text=_("How long before the start each occurrence's gathering time is."))
|
gathering_offset = models.DurationField(_("gathering offset"), null=True, blank=True, help_text=_("How long before the start each occurrence's gathering time is."))
|
||||||
deadline_offset = models.DurationField(_("deadline offset"), null=True, blank=True, help_text=_("How long before the start each occurrence's sign-up deadline is."))
|
deadline_offset = models.DurationField(_("registration deadline offset"), null=True, blank=True, help_text=_("How long before the start each occurrence's registration deadline is."))
|
||||||
excluded_dates = models.JSONField(_("excluded dates"), default=list, blank=True, help_text=_("ISO start datetimes of occurrences removed from the series (EXDATEs)."))
|
excluded_dates = models.JSONField(_("excluded dates"), default=list, blank=True, help_text=_("ISO start datetimes of occurrences removed from the series (EXDATEs)."))
|
||||||
generated_until = models.DateTimeField(_("generated until"), null=True, blank=True, help_text=_("Occurrences have been materialised up to this point."))
|
generated_until = models.DateTimeField(_("generated until"), null=True, blank=True, help_text=_("Occurrences have been materialised up to this point."))
|
||||||
|
|
||||||
@@ -123,13 +140,13 @@ class EventSeries(ClubScopedModel):
|
|||||||
|
|
||||||
class Attendance(UUIDModel):
|
class Attendance(UUIDModel):
|
||||||
class AttendanceStatus(models.TextChoices):
|
class AttendanceStatus(models.TextChoices):
|
||||||
PRESENT = "present", _("present")
|
PRESENT = "present", _("Present")
|
||||||
ABSENT = "absent", _("absent")
|
ABSENT = "absent", _("Absent")
|
||||||
EXCUSED = "excused", _("excused")
|
EXCUSED = "excused", _("Excused")
|
||||||
SELECTED = "selected", _("selected")
|
SELECTED = "selected", _("Selected")
|
||||||
NOT_SELECTED = "not_selected", _("not selected")
|
NOT_SELECTED = "not_selected", _("Not selected")
|
||||||
MAYBE = "maybe", _("maybe")
|
MAYBE = "maybe", _("Maybe")
|
||||||
NO_RESPONSE = "no_response", _("no response")
|
NO_RESPONSE = "no_response", _("No response")
|
||||||
|
|
||||||
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="attendances", verbose_name=_("event"))
|
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="attendances", verbose_name=_("event"))
|
||||||
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="attendances", verbose_name=_("member"))
|
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="attendances", verbose_name=_("member"))
|
||||||
@@ -153,3 +170,34 @@ class Attendance(UUIDModel):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.event} - {self.member}"
|
return f"{self.event} - {self.member}"
|
||||||
|
|
||||||
|
|
||||||
|
class Competition(models.Model):
|
||||||
|
"""A competition has a name with a specific URL to fetch data from. These are managed centrally."""
|
||||||
|
|
||||||
|
name = models.CharField(max_length=250)
|
||||||
|
module = models.CharField(max_length=250)
|
||||||
|
sport_type = models.CharField(
|
||||||
|
_("sport"),
|
||||||
|
max_length=20,
|
||||||
|
choices=Club.SportType.choices,
|
||||||
|
default=Club.SportType.OTHER,
|
||||||
|
help_text=_("Which sport this competition is for."),
|
||||||
|
)
|
||||||
|
flag = models.ForeignKey(
|
||||||
|
settings.WAFFLE_FLAG_MODEL,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="competitions",
|
||||||
|
verbose_name=_("feature flag"),
|
||||||
|
help_text=_("Which clubs this competition is offered to -- set (or leave blank to hide it everywhere) from the control panel's Features page. A competition with no flag never shows up on the Event admin's competition dropdown."),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("competition")
|
||||||
|
verbose_name_plural = _("competitions")
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|||||||
46
events/services/competitions.py
Normal file
46
events/services/competitions.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Refreshing a game's score/status from its competition's own data source.
|
||||||
|
|
||||||
|
Event.competition (free text) and Event.external_game_id (that source's id for this
|
||||||
|
fixture) exist so a real integration has somewhere to key off later -- nothing
|
||||||
|
fetches anything yet. This is that integration's landing spot: the button and view
|
||||||
|
that call it already exist (management/views.py::EventFetchGameInfoView), so wiring
|
||||||
|
up a real data source later is a matter of replacing fetch_game_info's body, not
|
||||||
|
building the UI around it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
from events.models import Competition
|
||||||
|
|
||||||
|
|
||||||
|
class CompetitionFetchError(Exception):
|
||||||
|
"""A game's info could not be fetched from its competition's data source."""
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_game_info(event) -> bool:
|
||||||
|
"""Refresh `event`'s score/live status from its competition's data source.
|
||||||
|
|
||||||
|
Gated on that competition's own feature flag being active for the event's
|
||||||
|
club: the management form's competition dropdown deliberately shows every
|
||||||
|
competition regardless of flag (see management.forms.EventForm), so this is
|
||||||
|
where per-club access actually gets enforced. No flag, an inactive one, or
|
||||||
|
a competition name that matches nothing -- there's no data source this club
|
||||||
|
is allowed to use, so this quietly does nothing and returns False rather
|
||||||
|
than erroring: missing access isn't a failure to report.
|
||||||
|
|
||||||
|
Returns whether a fetch was actually attempted.
|
||||||
|
"""
|
||||||
|
competition = Competition.objects.filter(name=event.competition).select_related("flag").first()
|
||||||
|
if competition is None or competition.flag is None or not competition.flag.is_active_for_club(event.club):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
module = importlib.import_module(competition.module)
|
||||||
|
competition = getattr(module, competition.name)
|
||||||
|
|
||||||
|
competition().update_game_information(event=event)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as error:
|
||||||
|
raise CompetitionFetchError("No competition data source is configured yet -- there's nothing to fetch from.") from error
|
||||||
297
events/services/rbihf_import.py
Normal file
297
events/services/rbihf_import.py
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
"""Importing a team's fixture list from RBIHF's own website.
|
||||||
|
|
||||||
|
RBIHF publishes a team's schedule at ``https://www.rbihf.be/league/team/<id>`` --
|
||||||
|
an HTML page, not an API, scraped with BeautifulSoup. This is the create/update/
|
||||||
|
delete counterpart to ``events.services.competitions.fetch_game_info``, which
|
||||||
|
only ever refreshes a single *existing* game's score: this is how those `Event`
|
||||||
|
rows get created in the first place.
|
||||||
|
|
||||||
|
Two-step flow, mirroring ``management.bulk_import`` (the member Excel import):
|
||||||
|
``build_plan`` is pure with respect to the outside world once it has the raw
|
||||||
|
HTML (no further network calls), so a view can render a preview from it and
|
||||||
|
then re-run it unchanged at confirm time -- see management/views.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from events.models import Event, Location, Opponent
|
||||||
|
|
||||||
|
TEAM_URL_RE = re.compile(r"^https://(?:www\.)?rbihf\.be/league/team/(?P<team_id>\d+)/?$")
|
||||||
|
|
||||||
|
REQUEST_HEADERS = {
|
||||||
|
"Cookie": "language=en",
|
||||||
|
"User-Agent": "Mozilla/5.0 (compatible; RosterChief/1.0)",
|
||||||
|
"Accept": "text/html",
|
||||||
|
}
|
||||||
|
REQUEST_TIMEOUT_SECONDS = 15
|
||||||
|
|
||||||
|
|
||||||
|
class RBIHFImportError(Exception):
|
||||||
|
"""The page couldn't be fetched, or didn't look like an RBIHF team schedule."""
|
||||||
|
|
||||||
|
|
||||||
|
def extract_team_id(url: str) -> str:
|
||||||
|
match = TEAM_URL_RE.match(url.strip())
|
||||||
|
if not match:
|
||||||
|
raise RBIHFImportError("That doesn't look like an RBIHF team page -- expected something like https://www.rbihf.be/league/team/4460.")
|
||||||
|
return match.group("team_id")
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_html(url: str) -> str:
|
||||||
|
try:
|
||||||
|
response = requests.get(url, headers=REQUEST_HEADERS, timeout=REQUEST_TIMEOUT_SECONDS)
|
||||||
|
except requests.RequestException as error:
|
||||||
|
raise RBIHFImportError(f"Could not reach {url}: {error}") from error
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise RBIHFImportError(f"{url} returned HTTP {response.status_code}.")
|
||||||
|
|
||||||
|
return response.text
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScrapedFixture:
|
||||||
|
external_game_id: str
|
||||||
|
start: datetime
|
||||||
|
is_home: bool
|
||||||
|
opponent_name: str
|
||||||
|
venue_text: str
|
||||||
|
|
||||||
|
|
||||||
|
def _team_id_from_href(href: str) -> str | None:
|
||||||
|
match = re.search(r"/league/team/(\d+)", href or "")
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_fixtures(html: str, team_id: str) -> tuple[str, list[ScrapedFixture]]:
|
||||||
|
"""Returns (the scraped team's own display name, its upcoming fixtures)."""
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
|
||||||
|
team_heading = soup.find("h2")
|
||||||
|
if team_heading is None:
|
||||||
|
raise RBIHFImportError("Could not find a team name on this page -- is this really an RBIHF team page?")
|
||||||
|
team_name = team_heading.get_text(strip=True)
|
||||||
|
|
||||||
|
games_heading = soup.find(id="games-upcoming")
|
||||||
|
if games_heading is None:
|
||||||
|
raise RBIHFImportError("Could not find an “Upcoming games” table on this page.")
|
||||||
|
table = games_heading.find_next("table")
|
||||||
|
if table is None:
|
||||||
|
raise RBIHFImportError("Could not find an “Upcoming games” table on this page.")
|
||||||
|
|
||||||
|
fixtures = []
|
||||||
|
for row in table.find_all("tr"):
|
||||||
|
cells = row.find_all("td")
|
||||||
|
if len(cells) < 6:
|
||||||
|
continue # the header row (<th>s) and any stray rows
|
||||||
|
|
||||||
|
game_link = cells[0].find("a")
|
||||||
|
game_id = game_link.get_text(strip=True) if game_link else cells[0].get_text(strip=True)
|
||||||
|
if not game_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
date_text = cells[1].get_text(strip=True)
|
||||||
|
hour_text = cells[2].get_text(strip=True)
|
||||||
|
try:
|
||||||
|
naive_start = datetime.strptime(f"{date_text} {hour_text}", "%Y-%m-%d %H:%M")
|
||||||
|
except ValueError:
|
||||||
|
continue # a row that doesn't match the expected shape -- skip rather than blow up the whole import
|
||||||
|
start = timezone.make_aware(naive_start)
|
||||||
|
|
||||||
|
venue_text = cells[3].get_text(strip=True)
|
||||||
|
|
||||||
|
home_link, visit_link = cells[4].find("a"), cells[5].find("a")
|
||||||
|
if home_link is None or visit_link is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
home_id = _team_id_from_href(home_link.get("href", ""))
|
||||||
|
visit_id = _team_id_from_href(visit_link.get("href", ""))
|
||||||
|
|
||||||
|
if home_id == team_id:
|
||||||
|
is_home, opponent_name = True, visit_link.get("title") or visit_link.get_text(strip=True)
|
||||||
|
elif visit_id == team_id:
|
||||||
|
is_home, opponent_name = False, home_link.get("title") or home_link.get_text(strip=True)
|
||||||
|
else:
|
||||||
|
continue # neither side is the team we asked about -- inconsistent row, skip it
|
||||||
|
|
||||||
|
fixtures.append(ScrapedFixture(external_game_id=game_id, start=start, is_home=is_home, opponent_name=opponent_name, venue_text=venue_text))
|
||||||
|
|
||||||
|
return team_name, fixtures
|
||||||
|
|
||||||
|
|
||||||
|
def suggested_location(club, fixture: ScrapedFixture):
|
||||||
|
if fixture.is_home:
|
||||||
|
return Location.objects.filter(club=club, is_home=True).first()
|
||||||
|
|
||||||
|
venue = fixture.venue_text.strip()
|
||||||
|
if not venue:
|
||||||
|
return None
|
||||||
|
return Location.objects.filter(club=club).filter(Q(city__iexact=venue) | Q(name__iexact=venue)).first()
|
||||||
|
|
||||||
|
|
||||||
|
def suggested_opponent(club, fixture: ScrapedFixture):
|
||||||
|
"""A case-insensitive name match among the club's existing Opponents, if
|
||||||
|
any -- RBIHF's own spelling doesn't always match punctuation/casing
|
||||||
|
already on file (e.g. "Rivals FC" vs "Rivals F.C."), and get_or_create's
|
||||||
|
exact-string match alone would just create a near-duplicate instead of
|
||||||
|
reusing it."""
|
||||||
|
name = fixture.opponent_name.strip()
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
return Opponent.objects.filter(club=club, name__iexact=name).first()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlannedCreate:
|
||||||
|
fixture: ScrapedFixture
|
||||||
|
suggested_location: Location | None
|
||||||
|
suggested_opponent: Opponent | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlannedUpdate:
|
||||||
|
fixture: ScrapedFixture
|
||||||
|
event: Event
|
||||||
|
changes: dict = field(default_factory=dict) # field name -> (old, new), display-ready strings
|
||||||
|
suggested_location: Location | None = None
|
||||||
|
suggested_opponent: Opponent | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImportPlan:
|
||||||
|
club: object
|
||||||
|
team: object
|
||||||
|
scraped_team_name: str
|
||||||
|
location_choices: object # QuerySet[Location], evaluated once for the template
|
||||||
|
opponent_choices: object = None # QuerySet[Opponent], evaluated once for the template
|
||||||
|
to_create: list[PlannedCreate] = field(default_factory=list)
|
||||||
|
to_update: list[PlannedUpdate] = field(default_factory=list)
|
||||||
|
to_delete: list[Event] = field(default_factory=list)
|
||||||
|
unchanged_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _describe_changes(event, fixture: ScrapedFixture) -> dict:
|
||||||
|
changes = {}
|
||||||
|
if event.start != fixture.start:
|
||||||
|
changes["start"] = (event.start, fixture.start)
|
||||||
|
existing_opponent = event.opponent.name if event.opponent_id else ""
|
||||||
|
if existing_opponent != fixture.opponent_name:
|
||||||
|
changes["opponent"] = (existing_opponent, fixture.opponent_name)
|
||||||
|
return changes
|
||||||
|
|
||||||
|
|
||||||
|
def build_plan(club, team, rbihf_team_id: str, html: str) -> ImportPlan:
|
||||||
|
"""Parses ``html`` (already fetched -- see ``fetch_html``) and diffs it
|
||||||
|
against this club/team's existing RBIHF events. Pure with respect to the
|
||||||
|
network: safe to call twice against the same stashed HTML (preview, then
|
||||||
|
confirm) and get an identical result modulo whatever changed in the DB
|
||||||
|
between the two calls."""
|
||||||
|
scraped_team_name, fixtures = parse_fixtures(html, rbihf_team_id)
|
||||||
|
location_choices = list(Location.objects.filter(club=club).order_by("name"))
|
||||||
|
opponent_choices = list(Opponent.objects.filter(club=club).order_by("name"))
|
||||||
|
|
||||||
|
existing_by_game_id = {event.external_game_id: event for event in Event.objects.filter(club=club, teams=team, competition="RBIHF").exclude(external_game_id="").select_related("opponent", "location")}
|
||||||
|
|
||||||
|
plan = ImportPlan(club=club, team=team, scraped_team_name=scraped_team_name, location_choices=location_choices, opponent_choices=opponent_choices)
|
||||||
|
|
||||||
|
seen_game_ids = set()
|
||||||
|
for fixture in fixtures:
|
||||||
|
seen_game_ids.add(fixture.external_game_id)
|
||||||
|
existing = existing_by_game_id.get(fixture.external_game_id)
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
plan.to_create.append(PlannedCreate(fixture=fixture, suggested_location=suggested_location(club, fixture), suggested_opponent=suggested_opponent(club, fixture)))
|
||||||
|
continue
|
||||||
|
|
||||||
|
changes = _describe_changes(existing, fixture)
|
||||||
|
if changes:
|
||||||
|
# Prefer whatever location/opponent is already on the event -- a
|
||||||
|
# previous run's manual pick -- over re-guessing, so a re-import
|
||||||
|
# doesn't silently discard it. Only fall back to a fresh guess
|
||||||
|
# when nothing's set yet.
|
||||||
|
default_location = existing.location if existing.location_id else suggested_location(club, fixture)
|
||||||
|
default_opponent = existing.opponent if existing.opponent_id else suggested_opponent(club, fixture)
|
||||||
|
plan.to_update.append(PlannedUpdate(fixture=fixture, event=existing, changes=changes, suggested_location=default_location, suggested_opponent=default_opponent))
|
||||||
|
else:
|
||||||
|
plan.unchanged_count += 1
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
for game_id, event in existing_by_game_id.items():
|
||||||
|
if game_id not in seen_game_ids and event.start >= now:
|
||||||
|
plan.to_delete.append(event)
|
||||||
|
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def apply_plan(plan: ImportPlan, locations_by_game_id: dict, opponents_by_game_id: dict | None = None) -> dict:
|
||||||
|
"""Applies everything in ``plan``, resolving each create/update row's
|
||||||
|
location from ``locations_by_game_id`` and opponent from
|
||||||
|
``opponents_by_game_id`` (both external_game_id -> pk as a plain string,
|
||||||
|
or None/""). A pk that doesn't belong to ``plan.club`` -- or isn't a real
|
||||||
|
pk at all -- is silently ignored rather than trusted: this is user input
|
||||||
|
straight from the confirm POST, not derived data. Compared as strings
|
||||||
|
since the input is whatever a <select> submitted, not already a UUID.
|
||||||
|
|
||||||
|
Blank/missing means different things for the two: no location is a valid,
|
||||||
|
final state (an away game with no venue on file yet), but a game always
|
||||||
|
needs *an* opponent -- so blank there falls back to the same
|
||||||
|
find-or-create-by-scraped-name this always did, rather than leaving it
|
||||||
|
unset."""
|
||||||
|
opponents_by_game_id = opponents_by_game_id or {}
|
||||||
|
locations_by_id = {str(location.pk): location for location in plan.location_choices}
|
||||||
|
opponents_by_id = {str(opponent.pk): opponent for opponent in plan.opponent_choices}
|
||||||
|
|
||||||
|
def resolve_location(game_id):
|
||||||
|
location_id = locations_by_game_id.get(game_id)
|
||||||
|
if not location_id:
|
||||||
|
return None
|
||||||
|
return locations_by_id.get(str(location_id))
|
||||||
|
|
||||||
|
def resolve_opponent(fixture):
|
||||||
|
opponent_id = opponents_by_game_id.get(fixture.external_game_id)
|
||||||
|
if opponent_id:
|
||||||
|
opponent = opponents_by_id.get(str(opponent_id))
|
||||||
|
if opponent is not None:
|
||||||
|
return opponent
|
||||||
|
opponent, _created = Opponent.objects.get_or_create(club=plan.club, name=fixture.opponent_name)
|
||||||
|
return opponent
|
||||||
|
|
||||||
|
created = updated = deleted = 0
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
for planned in plan.to_create:
|
||||||
|
opponent = resolve_opponent(planned.fixture)
|
||||||
|
event = Event.objects.create(
|
||||||
|
club=plan.club,
|
||||||
|
title=f"vs {opponent.name}",
|
||||||
|
kind=Event.EventKind.GAME,
|
||||||
|
start=planned.fixture.start,
|
||||||
|
competition="RBIHF",
|
||||||
|
external_game_id=planned.fixture.external_game_id,
|
||||||
|
opponent=opponent,
|
||||||
|
location=resolve_location(planned.fixture.external_game_id),
|
||||||
|
)
|
||||||
|
event.teams.add(plan.team)
|
||||||
|
created += 1
|
||||||
|
|
||||||
|
for planned in plan.to_update:
|
||||||
|
event = planned.event
|
||||||
|
event.start = planned.fixture.start
|
||||||
|
event.opponent = resolve_opponent(planned.fixture)
|
||||||
|
event.location = resolve_location(planned.fixture.external_game_id)
|
||||||
|
event.save(update_fields=["start", "opponent", "location"])
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
for event in plan.to_delete:
|
||||||
|
event.delete()
|
||||||
|
deleted += 1
|
||||||
|
|
||||||
|
return {"created": created, "updated": updated, "deleted": deleted}
|
||||||
378
events/tests.py
378
events/tests.py
@@ -6,12 +6,14 @@ from django.core.management import call_command
|
|||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from waffle import get_waffle_flag_model
|
||||||
|
|
||||||
from club.models import Club, Season
|
from club.models import Club, Season
|
||||||
from members.models import Member
|
from members.models import Member
|
||||||
from teams.models import Position, Team, TeamMembership
|
from teams.models import Position, Team, TeamMembership
|
||||||
|
|
||||||
from .models import Attendance, Event, EventSeries, Location, Opponent
|
from .admin import EventAdminForm
|
||||||
|
from .models import Attendance, Competition, Event, EventSeries, Location, Opponent
|
||||||
from .services import (
|
from .services import (
|
||||||
cancel_occurrence,
|
cancel_occurrence,
|
||||||
detach_occurrence,
|
detach_occurrence,
|
||||||
@@ -25,6 +27,7 @@ from .services import (
|
|||||||
team_attendance_rate,
|
team_attendance_rate,
|
||||||
team_no_shows,
|
team_no_shows,
|
||||||
)
|
)
|
||||||
|
from .services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, parse_fixtures, suggested_location, suggested_opponent
|
||||||
|
|
||||||
|
|
||||||
class EventsTestBase(TestCase):
|
class EventsTestBase(TestCase):
|
||||||
@@ -75,6 +78,76 @@ class EventModelTests(EventsTestBase):
|
|||||||
with self.assertRaises(IntegrityError):
|
with self.assertRaises(IntegrityError):
|
||||||
Attendance.objects.create(event=event, member=self.alice)
|
Attendance.objects.create(event=event, member=self.alice)
|
||||||
|
|
||||||
|
def test_is_home_game(self):
|
||||||
|
home_ground = Location.objects.create(club=self.club, name="Home Ground", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
|
||||||
|
away_ground = Location.objects.create(club=self.club, name="Away Ground", address="2 St", city="Town", zip_code="1000", country="BE")
|
||||||
|
|
||||||
|
home_game = self.make_event(kind=Event.EventKind.GAME, location=home_ground)
|
||||||
|
away_game = self.make_event(kind=Event.EventKind.GAME, location=away_ground)
|
||||||
|
game_with_no_location = self.make_event(kind=Event.EventKind.GAME)
|
||||||
|
training_at_home_ground = self.make_event(kind=Event.EventKind.TRAINING, location=home_ground)
|
||||||
|
|
||||||
|
self.assertTrue(home_game.is_home_game)
|
||||||
|
self.assertFalse(away_game.is_home_game)
|
||||||
|
self.assertFalse(game_with_no_location.is_home_game)
|
||||||
|
self.assertFalse(training_at_home_ground.is_home_game)
|
||||||
|
|
||||||
|
|
||||||
|
class CompetitionModelTests(EventsTestBase):
|
||||||
|
def test_sport_type_defaults_to_other(self):
|
||||||
|
competition = Competition.objects.create(name="Local League", module="events.competition.other")
|
||||||
|
|
||||||
|
self.assertEqual(competition.sport_type, Club.SportType.OTHER)
|
||||||
|
|
||||||
|
def test_the_seeded_hockey_competitions_are_backfilled_as_ice_hockey(self):
|
||||||
|
# Migration 0019's data migration backfills the two competitions seeded
|
||||||
|
# by 0017 (both real ice hockey leagues) rather than leaving them at the
|
||||||
|
# generic "other" default.
|
||||||
|
for name in ["RBIHF", "CEHL"]:
|
||||||
|
with self.subTest(name=name):
|
||||||
|
self.assertEqual(Competition.objects.get(name=name).sport_type, Club.SportType.ICE_HOCKEY)
|
||||||
|
|
||||||
|
|
||||||
|
class EventAdminFormCompetitionTests(EventsTestBase):
|
||||||
|
"""`competition` is a plain CharField, but the admin should only ever offer
|
||||||
|
competitions this club is actually allowed to use -- see events/admin.py."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
Flag = get_waffle_flag_model()
|
||||||
|
self.active_flag = Flag.objects.create(name="active-competition")
|
||||||
|
self.active_flag.clubs.add(self.club)
|
||||||
|
self.inactive_flag = Flag.objects.create(name="inactive-competition")
|
||||||
|
self.active_competition = Competition.objects.create(name="Active Cup", module="events.competition.active", flag=self.active_flag)
|
||||||
|
self.inactive_competition = Competition.objects.create(name="Inactive Cup", module="events.competition.inactive", flag=self.inactive_flag)
|
||||||
|
self.flagless_competition = Competition.objects.create(name="Flagless Cup", module="events.competition.flagless")
|
||||||
|
|
||||||
|
def test_a_flagless_competition_never_appears(self):
|
||||||
|
form = EventAdminForm(instance=Event())
|
||||||
|
choices = dict(form.fields["competition"].choices)
|
||||||
|
|
||||||
|
self.assertNotIn("Flagless Cup", choices)
|
||||||
|
|
||||||
|
def test_a_brand_new_event_offers_every_flagged_competition_unfiltered(self):
|
||||||
|
# The club is unknown until the event is actually saved (see
|
||||||
|
# EventClubScopeTests for the same "club_id is None pre-save" timing issue
|
||||||
|
# elsewhere), so a new event can't be filtered by club yet -- it falls back
|
||||||
|
# to showing everything with a flag rather than crashing or showing nothing.
|
||||||
|
form = EventAdminForm(instance=Event())
|
||||||
|
choices = dict(form.fields["competition"].choices)
|
||||||
|
|
||||||
|
self.assertIn("Active Cup", choices)
|
||||||
|
self.assertIn("Inactive Cup", choices)
|
||||||
|
|
||||||
|
def test_an_existing_event_only_offers_competitions_active_for_its_club(self):
|
||||||
|
event = self.make_event(kind=Event.EventKind.GAME)
|
||||||
|
form = EventAdminForm(instance=event)
|
||||||
|
choices = dict(form.fields["competition"].choices)
|
||||||
|
|
||||||
|
self.assertIn("Active Cup", choices)
|
||||||
|
self.assertNotIn("Inactive Cup", choices)
|
||||||
|
self.assertNotIn("Flagless Cup", choices)
|
||||||
|
|
||||||
|
|
||||||
class EffectiveMembersTests(EventsTestBase):
|
class EffectiveMembersTests(EventsTestBase):
|
||||||
def test_union_of_team_invited_minus_excluded(self):
|
def test_union_of_team_invited_minus_excluded(self):
|
||||||
@@ -517,3 +590,306 @@ class TeamAttendanceStatsTests(EventsTestBase):
|
|||||||
record_check_in(attendance, showed_up=True)
|
record_check_in(attendance, showed_up=True)
|
||||||
|
|
||||||
self.assertEqual(list(team_no_shows(self.team, self.season)), [])
|
self.assertEqual(list(team_no_shows(self.team, self.season)), [])
|
||||||
|
|
||||||
|
|
||||||
|
RBIHF_TEAM_ID = "4460"
|
||||||
|
RBIHF_TEAM_NAME = "Sportoase Antwerp Phantoms"
|
||||||
|
|
||||||
|
|
||||||
|
def rbihf_sample_html(rows):
|
||||||
|
"""A trimmed stand-in for an RBIHF team page (https://www.rbihf.be/league/team/<id>)
|
||||||
|
-- structure confirmed against the real page while designing this feature.
|
||||||
|
``rows`` is a list of dicts: game_id, date ("YYYY-MM-DD"), hour ("HH:MM"),
|
||||||
|
venue, home_id, home_name, visit_id, visit_name."""
|
||||||
|
row_html = "".join(
|
||||||
|
f'<tr><td class="game-nr"><a href="/game/{r["game_id"]}" title="Game {r["game_id"]}">{r["game_id"]}</a></td>'
|
||||||
|
f'<td class="date">{r["date"]}</td><td class="hour">{r["hour"]}</td><td>{r["venue"]}</td>'
|
||||||
|
f'<td><a href="/league/team/{r["home_id"]}" title="{r["home_name"]}">{r["home_name"]}</a></td>'
|
||||||
|
f'<td><a href="/league/team/{r["visit_id"]}" title="{r["visit_name"]}">{r["visit_name"]}</a></td></tr>'
|
||||||
|
for r in rows
|
||||||
|
)
|
||||||
|
return f"""<html><body>
|
||||||
|
<div class="block"><div class="block-header"><h2>{RBIHF_TEAM_NAME}</h2></div></div>
|
||||||
|
<div class="block"><div class="block-header"><h2 id="games-upcoming">Upcoming games</h2></div>
|
||||||
|
<div class="block-content"><table>
|
||||||
|
<tr><th class="game-nr">#</th><th class="date">Date</th><th class="hour">Hour</th><th>Location</th><th>Home</th><th>Visit</th></tr>
|
||||||
|
{row_html}
|
||||||
|
</table></div></div>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
class RBIHFParseFixturesTests(TestCase):
|
||||||
|
"""Pure parsing, no network, no DB -- events.services.rbihf_import.parse_fixtures."""
|
||||||
|
|
||||||
|
def test_parses_team_name_and_fixtures(self):
|
||||||
|
html = rbihf_sample_html(
|
||||||
|
[
|
||||||
|
{"game_id": "5002", "date": "2026-09-12", "hour": "12:15", "venue": "Deurne", "home_id": RBIHF_TEAM_ID, "home_name": RBIHF_TEAM_NAME, "visit_id": "4464", "visit_name": "Amsterdam Tigers"},
|
||||||
|
{"game_id": "5010", "date": "2026-09-20", "hour": "18:30", "venue": "Antwerp Ice Rink", "home_id": "4500", "home_name": "Brussels Bears", "visit_id": RBIHF_TEAM_ID, "visit_name": RBIHF_TEAM_NAME},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
team_name, fixtures = parse_fixtures(html, RBIHF_TEAM_ID)
|
||||||
|
|
||||||
|
self.assertEqual(team_name, RBIHF_TEAM_NAME)
|
||||||
|
self.assertEqual(len(fixtures), 2)
|
||||||
|
|
||||||
|
def test_resolves_home_fixture_correctly(self):
|
||||||
|
html = rbihf_sample_html([{"game_id": "5002", "date": "2026-09-12", "hour": "12:15", "venue": "Deurne", "home_id": RBIHF_TEAM_ID, "home_name": RBIHF_TEAM_NAME, "visit_id": "4464", "visit_name": "Amsterdam Tigers"}])
|
||||||
|
|
||||||
|
_team_name, fixtures = parse_fixtures(html, RBIHF_TEAM_ID)
|
||||||
|
|
||||||
|
self.assertTrue(fixtures[0].is_home)
|
||||||
|
self.assertEqual(fixtures[0].opponent_name, "Amsterdam Tigers")
|
||||||
|
self.assertEqual(fixtures[0].venue_text, "Deurne")
|
||||||
|
self.assertEqual(fixtures[0].external_game_id, "5002")
|
||||||
|
|
||||||
|
def test_resolves_away_fixture_correctly(self):
|
||||||
|
html = rbihf_sample_html([{"game_id": "5010", "date": "2026-09-20", "hour": "18:30", "venue": "Antwerp Ice Rink", "home_id": "4500", "home_name": "Brussels Bears", "visit_id": RBIHF_TEAM_ID, "visit_name": RBIHF_TEAM_NAME}])
|
||||||
|
|
||||||
|
_team_name, fixtures = parse_fixtures(html, RBIHF_TEAM_ID)
|
||||||
|
|
||||||
|
self.assertFalse(fixtures[0].is_home)
|
||||||
|
self.assertEqual(fixtures[0].opponent_name, "Brussels Bears")
|
||||||
|
|
||||||
|
def test_a_row_where_neither_side_matches_is_skipped(self):
|
||||||
|
html = rbihf_sample_html([{"game_id": "9999", "date": "2026-09-20", "hour": "18:30", "venue": "Elsewhere", "home_id": "1", "home_name": "Team One", "visit_id": "2", "visit_name": "Team Two"}])
|
||||||
|
|
||||||
|
_team_name, fixtures = parse_fixtures(html, RBIHF_TEAM_ID)
|
||||||
|
|
||||||
|
self.assertEqual(fixtures, [])
|
||||||
|
|
||||||
|
def test_missing_games_table_raises(self):
|
||||||
|
with self.assertRaises(RBIHFImportError):
|
||||||
|
parse_fixtures("<html><body><h2>Some Team</h2></body></html>", RBIHF_TEAM_ID)
|
||||||
|
|
||||||
|
|
||||||
|
class RBIHFExtractTeamIdTests(TestCase):
|
||||||
|
def test_accepts_the_documented_shape(self):
|
||||||
|
self.assertEqual(extract_team_id("https://www.rbihf.be/league/team/4460"), "4460")
|
||||||
|
|
||||||
|
def test_accepts_without_www(self):
|
||||||
|
self.assertEqual(extract_team_id("https://rbihf.be/league/team/4460"), "4460")
|
||||||
|
|
||||||
|
def test_rejects_a_different_host(self):
|
||||||
|
with self.assertRaises(RBIHFImportError):
|
||||||
|
extract_team_id("https://evil.example.com/league/team/4460")
|
||||||
|
|
||||||
|
def test_rejects_a_different_path(self):
|
||||||
|
with self.assertRaises(RBIHFImportError):
|
||||||
|
extract_team_id("https://www.rbihf.be/leagues")
|
||||||
|
|
||||||
|
|
||||||
|
class RBIHFImportPlanTests(EventsTestBase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.home_location = Location.objects.create(club=self.club, name="Home Arena", address="1 St", city="Antwerp", zip_code="1000", country="BE", is_home=True)
|
||||||
|
self.away_location = Location.objects.create(club=self.club, name="Deurne Ice Hall", address="2 St", city="Deurne", zip_code="2100", country="BE")
|
||||||
|
|
||||||
|
def home_fixture_row(self, game_id="5002", date="2026-09-12"):
|
||||||
|
return {"game_id": game_id, "date": date, "hour": "12:15", "venue": "Deurne", "home_id": RBIHF_TEAM_ID, "home_name": RBIHF_TEAM_NAME, "visit_id": "4464", "visit_name": "Amsterdam Tigers"}
|
||||||
|
|
||||||
|
# -- suggested_location --------------------------------------------------
|
||||||
|
|
||||||
|
def test_suggested_location_for_a_home_fixture_is_the_clubs_home_location(self):
|
||||||
|
_team_name, fixtures = parse_fixtures(rbihf_sample_html([self.home_fixture_row()]), RBIHF_TEAM_ID)
|
||||||
|
|
||||||
|
self.assertEqual(suggested_location(self.club, fixtures[0]), self.home_location)
|
||||||
|
|
||||||
|
def test_suggested_location_for_an_away_fixture_matches_by_city(self):
|
||||||
|
row = {"game_id": "5010", "date": "2026-09-20", "hour": "18:30", "venue": "Deurne", "home_id": "4500", "home_name": "Brussels Bears", "visit_id": RBIHF_TEAM_ID, "visit_name": RBIHF_TEAM_NAME}
|
||||||
|
_team_name, fixtures = parse_fixtures(rbihf_sample_html([row]), RBIHF_TEAM_ID)
|
||||||
|
|
||||||
|
self.assertEqual(suggested_location(self.club, fixtures[0]), self.away_location)
|
||||||
|
|
||||||
|
def test_suggested_location_is_none_when_nothing_matches(self):
|
||||||
|
row = {"game_id": "5010", "date": "2026-09-20", "hour": "18:30", "venue": "Nowhere Familiar", "home_id": "4500", "home_name": "Brussels Bears", "visit_id": RBIHF_TEAM_ID, "visit_name": RBIHF_TEAM_NAME}
|
||||||
|
_team_name, fixtures = parse_fixtures(rbihf_sample_html([row]), RBIHF_TEAM_ID)
|
||||||
|
|
||||||
|
self.assertIsNone(suggested_location(self.club, fixtures[0]))
|
||||||
|
|
||||||
|
# -- suggested_opponent ---------------------------------------------------
|
||||||
|
|
||||||
|
def test_suggested_opponent_matches_case_insensitively(self):
|
||||||
|
opponent = Opponent.objects.create(club=self.club, name="AMSTERDAM TIGERS")
|
||||||
|
_team_name, fixtures = parse_fixtures(rbihf_sample_html([self.home_fixture_row()]), RBIHF_TEAM_ID)
|
||||||
|
|
||||||
|
self.assertEqual(suggested_opponent(self.club, fixtures[0]), opponent)
|
||||||
|
|
||||||
|
def test_suggested_opponent_is_none_when_nothing_matches(self):
|
||||||
|
_team_name, fixtures = parse_fixtures(rbihf_sample_html([self.home_fixture_row()]), RBIHF_TEAM_ID)
|
||||||
|
|
||||||
|
self.assertIsNone(suggested_opponent(self.club, fixtures[0]))
|
||||||
|
|
||||||
|
# -- build_plan ------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_a_new_fixture_is_planned_as_a_create(self):
|
||||||
|
html = rbihf_sample_html([self.home_fixture_row()])
|
||||||
|
|
||||||
|
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||||
|
|
||||||
|
self.assertEqual(len(plan.to_create), 1)
|
||||||
|
self.assertEqual(plan.to_create[0].fixture.external_game_id, "5002")
|
||||||
|
self.assertEqual(plan.to_create[0].suggested_location, self.home_location)
|
||||||
|
|
||||||
|
def test_an_identical_existing_fixture_is_unchanged(self):
|
||||||
|
html = rbihf_sample_html([self.home_fixture_row()])
|
||||||
|
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||||
|
apply_plan(plan, {})
|
||||||
|
|
||||||
|
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||||
|
|
||||||
|
self.assertEqual(plan_again.to_create, [])
|
||||||
|
self.assertEqual(plan_again.to_update, [])
|
||||||
|
self.assertEqual(plan_again.unchanged_count, 1)
|
||||||
|
|
||||||
|
def test_a_changed_fixture_is_planned_as_an_update_with_a_diff(self):
|
||||||
|
html = rbihf_sample_html([self.home_fixture_row()])
|
||||||
|
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||||
|
apply_plan(plan, {})
|
||||||
|
|
||||||
|
changed_row = self.home_fixture_row()
|
||||||
|
changed_row["hour"] = "20:00" # same game id, different time
|
||||||
|
changed_html = rbihf_sample_html([changed_row])
|
||||||
|
|
||||||
|
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, changed_html)
|
||||||
|
|
||||||
|
self.assertEqual(len(plan_again.to_update), 1)
|
||||||
|
self.assertIn("start", plan_again.to_update[0].changes)
|
||||||
|
|
||||||
|
def test_a_future_fixture_no_longer_listed_is_planned_for_deletion(self):
|
||||||
|
html = rbihf_sample_html([self.home_fixture_row()])
|
||||||
|
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||||
|
apply_plan(plan, {})
|
||||||
|
|
||||||
|
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([]))
|
||||||
|
|
||||||
|
self.assertEqual(len(plan_again.to_delete), 1)
|
||||||
|
self.assertEqual(plan_again.to_delete[0].external_game_id, "5002")
|
||||||
|
|
||||||
|
def test_a_past_fixture_no_longer_listed_is_not_deleted(self):
|
||||||
|
# "Upcoming games" naturally stops listing a game once it's happened --
|
||||||
|
# that's not a signal it was cancelled.
|
||||||
|
past_row = self.home_fixture_row(date="2020-01-01")
|
||||||
|
html = rbihf_sample_html([past_row])
|
||||||
|
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||||
|
apply_plan(plan, {})
|
||||||
|
|
||||||
|
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([]))
|
||||||
|
|
||||||
|
self.assertEqual(plan_again.to_delete, [])
|
||||||
|
|
||||||
|
def test_a_fixture_for_a_different_team_is_not_touched(self):
|
||||||
|
other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd")
|
||||||
|
html = rbihf_sample_html([self.home_fixture_row()])
|
||||||
|
plan = build_plan(self.club, other_team, RBIHF_TEAM_ID, html)
|
||||||
|
apply_plan(plan, {})
|
||||||
|
|
||||||
|
# Re-running for *our* team should see no existing RBIHF events at all
|
||||||
|
# for it -- the one that exists belongs to other_team.
|
||||||
|
plan_for_our_team = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||||
|
|
||||||
|
self.assertEqual(len(plan_for_our_team.to_create), 1)
|
||||||
|
|
||||||
|
# -- apply_plan --------------------------------------------------------
|
||||||
|
|
||||||
|
def test_apply_plan_creates_events_with_the_expected_fields(self):
|
||||||
|
html = rbihf_sample_html([self.home_fixture_row()])
|
||||||
|
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||||
|
|
||||||
|
result = apply_plan(plan, {"5002": str(self.home_location.pk)})
|
||||||
|
|
||||||
|
self.assertEqual(result, {"created": 1, "updated": 0, "deleted": 0})
|
||||||
|
event = Event.objects.get(club=self.club, external_game_id="5002")
|
||||||
|
self.assertEqual(event.kind, Event.EventKind.GAME)
|
||||||
|
self.assertEqual(event.competition, "RBIHF")
|
||||||
|
self.assertEqual(event.opponent.name, "Amsterdam Tigers")
|
||||||
|
self.assertEqual(event.location, self.home_location)
|
||||||
|
self.assertIn(self.team, event.teams.all())
|
||||||
|
self.assertTrue(event.is_home_game)
|
||||||
|
|
||||||
|
def test_apply_plan_ignores_a_location_id_from_another_club(self):
|
||||||
|
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
|
||||||
|
foreign_location = Location.objects.create(club=other_club, name="Not ours", address="x", city="x", zip_code="x", country="BE")
|
||||||
|
html = rbihf_sample_html([self.home_fixture_row()])
|
||||||
|
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||||
|
|
||||||
|
apply_plan(plan, {"5002": str(foreign_location.pk)})
|
||||||
|
|
||||||
|
event = Event.objects.get(club=self.club, external_game_id="5002")
|
||||||
|
self.assertIsNone(event.location)
|
||||||
|
|
||||||
|
def test_apply_plan_uses_an_explicitly_chosen_opponent_instead_of_the_scraped_name(self):
|
||||||
|
renamed = Opponent.objects.create(club=self.club, name="Amsterdam Tigers HC")
|
||||||
|
html = rbihf_sample_html([self.home_fixture_row()])
|
||||||
|
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||||
|
|
||||||
|
apply_plan(plan, {}, {"5002": str(renamed.pk)})
|
||||||
|
|
||||||
|
event = Event.objects.get(club=self.club, external_game_id="5002")
|
||||||
|
self.assertEqual(event.opponent, renamed)
|
||||||
|
self.assertEqual(Opponent.objects.filter(club=self.club, name="Amsterdam Tigers").count(), 0)
|
||||||
|
|
||||||
|
def test_apply_plan_ignores_an_opponent_id_from_another_club(self):
|
||||||
|
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
|
||||||
|
foreign_opponent = Opponent.objects.create(club=other_club, name="Not ours")
|
||||||
|
html = rbihf_sample_html([self.home_fixture_row()])
|
||||||
|
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||||
|
|
||||||
|
apply_plan(plan, {}, {"5002": str(foreign_opponent.pk)})
|
||||||
|
|
||||||
|
event = Event.objects.get(club=self.club, external_game_id="5002")
|
||||||
|
# Falls back to find-or-create by the scraped name, not the foreign row.
|
||||||
|
self.assertEqual(event.opponent.name, "Amsterdam Tigers")
|
||||||
|
|
||||||
|
def test_apply_plan_preserves_a_previously_chosen_opponent_on_update(self):
|
||||||
|
renamed = Opponent.objects.create(club=self.club, name="Amsterdam Tigers HC")
|
||||||
|
html = rbihf_sample_html([self.home_fixture_row()])
|
||||||
|
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||||
|
apply_plan(plan, {}, {"5002": str(renamed.pk)})
|
||||||
|
|
||||||
|
# Re-run with a changed start time (something else triggers the update).
|
||||||
|
# build_plan should suggest (pre-select) the event's existing opponent
|
||||||
|
# rather than re-guessing from the scraped name -- a real <select>
|
||||||
|
# always resubmits whichever option is pre-selected, so that's what's
|
||||||
|
# passed to apply_plan here too, not a blank dict.
|
||||||
|
changed_row = self.home_fixture_row()
|
||||||
|
changed_row["hour"] = "20:00"
|
||||||
|
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([changed_row]))
|
||||||
|
self.assertEqual(plan_again.to_update[0].suggested_opponent, renamed)
|
||||||
|
apply_plan(plan_again, {}, {"5002": str(renamed.pk)})
|
||||||
|
|
||||||
|
event = Event.objects.get(club=self.club, external_game_id="5002")
|
||||||
|
self.assertEqual(event.opponent, renamed)
|
||||||
|
|
||||||
|
def test_apply_plan_updates_only_start_opponent_and_location(self):
|
||||||
|
html = rbihf_sample_html([self.home_fixture_row()])
|
||||||
|
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||||
|
apply_plan(plan, {})
|
||||||
|
event = Event.objects.get(club=self.club, external_game_id="5002")
|
||||||
|
event.end = self.future
|
||||||
|
event.gathering = self.future
|
||||||
|
event.score_for = 3
|
||||||
|
event.save()
|
||||||
|
|
||||||
|
changed_row = self.home_fixture_row()
|
||||||
|
changed_row["hour"] = "20:00"
|
||||||
|
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([changed_row]))
|
||||||
|
apply_plan(plan_again, {})
|
||||||
|
|
||||||
|
event.refresh_from_db()
|
||||||
|
self.assertEqual(timezone.localtime(event.start).strftime("%H:%M"), "20:00")
|
||||||
|
self.assertIsNotNone(event.end)
|
||||||
|
self.assertIsNotNone(event.gathering)
|
||||||
|
self.assertEqual(event.score_for, 3)
|
||||||
|
|
||||||
|
def test_apply_plan_deletes_only_whats_in_to_delete(self):
|
||||||
|
html = rbihf_sample_html([self.home_fixture_row()])
|
||||||
|
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||||
|
apply_plan(plan, {})
|
||||||
|
|
||||||
|
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([]))
|
||||||
|
result = apply_plan(plan_again, {})
|
||||||
|
|
||||||
|
self.assertEqual(result, {"created": 0, "updated": 0, "deleted": 1})
|
||||||
|
self.assertFalse(Event.objects.filter(club=self.club, external_game_id="5002").exists())
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ class Flag(AbstractUserFlag):
|
|||||||
|
|
||||||
def _get_club_ids(self) -> set:
|
def _get_club_ids(self) -> set:
|
||||||
"""Club ids this flag is on for, cached the way waffle caches its own M2Ms."""
|
"""Club ids this flag is on for, cached the way waffle caches its own M2Ms."""
|
||||||
|
if self.pk is None:
|
||||||
|
# waffle's own BaseModel.get() falls back to a transient, unsaved
|
||||||
|
# Flag(name=...) instance for a name nothing in the DB matches yet
|
||||||
|
# (see waffle/models.py) -- an M2M lookup can't run against that, and
|
||||||
|
# there's genuinely nothing for it to be on for anyway.
|
||||||
|
return set()
|
||||||
|
|
||||||
cache = get_cache()
|
cache = get_cache()
|
||||||
cache_key = keyfmt(FLAG_CLUBS_CACHE_KEY, self.name)
|
cache_key = keyfmt(FLAG_CLUBS_CACHE_KEY, self.name)
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ class ClubScopedFlagTests(TestCase):
|
|||||||
|
|
||||||
self.assertFalse(flag_is_active(self.request_for(None), "shop"))
|
self.assertFalse(flag_is_active(self.request_for(None), "shop"))
|
||||||
|
|
||||||
|
def test_a_flag_name_with_no_matching_row_is_off_not_an_error(self):
|
||||||
|
# Regression: waffle's own BaseModel.get() falls back to a transient,
|
||||||
|
# unsaved Flag(name=...) instance when nothing in the DB matches the
|
||||||
|
# name -- an M2M lookup (self.clubs) against that unsaved instance used
|
||||||
|
# to raise ValueError instead of just resolving to "off". This is the
|
||||||
|
# normal state for any flag nobody has created in the control panel yet.
|
||||||
|
self.assertFalse(flag_is_active(self.request_for(self.club), "no-such-flag"))
|
||||||
|
|
||||||
def test_is_active_for_club_without_a_request(self):
|
def test_is_active_for_club_without_a_request(self):
|
||||||
self.flag.clubs.add(self.club)
|
self.flag.clubs.add(self.club)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ Same reasoning for ``news_permissions`` below, gating just the "New news item"
|
|||||||
action rather than the whole section (``NewsAuthorRequiredMixin``/``can_add_news``).
|
action rather than the whole section (``NewsAuthorRequiredMixin``/``can_add_news``).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from waffle import flag_is_active
|
||||||
|
|
||||||
from club.services.access import can_add_news, has_management_access, is_club_admin, is_coach_manager
|
from club.services.access import can_add_news, has_management_access, is_club_admin, is_coach_manager
|
||||||
|
|
||||||
#: Every management URL name, mapped to the nav item it should light up --
|
#: Every management URL name, mapped to the nav item it should light up --
|
||||||
@@ -54,6 +56,8 @@ _NAV_SECTIONS = {
|
|||||||
"team_staff_add": "team_list",
|
"team_staff_add": "team_list",
|
||||||
"team_staff_update": "team_list",
|
"team_staff_update": "team_list",
|
||||||
"team_staff_remove": "team_list",
|
"team_staff_remove": "team_list",
|
||||||
|
"team_photo_set": "team_list",
|
||||||
|
"team_photo_delete": "team_list",
|
||||||
"news_list": "news_list",
|
"news_list": "news_list",
|
||||||
"news_create": "news_list",
|
"news_create": "news_list",
|
||||||
"news_detail": "news_list",
|
"news_detail": "news_list",
|
||||||
@@ -65,7 +69,20 @@ _NAV_SECTIONS = {
|
|||||||
"news_photo_set_main": "news_list",
|
"news_photo_set_main": "news_list",
|
||||||
"news_photo_delete": "news_list",
|
"news_photo_delete": "news_list",
|
||||||
"event_list": "event_list",
|
"event_list": "event_list",
|
||||||
"event_series_list": "event_series_list",
|
"event_create": "event_list",
|
||||||
|
"event_detail": "event_list",
|
||||||
|
"event_update": "event_list",
|
||||||
|
"event_delete": "event_list",
|
||||||
|
"event_detach": "event_list",
|
||||||
|
"event_fetch_game_info": "event_list",
|
||||||
|
"rbihf_import": "event_list",
|
||||||
|
"rbihf_import_confirm": "event_list",
|
||||||
|
"event_series_create": "event_list",
|
||||||
|
"event_series_detail": "event_list",
|
||||||
|
"event_series_update": "event_list",
|
||||||
|
"event_series_delete": "event_list",
|
||||||
|
"event_series_stop": "event_list",
|
||||||
|
"event_series_generate": "event_list",
|
||||||
"location_list": "location_list",
|
"location_list": "location_list",
|
||||||
"location_create": "location_list",
|
"location_create": "location_list",
|
||||||
"location_update": "location_list",
|
"location_update": "location_list",
|
||||||
@@ -74,6 +91,10 @@ _NAV_SECTIONS = {
|
|||||||
"opponent_create": "opponent_list",
|
"opponent_create": "opponent_list",
|
||||||
"opponent_update": "opponent_list",
|
"opponent_update": "opponent_list",
|
||||||
"opponent_delete": "opponent_list",
|
"opponent_delete": "opponent_list",
|
||||||
|
"sponsor_list": "sponsor_list",
|
||||||
|
"sponsor_create": "sponsor_list",
|
||||||
|
"sponsor_update": "sponsor_list",
|
||||||
|
"sponsor_delete": "sponsor_list",
|
||||||
"product_list": "product_list",
|
"product_list": "product_list",
|
||||||
"order_list": "order_list",
|
"order_list": "order_list",
|
||||||
"discount_list": "discount_list",
|
"discount_list": "discount_list",
|
||||||
@@ -124,6 +145,23 @@ def management_link(request):
|
|||||||
return {"has_management_access": has_management_access(request.user, club)}
|
return {"has_management_access": has_management_access(request.user, club)}
|
||||||
|
|
||||||
|
|
||||||
|
def feature_sections(request):
|
||||||
|
"""Whether the nav's Shop/Forms sections -- and the Events page's "Import
|
||||||
|
from RBIHF" button -- should show at all. Each is gated behind its own
|
||||||
|
waffle Flag (see club.mixins.FeatureRequiredMixin, which gates the
|
||||||
|
underlying views regardless), on top of the existing is_club_admin check
|
||||||
|
those all already require."""
|
||||||
|
club = getattr(request, "club", None)
|
||||||
|
if club is None or not request.user.is_authenticated:
|
||||||
|
return {"shop_enabled": False, "forms_enabled": False, "rbihf_enabled": False}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"shop_enabled": flag_is_active(request, "shop"),
|
||||||
|
"forms_enabled": flag_is_active(request, "formbuilder"),
|
||||||
|
"rbihf_enabled": flag_is_active(request, "RBIHF"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def news_permissions(request):
|
def news_permissions(request):
|
||||||
"""Whether the "New news item" action should show -- viewing the News
|
"""Whether the "New news item" action should show -- viewing the News
|
||||||
section itself is open to any staff, same as Roster/Staff."""
|
section itself is open to any staff, same as Roster/Staff."""
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
@@ -5,12 +6,16 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from club.models import ClubMembership, ClubRole, FeePayment
|
from club.models import ClubMembership, ClubRole, FeePayment, Season, Sponsor
|
||||||
from events.models import Location, Opponent
|
from club.services.access import is_club_admin, teams_managed_by
|
||||||
|
from events.models import Competition, Event, EventSeries, Location, Opponent
|
||||||
|
from events.services.rbihf_import import RBIHFImportError, extract_team_id
|
||||||
from members.models import Family, FamilyMembership, Member
|
from members.models import Family, FamilyMembership, Member
|
||||||
from members.services.family import find_member_by_email
|
from members.services.family import find_member_by_email
|
||||||
from news.models import News
|
from news.models import News
|
||||||
from teams.models import Position, StaffAssignment, Team, TeamMembership
|
from teams.models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto
|
||||||
|
|
||||||
|
from .recurrence_ui import FREQUENCY_CHOICES, WEEKDAY_CHOICES, build_rrule, parse_rrule
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
@@ -28,6 +33,16 @@ class TeamForm(forms.ModelForm):
|
|||||||
fields = ["name", "short_name"]
|
fields = ["name", "short_name"]
|
||||||
|
|
||||||
|
|
||||||
|
class TeamPhotoForm(forms.ModelForm):
|
||||||
|
"""One photo per (team, season) -- bound to the existing TeamPhoto (if
|
||||||
|
any) by the view's get_form_kwargs, same "create or update the one row
|
||||||
|
for this scope" pattern as controlpanel.forms.HomeLocationForm."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = TeamPhoto
|
||||||
|
fields = ["image"]
|
||||||
|
|
||||||
|
|
||||||
class TeamMembershipForm(forms.ModelForm):
|
class TeamMembershipForm(forms.ModelForm):
|
||||||
"""Add/edit one roster entry -- team and season come from the view (the URL
|
"""Add/edit one roster entry -- team and season come from the view (the URL
|
||||||
already identifies both), never from the form itself."""
|
already identifies both), never from the form itself."""
|
||||||
@@ -41,7 +56,13 @@ class TeamMembershipForm(forms.ModelForm):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.team = team
|
self.team = team
|
||||||
self.season = season
|
self.season = season
|
||||||
members = Member.objects.filter(member_of__club=club).distinct()
|
# Eligible to be added regardless of which season's roster is being edited
|
||||||
|
# (the team detail page's season switcher can be pointed at an older
|
||||||
|
# season): active this season or the next one, not lapsed/pending/cancelled
|
||||||
|
# or active only in some other season.
|
||||||
|
today = timezone.localdate()
|
||||||
|
eligible_seasons = [s for s in (Season.covering(club, today), Season.next_after(club, today)) if s is not None]
|
||||||
|
members = Member.objects.filter(member_of__club=club, member_of__season__in=eligible_seasons, member_of__status=ClubMembership.StatusChoices.ACTIVE).distinct()
|
||||||
if team is not None and season is not None:
|
if team is not None and season is not None:
|
||||||
# Already on this team's roster this season -- offering them again
|
# Already on this team's roster this season -- offering them again
|
||||||
# would just fail the unique_member_per_team_per_season constraint.
|
# would just fail the unique_member_per_team_per_season constraint.
|
||||||
@@ -76,7 +97,11 @@ class StaffAssignmentForm(forms.ModelForm):
|
|||||||
|
|
||||||
def __init__(self, *args, club=None, team=None, season=None, **kwargs):
|
def __init__(self, *args, club=None, team=None, season=None, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
members = Member.objects.filter(member_of__club=club).distinct()
|
# See TeamMembershipForm for why current-or-next-season: eligible to be
|
||||||
|
# assigned regardless of which season's staff list is being edited.
|
||||||
|
today = timezone.localdate()
|
||||||
|
eligible_seasons = [s for s in (Season.covering(club, today), Season.next_after(club, today)) if s is not None]
|
||||||
|
members = Member.objects.filter(member_of__club=club, member_of__season__in=eligible_seasons, member_of__status=ClubMembership.StatusChoices.ACTIVE).distinct()
|
||||||
if team is not None and season is not None:
|
if team is not None and season is not None:
|
||||||
taken = StaffAssignment.objects.filter(team=team, season=season).exclude(pk=self.instance.pk).values_list("member_id", flat=True)
|
taken = StaffAssignment.objects.filter(team=team, season=season).exclude(pk=self.instance.pk).values_list("member_id", flat=True)
|
||||||
members = members.exclude(pk__in=taken)
|
members = members.exclude(pk__in=taken)
|
||||||
@@ -117,6 +142,206 @@ class OpponentForm(forms.ModelForm):
|
|||||||
fields = ["name", "logo"]
|
fields = ["name", "logo"]
|
||||||
|
|
||||||
|
|
||||||
|
class SponsorForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Sponsor
|
||||||
|
fields = ["name", "logo", "url", "start_date", "end_date"]
|
||||||
|
widgets = {
|
||||||
|
"start_date": forms.DateInput(attrs={"type": "date"}),
|
||||||
|
"end_date": forms.DateInput(attrs={"type": "date"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned = super().clean()
|
||||||
|
# Mirrors Sponsor.clean() -- caught here too so it reads as a form
|
||||||
|
# error tied to the end_date field, not a raw ValidationError.
|
||||||
|
start_date, end_date = cleaned.get("start_date"), cleaned.get("end_date")
|
||||||
|
if start_date and end_date and end_date < start_date:
|
||||||
|
self.add_error("end_date", _("End date can't be before the start date."))
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
class EventAudienceFormMixin:
|
||||||
|
"""Shared club/user-scoped audience fields for EventForm and EventSeriesForm:
|
||||||
|
teams restricted to the ones the requester manages (all of them for an
|
||||||
|
admin), and a non-admin must pick at least one -- a team-less/club-wide
|
||||||
|
event (e.g. an AGM) has no team-manager claim to anchor it to, so that's
|
||||||
|
admin-only."""
|
||||||
|
|
||||||
|
def scope_audience_fields(self, club, user):
|
||||||
|
self.club = club
|
||||||
|
self.user = user
|
||||||
|
self.fields["teams"].queryset = Team.objects.filter(club=club) if is_club_admin(user, club) else teams_managed_by(user, club)
|
||||||
|
self.fields["location"].queryset = Location.objects.filter(club=club)
|
||||||
|
self.fields["opponent"].queryset = Opponent.objects.filter(club=club)
|
||||||
|
members = Member.objects.filter(member_of__club=club).distinct()
|
||||||
|
self.fields["invited_members"].queryset = members
|
||||||
|
self.fields["excluded_members"].queryset = members
|
||||||
|
|
||||||
|
def clean_teams_requires_one_for_non_admins(self, cleaned):
|
||||||
|
teams = cleaned.get("teams")
|
||||||
|
if teams is not None and not teams.exists() and not is_club_admin(self.user, self.club):
|
||||||
|
self.add_error("teams", _("Select at least one of your teams, or ask an admin to create a club-wide event."))
|
||||||
|
|
||||||
|
|
||||||
|
_AUDIENCE_WIDGETS = {
|
||||||
|
"teams": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a team to search...")}),
|
||||||
|
"invited_members": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")}),
|
||||||
|
"excluded_members": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EventForm(EventAudienceFormMixin, forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Event
|
||||||
|
fields = ["title", "kind", "teams", "invited_members", "excluded_members", "location", "opponent", "start", "end", "gathering", "deadline", "competition", "external_game_id", "score_for", "score_against", "is_live"]
|
||||||
|
widgets = {
|
||||||
|
"start": forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
||||||
|
"end": forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
||||||
|
"gathering": forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
||||||
|
"deadline": forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
||||||
|
"score_for": forms.NumberInput(attrs={"min": 0}),
|
||||||
|
"score_against": forms.NumberInput(attrs={"min": 0}),
|
||||||
|
**_AUDIENCE_WIDGETS,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, club=None, user=None, editing=False, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.scope_audience_fields(club, user)
|
||||||
|
# Unlike the Django-admin form (events.admin.EventAdminForm), this dropdown
|
||||||
|
# isn't filtered by which competitions have their feature flag on for the
|
||||||
|
# club -- a manager should be able to pick any competition when scheduling a
|
||||||
|
# game; it's fetch_game_info (events.services.competitions) that gates the
|
||||||
|
# actual per-club fetch once a competition is set.
|
||||||
|
self.fields["competition"] = forms.ChoiceField(
|
||||||
|
choices=[("", "---------"), *[(competition.name, competition.name) for competition in Competition.objects.all()]],
|
||||||
|
required=False,
|
||||||
|
label=self.fields["competition"].label,
|
||||||
|
help_text=self.fields["competition"].help_text,
|
||||||
|
)
|
||||||
|
if not editing:
|
||||||
|
# Score/live status don't exist yet for a game that's only just being
|
||||||
|
# scheduled -- offering them on the add form is just noise. Editing an
|
||||||
|
# existing game is the only time there's anything to record here.
|
||||||
|
del self.fields["score_for"]
|
||||||
|
del self.fields["score_against"]
|
||||||
|
del self.fields["is_live"]
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned = super().clean()
|
||||||
|
self.clean_teams_requires_one_for_non_admins(cleaned)
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
class RBIHFImportForm(forms.Form):
|
||||||
|
"""Step 1 of importing a team's fixtures from RBIHF's own website -- see
|
||||||
|
events.services.rbihf_import. Only the URL and which of the club's own
|
||||||
|
teams it applies to; everything else is scraped."""
|
||||||
|
|
||||||
|
url = forms.CharField(
|
||||||
|
label=_("RBIHF team page URL"),
|
||||||
|
help_text=_("E.g. https://www.rbihf.be/league/team/4460"),
|
||||||
|
widget=forms.URLInput(attrs={"placeholder": "https://www.rbihf.be/league/team/4460"}),
|
||||||
|
)
|
||||||
|
team = forms.ModelChoiceField(queryset=Team.objects.none(), label=_("Team"), help_text=_("Which of your teams this fixture list is for."))
|
||||||
|
|
||||||
|
def __init__(self, *args, club=None, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["team"].queryset = Team.objects.filter(club=club)
|
||||||
|
|
||||||
|
def clean_url(self):
|
||||||
|
url = self.cleaned_data["url"].strip()
|
||||||
|
try:
|
||||||
|
extract_team_id(url)
|
||||||
|
except RBIHFImportError as error:
|
||||||
|
raise forms.ValidationError(str(error)) from error
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
class EventSeriesForm(EventAudienceFormMixin, forms.ModelForm):
|
||||||
|
"""A friendly weekly/monthly recurrence picker on top of EventSeries' raw
|
||||||
|
RRULE (see management.recurrence_ui) -- covers the common case (every N
|
||||||
|
weeks/months) with an advanced raw-RRULE field as an escape hatch for
|
||||||
|
anything else."""
|
||||||
|
|
||||||
|
frequency = forms.ChoiceField(choices=FREQUENCY_CHOICES, initial="weekly", label=_("Repeats"))
|
||||||
|
interval = forms.IntegerField(min_value=1, initial=1, label=_("Every"), help_text=_("E.g. 2 for every other week/month."))
|
||||||
|
# A plain multi-select, not CheckboxSelectMultiple: its widget_type ("checkboxselectmultiple")
|
||||||
|
# isn't one form_field's ui.py recognises, which would silently render a broken
|
||||||
|
# plain text input instead -- see the "lazyselect" fix for the same class of bug.
|
||||||
|
weekdays = forms.MultipleChoiceField(choices=WEEKDAY_CHOICES, required=False, label=_("On"), widget=forms.SelectMultiple(attrs={"data-searchable": "true"}))
|
||||||
|
duration_hours = forms.IntegerField(min_value=0, required=False, label=_("Duration (hours)"))
|
||||||
|
duration_minutes = forms.IntegerField(min_value=0, max_value=59, required=False, label=_("Duration (minutes)"))
|
||||||
|
gathering_minutes_before = forms.IntegerField(min_value=0, required=False, label=_("Gather (minutes before)"))
|
||||||
|
deadline_minutes_before = forms.IntegerField(min_value=0, required=False, label=_("Registration deadline (minutes before)"))
|
||||||
|
advanced_rrule = forms.CharField(required=False, label=_("Advanced: raw recurrence rule"), help_text=_("Overrides the fields above if filled in. RFC 5545 RRULE, e.g. FREQ=WEEKLY;BYDAY=MO,WE."))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = EventSeries
|
||||||
|
fields = ["title", "kind", "dtstart", "until", "teams", "invited_members", "excluded_members", "location", "opponent"]
|
||||||
|
widgets = {
|
||||||
|
"dtstart": forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
||||||
|
"until": forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
||||||
|
**_AUDIENCE_WIDGETS,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, club=None, user=None, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.scope_audience_fields(club, user)
|
||||||
|
|
||||||
|
if self.instance.pk:
|
||||||
|
parsed = parse_rrule(self.instance.rrule)
|
||||||
|
if parsed is not None:
|
||||||
|
self.fields["frequency"].initial = parsed["frequency"]
|
||||||
|
self.fields["interval"].initial = parsed["interval"]
|
||||||
|
self.fields["weekdays"].initial = parsed["weekdays"]
|
||||||
|
else:
|
||||||
|
# Not one of our two shapes (hand-written/imported) -- show it
|
||||||
|
# verbatim in the advanced field rather than guessing at it.
|
||||||
|
self.fields["advanced_rrule"].initial = self.instance.rrule
|
||||||
|
|
||||||
|
if self.instance.duration is not None:
|
||||||
|
total_minutes = int(self.instance.duration.total_seconds() // 60)
|
||||||
|
self.fields["duration_hours"].initial = total_minutes // 60
|
||||||
|
self.fields["duration_minutes"].initial = total_minutes % 60
|
||||||
|
if self.instance.gathering_offset is not None:
|
||||||
|
self.fields["gathering_minutes_before"].initial = int(self.instance.gathering_offset.total_seconds() // 60)
|
||||||
|
if self.instance.deadline_offset is not None:
|
||||||
|
self.fields["deadline_minutes_before"].initial = int(self.instance.deadline_offset.total_seconds() // 60)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned = super().clean()
|
||||||
|
self.clean_teams_requires_one_for_non_admins(cleaned)
|
||||||
|
|
||||||
|
if cleaned.get("advanced_rrule"):
|
||||||
|
return cleaned # the advanced field wins outright; nothing else to check
|
||||||
|
|
||||||
|
if cleaned.get("frequency") == "weekly" and not cleaned.get("weekdays"):
|
||||||
|
self.add_error("weekdays", _("Pick at least one day of the week."))
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
instance = super().save(commit=False)
|
||||||
|
cleaned = self.cleaned_data
|
||||||
|
|
||||||
|
advanced = (cleaned.get("advanced_rrule") or "").strip()
|
||||||
|
instance.rrule = advanced or build_rrule(cleaned["frequency"], cleaned["interval"], cleaned.get("weekdays"))
|
||||||
|
|
||||||
|
hours, minutes = cleaned.get("duration_hours") or 0, cleaned.get("duration_minutes") or 0
|
||||||
|
instance.duration = datetime.timedelta(hours=hours, minutes=minutes) if (hours or minutes) else None
|
||||||
|
|
||||||
|
gathering_minutes = cleaned.get("gathering_minutes_before")
|
||||||
|
instance.gathering_offset = datetime.timedelta(minutes=gathering_minutes) if gathering_minutes else None
|
||||||
|
|
||||||
|
deadline_minutes = cleaned.get("deadline_minutes_before")
|
||||||
|
instance.deadline_offset = datetime.timedelta(minutes=deadline_minutes) if deadline_minutes else None
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
instance.save()
|
||||||
|
self.save_m2m()
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
class ClubRoleAssignForm(forms.ModelForm):
|
class ClubRoleAssignForm(forms.ModelForm):
|
||||||
"""Grant a club-wide role to a member already affiliated with this club."""
|
"""Grant a club-wide role to a member already affiliated with this club."""
|
||||||
|
|
||||||
|
|||||||
84
management/recurrence_ui.py
Normal file
84
management/recurrence_ui.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""A friendly weekly/monthly recurrence picker on top of EventSeries.rrule (a raw
|
||||||
|
RFC 5545 string, e.g. "FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE") -- covers the common
|
||||||
|
club-scheduling case (every N weeks on these weekdays, or every N months on the
|
||||||
|
same day of month) without building a full RRULE UI.
|
||||||
|
|
||||||
|
dateutil's rrule recurs on dtstart's own day-of-month for FREQ=MONTHLY with no
|
||||||
|
BYMONTHDAY needed, which is what keeps monthly this cheap to support alongside
|
||||||
|
weekly.
|
||||||
|
|
||||||
|
Anything that isn't exactly one of the two shapes build_rrule() produces --
|
||||||
|
hand-written, imported, or from a future frequency this module doesn't cover --
|
||||||
|
is left alone: parse_rrule() returns None, and EventSeriesForm falls back to
|
||||||
|
showing the raw string in an "Advanced" field instead of the friendly picker.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
WEEKDAY_CODES = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]
|
||||||
|
|
||||||
|
WEEKDAY_LABELS = {
|
||||||
|
"MO": _("Mon"),
|
||||||
|
"TU": _("Tue"),
|
||||||
|
"WE": _("Wed"),
|
||||||
|
"TH": _("Thu"),
|
||||||
|
"FR": _("Fri"),
|
||||||
|
"SA": _("Sat"),
|
||||||
|
"SU": _("Sun"),
|
||||||
|
}
|
||||||
|
|
||||||
|
WEEKDAY_CHOICES = [(code, WEEKDAY_LABELS[code]) for code in WEEKDAY_CODES]
|
||||||
|
|
||||||
|
FREQUENCY_CHOICES = [
|
||||||
|
("weekly", _("Weekly")),
|
||||||
|
("monthly", _("Monthly")),
|
||||||
|
]
|
||||||
|
|
||||||
|
_WEEKLY_RE = re.compile(r"^FREQ=WEEKLY;INTERVAL=(\d+);BYDAY=([A-Z,]+)$")
|
||||||
|
_MONTHLY_RE = re.compile(r"^FREQ=MONTHLY;INTERVAL=(\d+)$")
|
||||||
|
|
||||||
|
|
||||||
|
def build_rrule(frequency, interval, weekdays=None):
|
||||||
|
"""Compose an RRULE string from the friendly fields. ``weekdays`` is a list
|
||||||
|
of two-letter codes (e.g. ["MO", "WE"]), required for "weekly", ignored for
|
||||||
|
"monthly"."""
|
||||||
|
interval = interval or 1
|
||||||
|
if frequency == "weekly":
|
||||||
|
# Preserve WEEKDAY_CODES order regardless of the order the caller passed
|
||||||
|
# them in, so the same pattern always serializes to the same string.
|
||||||
|
ordered = [code for code in WEEKDAY_CODES if code in (weekdays or [])]
|
||||||
|
return f"FREQ=WEEKLY;INTERVAL={interval};BYDAY={','.join(ordered)}"
|
||||||
|
if frequency == "monthly":
|
||||||
|
return f"FREQ=MONTHLY;INTERVAL={interval}"
|
||||||
|
raise ValueError(f"Unknown frequency: {frequency!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_rrule(rrule):
|
||||||
|
"""The friendly fields that produced ``rrule``, or None if it doesn't match
|
||||||
|
one of build_rrule()'s two shapes."""
|
||||||
|
if match := _WEEKLY_RE.match(rrule):
|
||||||
|
interval, days = match.groups()
|
||||||
|
return {"frequency": "weekly", "interval": int(interval), "weekdays": days.split(",")}
|
||||||
|
if match := _MONTHLY_RE.match(rrule):
|
||||||
|
return {"frequency": "monthly", "interval": int(match.group(1)), "weekdays": []}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def describe_rrule(rrule):
|
||||||
|
"""A human-readable summary for list/detail pages, e.g. "Every week on Mon,
|
||||||
|
Wed" or "Every 2 months" -- or the raw string verbatim if it isn't one of
|
||||||
|
ours (an imported/hand-written RRULE)."""
|
||||||
|
parsed = parse_rrule(rrule)
|
||||||
|
if parsed is None:
|
||||||
|
return rrule
|
||||||
|
|
||||||
|
interval = parsed["interval"]
|
||||||
|
if parsed["frequency"] == "weekly":
|
||||||
|
days = ", ".join(str(WEEKDAY_LABELS[code]) for code in parsed["weekdays"] if code in WEEKDAY_LABELS)
|
||||||
|
if not days:
|
||||||
|
return _("Every week") if interval == 1 else _("Every %(n)d weeks") % {"n": interval}
|
||||||
|
return _("Every week on %(days)s") % {"days": days} if interval == 1 else _("Every %(n)d weeks on %(days)s") % {"n": interval, "days": days}
|
||||||
|
|
||||||
|
return _("Every month") if interval == 1 else _("Every %(n)d months") % {"n": interval}
|
||||||
@@ -45,11 +45,11 @@ leaves it unset, since staying on the family page is already the right place the
|
|||||||
{% if is_club_admin %}
|
{% if is_club_admin %}
|
||||||
<div class="flex justify-end gap-1">
|
<div class="flex justify-end gap-1">
|
||||||
{% if person.grant_login_form %}
|
{% if person.grant_login_form %}
|
||||||
<button class="btn btn-sm btn-outline btn-neutral" type="button" onclick="document.getElementById('grant_login_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Grant login' %}">
|
<button class="btn btn-outline btn-sm" type="button" onclick="document.getElementById('grant_login_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Grant login' %}">
|
||||||
{% lucide "key-round" size=14 %} {% trans "Grant login" %}
|
{% lucide "key-round" size=14 %} {% trans "Grant login" %}
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:member_detail' person.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
<a class="btn btn-outline btn-sm" href="{% url 'management:member_detail' person.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
||||||
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('remove_family_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Remove from family' %}">
|
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('remove_family_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Remove from family' %}">
|
||||||
{% lucide "user-x" size=14 %} {% trans "Remove" %}
|
{% lucide "user-x" size=14 %} {% trans "Remove" %}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -30,19 +30,25 @@
|
|||||||
|
|
||||||
<li class="menu-title">{% trans "Calendar" %}</li>
|
<li class="menu-title">{% trans "Calendar" %}</li>
|
||||||
<li><a class="{% if nav == 'event_list' %}menu-active{% endif %}" href="{% url 'management:event_list' %}">{% lucide "calendar" size=16 %} {% trans "Events" %}</a></li>
|
<li><a class="{% if nav == 'event_list' %}menu-active{% endif %}" href="{% url 'management:event_list' %}">{% lucide "calendar" size=16 %} {% trans "Events" %}</a></li>
|
||||||
<li><a class="{% if nav == 'event_series_list' %}menu-active{% endif %}" href="{% url 'management:event_series_list' %}">{% lucide "repeat" size=16 %} {% trans "Event series" %}</a></li>
|
|
||||||
{% if has_management_position %}
|
{% if has_management_position %}
|
||||||
<li><a class="{% if nav == 'location_list' %}menu-active{% endif %}" href="{% url 'management:location_list' %}">{% lucide "map-pin" size=16 %} {% trans "Locations" %}</a></li>
|
<li><a class="{% if nav == 'location_list' %}menu-active{% endif %}" href="{% url 'management:location_list' %}">{% lucide "map-pin" size=16 %} {% trans "Locations" %}</a></li>
|
||||||
<li><a class="{% if nav == 'opponent_list' %}menu-active{% endif %}" href="{% url 'management:opponent_list' %}">{% lucide "swords" size=16 %} {% trans "Opponents" %}</a></li>
|
<li><a class="{% if nav == 'opponent_list' %}menu-active{% endif %}" href="{% url 'management:opponent_list' %}">{% lucide "swords" size=16 %} {% trans "Opponents" %}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if is_club_admin %}
|
{% if is_club_admin %}
|
||||||
|
<li class="menu-title">{% trans "Sponsors" %}</li>
|
||||||
|
<li><a class="{% if nav == 'sponsor_list' %}menu-active{% endif %}" href="{% url 'management:sponsor_list' %}">{% lucide "handshake" size=16 %} {% trans "Sponsors" %}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if is_club_admin and shop_enabled %}
|
||||||
<li class="menu-title">{% trans "Shop" %}</li>
|
<li class="menu-title">{% trans "Shop" %}</li>
|
||||||
<li><a class="{% if nav == 'product_list' %}menu-active{% endif %}" href="{% url 'management:product_list' %}">{% lucide "package" size=16 %} {% trans "Products" %}</a></li>
|
<li><a class="{% if nav == 'product_list' %}menu-active{% endif %}" href="{% url 'management:product_list' %}">{% lucide "package" size=16 %} {% trans "Products" %}</a></li>
|
||||||
<li><a class="{% if nav == 'order_list' %}menu-active{% endif %}" href="{% url 'management:order_list' %}">{% lucide "shopping-cart" size=16 %} {% trans "Orders" %}</a></li>
|
<li><a class="{% if nav == 'order_list' %}menu-active{% endif %}" href="{% url 'management:order_list' %}">{% lucide "shopping-cart" size=16 %} {% trans "Orders" %}</a></li>
|
||||||
<li><a class="{% if nav == 'discount_list' %}menu-active{% endif %}" href="{% url 'management:discount_list' %}">{% lucide "percent" size=16 %} {% trans "Discounts" %}</a></li>
|
<li><a class="{% if nav == 'discount_list' %}menu-active{% endif %}" href="{% url 'management:discount_list' %}">{% lucide "percent" size=16 %} {% trans "Discounts" %}</a></li>
|
||||||
<li><a class="{% if nav == 'invoice_list' %}menu-active{% endif %}" href="{% url 'management:invoice_list' %}">{% lucide "receipt" size=16 %} {% trans "Invoices" %}</a></li>
|
<li><a class="{% if nav == 'invoice_list' %}menu-active{% endif %}" href="{% url 'management:invoice_list' %}">{% lucide "receipt" size=16 %} {% trans "Invoices" %}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if is_club_admin and forms_enabled %}
|
||||||
<li class="menu-title">{% trans "Forms" %}</li>
|
<li class="menu-title">{% trans "Forms" %}</li>
|
||||||
<li><a class="{% if nav == 'form_list' %}menu-active{% endif %}" href="{% url 'management:form_list' %}">{% lucide "clipboard-list" size=16 %} {% trans "Forms" %}</a></li>
|
<li><a class="{% if nav == 'form_list' %}menu-active{% endif %}" href="{% url 'management:form_list' %}">{% lucide "clipboard-list" size=16 %} {% trans "Forms" %}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
215
management/templates/management/event_detail.html
Normal file
215
management/templates/management/event_detail.html
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n lucide %}
|
||||||
|
|
||||||
|
{% block heading %}{{ event.title }}{% endblock heading %}
|
||||||
|
{% block subheading %}{{ event.get_kind_display }}{% endblock subheading %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
{% if can_manage %}
|
||||||
|
<a class="btn btn-outline gap-2" href="{% url 'management:event_update' event.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
|
||||||
|
{% if event.series_id and not event.detached %}
|
||||||
|
<form method="post" action="{% url 'management:event_detach' event.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="btn btn-outline gap-2" type="submit">{% lucide "unlink" size=16 %} {% trans "Detach from series" %}</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<button class="btn btn-outline btn-error gap-2" type="button" onclick="document.getElementById('event_delete_modal').showModal()">
|
||||||
|
{% lucide "trash-2" size=16 %}
|
||||||
|
{% if event.series_id %}{% trans "Cancel occurrence" %}{% else %}{% trans "Delete" %}{% endif %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock actions %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
{% if event.series_id %}
|
||||||
|
<div class="alert alert-info mb-6">
|
||||||
|
{% lucide "repeat" size=20 %}
|
||||||
|
<span>
|
||||||
|
{% blocktrans with series=event.series %}Part of the recurring series “{{ series }}”.{% endblocktrans %}
|
||||||
|
<a class="link" href="{% url 'management:event_series_detail' event.series_id %}">{% trans "View series" %}</a>
|
||||||
|
{% if event.detached %}<span class="badge badge-sm badge-neutral ml-2">{% trans "Detached" %}</span>{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mb-6 grid gap-4 lg:grid-cols-2">
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">{% lucide "info" size=18 %} {% trans "Details" %}</h2>
|
||||||
|
<dl class="divide-y divide-base-200">
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-sm opacity-70">{% trans "Start" %}</dt>
|
||||||
|
<dd>{{ event.start|date:"j M Y H:i" }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-sm opacity-70">{% trans "End" %}</dt>
|
||||||
|
<dd>{{ event.end|date:"j M Y H:i"|default:"—" }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-sm opacity-70">{% trans "Gathering" %}</dt>
|
||||||
|
<dd>{{ event.gathering|date:"j M Y H:i"|default:"—" }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-sm opacity-70">{% trans "Registration deadline" %}</dt>
|
||||||
|
<dd>{{ event.deadline|date:"j M Y H:i"|default:"—" }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-sm opacity-70">{% trans "Location" %}</dt>
|
||||||
|
<dd>{{ event.location|default:"—" }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-sm opacity-70">{% trans "Opponent" %}</dt>
|
||||||
|
<dd>{{ event.opponent|default:"—" }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-sm opacity-70">{% trans "Teams" %}</dt>
|
||||||
|
<dd>{% for team in event.teams.all %}{{ team.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="card-title text-base">{% lucide "user-check" size=18 %} {% trans "RSVPs" %}</h2>
|
||||||
|
{% if has_attendance_rows %}
|
||||||
|
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('rsvp_modal').showModal()">
|
||||||
|
{% lucide "list" size=14 %} {% trans "View responses" %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<dl class="divide-y divide-base-200">
|
||||||
|
{% for group in attendance_groups %}
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-sm opacity-70">{{ group.label }}</dt>
|
||||||
|
<dd class="font-semibold tabular-nums font-mono">{{ group.rows|length }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</dl>
|
||||||
|
{% if not has_attendance_rows %}
|
||||||
|
<p class="text-sm opacity-60">{% trans "No one invited yet." %}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if event.kind == "game" %}
|
||||||
|
<div class="mb-6 card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="card-title text-base">
|
||||||
|
{% lucide "trophy" size=18 %} {% trans "Game" %}
|
||||||
|
{% if event.is_live %}<span class="badge badge-error gap-1 animate-pulse">{% lucide "circle" size=10 %} {% trans "Live" %}</span>{% endif %}
|
||||||
|
</h2>
|
||||||
|
{% if can_manage and event.competition %}
|
||||||
|
<form method="post" action="{% url 'management:event_fetch_game_info' event.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="btn btn-outline btn-sm gap-2" type="submit">{% lucide "refresh-cw" size=14 %} {% trans "Fetch new game info" %}</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm opacity-70">{% trans "Score" %}</div>
|
||||||
|
<div class="text-sm font-semibold tabular-nums font-mono">
|
||||||
|
{% if event.score_for is not None and event.score_against is not None %}{{ event.score_for }} - {{ event.score_against }}{% else %}—{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm opacity-70">{% trans "Competition" %}</div>
|
||||||
|
<div class="text-sm">{{ event.competition|default:"—" }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm opacity-70">{% trans "External game ID" %}</div>
|
||||||
|
<div class="font-mono text-sm">{{ event.external_game_id|default:"—" }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock panel %}
|
||||||
|
|
||||||
|
{% block extra_body %}
|
||||||
|
{% if can_manage %}
|
||||||
|
<dialog id="event_delete_modal" class="modal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="text-lg font-bold">
|
||||||
|
{% if event.series_id %}{% trans "Cancel occurrence" %}{% else %}{% trans "Delete event" %}{% endif %}
|
||||||
|
</h3>
|
||||||
|
<form method="post" action="{% url 'management:event_delete' event.pk %}" id="event_delete_form">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if event.series_id %}
|
||||||
|
<p class="py-2 text-sm opacity-70">{% trans "Removes this occurrence from the schedule. It won't be regenerated." %}</p>
|
||||||
|
<label class="label cursor-pointer justify-start gap-2">
|
||||||
|
<input type="checkbox" name="keep_record" class="checkbox">
|
||||||
|
<span class="label-text">{% trans "Keep a record of it (marks it cancelled instead of deleting it)" %}</span>
|
||||||
|
</label>
|
||||||
|
{% else %}
|
||||||
|
<p class="py-2 text-sm opacity-70">{% trans "This cannot be undone." %}</p>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
<div class="modal-action">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn btn-outline gap-2">{% lucide "x" size=16 %} {% trans "Cancel" %}</button>
|
||||||
|
</form>
|
||||||
|
<button class="btn btn-error gap-2" type="submit" form="event_delete_form">{% lucide "trash-2" size=16 %} {% trans "Confirm" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button>close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if has_attendance_rows %}
|
||||||
|
<dialog id="rsvp_modal" class="modal">
|
||||||
|
<div class="modal-box max-w-2xl">
|
||||||
|
<h3 class="text-lg font-bold">{% trans "Who responded" %}</h3>
|
||||||
|
<div class="mt-2 space-y-2">
|
||||||
|
{% for group in attendance_groups %}
|
||||||
|
{% if group.rows %}
|
||||||
|
<details class="collapse collapse-arrow border border-base-300 bg-base-100">
|
||||||
|
<summary class="collapse-title text-sm font-medium">
|
||||||
|
<span class="badge badge-sm
|
||||||
|
{% if group.value == "present" %}badge-success
|
||||||
|
{% elif group.value == "absent" %}badge-error
|
||||||
|
{% elif group.value == "excused" %}badge-warning
|
||||||
|
{% elif group.value == "selected" %}badge-info
|
||||||
|
{% elif group.value == "maybe" %}badge-warning badge-outline
|
||||||
|
{% elif group.value == "not_selected" %}badge-neutral
|
||||||
|
{% else %}badge-outline{% endif %}">
|
||||||
|
{{ group.label }}
|
||||||
|
</span>
|
||||||
|
<span class="opacity-60">({{ group.rows|length }})</span>
|
||||||
|
</summary>
|
||||||
|
<div class="collapse-content">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<tbody>
|
||||||
|
{% for row in group.rows %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.member }}</td>
|
||||||
|
<td class="text-sm opacity-70">{{ row.note|default:"—" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="modal-action">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn btn-outline gap-2">{% lucide "x" size=16 %} {% trans "Close" %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button>close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock extra_body %}
|
||||||
96
management/templates/management/event_form.html
Normal file
96
management/templates/management/event_form.html
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n lucide static ui %}
|
||||||
|
|
||||||
|
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New event" %}{% endif %}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="card w-full bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
<div class="alert alert-error my-2">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{% form_field form.title %}
|
||||||
|
{% form_field form.kind %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
<h3 class="text-lg font-semibold mb-3">{% trans "Audience" %}</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{% form_field form.teams %}
|
||||||
|
{% form_field form.invited_members %}
|
||||||
|
{% form_field form.excluded_members %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
<h3 class="text-lg font-semibold mb-3">{% trans "Where" %}</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{% form_field form.location %}
|
||||||
|
<div class="game-only">
|
||||||
|
{% form_field form.opponent %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
<h3 class="text-lg font-semibold mb-3">{% trans "When" %}</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
{% form_field form.start %}
|
||||||
|
{% form_field form.end %}
|
||||||
|
{% form_field form.gathering %}
|
||||||
|
{% form_field form.deadline %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="game-details-section" class="game-only">
|
||||||
|
<div class="divider"></div>
|
||||||
|
<h3 class="text-lg font-semibold mb-3">{% trans "Game" %}</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{% form_field form.competition %}
|
||||||
|
{% form_field form.external_game_id %}
|
||||||
|
</div>
|
||||||
|
{% if update_view %}
|
||||||
|
{% comment %}
|
||||||
|
Score/live status only exist to record once a game has
|
||||||
|
actually been created -- the add form leaves them out
|
||||||
|
entirely (see EventForm's editing=False path).
|
||||||
|
{% endcomment %}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{% form_field form.score_for %}
|
||||||
|
{% form_field form.score_against %}
|
||||||
|
{% form_field form.is_live %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-start pt-2 mt-2">
|
||||||
|
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "management:event_detail" object.pk %}{% else %}{% url "management:event_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
||||||
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock panel %}
|
||||||
|
|
||||||
|
{% block extra_body %}
|
||||||
|
<script src="{% static 'js/searchable-select.js' %}"></script>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
// Progressive enhancement only -- the fields stay in the form and post
|
||||||
|
// normally either way, this just hides them when they don't apply so a
|
||||||
|
// training/social/etc. event isn't cluttered with opponent/score/competition
|
||||||
|
// inputs. Anything gated on "only makes sense for a game" carries this class.
|
||||||
|
const kindField = document.getElementById("id_kind");
|
||||||
|
const gameOnlyElements = document.querySelectorAll(".game-only");
|
||||||
|
if (!kindField || !gameOnlyElements.length) return;
|
||||||
|
|
||||||
|
const sync = () => gameOnlyElements.forEach((element) => element.classList.toggle("hidden", kindField.value !== "game"));
|
||||||
|
kindField.addEventListener("change", sync);
|
||||||
|
sync();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock extra_body %}
|
||||||
118
management/templates/management/event_list.html
Normal file
118
management/templates/management/event_list.html
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n lucide ui %}
|
||||||
|
|
||||||
|
{% block heading %}{% trans "Events" %}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
{% if is_club_admin and rbihf_enabled %}
|
||||||
|
<a class="btn btn-outline gap-2" href="{% url 'management:rbihf_import' %}">{% lucide "download" size=16 %} {% trans "Import from RBIHF" %}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_create %}
|
||||||
|
<a class="btn btn-outline gap-2" href="{% url 'management:event_series_create' %}">{% lucide "repeat" size=16 %} {% trans "New series" %}</a>
|
||||||
|
<a class="btn btn-outline gap-2" href="{% url 'management:event_create' %}">{% lucide "plus" size=16 %} {% trans "New event" %}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock actions %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<form method="get" class="mb-4">
|
||||||
|
<div class="flex flex-row flex-wrap items-center gap-2">
|
||||||
|
<select name="season" class="select select-bordered">
|
||||||
|
{% for season in seasons %}
|
||||||
|
<option value="{{ season.pk }}" {% if season.pk == selected_season.pk %}selected{% endif %}>{% trans "Season" %} {{ season.start_date|date:"Y" }} - {{ season.end_date|date:"Y" }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select name="kind" class="select select-bordered">
|
||||||
|
<option value="">{% trans "All kinds" %}</option>
|
||||||
|
{% for value, label in event_kinds %}
|
||||||
|
<option value="{{ value }}" {% if value == selected_kind %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input type="checkbox" name="show_past" value="1" class="checkbox" {% if show_past %}checked{% endif %} onchange="this.form.submit()">
|
||||||
|
<span class="label-text">{% trans "Show past events" %}</span>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "View" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Title" %}</th>
|
||||||
|
<th>{% trans "Kind" %}</th>
|
||||||
|
<th>{% trans "When" %}</th>
|
||||||
|
<th>{% trans "Teams" %}</th>
|
||||||
|
<th>{% trans "Location" %}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for event in events %}
|
||||||
|
<tr>
|
||||||
|
<td class="flex flex-row items-center gap-2">
|
||||||
|
<a class="link link-hover" href="{% url 'management:event_detail' event.pk %}">{{ event.title }}</a>
|
||||||
|
{% if event.series_id %}<span class="tooltip" data-tip="{% trans 'Part of a series' %}">{% lucide "repeat" size=12 %}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ event.get_kind_display }}</td>
|
||||||
|
<td>{{ event.start|date:"j M Y H:i" }}</td>
|
||||||
|
<td>{% for team in event.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</td>
|
||||||
|
<td>{{ event.location.name|default:"—" }}</td>
|
||||||
|
<td class="text-right">
|
||||||
|
{% if event.can_manage %}
|
||||||
|
<div class="flex justify-end gap-1">
|
||||||
|
<a class="btn btn-outline btn-sm" href="{% url 'management:event_detail' event.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
||||||
|
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ event.pk|dom_id:"event_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center opacity-60">{% trans "No events." %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
|
||||||
|
{% for event in events %}
|
||||||
|
{% if event.can_manage %}
|
||||||
|
<dialog id="{{ event.pk|dom_id:"event_delete_modal" }}" class="modal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="text-lg font-bold">
|
||||||
|
{% if event.series_id %}{% trans "Cancel occurrence" %}{% else %}{% trans "Delete event" %}{% endif %}
|
||||||
|
</h3>
|
||||||
|
<form method="post" action="{% url 'management:event_delete' event.pk %}" id="{{ event.pk|dom_id:"event_delete_form" }}">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if event.series_id %}
|
||||||
|
{% blocktrans with name=event.title asvar delete_body %}Removes “{{ name }}” from the schedule. It won't be regenerated.{% endblocktrans %}
|
||||||
|
<p class="py-2 text-sm opacity-70">{{ delete_body }}</p>
|
||||||
|
<label class="label cursor-pointer justify-start gap-2">
|
||||||
|
<input type="checkbox" name="keep_record" class="checkbox">
|
||||||
|
<span class="label-text">{% trans "Keep a record of it (marks it cancelled instead of deleting it)" %}</span>
|
||||||
|
</label>
|
||||||
|
{% else %}
|
||||||
|
{% blocktrans with name=event.title asvar delete_body %}Delete “{{ name }}”? This cannot be undone.{% endblocktrans %}
|
||||||
|
<p class="py-2 text-sm opacity-70">{{ delete_body }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
<div class="modal-action">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn btn-outline gap-2">{% lucide "x" size=16 %} {% trans "Cancel" %}</button>
|
||||||
|
</form>
|
||||||
|
<button class="btn btn-error gap-2" type="submit" form="{{ event.pk|dom_id:"event_delete_form" }}">{% lucide "trash-2" size=16 %} {% trans "Confirm" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button>close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock panel %}
|
||||||
117
management/templates/management/event_series_detail.html
Normal file
117
management/templates/management/event_series_detail.html
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n lucide ui %}
|
||||||
|
|
||||||
|
{% block heading %}{{ series.title }}{% endblock heading %}
|
||||||
|
{% block subheading %}{{ series.get_kind_display }} · {{ recurrence_summary }}{% endblock subheading %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
{% if can_manage %}
|
||||||
|
<a class="btn btn-outline gap-2" href="{% url 'management:event_series_update' series.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
|
||||||
|
<form method="post" action="{% url 'management:event_series_generate' series.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="btn btn-outline gap-2" type="submit">{% lucide "refresh-cw" size=16 %} {% trans "Regenerate occurrences" %}</button>
|
||||||
|
</form>
|
||||||
|
<button class="btn btn-outline btn-warning gap-2" type="button" onclick="document.getElementById('series_stop_modal').showModal()">{% lucide "octagon-pause" size=16 %} {% trans "Stop repeating" %}</button>
|
||||||
|
<button class="btn btn-outline btn-error gap-2" type="button" onclick="document.getElementById('series_delete_modal').showModal()">{% lucide "trash-2" size=16 %} {% trans "Delete series" %}</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock actions %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="mb-6 grid gap-4 lg:grid-cols-2">
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">{% lucide "repeat" size=18 %} {% trans "Recurrence" %}</h2>
|
||||||
|
<dl class="divide-y divide-base-200">
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-sm opacity-70">{% trans "Pattern" %}</dt>
|
||||||
|
<dd>{{ recurrence_summary }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-sm opacity-70">{% trans "First occurrence" %}</dt>
|
||||||
|
<dd>{{ series.dtstart|date:"j M Y H:i" }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-sm opacity-70">{% trans "Repeats until" %}</dt>
|
||||||
|
<dd>{{ series.until|date:"j M Y H:i"|default:"—" }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-sm opacity-70">{% trans "Generated up to" %}</dt>
|
||||||
|
<dd>{{ series.generated_until|date:"j M Y"|default:"—" }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">{% lucide "info" size=18 %} {% trans "Template" %}</h2>
|
||||||
|
<dl class="divide-y divide-base-200">
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-sm opacity-70">{% trans "Location" %}</dt>
|
||||||
|
<dd>{{ series.location|default:"—" }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-sm opacity-70">{% trans "Opponent" %}</dt>
|
||||||
|
<dd>{{ series.opponent|default:"—" }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-sm opacity-70">{% trans "Teams" %}</dt>
|
||||||
|
<dd>{% for team in series.teams.all %}{{ team.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">{% lucide "calendar" size=18 %} {% trans "Occurrences" %}</h2>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "When" %}</th>
|
||||||
|
<th>{% trans "Status" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for occurrence in occurrences %}
|
||||||
|
<tr>
|
||||||
|
<td><a class="link link-hover" href="{% url 'management:event_detail' occurrence.pk %}">{{ occurrence.start|date:"j M Y H:i" }}</a></td>
|
||||||
|
<td>
|
||||||
|
{% if occurrence.cancelled %}
|
||||||
|
<span class="badge badge-outline gap-1">{% lucide "ban" size=12 %} {% trans "Cancelled" %}</span>
|
||||||
|
{% elif occurrence.detached %}
|
||||||
|
<span class="badge badge-neutral gap-1">{% lucide "unlink" size=12 %} {% trans "Detached" %}</span>
|
||||||
|
{% elif occurrence.is_past %}
|
||||||
|
<span class="badge badge-outline">{% trans "Past" %}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-success">{% trans "Upcoming" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" class="text-center opacity-60">{% trans "No occurrences generated yet." %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock panel %}
|
||||||
|
|
||||||
|
{% block extra_body %}
|
||||||
|
{% if can_manage %}
|
||||||
|
{% trans "Stop repeating" as stop_title %}
|
||||||
|
{% blocktrans with name=series.title asvar stop_body %}Stop “{{ name }}” from generating new occurrences? Existing ones (past and future) are left exactly as they are — this only affects what happens from here on.{% endblocktrans %}
|
||||||
|
{% url 'management:event_series_stop' series.pk as stop_url %}
|
||||||
|
{% include "controlpanel/_confirm_modal.html" with modal_id="series_stop_modal" title=stop_title body=stop_body action_url=stop_url submit_label=stop_title submit_icon="octagon-pause" %}
|
||||||
|
|
||||||
|
{% trans "Delete series" as delete_title %}
|
||||||
|
{% blocktrans with name=series.title asvar delete_body %}Delete “{{ name }}”? This also deletes every occurrence it has ever generated, past and future, and their attendance records. This cannot be undone — use “Stop repeating” instead if you just want it to stop.{% endblocktrans %}
|
||||||
|
{% url 'management:event_series_delete' series.pk as delete_url %}
|
||||||
|
{% include "controlpanel/_confirm_modal.html" with modal_id="series_delete_modal" title=delete_title body=delete_body action_url=delete_url submit_label=delete_title %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock extra_body %}
|
||||||
75
management/templates/management/event_series_form.html
Normal file
75
management/templates/management/event_series_form.html
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n lucide static ui %}
|
||||||
|
|
||||||
|
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New series" %}{% endif %}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="card w-full bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
<div class="alert alert-error my-2">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{% form_field form.title %}
|
||||||
|
{% form_field form.kind %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
<h3 class="text-lg font-semibold mb-3">{% trans "Repeats" %}</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{% form_field form.frequency %}
|
||||||
|
{% form_field form.interval %}
|
||||||
|
{% form_field form.dtstart %}
|
||||||
|
{% form_field form.until %}
|
||||||
|
</div>
|
||||||
|
{% trans "Only used for a weekly pattern -- a monthly one repeats on the same day of the month as the first occurrence above." as weekdays_help %}
|
||||||
|
{% form_field form.weekdays help_text=weekdays_help %}
|
||||||
|
<details class="collapse collapse-arrow bg-base-200 mt-2">
|
||||||
|
<summary class="collapse-title text-sm font-medium">{% trans "Advanced: raw recurrence rule" %}</summary>
|
||||||
|
<div class="collapse-content">
|
||||||
|
{% form_field form.advanced_rrule %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
<h3 class="text-lg font-semibold mb-3">{% trans "Audience" %}</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{% form_field form.teams %}
|
||||||
|
{% form_field form.invited_members %}
|
||||||
|
{% form_field form.excluded_members %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
<h3 class="text-lg font-semibold mb-3">{% trans "Where" %}</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{% form_field form.location %}
|
||||||
|
{% form_field form.opponent %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
<h3 class="text-lg font-semibold mb-3">{% trans "Timing" %}</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
{% form_field form.duration_hours %}
|
||||||
|
{% form_field form.duration_minutes %}
|
||||||
|
{% form_field form.gathering_minutes_before %}
|
||||||
|
{% form_field form.deadline_minutes_before %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-start pt-2 mt-2">
|
||||||
|
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "management:event_series_detail" object.pk %}{% else %}{% url "management:event_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
||||||
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock panel %}
|
||||||
|
|
||||||
|
{% block extra_body %}
|
||||||
|
<script src="{% static 'js/searchable-select.js' %}"></script>
|
||||||
|
{% endblock extra_body %}
|
||||||
@@ -7,8 +7,8 @@
|
|||||||
{% if is_club_admin %}
|
{% if is_club_admin %}
|
||||||
{% trans "Add parent" as add_parent_label %}
|
{% trans "Add parent" as add_parent_label %}
|
||||||
{% trans "Add child" as add_child_label %}
|
{% trans "Add child" as add_child_label %}
|
||||||
<button class="btn btn-outline btn-neutral gap-2" type="button" onclick="document.getElementById('add_parent_modal').showModal()">{% lucide "user-plus" size=16 %} {{ add_parent_label }}</button>
|
<button class="btn btn-outline gap-2" type="button" onclick="document.getElementById('add_parent_modal').showModal()">{% lucide "user-plus" size=16 %} {{ add_parent_label }}</button>
|
||||||
<button class="btn btn-outline btn-neutral gap-2" type="button" onclick="document.getElementById('add_child_modal').showModal()">{% lucide "baby" size=16 %} {{ add_child_label }}</button>
|
<button class="btn btn-outline gap-2" type="button" onclick="document.getElementById('add_child_modal').showModal()">{% lucide "baby" size=16 %} {{ add_child_label }}</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-start pt-2 mt-2">
|
<div class="card-actions justify-start pt-2 mt-2">
|
||||||
<a class="btn btn-outline btn-neutral gap-2" href="{% url "management:member_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
<a class="btn btn-outline gap-2" href="{% url "management:member_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
||||||
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -14,6 +14,19 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if is_club_admin and billing_ends_at %}
|
||||||
|
<div class="alert alert-warning mb-6">
|
||||||
|
{% lucide "calendar-clock" size=20 %}
|
||||||
|
<span>
|
||||||
|
{% if billing_auto_renews %}
|
||||||
|
{% blocktrans with date=billing_ends_at|date:"j M Y" %}Your current billing period ends {{ date }} and will renew automatically.{% endblocktrans %}
|
||||||
|
{% else %}
|
||||||
|
{% blocktrans with date=billing_ends_at|date:"j M Y" %}Your current billing period ends {{ date }} and is not set to renew automatically — billing is about to stop. Contact us to keep access.{% endblocktrans %}
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% comment %}
|
{% comment %}
|
||||||
The club's own numbers that should be zero -- same attention/chart/stat-group
|
The club's own numbers that should be zero -- same attention/chart/stat-group
|
||||||
data controlpanel/club_detail.html shows a platform admin drilling into this
|
data controlpanel/club_detail.html shows a platform admin drilling into this
|
||||||
@@ -82,7 +95,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="card-title text-base">{% lucide "calendar" size=18 %} {% trans "Upcoming events" %}</h2>
|
<h2 class="card-title text-base">{% lucide "calendar" size=18 %} {% trans "Upcoming events" %}</h2>
|
||||||
<a class="btn btn-outline btn-neutral btn-sm gap-2" href="{% url 'management:event_list' %}">{% lucide "arrow-right" size=14 %} {% trans "View all" %}</a>
|
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:event_list' %}">{% lucide "arrow-right" size=14 %} {% trans "View all" %}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
@@ -91,7 +104,8 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="whitespace-nowrap">{{ event.start|date:"D j M, H:i" }}</td>
|
<td class="whitespace-nowrap">{{ event.start|date:"D j M, H:i" }}</td>
|
||||||
<td>{{ event.title }}</td>
|
<td>{{ event.title }}</td>
|
||||||
<td>{{ event.get_kind_display|capfirst }}</td>
|
<td>{{ event.get_kind_display }}</td>
|
||||||
|
<td>{% for team in event.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -108,7 +122,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="card-title text-base">{% lucide "newspaper" size=18 %} {% trans "News" %}</h2>
|
<h2 class="card-title text-base">{% lucide "newspaper" size=18 %} {% trans "News" %}</h2>
|
||||||
<a class="btn btn-outline btn-neutral btn-sm gap-2" href="{% url 'management:news_list' %}">{% lucide "arrow-right" size=14 %} {% trans "View all" %}</a>
|
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:news_list' %}">{% lucide "arrow-right" size=14 %} {% trans "View all" %}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-start pt-2 mt-2">
|
<div class="card-actions justify-start pt-2 mt-2">
|
||||||
<a class="btn btn-outline btn-neutral gap-2" href="{% url "management:location_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
<a class="btn btn-outline gap-2" href="{% url "management:location_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
||||||
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
<td>{{ location.country }}</td>
|
<td>{{ location.country }}</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<div class="flex justify-end gap-1">
|
<div class="flex justify-end gap-1">
|
||||||
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:location_update' location.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
<a class="btn btn-outline btn-sm" href="{% url 'management:location_update' location.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
||||||
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ location.pk|dom_id:"location_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
|
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ location.pk|dom_id:"location_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
{% block actions %}
|
{% block actions %}
|
||||||
{% if is_club_admin %}
|
{% if is_club_admin %}
|
||||||
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_update' member.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
|
<a class="btn btn-outline gap-2" href="{% url 'management:member_update' member.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
@@ -114,8 +114,8 @@
|
|||||||
</h2>
|
</h2>
|
||||||
{% if is_club_admin %}
|
{% if is_club_admin %}
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"add_parent_modal" }}').showModal()">{% lucide "user-plus" size=14 %} {{ add_parent_label }}</button>
|
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"add_parent_modal" }}').showModal()">{% lucide "user-plus" size=14 %} {{ add_parent_label }}</button>
|
||||||
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"add_child_modal" }}').showModal()">{% lucide "baby" size=14 %} {{ add_child_label }}</button>
|
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"add_child_modal" }}').showModal()">{% lucide "baby" size=14 %} {{ add_child_label }}</button>
|
||||||
<button class="btn btn-outline btn-error btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"detach_family_modal" }}').showModal()">{% lucide "user-x" size=14 %} {{ remove_from_family_label }}</button>
|
<button class="btn btn-outline btn-error btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"detach_family_modal" }}').showModal()">{% lucide "user-x" size=14 %} {{ remove_from_family_label }}</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -140,7 +140,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="card-title text-base">{% lucide "users" size=18 %} {% trans "Family" %}</h2>
|
<h2 class="card-title text-base">{% lucide "users" size=18 %} {% trans "Family" %}</h2>
|
||||||
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('attach_family_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Add to family" %}</button>
|
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('attach_family_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Add to family" %}</button>
|
||||||
</div>
|
</div>
|
||||||
{% if not family_groups %}
|
{% if not family_groups %}
|
||||||
<p class="text-sm opacity-60">{% trans "Not part of a family." %}</p>
|
<p class="text-sm opacity-60">{% trans "Not part of a family." %}</p>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="card-actions justify-start pt-2 mt-2">
|
<div class="card-actions justify-start pt-2 mt-2">
|
||||||
<a class="btn btn-outline btn-neutral gap-2" href="{% if update_view %}{% url "management:member_detail" object.pk %}{% else %}{% url "management:member_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "management:member_detail" object.pk %}{% else %}{% url "management:member_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
||||||
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
{% block heading %}{% trans "Mass upload members" %}{% endblock heading %}
|
{% block heading %}{% trans "Mass upload members" %}{% endblock heading %}
|
||||||
|
|
||||||
{% block actions %}
|
{% block actions %}
|
||||||
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_import_template' %}">{% lucide "download" size=16 %} {% trans "Download template" %}</a>
|
<a class="btn btn-outline gap-2" href="{% url 'management:member_import_template' %}">{% lucide "download" size=16 %} {% trans "Download template" %}</a>
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="card-actions justify-start pt-2 mt-2">
|
<div class="card-actions justify-start pt-2 mt-2">
|
||||||
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_list' %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
<a class="btn btn-outline gap-2" href="{% url 'management:member_list' %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
||||||
<button class="btn btn-primary gap-2" type="submit">{% lucide "upload" size=16 %} {% trans "Upload and preview" %}</button>
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "upload" size=16 %} {% trans "Upload and preview" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-start pt-2 mt-2">
|
<div class="card-actions justify-start pt-2 mt-2">
|
||||||
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_import' %}">{% lucide "arrow-left" size=16 %} {% trans "Back" %}</a>
|
<a class="btn btn-outline gap-2" href="{% url 'management:member_import' %}">{% lucide "arrow-left" size=16 %} {% trans "Back" %}</a>
|
||||||
{% if valid_count %}
|
{% if valid_count %}
|
||||||
<form method="post" action="{% url 'management:member_import_confirm' %}">
|
<form method="post" action="{% url 'management:member_import_confirm' %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
<a class="btn btn-outline btn-info gap-2" href="{% url 'management:member_import_template' %}" xmlns:management="http://www.w3.org/1999/xhtml">{% lucide "download" size=16 %} {% trans "Download upload template" %}</a>
|
<a class="btn btn-outline btn-info gap-2" href="{% url 'management:member_import_template' %}" xmlns:management="http://www.w3.org/1999/xhtml">{% lucide "download" size=16 %} {% trans "Download upload template" %}</a>
|
||||||
{% if is_club_admin %}
|
{% if is_club_admin %}
|
||||||
<a class="btn btn-outline btn-info gap-2" href="{% url 'management:member_import' %}">{% lucide "upload" size=16 %} {% trans "Mass upload" %}</a>
|
<a class="btn btn-outline btn-info gap-2" href="{% url 'management:member_import' %}">{% lucide "upload" size=16 %} {% trans "Mass upload" %}</a>
|
||||||
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:family_create' %}">{% lucide "users" size=16 %} {% trans "Add family" %}</a>
|
<a class="btn btn-outline gap-2" href="{% url 'management:family_create' %}">{% lucide "users" size=16 %} {% trans "Add family" %}</a>
|
||||||
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_create' %}">{% lucide "user-plus" size=16 %} {% trans "Add member" %}</a>
|
<a class="btn btn-outline gap-2" href="{% url 'management:member_create' %}">{% lucide "user-plus" size=16 %} {% trans "Add member" %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
@@ -18,9 +18,9 @@
|
|||||||
<span class="opacity-50">{% lucide "search" size=16 %}</span>
|
<span class="opacity-50">{% lucide "search" size=16 %}</span>
|
||||||
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered w-full max-w-xs">
|
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered w-full max-w-xs">
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-outline btn-neutral gap-2" type="submit">{% lucide "search" size=16 %} {% trans "Search" %}</button>
|
<button class="btn btn-outline gap-2" type="submit">{% lucide "search" size=16 %} {% trans "Search" %}</button>
|
||||||
{% if search %}
|
{% if search %}
|
||||||
<a class="btn btn-neutral gap-2" href="{% url "management:member_list" %}">{% lucide "x" size=16 %} {% trans "Clear filter" %}</a>
|
<a class="btn gap-2" href="{% url "management:member_list" %}">{% lucide "x" size=16 %} {% trans "Clear filter" %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
{% for fm in member.family_memberships_display %}
|
{% for fm in member.family_memberships_display %}
|
||||||
<div>
|
<div>
|
||||||
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:family_detail' fm.family.pk %}">{% lucide "users" size=14 %} {{ fm.family }} {% lucide "arrow-right" size=14 %}</a>
|
<a class="btn btn-outline btn-sm" href="{% url 'management:family_detail' fm.family.pk %}">{% lucide "users" size=14 %} {{ fm.family }} {% lucide "arrow-right" size=14 %}</a>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
{% if is_club_admin %}
|
{% if is_club_admin %}
|
||||||
<div class="flex justify-end gap-1">
|
<div class="flex justify-end gap-1">
|
||||||
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:member_detail' member.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
<a class="btn btn-outline btn-sm" href="{% url 'management:member_detail' member.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
||||||
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ member.pk|dom_id:"member_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
|
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ member.pk|dom_id:"member_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block subheading %}{% if current_season %}{% blocktrans %}Fee status for season{% endblocktrans %}{% endif %}{% endblock subheading %}
|
{% block subheading %}{% if current_season %}{% blocktrans %}Fee status for season{% endblocktrans %}{% endif %}{% endblock subheading %}
|
||||||
|
|
||||||
{% block actions %}
|
{% block actions %}
|
||||||
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:membership_export_pdf' %}?{{ request.GET.urlencode }}">{% lucide "file-down" size=16 %} {% trans "Export to PDF" %}</a>
|
<a class="btn btn-outline gap-2" href="{% url 'management:membership_export_pdf' %}?{{ request.GET.urlencode }}">{% lucide "file-down" size=16 %} {% trans "Export to PDF" %}</a>
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
@@ -93,8 +93,8 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button class="btn btn-outline btn-neutral gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button>
|
<button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button>
|
||||||
<a class="btn btn-neutral gap-2" href="{% url 'management:membership_list' %}">{% lucide "x" size=16 %} {% trans "Reset" %}</a>
|
<a class="btn gap-2" href="{% url 'management:membership_list' %}">{% lucide "x" size=16 %} {% trans "Reset" %}</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
{% if membership.record_payment_form %}
|
{% if membership.record_payment_form %}
|
||||||
<div class="flex justify-end gap-1">
|
<div class="flex justify-end gap-1">
|
||||||
<button class="btn btn-sm btn-outline btn-neutral" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"record_payment_modal" }}').showModal()">
|
<button class="btn btn-outline btn-sm" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"record_payment_modal" }}').showModal()">
|
||||||
{% lucide "receipt" size=12 %} {% trans "Record payment" %}
|
{% lucide "receipt" size=12 %} {% trans "Record payment" %}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline btn-success" type="submit" form="{{ membership.pk|dom_id:"mark_fully_paid_form" }}">
|
<button class="btn btn-sm btn-outline btn-success" type="submit" form="{{ membership.pk|dom_id:"mark_fully_paid_form" }}">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
{% block actions %}
|
{% block actions %}
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:news_update' news_item.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
|
<a class="btn btn-outline gap-2" href="{% url 'management:news_update' news_item.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
|
||||||
<button class="btn btn-outline btn-error gap-2" type="button" onclick="document.getElementById('delete_news_modal').showModal()">{% lucide "trash-2" size=16 %} {% trans "Delete" %}</button>
|
<button class="btn btn-outline btn-error gap-2" type="button" onclick="document.getElementById('delete_news_modal').showModal()">{% lucide "trash-2" size=16 %} {% trans "Delete" %}</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if can_publish %}
|
{% if can_publish %}
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="card-title text-base">{% trans "Photos" %}</h2>
|
<h2 class="card-title text-base">{% trans "Photos" %}</h2>
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('add_photos_modal').showModal()">{% lucide "image-plus" size=14 %} {% trans "Add photos" %}</button>
|
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('add_photos_modal').showModal()">{% lucide "image-plus" size=14 %} {% trans "Add photos" %}</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
{% if not photo.is_main %}
|
{% if not photo.is_main %}
|
||||||
<form method="post" action="{% url 'management:news_photo_set_main' news_item.pk photo.pk %}">
|
<form method="post" action="{% url 'management:news_photo_set_main' news_item.pk photo.pk %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button class="btn btn-outline btn-neutral btn-xs gap-1" type="submit">{% lucide "star" size=12 %} {% trans "Set main" %}</button>
|
<button class="btn btn-outline btn-xs gap-1" type="submit">{% lucide "star" size=12 %} {% trans "Set main" %}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button class="btn btn-outline btn-error btn-xs gap-1" type="button" onclick="document.getElementById('{{ photo.pk|dom_id:"delete_photo_modal" }}').showModal()">{% lucide "trash-2" size=12 %} {% trans "Delete" %}</button>
|
<button class="btn btn-outline btn-error btn-xs gap-1" type="button" onclick="document.getElementById('{{ photo.pk|dom_id:"delete_photo_modal" }}').showModal()">{% lucide "trash-2" size=12 %} {% trans "Delete" %}</button>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-start pt-2 mt-2">
|
<div class="card-actions justify-start pt-2 mt-2">
|
||||||
<a class="btn btn-outline btn-neutral gap-2" href="{% if update_view %}{% url "management:news_detail" object.pk %}{% else %}{% url "management:news_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "management:news_detail" object.pk %}{% else %}{% url "management:news_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
||||||
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{% block actions %}
|
{% block actions %}
|
||||||
{% if can_add_news %}
|
{% if can_add_news %}
|
||||||
<a class="btn btn-primary gap-2" href="{% url 'management:news_create' %}">{% lucide "plus" size=16 %} {% trans "New news item" %}</a>
|
<a class="btn btn-outline gap-2" href="{% url 'management:news_create' %}">{% lucide "plus" size=16 %} {% trans "New news item" %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
{% if news_item.can_edit %}
|
{% if news_item.can_edit %}
|
||||||
<div class="flex justify-end gap-1">
|
<div class="flex justify-end gap-1">
|
||||||
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:news_detail' news_item.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
<a class="btn btn-outline btn-sm" href="{% url 'management:news_detail' news_item.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
||||||
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ news_item.pk|dom_id:"news_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
|
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ news_item.pk|dom_id:"news_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-start pt-2 mt-2">
|
<div class="card-actions justify-start pt-2 mt-2">
|
||||||
<a class="btn btn-outline btn-neutral gap-2" href="{% url "management:opponent_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
<a class="btn btn-outline gap-2" href="{% url "management:opponent_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
||||||
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<td>{{ opponent.name }}</td>
|
<td>{{ opponent.name }}</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<div class="flex justify-end gap-1">
|
<div class="flex justify-end gap-1">
|
||||||
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:opponent_update' opponent.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
<a class="btn btn-outline btn-sm" href="{% url 'management:opponent_update' opponent.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
||||||
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ opponent.pk|dom_id:"opponent_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
|
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ opponent.pk|dom_id:"opponent_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-start pt-2 mt-2">
|
<div class="card-actions justify-start pt-2 mt-2">
|
||||||
<a class="btn btn-outline btn-neutral gap-2" href="{% url "management:position_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
<a class="btn btn-outline gap-2" href="{% url "management:position_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
||||||
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{% block actions %}
|
{% block actions %}
|
||||||
{% if is_club_admin %}
|
{% if is_club_admin %}
|
||||||
<a class="btn btn-primary gap-2" href="{% url 'management:position_create' %}">{% lucide "plus" size=16 %} {% trans "New position" %}</a>
|
<a class="btn btn-outline gap-2" href="{% url 'management:position_create' %}">{% lucide "plus" size=16 %} {% trans "New position" %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
{% if is_club_admin %}
|
{% if is_club_admin %}
|
||||||
<a class="btn btn-outline btn-neutral btn-sm gap-2" href="{% url 'management:position_update' position.pk %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:position_update' position.pk %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
35
management/templates/management/rbihf_import_form.html
Normal file
35
management/templates/management/rbihf_import_form.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load lucide ui i18n %}
|
||||||
|
|
||||||
|
{% block heading %}{% trans "Import from RBIHF" %}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="card w-full bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-sm opacity-70">
|
||||||
|
{% blocktrans %}Paste the URL of a team's page on the RBIHF website and pick which of
|
||||||
|
your teams it's for. Nothing is created yet: you'll see exactly what will
|
||||||
|
be added, changed, or removed before anything is saved.{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
<div class="alert alert-error my-2">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for field in form %}
|
||||||
|
{% form_field field %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="card-actions justify-start pt-2 mt-2">
|
||||||
|
<a class="btn btn-outline gap-2" href="{% url 'management:event_list' %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
||||||
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "download" size=16 %} {% trans "Fetch and preview" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock panel %}
|
||||||
152
management/templates/management/rbihf_import_preview.html
Normal file
152
management/templates/management/rbihf_import_preview.html
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load lucide i18n %}
|
||||||
|
|
||||||
|
{% block heading %}{% trans "Review import" %}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="alert alert-info mb-4">
|
||||||
|
{% lucide "info" size=16 %}
|
||||||
|
<span>{% blocktrans with scraped=plan.scraped_team_name team=plan.team %}Importing fixtures for “{{ scraped }}” onto your team “{{ team }}”. Double check that's the right match before confirming.{% endblocktrans %}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'management:rbihf_import_confirm' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">{% lucide "plus" size=18 %} {% blocktrans count counter=plan.to_create|length %}{{ counter }} game to create{% plural %}{{ counter }} games to create{% endblocktrans %}</h2>
|
||||||
|
|
||||||
|
{% if plan.to_create %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "#" %}</th>
|
||||||
|
<th>{% trans "Start" %}</th>
|
||||||
|
<th>{% trans "Opponent" %}</th>
|
||||||
|
<th>{% trans "Location" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for planned in plan.to_create %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ planned.fixture.external_game_id }}</td>
|
||||||
|
<td>{{ planned.fixture.start|date:"Y-m-d H:i" }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<select name="opponent_{{ planned.fixture.external_game_id }}" class="select select-sm w-full">
|
||||||
|
<option value="">{% trans "Use scraped name" %}</option>
|
||||||
|
{% for opponent in plan.opponent_choices %}
|
||||||
|
<option value="{{ opponent.pk }}" {% if planned.suggested_opponent.pk == opponent.pk %}selected{% endif %}>{{ opponent.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<span class="text-xs opacity-60 whitespace-nowrap">{% blocktrans with opponent=planned.fixture.opponent_name %}RBIHF: {{ opponent }}{% endblocktrans %}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<select name="location_{{ planned.fixture.external_game_id }}" class="select select-sm w-full">
|
||||||
|
<option value="">—</option>
|
||||||
|
{% for location in plan.location_choices %}
|
||||||
|
<option value="{{ location.pk }}" {% if planned.suggested_location.pk == location.pk %}selected{% endif %}>{{ location.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<span class="text-xs opacity-60 whitespace-nowrap">{% blocktrans with venue=planned.fixture.venue_text %}RBIHF: {{ venue }}{% endblocktrans %}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">{% lucide "refresh-cw" size=18 %} {% blocktrans count counter=plan.to_update|length %}{{ counter }} game to update{% plural %}{{ counter }} games to update{% endblocktrans %}</h2>
|
||||||
|
|
||||||
|
{% if plan.to_update %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "#" %}</th>
|
||||||
|
<th>{% trans "Changes" %}</th>
|
||||||
|
<th>{% trans "Opponent" %}</th>
|
||||||
|
<th>{% trans "Location" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for planned in plan.to_update %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ planned.fixture.external_game_id }}</td>
|
||||||
|
<td>
|
||||||
|
{% for field, change in planned.changes.items %}
|
||||||
|
<div class="text-xs">
|
||||||
|
<span class="font-semibold">{{ field }}:</span>
|
||||||
|
<span class="opacity-60 line-through">{{ change.0|default:"—" }}</span>
|
||||||
|
→
|
||||||
|
<span>{{ change.1 }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<select name="opponent_{{ planned.fixture.external_game_id }}" class="select select-sm w-full">
|
||||||
|
<option value="">{% trans "Use scraped name" %}</option>
|
||||||
|
{% for opponent in plan.opponent_choices %}
|
||||||
|
<option value="{{ opponent.pk }}" {% if planned.suggested_opponent.pk == opponent.pk %}selected{% endif %}>{{ opponent.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<span class="text-xs opacity-60 whitespace-nowrap">{% blocktrans with opponent=planned.fixture.opponent_name %}RBIHF: {{ opponent }}{% endblocktrans %}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<select name="location_{{ planned.fixture.external_game_id }}" class="select select-sm w-full">
|
||||||
|
<option value="">—</option>
|
||||||
|
{% for location in plan.location_choices %}
|
||||||
|
<option value="{{ location.pk }}" {% if planned.suggested_location.pk == location.pk %}selected{% endif %}>{{ location.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<span class="text-xs opacity-60 whitespace-nowrap">{% blocktrans with venue=planned.fixture.venue_text %}RBIHF: {{ venue }}{% endblocktrans %}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">{% lucide "trash-2" size=18 %} {% blocktrans count counter=plan.to_delete|length %}{{ counter }} game to delete{% plural %}{{ counter }} games to delete{% endblocktrans %}</h2>
|
||||||
|
<p class="text-sm opacity-70">{% trans "No longer listed on the RBIHF page — only upcoming games are ever removed this way, past results are left alone." %}</p>
|
||||||
|
|
||||||
|
{% if plan.to_delete %}
|
||||||
|
<ul class="list-disc list-inside text-sm">
|
||||||
|
{% for event in plan.to_delete %}
|
||||||
|
<li>{{ event.start|date:"Y-m-d H:i" }} — {{ event }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if plan.unchanged_count %}
|
||||||
|
<p class="text-sm opacity-60 mb-4">{% blocktrans count counter=plan.unchanged_count %}{{ counter }} game is already up to date.{% plural %}{{ counter }} games are already up to date.{% endblocktrans %}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card-actions justify-start pt-2 mt-2">
|
||||||
|
<a class="btn btn-outline gap-2" href="{% url 'management:rbihf_import' %}">{% lucide "arrow-left" size=16 %} {% trans "Back" %}</a>
|
||||||
|
{% if plan.to_create or plan.to_update or plan.to_delete %}
|
||||||
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "check" size=16 %} {% trans "Confirm import" %}</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock panel %}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
{% block heading %}{% trans "Roles" %}{% endblock heading %}
|
{% block heading %}{% trans "Roles" %}{% endblock heading %}
|
||||||
|
|
||||||
{% block actions %}
|
{% block actions %}
|
||||||
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('grant_role_modal').showModal()">{% lucide "plus" size=16 %} {% trans "Grant role" %}</button>
|
<button class="btn btn-outline gap-2" type="button" onclick="document.getElementById('grant_role_modal').showModal()">{% lucide "plus" size=16 %} {% trans "Grant role" %}</button>
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
|
|||||||
31
management/templates/management/sponsor_form.html
Normal file
31
management/templates/management/sponsor_form.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n lucide ui %}
|
||||||
|
|
||||||
|
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New sponsor" %}{% endif %}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="card w-full bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
<div class="alert alert-error my-2">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{% for field in form %}
|
||||||
|
{% form_field field %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-start pt-2 mt-2">
|
||||||
|
<a class="btn btn-outline gap-2" href="{% url "management:sponsor_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
||||||
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock panel %}
|
||||||
68
management/templates/management/sponsor_list.html
Normal file
68
management/templates/management/sponsor_list.html
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n lucide ui %}
|
||||||
|
|
||||||
|
{% block heading %}{% trans "Sponsors" %}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
<a class="btn btn-primary gap-2" href="{% url 'management:sponsor_create' %}">{% lucide "plus" size=16 %} {% trans "New sponsor" %}</a>
|
||||||
|
{% endblock actions %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>{% trans "Name" %}</th>
|
||||||
|
<th>{% trans "URL" %}</th>
|
||||||
|
<th>{% trans "Start date" %}</th>
|
||||||
|
<th>{% trans "End date" %}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for sponsor in sponsors %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% if sponsor.logo %}
|
||||||
|
<img src="{{ sponsor.logo.url }}" alt="" class="size-8 rounded object-contain">
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ sponsor.name }}</td>
|
||||||
|
<td>
|
||||||
|
{% if sponsor.url %}
|
||||||
|
<a class="link link-hover" href="{{ sponsor.url }}" target="_blank" rel="noopener noreferrer">{{ sponsor.url }}</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="opacity-50">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ sponsor.start_date|date:"Y-m-d" }}</td>
|
||||||
|
<td>{{ sponsor.end_date|date:"Y-m-d"|default:"—" }}</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<div class="flex justify-end gap-1">
|
||||||
|
<a class="btn btn-outline btn-sm" href="{% url 'management:sponsor_update' sponsor.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
||||||
|
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ sponsor.pk|dom_id:"sponsor_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center opacity-60">{% trans "No sponsors yet." %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% trans "Delete sponsor" as delete_sponsor_title %}
|
||||||
|
{% trans "Delete" as delete_label %}
|
||||||
|
{% for sponsor in sponsors %}
|
||||||
|
{% url 'management:sponsor_delete' sponsor.pk as sponsor_delete_url %}
|
||||||
|
{% blocktrans with name=sponsor.name asvar delete_sponsor_body %}Delete “{{ name }}”? This cannot be undone.{% endblocktrans %}
|
||||||
|
{% include "controlpanel/_confirm_modal.html" with modal_id=sponsor.pk|dom_id:"sponsor_delete_modal" title=delete_sponsor_title body=delete_sponsor_body action_url=sponsor_delete_url submit_label=delete_label %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock panel %}
|
||||||
@@ -2,11 +2,17 @@
|
|||||||
{% load i18n lucide static ui %}
|
{% load i18n lucide static ui %}
|
||||||
|
|
||||||
{% block heading %}{{ team.name }}{% endblock heading %}
|
{% block heading %}{{ team.name }}{% endblock heading %}
|
||||||
{% block subheading %}{{ team.short_name }}{% endblock subheading %}
|
{% block subheading %}
|
||||||
|
<span class="flex flex-row gap-2 items-center">
|
||||||
|
<span>{{ team.short_name }}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span class="badge-sm badge badge-outline">{% lucide "bookmark" size=14 %} {{ team.pk }}</span>
|
||||||
|
</span>
|
||||||
|
{% endblock subheading %}
|
||||||
|
|
||||||
{% block actions %}
|
{% block actions %}
|
||||||
{% if is_club_admin %}
|
{% if is_club_admin %}
|
||||||
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:team_update' team.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
|
<a class="btn btn-outline gap-2" href="{% url 'management:team_update' team.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
@@ -18,7 +24,7 @@
|
|||||||
<option value="{{ season.pk }}" {% if season.pk == selected_season.pk %}selected{% endif %}>{% trans "Season" %} {{ season.start_date|date:"Y" }} - {{ season.end_date|date:"Y" }}</option>
|
<option value="{{ season.pk }}" {% if season.pk == selected_season.pk %}selected{% endif %}>{% trans "Season" %} {{ season.start_date|date:"Y" }} - {{ season.end_date|date:"Y" }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<button class="btn btn-outline btn-neutral gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "View" %}</button>
|
<button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "View" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -28,6 +34,23 @@
|
|||||||
<span>{% trans "This club has no seasons yet, so there's no roster or staff to show." %}</span>
|
<span>{% trans "This club has no seasons yet, so there's no roster or staff to show." %}</span>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
{% if can_manage %}
|
||||||
|
{% if team_photo %}
|
||||||
|
{% trans "Replace photo" as photo_modal_title %}
|
||||||
|
{% else %}
|
||||||
|
{% trans "Upload photo" as photo_modal_title %}
|
||||||
|
{% endif %}
|
||||||
|
{% url 'management:team_photo_set' team.pk selected_season.pk as team_photo_set_url %}
|
||||||
|
{% include "controlpanel/_modal_form.html" with modal_id="team_photo_modal" title=photo_modal_title form=team_photo_form action_url=team_photo_set_url submit_label="Save" submit_icon="save" %}
|
||||||
|
|
||||||
|
{% if team_photo %}
|
||||||
|
{% trans "Remove team photo?" as remove_photo_body %}
|
||||||
|
{% trans "Remove photo" as remove_photo_title %}
|
||||||
|
{% url 'management:team_photo_delete' team.pk selected_season.pk as team_photo_delete_url %}
|
||||||
|
{% include "controlpanel/_confirm_modal.html" with modal_id="team_photo_delete_modal" title=remove_photo_title body=remove_photo_body action_url=team_photo_delete_url submit_label="Remove" submit_icon="trash-2" %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="mb-4 grid gap-4 lg:grid-cols-3">
|
<div class="mb-4 grid gap-4 lg:grid-cols-3">
|
||||||
<div class="card bg-base-100 shadow border-l-4 {% if attendance_rate is None %}border-info{% elif attendance_rate < 30 %}border-error{% elif attendance_rate < 65 %}border-warning{% else %}border-success{% endif %}">
|
<div class="card bg-base-100 shadow border-l-4 {% if attendance_rate is None %}border-info{% elif attendance_rate < 30 %}border-error{% elif attendance_rate < 65 %}border-warning{% else %}border-success{% endif %}">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
@@ -126,7 +149,7 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="card-title text-base">{% trans "Roster" %}</h2>
|
<h2 class="card-title text-base">{% trans "Roster" %}</h2>
|
||||||
{% if can_manage %}
|
{% if can_manage %}
|
||||||
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('add_player_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Add player" %}</button>
|
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('add_player_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Add player" %}</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
@@ -153,7 +176,7 @@
|
|||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
{% if can_manage %}
|
{% if can_manage %}
|
||||||
<div class="flex justify-end gap-1">
|
<div class="flex justify-end gap-1">
|
||||||
<button class="btn btn-sm btn-outline btn-neutral" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"edit_player_modal" }}').showModal()" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</button>
|
<button class="btn btn-outline btn-sm" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"edit_player_modal" }}').showModal()" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</button>
|
||||||
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"remove_player_modal" }}').showModal()" aria-label="{% trans 'Remove' %}">{% lucide "user-minus" size=14 %} {% trans "Remove" %}</button>
|
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"remove_player_modal" }}').showModal()" aria-label="{% trans 'Remove' %}">{% lucide "user-minus" size=14 %} {% trans "Remove" %}</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -175,7 +198,7 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="card-title text-base">{% trans "Staff" %}</h2>
|
<h2 class="card-title text-base">{% trans "Staff" %}</h2>
|
||||||
{% if can_manage %}
|
{% if can_manage %}
|
||||||
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('add_staff_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Assign staff" %}</button>
|
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('add_staff_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Assign staff" %}</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
@@ -195,7 +218,7 @@
|
|||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
{% if can_manage %}
|
{% if can_manage %}
|
||||||
<div class="flex justify-end gap-1">
|
<div class="flex justify-end gap-1">
|
||||||
<button class="btn btn-sm btn-outline btn-neutral" type="button" onclick="document.getElementById('{{ assignment.pk|dom_id:"edit_staff_modal" }}').showModal()" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</button>
|
<button class="btn btn-outline btn-sm" type="button" onclick="document.getElementById('{{ assignment.pk|dom_id:"edit_staff_modal" }}').showModal()" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</button>
|
||||||
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ assignment.pk|dom_id:"remove_staff_modal" }}').showModal()" aria-label="{% trans 'Remove' %}">{% lucide "user-minus" size=14 %} {% trans "Remove" %}</button>
|
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ assignment.pk|dom_id:"remove_staff_modal" }}').showModal()" aria-label="{% trans 'Remove' %}">{% lucide "user-minus" size=14 %} {% trans "Remove" %}</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -212,6 +235,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow mt-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="card-title text-base">{% trans "Team photo" %}</h2>
|
||||||
|
{% if can_manage %}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{% trans "Upload photo" as upload_photo_label %}
|
||||||
|
{% trans "Replace photo" as replace_photo_label %}
|
||||||
|
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('team_photo_modal').showModal()">
|
||||||
|
{% if team_photo %}
|
||||||
|
{% lucide "pencil" size=14 %} {{ replace_photo_label }}
|
||||||
|
{% else %}
|
||||||
|
{% lucide "upload" size=14 %} {{ upload_photo_label }}
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
{% if team_photo %}
|
||||||
|
<button class="btn btn-outline btn-error btn-sm gap-2" type="button" onclick="document.getElementById('team_photo_delete_modal').showModal()">{% lucide "trash-2" size=14 %} {% trans "Remove" %}</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if team_photo %}
|
||||||
|
<img class="mt-2 w-full max-w-md rounded-lg object-cover" src="{{ team_photo.image.url }}" alt="{{ team }}">
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm opacity-60">{% trans "No photo uploaded for this season yet." %}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if can_manage %}
|
{% if can_manage %}
|
||||||
{% trans "Add player" as add_player_label %}
|
{% trans "Add player" as add_player_label %}
|
||||||
{% url 'management:team_roster_add' team.pk selected_season.pk as add_player_url %}
|
{% url 'management:team_roster_add' team.pk selected_season.pk as add_player_url %}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-start pt-2 mt-2">
|
<div class="card-actions justify-start pt-2 mt-2">
|
||||||
<a class="btn btn-outline btn-neutral gap-2" href="{% if update_view %}{% url "management:team_detail" object.pk %}{% else %}{% url "management:team_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "management:team_detail" object.pk %}{% else %}{% url "management:team_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
||||||
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{% block actions %}
|
{% block actions %}
|
||||||
{% if is_club_admin %}
|
{% if is_club_admin %}
|
||||||
<a class="btn btn-primary gap-2" href="{% url 'management:team_create' %}">{% lucide "plus" size=16 %} {% trans "New team" %}</a>
|
<a class="btn btn-outline gap-2" href="{% url 'management:team_create' %}">{% lucide "plus" size=16 %} {% trans "New team" %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
{% if is_club_admin %}
|
{% if is_club_admin %}
|
||||||
<div class="flex justify-end gap-1">
|
<div class="flex justify-end gap-1">
|
||||||
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:team_detail' team.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
<a class="btn btn-outline btn-sm" href="{% url 'management:team_detail' team.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
||||||
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ team.pk|dom_id:"team_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
|
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ team.pk|dom_id:"team_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
1120
management/tests.py
1120
management/tests.py
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,8 @@ urlpatterns = [
|
|||||||
path("teams/<uuid:pk>/staff/<uuid:season_pk>/add/", views.TeamStaffAddView.as_view(), name="team_staff_add"),
|
path("teams/<uuid:pk>/staff/<uuid:season_pk>/add/", views.TeamStaffAddView.as_view(), name="team_staff_add"),
|
||||||
path("teams/<uuid:pk>/staff/<uuid:assignment_pk>/edit/", views.TeamStaffUpdateView.as_view(), name="team_staff_update"),
|
path("teams/<uuid:pk>/staff/<uuid:assignment_pk>/edit/", views.TeamStaffUpdateView.as_view(), name="team_staff_update"),
|
||||||
path("teams/<uuid:pk>/staff/<uuid:assignment_pk>/remove/", views.TeamStaffRemoveView.as_view(), name="team_staff_remove"),
|
path("teams/<uuid:pk>/staff/<uuid:assignment_pk>/remove/", views.TeamStaffRemoveView.as_view(), name="team_staff_remove"),
|
||||||
|
path("teams/<uuid:pk>/photo/<uuid:season_pk>/set/", views.TeamPhotoSetView.as_view(), name="team_photo_set"),
|
||||||
|
path("teams/<uuid:pk>/photo/<uuid:season_pk>/delete/", views.TeamPhotoDeleteView.as_view(), name="team_photo_delete"),
|
||||||
# News
|
# News
|
||||||
path("news/", views.NewsListView.as_view(), name="news_list"),
|
path("news/", views.NewsListView.as_view(), name="news_list"),
|
||||||
path("news/new/", views.NewsCreateView.as_view(), name="news_create"),
|
path("news/new/", views.NewsCreateView.as_view(), name="news_create"),
|
||||||
@@ -60,7 +62,20 @@ urlpatterns = [
|
|||||||
path("news/<uuid:pk>/photos/<uuid:photo_pk>/delete/", views.NewsPhotoDeleteView.as_view(), name="news_photo_delete"),
|
path("news/<uuid:pk>/photos/<uuid:photo_pk>/delete/", views.NewsPhotoDeleteView.as_view(), name="news_photo_delete"),
|
||||||
# Calendar
|
# Calendar
|
||||||
path("events/", views.EventListView.as_view(), name="event_list"),
|
path("events/", views.EventListView.as_view(), name="event_list"),
|
||||||
path("event-series/", views.EventSeriesListView.as_view(), name="event_series_list"),
|
path("events/new/", views.EventCreateView.as_view(), name="event_create"),
|
||||||
|
path("events/<uuid:pk>/", views.EventDetailView.as_view(), name="event_detail"),
|
||||||
|
path("events/<uuid:pk>/edit/", views.EventUpdateView.as_view(), name="event_update"),
|
||||||
|
path("events/<uuid:pk>/delete/", views.EventDeleteView.as_view(), name="event_delete"),
|
||||||
|
path("events/<uuid:pk>/detach/", views.EventDetachView.as_view(), name="event_detach"),
|
||||||
|
path("events/<uuid:pk>/fetch-game-info/", views.EventFetchGameInfoView.as_view(), name="event_fetch_game_info"),
|
||||||
|
path("events/rbihf-import/", views.RBIHFImportView.as_view(), name="rbihf_import"),
|
||||||
|
path("events/rbihf-import/confirm/", views.RBIHFImportConfirmView.as_view(), name="rbihf_import_confirm"),
|
||||||
|
path("event-series/new/", views.EventSeriesCreateView.as_view(), name="event_series_create"),
|
||||||
|
path("event-series/<uuid:pk>/", views.EventSeriesDetailView.as_view(), name="event_series_detail"),
|
||||||
|
path("event-series/<uuid:pk>/edit/", views.EventSeriesUpdateView.as_view(), name="event_series_update"),
|
||||||
|
path("event-series/<uuid:pk>/delete/", views.EventSeriesDeleteView.as_view(), name="event_series_delete"),
|
||||||
|
path("event-series/<uuid:pk>/stop/", views.EventSeriesStopView.as_view(), name="event_series_stop"),
|
||||||
|
path("event-series/<uuid:pk>/generate/", views.EventSeriesGenerateView.as_view(), name="event_series_generate"),
|
||||||
path("locations/", views.LocationListView.as_view(), name="location_list"),
|
path("locations/", views.LocationListView.as_view(), name="location_list"),
|
||||||
path("locations/new/", views.LocationCreateView.as_view(), name="location_create"),
|
path("locations/new/", views.LocationCreateView.as_view(), name="location_create"),
|
||||||
path("locations/<uuid:pk>/edit/", views.LocationUpdateView.as_view(), name="location_update"),
|
path("locations/<uuid:pk>/edit/", views.LocationUpdateView.as_view(), name="location_update"),
|
||||||
@@ -69,6 +84,11 @@ urlpatterns = [
|
|||||||
path("opponents/new/", views.OpponentCreateView.as_view(), name="opponent_create"),
|
path("opponents/new/", views.OpponentCreateView.as_view(), name="opponent_create"),
|
||||||
path("opponents/<uuid:pk>/edit/", views.OpponentUpdateView.as_view(), name="opponent_update"),
|
path("opponents/<uuid:pk>/edit/", views.OpponentUpdateView.as_view(), name="opponent_update"),
|
||||||
path("opponents/<uuid:pk>/delete/", views.OpponentDeleteView.as_view(), name="opponent_delete"),
|
path("opponents/<uuid:pk>/delete/", views.OpponentDeleteView.as_view(), name="opponent_delete"),
|
||||||
|
# Sponsors (admin only)
|
||||||
|
path("sponsors/", views.SponsorListView.as_view(), name="sponsor_list"),
|
||||||
|
path("sponsors/new/", views.SponsorCreateView.as_view(), name="sponsor_create"),
|
||||||
|
path("sponsors/<uuid:pk>/edit/", views.SponsorUpdateView.as_view(), name="sponsor_update"),
|
||||||
|
path("sponsors/<uuid:pk>/delete/", views.SponsorDeleteView.as_view(), name="sponsor_delete"),
|
||||||
# Shop (admin only)
|
# Shop (admin only)
|
||||||
path("shop/products/", views.ProductListView.as_view(), name="product_list"),
|
path("shop/products/", views.ProductListView.as_view(), name="product_list"),
|
||||||
path("shop/orders/", views.OrderListView.as_view(), name="order_list"),
|
path("shop/orders/", views.OrderListView.as_view(), name="order_list"),
|
||||||
|
|||||||
@@ -9,22 +9,36 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from django.utils.translation import ngettext
|
from django.utils.translation import ngettext
|
||||||
from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View
|
from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View
|
||||||
|
|
||||||
from club.mixins import ClubAdminRequiredMixin, ClubStaffRequiredMixin, ManagementPositionRequiredMixin, NewsAuthorRequiredMixin, NewsEditRequiredMixin, NewsPublisherRequiredMixin, TeamManagerRequiredMixin
|
from billing.models import RENEWAL_LEAD_DAYS, Due
|
||||||
from club.models import ClubMembership, ClubRole, Season
|
from club.mixins import (
|
||||||
|
ClubAdminRequiredMixin,
|
||||||
|
ClubStaffRequiredMixin,
|
||||||
|
EventManagerRequiredMixin,
|
||||||
|
FeatureRequiredMixin,
|
||||||
|
ManagementPositionRequiredMixin,
|
||||||
|
NewsAuthorRequiredMixin,
|
||||||
|
NewsEditRequiredMixin,
|
||||||
|
NewsPublisherRequiredMixin,
|
||||||
|
TeamManagerRequiredMixin,
|
||||||
|
)
|
||||||
|
from club.models import ClubMembership, ClubRole, Season, Sponsor
|
||||||
from club.services.access import can_edit_news, can_publish_news, current_season, is_club_admin, members_visible_to, teams_managed_by, teams_staffed_by
|
from club.services.access import can_edit_news, can_publish_news, current_season, is_club_admin, members_visible_to, teams_managed_by, teams_staffed_by
|
||||||
from club.services.fees import mark_as_paid, record_payment, remaining_balance
|
from club.services.fees import mark_as_paid, record_payment, remaining_balance
|
||||||
from controlpanel.messages import notify
|
from controlpanel.messages import notify
|
||||||
from controlpanel.mixins import RedirectOnInvalidMixin
|
from controlpanel.mixins import RedirectOnInvalidMixin
|
||||||
from controlpanel.services.statistics import club_attention, club_charts, club_statistics
|
from controlpanel.services.statistics import club_attention, club_charts, club_statistics
|
||||||
from events.models import Event, EventSeries, Location, Opponent
|
from events.models import Attendance, Event, EventSeries, Location, Opponent
|
||||||
from events.services.attendance import player_attendance_rankings, players_who_missed_recent_practices, team_attendance_rate, team_no_shows
|
from events.services.attendance import player_attendance_rankings, players_who_missed_recent_practices, team_attendance_rate, team_no_shows
|
||||||
|
from events.services.competitions import CompetitionFetchError, fetch_game_info
|
||||||
|
from events.services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, fetch_html
|
||||||
|
from events.services.recurrence import cancel_occurrence, detach_occurrence, generate_occurrences, propagate_series
|
||||||
from formbuilder.models import Form as FormBuilderForm
|
from formbuilder.models import Form as FormBuilderForm
|
||||||
from formbuilder.models import Submission
|
from formbuilder.models import Submission
|
||||||
from members.models import Family, FamilyMembership, Member
|
from members.models import Family, FamilyMembership, Member
|
||||||
from members.services.family import add_child_to_family, add_parent_to_family, attach_to_family, detach_from_family, grant_login, register_family
|
from members.services.family import add_child_to_family, add_parent_to_family, attach_to_family, detach_from_family, grant_login, register_family
|
||||||
from news.models import News, NewsPhoto
|
from news.models import News, NewsPhoto
|
||||||
from shop.models import Discount, Invoice, Order, Product
|
from shop.models import Discount, Invoice, Order, Product
|
||||||
from teams.models import Position, StaffAssignment, Team, TeamMembership
|
from teams.models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto
|
||||||
|
|
||||||
from .bulk_import import build_member_import_template, parse_member_import_rows, read_member_import_workbook
|
from .bulk_import import build_member_import_template, parse_member_import_rows, read_member_import_workbook
|
||||||
from .forms import (
|
from .forms import (
|
||||||
@@ -33,6 +47,8 @@ from .forms import (
|
|||||||
AttachToFamilyForm,
|
AttachToFamilyForm,
|
||||||
ClubMembershipForm,
|
ClubMembershipForm,
|
||||||
ClubRoleAssignForm,
|
ClubRoleAssignForm,
|
||||||
|
EventForm,
|
||||||
|
EventSeriesForm,
|
||||||
FamilyCreateForm,
|
FamilyCreateForm,
|
||||||
GrantLoginForm,
|
GrantLoginForm,
|
||||||
LocationForm,
|
LocationForm,
|
||||||
@@ -43,12 +59,16 @@ from .forms import (
|
|||||||
NewsPublishForm,
|
NewsPublishForm,
|
||||||
OpponentForm,
|
OpponentForm,
|
||||||
PositionForm,
|
PositionForm,
|
||||||
|
RBIHFImportForm,
|
||||||
RecordFeePaymentForm,
|
RecordFeePaymentForm,
|
||||||
|
SponsorForm,
|
||||||
StaffAssignmentForm,
|
StaffAssignmentForm,
|
||||||
TeamForm,
|
TeamForm,
|
||||||
TeamMembershipForm,
|
TeamMembershipForm,
|
||||||
|
TeamPhotoForm,
|
||||||
)
|
)
|
||||||
from .pdf import PDFExportError, membership_list_pdf
|
from .pdf import PDFExportError, membership_list_pdf
|
||||||
|
from .recurrence_ui import describe_rrule
|
||||||
|
|
||||||
|
|
||||||
class HomeView(ClubStaffRequiredMixin, TemplateView):
|
class HomeView(ClubStaffRequiredMixin, TemplateView):
|
||||||
@@ -56,18 +76,32 @@ class HomeView(ClubStaffRequiredMixin, TemplateView):
|
|||||||
club_attention/club_charts/club_statistics are the exact functions
|
club_attention/club_charts/club_statistics are the exact functions
|
||||||
controlpanel/club_detail.html uses for the platform admin's per-club drill-down --
|
controlpanel/club_detail.html uses for the platform admin's per-club drill-down --
|
||||||
already club-scoped, so directly reusable for this club's own staff. Published
|
already club-scoped, so directly reusable for this club's own staff. Published
|
||||||
news sits alongside upcoming events -- open to everyone here, same as events."""
|
news is open to everyone; upcoming events are scoped the same way the events
|
||||||
|
list is (see scoped_to_managed_teams) -- a manager shouldn't see another
|
||||||
|
team's practice show up here either."""
|
||||||
|
|
||||||
template_name = "management/home.html"
|
template_name = "management/home.html"
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
club = self.request.club
|
club, user = self.request.club, self.request.user
|
||||||
|
subscription = getattr(club, "subscription", None)
|
||||||
|
|
||||||
|
billing_ends_at = None
|
||||||
|
if subscription is not None:
|
||||||
|
latest_due = club.dues.exclude(status=Due.Status.CANCELLED).order_by("-period_end").first()
|
||||||
|
if latest_due is not None and 0 <= (latest_due.period_end - timezone.localdate()).days <= RENEWAL_LEAD_DAYS:
|
||||||
|
billing_ends_at = latest_due.period_end
|
||||||
|
|
||||||
|
upcoming_events = scoped_to_managed_teams(Event.objects.filter(club=club, start__gte=timezone.now()), user, club).order_by("start").prefetch_related("teams")[:5]
|
||||||
|
|
||||||
return super().get_context_data(
|
return super().get_context_data(
|
||||||
attention=club_attention(club),
|
attention=club_attention(club),
|
||||||
charts=club_charts(club),
|
charts=club_charts(club),
|
||||||
groups=club_statistics(club),
|
groups=club_statistics(club),
|
||||||
upcoming_events=Event.objects.filter(club=club, start__gte=timezone.now()).order_by("start")[:5],
|
upcoming_events=upcoming_events,
|
||||||
published_news=News.objects.filter(club=club, status=News.Status.PUBLISHED, published_at__lte=timezone.now()).order_by("-published_at")[:5],
|
published_news=News.objects.filter(club=club, status=News.Status.PUBLISHED, published_at__lte=timezone.now()).order_by("-published_at")[:5],
|
||||||
|
billing_ends_at=billing_ends_at,
|
||||||
|
billing_auto_renews=subscription.auto_renew if subscription else False,
|
||||||
today=timezone.localdate(),
|
today=timezone.localdate(),
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
@@ -774,6 +808,7 @@ class TeamDetailView(ClubStaffRequiredMixin, DetailView):
|
|||||||
top_attenders, bottom_attenders = [], []
|
top_attenders, bottom_attenders = [], []
|
||||||
missed_practices = Member.objects.none()
|
missed_practices = Member.objects.none()
|
||||||
no_shows = []
|
no_shows = []
|
||||||
|
team_photo = TeamPhoto.objects.filter(team=team, season=season).first() if season is not None else None
|
||||||
if season is not None:
|
if season is not None:
|
||||||
roster = list(TeamMembership.objects.filter(team=team, season=season).select_related("member", "position").order_by("position__ordering", "member__last_name"))
|
roster = list(TeamMembership.objects.filter(team=team, season=season).select_related("member", "position").order_by("position__ordering", "member__last_name"))
|
||||||
staff = list(StaffAssignment.objects.filter(team=team, season=season).select_related("member", "position").order_by("position__ordering", "member__last_name"))
|
staff = list(StaffAssignment.objects.filter(team=team, season=season).select_related("member", "position").order_by("position__ordering", "member__last_name"))
|
||||||
@@ -798,6 +833,8 @@ class TeamDetailView(ClubStaffRequiredMixin, DetailView):
|
|||||||
can_manage=can_manage,
|
can_manage=can_manage,
|
||||||
roster_form=TeamMembershipForm(club=club, team=team, season=season) if can_manage and season else None,
|
roster_form=TeamMembershipForm(club=club, team=team, season=season) if can_manage and season else None,
|
||||||
staff_form=StaffAssignmentForm(club=club, team=team, season=season) if can_manage and season else None,
|
staff_form=StaffAssignmentForm(club=club, team=team, season=season) if can_manage and season else None,
|
||||||
|
team_photo=team_photo,
|
||||||
|
team_photo_form=TeamPhotoForm(instance=team_photo) if can_manage and season else None,
|
||||||
attendance_rate=attendance_rate,
|
attendance_rate=attendance_rate,
|
||||||
top_attenders=top_attenders,
|
top_attenders=top_attenders,
|
||||||
bottom_attenders=bottom_attenders,
|
bottom_attenders=bottom_attenders,
|
||||||
@@ -983,6 +1020,56 @@ class TeamStaffRemoveView(TeamManagerRequiredMixin, View):
|
|||||||
return redirect(f"{reverse('management:team_detail', args=[pk])}?season={season_id}")
|
return redirect(f"{reverse('management:team_detail', args=[pk])}?season={season_id}")
|
||||||
|
|
||||||
|
|
||||||
|
class TeamPhotoSetView(TeamManagerRequiredMixin, FormView):
|
||||||
|
"""Reachable only via the "Upload"/"Replace" modal on the team page. Binds
|
||||||
|
to the existing TeamPhoto for this team+season (if any) so re-uploading
|
||||||
|
replaces it in place -- same "create or update the one row for this
|
||||||
|
scope" pattern as controlpanel.views.ClubHomeLocationSetView. Not
|
||||||
|
RedirectOnInvalidMixin, same reasoning as TeamRosterAddView: that can't
|
||||||
|
carry ?season= through a plain redirect(view_name, **kwargs)."""
|
||||||
|
|
||||||
|
form_class = TeamPhotoForm
|
||||||
|
http_method_names = ["post"]
|
||||||
|
|
||||||
|
def get_team(self):
|
||||||
|
return get_object_or_404(Team.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
|
||||||
|
|
||||||
|
def get_season(self):
|
||||||
|
return get_object_or_404(Season.objects.filter(club=self.request.club), pk=self.kwargs["season_pk"])
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
return super().get_form_kwargs() | {"instance": TeamPhoto.objects.filter(team=self.get_team(), season=self.get_season()).first()}
|
||||||
|
|
||||||
|
def team_detail_url(self):
|
||||||
|
return f"{reverse('management:team_detail', args=[self.kwargs['pk']])}?season={self.kwargs['season_pk']}"
|
||||||
|
|
||||||
|
def form_invalid(self, form):
|
||||||
|
for error in form.errors.values():
|
||||||
|
notify(self.request, f"e|{_('Could not upload photo')}|{' '.join(error)}")
|
||||||
|
return redirect(self.team_detail_url())
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
photo = form.save(commit=False)
|
||||||
|
photo.team = self.get_team()
|
||||||
|
photo.season = self.get_season()
|
||||||
|
photo.save()
|
||||||
|
|
||||||
|
notify(self.request, f"s|{_('Photo uploaded')}|{_('Team photo updated.')}")
|
||||||
|
return redirect(self.team_detail_url())
|
||||||
|
|
||||||
|
|
||||||
|
class TeamPhotoDeleteView(TeamManagerRequiredMixin, View):
|
||||||
|
def get_team(self):
|
||||||
|
return get_object_or_404(Team.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
|
||||||
|
|
||||||
|
def post(self, request, pk, season_pk):
|
||||||
|
team = self.get_team()
|
||||||
|
TeamPhoto.objects.filter(team=team, season_id=season_pk).delete()
|
||||||
|
|
||||||
|
notify(request, f"w|{_('Photo removed')}|{_('Team photo removed.')}")
|
||||||
|
return redirect(f"{reverse('management:team_detail', args=[pk])}?season={season_pk}")
|
||||||
|
|
||||||
|
|
||||||
# --- Club roles (full tier: assign / revoke, no update -- a role isn't edited, just
|
# --- Club roles (full tier: assign / revoke, no update -- a role isn't edited, just
|
||||||
# granted or taken away) -------------------------------------------------------------
|
# granted or taken away) -------------------------------------------------------------
|
||||||
|
|
||||||
@@ -1411,19 +1498,440 @@ class NewsPhotoDeleteView(NewsEditRequiredMixin, View):
|
|||||||
return redirect("management:news_detail", pk=news_item.pk)
|
return redirect("management:news_detail", pk=news_item.pk)
|
||||||
|
|
||||||
|
|
||||||
class EventListView(ClubStaffRequiredMixin, StubListMixin, ListView):
|
def scoped_to_managed_teams(queryset, user, club):
|
||||||
page_title = _("Events")
|
"""Non-admin: only rows for a team they manage, plus team-less/club-wide
|
||||||
|
ones (a social, an AGM) -- there's no team to scope those to, so they stay
|
||||||
|
visible to everyone. Same "manages" rule as who can edit (teams_managed_by),
|
||||||
|
not the broader "staffed on any role" one. Works for any queryset whose
|
||||||
|
model has a `teams` M2M -- Event and EventSeries both do -- so it backs the
|
||||||
|
events list, the dashboard's upcoming-events widget, and both detail views
|
||||||
|
(an out-of-scope one 404s if opened directly, same as any other
|
||||||
|
queryset-scoped detail view here, not just unlisted)."""
|
||||||
|
if is_club_admin(user, club):
|
||||||
|
return queryset
|
||||||
|
managed_team_ids = teams_managed_by(user, club).values_list("pk", flat=True)
|
||||||
|
return queryset.filter(Q(teams__in=managed_team_ids) | Q(teams__isnull=True)).distinct()
|
||||||
|
|
||||||
|
|
||||||
|
class EventListView(ClubStaffRequiredMixin, ListView):
|
||||||
|
"""Upcoming by default (what a coach actually opens this page to check);
|
||||||
|
``?show_past=1`` flips to the most recent past events instead. Season
|
||||||
|
filtering mirrors Event.season's own "explicit, else derived from start
|
||||||
|
date" rule (events/models.py) rather than requiring a stored season on
|
||||||
|
every row."""
|
||||||
|
|
||||||
|
template_name = "management/event_list.html"
|
||||||
|
context_object_name = "events"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
club = self.request.club
|
||||||
|
user = self.request.user
|
||||||
|
events = Event.objects.filter(club=club).select_related("location", "opponent").prefetch_related("teams")
|
||||||
|
|
||||||
|
season = selected_season_from_request(self.request, club)
|
||||||
|
if season is not None:
|
||||||
|
events = events.filter(Q(season=season) | Q(season__isnull=True, start__date__gte=season.start_date, start__date__lte=season.end_date))
|
||||||
|
|
||||||
|
kind = self.request.GET.get("kind", "")
|
||||||
|
if kind:
|
||||||
|
events = events.filter(kind=kind)
|
||||||
|
|
||||||
|
events = scoped_to_managed_teams(events, user, club)
|
||||||
|
|
||||||
|
is_admin = is_club_admin(user, club)
|
||||||
|
managed_team_ids = set() if is_admin else set(teams_managed_by(user, club).values_list("pk", flat=True))
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
if self.request.GET.get("show_past") == "1":
|
||||||
|
events = list(events.filter(start__lt=now).order_by("-start"))
|
||||||
|
else:
|
||||||
|
events = list(events.filter(start__gte=now).order_by("start"))
|
||||||
|
|
||||||
|
# Attached per row so the template can show/hide Edit/Delete per event --
|
||||||
|
# computed once here rather than a query per row (teams is already
|
||||||
|
# prefetched above).
|
||||||
|
for event in events:
|
||||||
|
event.can_manage = is_admin or any(team.pk in managed_team_ids for team in event.teams.all())
|
||||||
|
return events
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
club, user = self.request.club, self.request.user
|
||||||
|
return super().get_context_data(
|
||||||
|
seasons=Season.objects.filter(club=club).order_by("-start_date"),
|
||||||
|
selected_season=selected_season_from_request(self.request, club),
|
||||||
|
selected_kind=self.request.GET.get("kind", ""),
|
||||||
|
show_past=self.request.GET.get("show_past") == "1",
|
||||||
|
event_kinds=Event.EventKind.choices,
|
||||||
|
can_create=is_club_admin(user, club) or teams_managed_by(user, club).exists(),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EventDetailView(ClubStaffRequiredMixin, DetailView):
|
||||||
|
template_name = "management/event_detail.html"
|
||||||
|
context_object_name = "event"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
events = Event.objects.filter(club=self.request.club).select_related("series", "location", "opponent", "season").prefetch_related("teams", "invited_members", "excluded_members")
|
||||||
|
return scoped_to_managed_teams(events, self.request.user, self.request.club)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
club, user, event = self.request.club, self.request.user, self.object
|
||||||
|
can_manage = is_club_admin(user, club) or teams_managed_by(user, club).filter(pk__in=event.teams.values_list("pk", flat=True)).exists()
|
||||||
|
|
||||||
|
rows_by_status = {}
|
||||||
|
for row in event.attendances.select_related("member").order_by("member__last_name", "member__first_name"):
|
||||||
|
rows_by_status.setdefault(row.status, []).append(row)
|
||||||
|
|
||||||
|
# Every status, in its declared order -- not just the ones that happen to have
|
||||||
|
# a response yet, or "0 people excused" silently disappears instead of reading
|
||||||
|
# as good news. Same grouping backs both the breakdown counts and the modal's
|
||||||
|
# per-status sections, so there's exactly one query, not two.
|
||||||
|
attendance_groups = [{"value": value, "label": label, "rows": rows_by_status.get(value, [])} for value, label in Attendance.AttendanceStatus.choices]
|
||||||
|
|
||||||
|
return super().get_context_data(
|
||||||
|
can_manage=can_manage,
|
||||||
|
attendance_groups=attendance_groups,
|
||||||
|
has_attendance_rows=any(group["rows"] for group in attendance_groups),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EventCreateView(ClubStaffRequiredMixin, CreateView):
|
||||||
|
"""Broader than EventManagerRequiredMixin's own gate (no object yet to check
|
||||||
|
teams against): anyone managing at least one team, or an admin. EventForm
|
||||||
|
itself then restricts *which* teams a non-admin can pick and requires at
|
||||||
|
least one, so a team-less/club-wide event stays admin-only."""
|
||||||
|
|
||||||
|
model = Event
|
||||||
|
form_class = EventForm
|
||||||
|
template_name = "management/event_form.html"
|
||||||
|
|
||||||
|
def test_func(self):
|
||||||
|
user, club = self.request.user, self.request.club
|
||||||
|
return is_club_admin(user, club) or teams_managed_by(user, club).exists()
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
# Event.clean() rejects a location/opponent from another club by comparing
|
||||||
|
# against self.club_id -- on a brand-new instance that's still None until
|
||||||
|
# ClubScopedModel.save() auto-assigns it, which only happens *after*
|
||||||
|
# validation. Set it here so full_clean() sees the real club, not None.
|
||||||
|
return super().get_form_kwargs() | {"club": self.request.club, "user": self.request.user, "instance": Event(club=self.request.club)}
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
response = super().form_valid(form)
|
||||||
|
body = _("“%(event)s” created.") % {"event": self.object}
|
||||||
|
notify(self.request, f"s|{_('Event created')}|{body}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse("management:event_detail", args=[self.object.pk])
|
||||||
|
|
||||||
|
|
||||||
|
class EventUpdateView(EventManagerRequiredMixin, UpdateView):
|
||||||
|
model = Event
|
||||||
|
form_class = EventForm
|
||||||
|
template_name = "management/event_form.html"
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Event.objects.filter(club=self.request.club)
|
return Event.objects.filter(club=self.request.club)
|
||||||
|
|
||||||
|
def get_teams(self):
|
||||||
|
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"]).teams.all()
|
||||||
|
|
||||||
class EventSeriesListView(ClubStaffRequiredMixin, StubListMixin, ListView):
|
def get_form_kwargs(self):
|
||||||
page_title = _("Event series")
|
return super().get_form_kwargs() | {"club": self.request.club, "user": self.request.user, "editing": True}
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
response = super().form_valid(form)
|
||||||
|
body = _("“%(event)s” updated.") % {"event": self.object}
|
||||||
|
notify(self.request, f"s|{_('Event updated')}|{body}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse("management:event_detail", args=[self.object.pk])
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
return super().get_context_data(update_view=True, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class EventDeleteView(EventManagerRequiredMixin, View):
|
||||||
|
"""A series occurrence is cancelled (keeps the series' excluded_dates in
|
||||||
|
sync -- see cancel_occurrence), never just deleted outright; a one-off
|
||||||
|
event is deleted outright. One button, the branch is internal."""
|
||||||
|
|
||||||
|
def get_event(self):
|
||||||
|
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
|
||||||
|
|
||||||
|
def get_teams(self):
|
||||||
|
return self.get_event().teams.all()
|
||||||
|
|
||||||
|
def post(self, request, pk):
|
||||||
|
event = self.get_event()
|
||||||
|
title = str(event)
|
||||||
|
if event.series_id:
|
||||||
|
cancel_occurrence(event, hard_delete=request.POST.get("keep_record") != "on")
|
||||||
|
notify(request, f"w|{_('Occurrence cancelled')}|{_('“%(event)s” was cancelled.') % {'event': title}}")
|
||||||
|
else:
|
||||||
|
event.delete()
|
||||||
|
notify(request, f"w|{_('Event deleted')}|{_('“%(event)s” has been deleted.') % {'event': title}}")
|
||||||
|
return redirect("management:event_list")
|
||||||
|
|
||||||
|
|
||||||
|
class EventDetachView(EventManagerRequiredMixin, View):
|
||||||
|
"""Stop this occurrence from being touched by future series-wide edits --
|
||||||
|
see detach_occurrence. Editing a still-attached occurrence directly would
|
||||||
|
otherwise be silently overwritten by the next propagate_series() call."""
|
||||||
|
|
||||||
|
def get_event(self):
|
||||||
|
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
|
||||||
|
|
||||||
|
def get_teams(self):
|
||||||
|
return self.get_event().teams.all()
|
||||||
|
|
||||||
|
def post(self, request, pk):
|
||||||
|
event = self.get_event()
|
||||||
|
detach_occurrence(event)
|
||||||
|
notify(request, f"s|{_('Detached from series')}|{_('“%(event)s” is now edited independently and will not be touched by future series-wide changes.') % {'event': event}}")
|
||||||
|
return redirect("management:event_detail", pk=event.pk)
|
||||||
|
|
||||||
|
|
||||||
|
class EventFetchGameInfoView(EventManagerRequiredMixin, View):
|
||||||
|
"""Refresh a game's score/status from its competition -- see
|
||||||
|
events.services.competitions.fetch_game_info, which gates on the
|
||||||
|
competition's feature flag being active for this club and otherwise
|
||||||
|
no-ops. No data source is wired up yet either way, so an actual fetch
|
||||||
|
attempt always reports the same honest "not configured" error; the
|
||||||
|
button/view exist so a real integration only has to replace that function."""
|
||||||
|
|
||||||
|
def get_event(self):
|
||||||
|
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
|
||||||
|
|
||||||
|
def get_teams(self):
|
||||||
|
return self.get_event().teams.all()
|
||||||
|
|
||||||
|
def post(self, request, pk):
|
||||||
|
event = self.get_event()
|
||||||
|
try:
|
||||||
|
if fetch_game_info(event):
|
||||||
|
notify(request, f"s|{_('Game info updated')}|{_('“%(event)s” was refreshed from its competition.') % {'event': event}}")
|
||||||
|
else:
|
||||||
|
notify(request, f"i|{_('Nothing to fetch')}|{_('“%(competition)s” is not enabled for this club.') % {'competition': event.competition}}")
|
||||||
|
except CompetitionFetchError as error:
|
||||||
|
notify(request, f"e|{_('Could not fetch game info')}|{error}")
|
||||||
|
return redirect("management:event_detail", pk=event.pk)
|
||||||
|
|
||||||
|
|
||||||
|
class RBIHFImportView(FeatureRequiredMixin, View):
|
||||||
|
"""Step 1: paste an RBIHF team page URL, pick which of the club's teams it's
|
||||||
|
for. Fetches and parses the page server-side, stashes the raw HTML (not
|
||||||
|
client-trusted parsed data) in the session, and renders a create/update/
|
||||||
|
delete preview -- see events.services.rbihf_import and
|
||||||
|
RBIHFImportConfirmView, which mirrors MemberImportView/
|
||||||
|
MemberImportConfirmView's session-stash-and-reparse shape."""
|
||||||
|
|
||||||
|
feature_flag = "RBIHF"
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
return render(request, "management/rbihf_import_form.html", {"form": RBIHFImportForm(club=request.club)})
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
form = RBIHFImportForm(request.POST, club=request.club)
|
||||||
|
if not form.is_valid():
|
||||||
|
return render(request, "management/rbihf_import_form.html", {"form": form})
|
||||||
|
|
||||||
|
url = form.cleaned_data["url"]
|
||||||
|
team = form.cleaned_data["team"]
|
||||||
|
|
||||||
|
rbihf_team_id = extract_team_id(url)
|
||||||
|
try:
|
||||||
|
html = fetch_html(url)
|
||||||
|
plan = build_plan(request.club, team, rbihf_team_id, html)
|
||||||
|
except RBIHFImportError as error:
|
||||||
|
form.add_error("url", str(error))
|
||||||
|
return render(request, "management/rbihf_import_form.html", {"form": form})
|
||||||
|
|
||||||
|
request.session["rbihf_import_html"] = html
|
||||||
|
request.session["rbihf_import_team_id"] = str(team.pk)
|
||||||
|
request.session["rbihf_import_rbihf_team_id"] = rbihf_team_id
|
||||||
|
return render(request, "management/rbihf_import_preview.html", {"plan": plan})
|
||||||
|
|
||||||
|
|
||||||
|
class RBIHFImportConfirmView(FeatureRequiredMixin, View):
|
||||||
|
"""Step 2: re-parses and re-diffs the HTML stashed by RBIHFImportView
|
||||||
|
against the *current* DB state (catching anything that changed since the
|
||||||
|
preview was shown), reads each row's chosen location/opponent back from
|
||||||
|
the preview form, and applies the result in one transaction."""
|
||||||
|
|
||||||
|
feature_flag = "RBIHF"
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
html = request.session.pop("rbihf_import_html", None)
|
||||||
|
team_id = request.session.pop("rbihf_import_team_id", None)
|
||||||
|
rbihf_team_id = request.session.pop("rbihf_import_rbihf_team_id", None)
|
||||||
|
if not html or not team_id or not rbihf_team_id:
|
||||||
|
notify(request, f"w|{_('Nothing to import')}|{_('Start over by pasting the RBIHF team URL again.')}")
|
||||||
|
return redirect("management:rbihf_import")
|
||||||
|
|
||||||
|
team = get_object_or_404(Team.objects.filter(club=request.club), pk=team_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
plan = build_plan(request.club, team, rbihf_team_id, html)
|
||||||
|
except RBIHFImportError:
|
||||||
|
notify(request, f"e|{_('Could not import')}|{_('Something went wrong re-reading the fetched page. Try again.')}")
|
||||||
|
return redirect("management:rbihf_import")
|
||||||
|
|
||||||
|
locations_by_game_id = {}
|
||||||
|
opponents_by_game_id = {}
|
||||||
|
for planned in [*plan.to_create, *plan.to_update]:
|
||||||
|
game_id = planned.fixture.external_game_id
|
||||||
|
locations_by_game_id[game_id] = request.POST.get(f"location_{game_id}", "")
|
||||||
|
opponents_by_game_id[game_id] = request.POST.get(f"opponent_{game_id}", "")
|
||||||
|
|
||||||
|
result = apply_plan(plan, locations_by_game_id, opponents_by_game_id)
|
||||||
|
|
||||||
|
body = _("%(created)s created, %(updated)s updated, %(deleted)s deleted.") % result
|
||||||
|
notify(request, f"s|{_('Fixtures imported')}|{body}")
|
||||||
|
return redirect("management:event_list")
|
||||||
|
|
||||||
|
|
||||||
|
class EventSeriesDetailView(ClubStaffRequiredMixin, DetailView):
|
||||||
|
template_name = "management/event_series_detail.html"
|
||||||
|
context_object_name = "series"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
series = EventSeries.objects.filter(club=self.request.club).select_related("location", "opponent").prefetch_related("teams", "invited_members", "excluded_members")
|
||||||
|
return scoped_to_managed_teams(series, self.request.user, self.request.club)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
club, user, series = self.request.club, self.request.user, self.object
|
||||||
|
can_manage = is_club_admin(user, club) or teams_managed_by(user, club).filter(pk__in=series.teams.values_list("pk", flat=True)).exists()
|
||||||
|
now = timezone.now()
|
||||||
|
occurrences = list(series.occurrences.order_by("start"))
|
||||||
|
for occurrence in occurrences:
|
||||||
|
occurrence.is_past = occurrence.start < now
|
||||||
|
return super().get_context_data(
|
||||||
|
can_manage=can_manage,
|
||||||
|
recurrence_summary=describe_rrule(series.rrule),
|
||||||
|
occurrences=occurrences,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EventSeriesCreateView(ClubStaffRequiredMixin, CreateView):
|
||||||
|
model = EventSeries
|
||||||
|
form_class = EventSeriesForm
|
||||||
|
template_name = "management/event_series_form.html"
|
||||||
|
|
||||||
|
def test_func(self):
|
||||||
|
user, club = self.request.user, self.request.club
|
||||||
|
return is_club_admin(user, club) or teams_managed_by(user, club).exists()
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
# Same reasoning as EventCreateView: EventSeries.clean() needs a real
|
||||||
|
# club_id on the instance before full_clean() runs, not the None a
|
||||||
|
# brand-new instance starts with.
|
||||||
|
return super().get_form_kwargs() | {"club": self.request.club, "user": self.request.user, "instance": EventSeries(club=self.request.club)}
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
response = super().form_valid(form)
|
||||||
|
# Not automatic on save -- without this the series would exist with zero
|
||||||
|
# occurrences until the extend_event_series cron command next runs.
|
||||||
|
created = generate_occurrences(self.object)
|
||||||
|
body = ngettext("“%(series)s” created, with %(count)d occurrence scheduled.", "“%(series)s” created, with %(count)d occurrences scheduled.", len(created)) % {"series": self.object, "count": len(created)}
|
||||||
|
notify(self.request, f"s|{_('Series created')}|{body}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse("management:event_series_detail", args=[self.object.pk])
|
||||||
|
|
||||||
|
|
||||||
|
class EventSeriesUpdateView(EventManagerRequiredMixin, UpdateView):
|
||||||
|
model = EventSeries
|
||||||
|
form_class = EventSeriesForm
|
||||||
|
template_name = "management/event_series_form.html"
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return EventSeries.objects.filter(club=self.request.club)
|
return EventSeries.objects.filter(club=self.request.club)
|
||||||
|
|
||||||
|
def get_teams(self):
|
||||||
|
return get_object_or_404(EventSeries.objects.filter(club=self.request.club), pk=self.kwargs["pk"]).teams.all()
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
return super().get_form_kwargs() | {"club": self.request.club, "user": self.request.user}
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
response = super().form_valid(form)
|
||||||
|
# Push the template change to future, non-detached occurrences, then fill
|
||||||
|
# in any further-out dates the (possibly changed) pattern now implies.
|
||||||
|
# Occurrences that no longer match a changed pattern are NOT auto-removed
|
||||||
|
# -- reconciling that is ambiguous (which to drop vs. keep attendance
|
||||||
|
# history for) and is left as a manual "Cancel" per stale occurrence.
|
||||||
|
propagate_series(self.object)
|
||||||
|
generate_occurrences(self.object)
|
||||||
|
body = _("“%(series)s” updated.") % {"series": self.object}
|
||||||
|
notify(self.request, f"s|{_('Series updated')}|{body}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse("management:event_series_detail", args=[self.object.pk])
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
return super().get_context_data(update_view=True, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class EventSeriesDeleteView(EventManagerRequiredMixin, View):
|
||||||
|
"""series is on_delete=CASCADE -- this also deletes every occurrence and its
|
||||||
|
attendance history. The confirm modal must say so; "Stop repeating"
|
||||||
|
(EventSeriesStopView) is the non-destructive alternative."""
|
||||||
|
|
||||||
|
def get_series(self):
|
||||||
|
return get_object_or_404(EventSeries.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
|
||||||
|
|
||||||
|
def get_teams(self):
|
||||||
|
return self.get_series().teams.all()
|
||||||
|
|
||||||
|
def post(self, request, pk):
|
||||||
|
series = self.get_series()
|
||||||
|
title = str(series)
|
||||||
|
series.delete()
|
||||||
|
notify(request, f"w|{_('Series deleted')}|{_('“%(series)s” and all of its occurrences have been deleted.') % {'series': title}}")
|
||||||
|
return redirect("management:event_list")
|
||||||
|
|
||||||
|
|
||||||
|
class EventSeriesStopView(EventManagerRequiredMixin, View):
|
||||||
|
"""Stop future generation without touching any existing occurrence or its
|
||||||
|
attendance history -- the non-destructive alternative to deleting the
|
||||||
|
series outright."""
|
||||||
|
|
||||||
|
def get_series(self):
|
||||||
|
return get_object_or_404(EventSeries.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
|
||||||
|
|
||||||
|
def get_teams(self):
|
||||||
|
return self.get_series().teams.all()
|
||||||
|
|
||||||
|
def post(self, request, pk):
|
||||||
|
series = self.get_series()
|
||||||
|
series.until = timezone.now()
|
||||||
|
series.save(update_fields=["until"])
|
||||||
|
notify(request, f"s|{_('Series stopped')}|{_('“%(series)s” will no longer generate new occurrences. Existing ones are untouched.') % {'series': series}}")
|
||||||
|
return redirect("management:event_series_detail", pk=series.pk)
|
||||||
|
|
||||||
|
|
||||||
|
class EventSeriesGenerateView(EventManagerRequiredMixin, View):
|
||||||
|
def get_series(self):
|
||||||
|
return get_object_or_404(EventSeries.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
|
||||||
|
|
||||||
|
def get_teams(self):
|
||||||
|
return self.get_series().teams.all()
|
||||||
|
|
||||||
|
def post(self, request, pk):
|
||||||
|
series = self.get_series()
|
||||||
|
created = generate_occurrences(series)
|
||||||
|
body = ngettext("%(count)d new occurrence generated.", "%(count)d new occurrences generated.", len(created)) % {"count": len(created)}
|
||||||
|
notify(request, f"s|{_('Occurrences generated')}|{body}")
|
||||||
|
return redirect("management:event_series_detail", pk=series.pk)
|
||||||
|
|
||||||
|
|
||||||
class LocationListView(ManagementPositionRequiredMixin, ListView):
|
class LocationListView(ManagementPositionRequiredMixin, ListView):
|
||||||
template_name = "management/location_list.html"
|
template_name = "management/location_list.html"
|
||||||
@@ -1537,42 +2045,107 @@ class OpponentDeleteView(ManagementPositionRequiredMixin, View):
|
|||||||
return redirect("management:opponent_list")
|
return redirect("management:opponent_list")
|
||||||
|
|
||||||
|
|
||||||
class ProductListView(ClubAdminRequiredMixin, StubListMixin, ListView):
|
class SponsorListView(ClubAdminRequiredMixin, ListView):
|
||||||
|
"""Sponsors are a business/revenue relationship, same bucket as
|
||||||
|
memberships/roles/shop -- admin-only, unlike Location/Opponent which any
|
||||||
|
management position can maintain."""
|
||||||
|
|
||||||
|
template_name = "management/sponsor_list.html"
|
||||||
|
context_object_name = "sponsors"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Sponsor.objects.filter(club=self.request.club)
|
||||||
|
|
||||||
|
|
||||||
|
class SponsorCreateView(ClubAdminRequiredMixin, CreateView):
|
||||||
|
model = Sponsor
|
||||||
|
form_class = SponsorForm
|
||||||
|
template_name = "management/sponsor_form.html"
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
response = super().form_valid(form)
|
||||||
|
body = _("“%(sponsor)s” created.") % {"sponsor": self.object}
|
||||||
|
notify(self.request, f"s|{_('Sponsor created')}|{body}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse("management:sponsor_list")
|
||||||
|
|
||||||
|
|
||||||
|
class SponsorUpdateView(ClubAdminRequiredMixin, UpdateView):
|
||||||
|
model = Sponsor
|
||||||
|
form_class = SponsorForm
|
||||||
|
template_name = "management/sponsor_form.html"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Sponsor.objects.filter(club=self.request.club)
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
response = super().form_valid(form)
|
||||||
|
body = _("“%(sponsor)s” updated.") % {"sponsor": self.object}
|
||||||
|
notify(self.request, f"s|{_('Sponsor updated')}|{body}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse("management:sponsor_list")
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
return super().get_context_data(update_view=True, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class SponsorDeleteView(ClubAdminRequiredMixin, View):
|
||||||
|
def post(self, request, pk):
|
||||||
|
sponsor = get_object_or_404(Sponsor.objects.filter(club=request.club), pk=pk)
|
||||||
|
name = str(sponsor)
|
||||||
|
sponsor.delete()
|
||||||
|
|
||||||
|
body = _("“%(sponsor)s” has been deleted.") % {"sponsor": name}
|
||||||
|
notify(request, f"w|{_('Sponsor deleted')}|{body}")
|
||||||
|
return redirect("management:sponsor_list")
|
||||||
|
|
||||||
|
|
||||||
|
class ProductListView(FeatureRequiredMixin, StubListMixin, ListView):
|
||||||
|
feature_flag = "shop"
|
||||||
page_title = _("Products")
|
page_title = _("Products")
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Product.objects.filter(club=self.request.club)
|
return Product.objects.filter(club=self.request.club)
|
||||||
|
|
||||||
|
|
||||||
class OrderListView(ClubAdminRequiredMixin, StubListMixin, ListView):
|
class OrderListView(FeatureRequiredMixin, StubListMixin, ListView):
|
||||||
|
feature_flag = "shop"
|
||||||
page_title = _("Orders")
|
page_title = _("Orders")
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Order.objects.filter(club=self.request.club)
|
return Order.objects.filter(club=self.request.club)
|
||||||
|
|
||||||
|
|
||||||
class DiscountListView(ClubAdminRequiredMixin, StubListMixin, ListView):
|
class DiscountListView(FeatureRequiredMixin, StubListMixin, ListView):
|
||||||
|
feature_flag = "shop"
|
||||||
page_title = _("Discounts")
|
page_title = _("Discounts")
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Discount.objects.filter(club=self.request.club)
|
return Discount.objects.filter(club=self.request.club)
|
||||||
|
|
||||||
|
|
||||||
class InvoiceListView(ClubAdminRequiredMixin, StubListMixin, ListView):
|
class InvoiceListView(FeatureRequiredMixin, StubListMixin, ListView):
|
||||||
|
feature_flag = "shop"
|
||||||
page_title = _("Invoices")
|
page_title = _("Invoices")
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Invoice.objects.filter(club=self.request.club)
|
return Invoice.objects.filter(club=self.request.club)
|
||||||
|
|
||||||
|
|
||||||
class FormListView(ClubAdminRequiredMixin, StubListMixin, ListView):
|
class FormListView(FeatureRequiredMixin, StubListMixin, ListView):
|
||||||
|
feature_flag = "formbuilder"
|
||||||
page_title = _("Forms")
|
page_title = _("Forms")
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return FormBuilderForm.objects.filter(club=self.request.club)
|
return FormBuilderForm.objects.filter(club=self.request.club)
|
||||||
|
|
||||||
|
|
||||||
class SubmissionListView(ClubAdminRequiredMixin, StubListMixin, ListView):
|
class SubmissionListView(FeatureRequiredMixin, StubListMixin, ListView):
|
||||||
|
feature_flag = "formbuilder"
|
||||||
page_title = _("Submissions")
|
page_title = _("Submissions")
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
|||||||
83
news/api.py
Normal file
83
news/api.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""Public read-only news endpoint -- see api/urls.py for how this is mounted.
|
||||||
|
|
||||||
|
Pagination is hand-rolled (limit/offset params) rather than Ninja's built-in
|
||||||
|
@paginate: a photo's URL has to be made absolute against `request`
|
||||||
|
(api/urls.py's docstring explains why), and Ninja's response-schema resolvers
|
||||||
|
don't have request access, so building the page manually here is simpler than
|
||||||
|
fighting that.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
from ninja import Router, Schema
|
||||||
|
|
||||||
|
from api.errors import require_club
|
||||||
|
|
||||||
|
from .models import News
|
||||||
|
|
||||||
|
router = Router(tags=["news"])
|
||||||
|
|
||||||
|
DEFAULT_LIMIT = 20
|
||||||
|
MAX_LIMIT = 100
|
||||||
|
|
||||||
|
|
||||||
|
class NewsPhotoOut(Schema):
|
||||||
|
url: str
|
||||||
|
is_main: bool
|
||||||
|
ordering: int
|
||||||
|
|
||||||
|
|
||||||
|
class NewsItemOut(Schema):
|
||||||
|
id: uuid.UUID
|
||||||
|
title: str
|
||||||
|
slug: str
|
||||||
|
body: str
|
||||||
|
published_at: datetime
|
||||||
|
teams: list[str]
|
||||||
|
photos: list[NewsPhotoOut]
|
||||||
|
|
||||||
|
|
||||||
|
class NewsListOut(Schema):
|
||||||
|
count: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
results: list[NewsItemOut]
|
||||||
|
|
||||||
|
|
||||||
|
def _to_news_item_out(item, request) -> NewsItemOut:
|
||||||
|
return NewsItemOut(
|
||||||
|
id=item.pk,
|
||||||
|
title=item.title,
|
||||||
|
slug=item.slug,
|
||||||
|
body=item.body,
|
||||||
|
published_at=item.published_at,
|
||||||
|
teams=[team.name for team in item.teams.all()],
|
||||||
|
photos=[NewsPhotoOut(url=request.build_absolute_uri(photo.image.url), is_main=photo.is_main, ordering=photo.ordering) for photo in item.photos.all()],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response=NewsListOut, summary="Published news")
|
||||||
|
def list_news(request, limit: int = DEFAULT_LIMIT, offset: int = 0):
|
||||||
|
"""Published news items whose release date has passed and that are marked
|
||||||
|
visible outside the club (`external` or `both`) -- newest first."""
|
||||||
|
club = require_club(request)
|
||||||
|
limit = max(1, min(limit, MAX_LIMIT))
|
||||||
|
offset = max(0, offset)
|
||||||
|
|
||||||
|
queryset = (
|
||||||
|
News.objects.filter(
|
||||||
|
club=club,
|
||||||
|
status=News.Status.PUBLISHED,
|
||||||
|
published_at__lte=timezone.now(),
|
||||||
|
visibility__in=[News.Visibility.EXTERNAL, News.Visibility.BOTH],
|
||||||
|
)
|
||||||
|
.prefetch_related("photos", "teams")
|
||||||
|
.order_by("-published_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
count = queryset.count()
|
||||||
|
page = queryset[offset : offset + limit]
|
||||||
|
|
||||||
|
return NewsListOut(count=count, limit=limit, offset=offset, results=[_to_news_item_out(item, request) for item in page])
|
||||||
@@ -3,11 +3,13 @@ name = "rosterchief"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"beautifulsoup4>=4.15.0",
|
||||||
"dj-database-url>=3.1.2",
|
"dj-database-url>=3.1.2",
|
||||||
"django>=6.0.6",
|
"django>=6.0.6",
|
||||||
"django-allauth[mfa]>=65.18.0",
|
"django-allauth[mfa]>=65.18.0",
|
||||||
"django-countries>=9.0.0",
|
"django-countries>=9.0.0",
|
||||||
"django-lucide",
|
"django-lucide",
|
||||||
|
"django-ninja>=1.6.2",
|
||||||
"django-phonenumber-field[phonenumbers]>=8.4.0",
|
"django-phonenumber-field[phonenumbers]>=8.4.0",
|
||||||
"django-redis>=7.0.0",
|
"django-redis>=7.0.0",
|
||||||
"django-storages[s3]>=1.14.6",
|
"django-storages[s3]>=1.14.6",
|
||||||
@@ -18,6 +20,7 @@ dependencies = [
|
|||||||
"psycopg[binary]>=3.3.4",
|
"psycopg[binary]>=3.3.4",
|
||||||
"python-dateutil>=2.9.0.post0",
|
"python-dateutil>=2.9.0.post0",
|
||||||
"python-decouple>=3.8",
|
"python-decouple>=3.8",
|
||||||
|
"requests>=2.34.2",
|
||||||
"weasyprint>=69.0",
|
"weasyprint>=69.0",
|
||||||
"whitenoise>=6.12.0",
|
"whitenoise>=6.12.0",
|
||||||
]
|
]
|
||||||
@@ -58,7 +61,7 @@ ignore = [
|
|||||||
|
|
||||||
[tool.ruff.lint.isort]
|
[tool.ruff.lint.isort]
|
||||||
known-first-party = [
|
known-first-party = [
|
||||||
"billing", "authentication", "club", "members", "teams", "events", "formbuilder", "shop", "controlpanel", "management", "news", "pages", "home", "search", "rosterchief"]
|
"api", "billing", "authentication", "club", "members", "teams", "events", "formbuilder", "shop", "controlpanel", "management", "news", "pages", "home", "search", "rosterchief"]
|
||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
django-lucide = { git = "https://github.com/bsiebens/lucide" }
|
django-lucide = { git = "https://github.com/bsiebens/lucide" }
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ INSTALLED_APPS = [
|
|||||||
# Club-facing UI for team managers, coaches and admins -- not controlpanel (platform
|
# Club-facing UI for team managers, coaches and admins -- not controlpanel (platform
|
||||||
# staff managing every club) and not the future parent/player app.
|
# staff managing every club) and not the future parent/player app.
|
||||||
"management.apps.ManagementConfig",
|
"management.apps.ManagementConfig",
|
||||||
|
# Public, read-only JSON API for a club's own external website -- see api/urls.py.
|
||||||
|
"api.apps.ApiConfig",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Feature flags (django-waffle). The Flag model is swappable, like AUTH_USER_MODEL:
|
# Feature flags (django-waffle). The Flag model is swappable, like AUTH_USER_MODEL:
|
||||||
@@ -100,6 +102,9 @@ MIDDLEWARE = [
|
|||||||
"club.tenancy.ClubTenantMiddleware",
|
"club.tenancy.ClubTenantMiddleware",
|
||||||
# After tenancy: it decides club-vs-platform from request.club, which was just resolved.
|
# After tenancy: it decides club-vs-platform from request.club, which was just resolved.
|
||||||
"features.middleware.MaintenanceMiddleware",
|
"features.middleware.MaintenanceMiddleware",
|
||||||
|
# After maintenance: a club under maintenance closes its public API too, same as
|
||||||
|
# everything else on its subdomain.
|
||||||
|
"api.middleware.PublicApiCorsMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
]
|
]
|
||||||
@@ -193,6 +198,7 @@ TEMPLATES = [
|
|||||||
"management.context_processors.management_link",
|
"management.context_processors.management_link",
|
||||||
"management.context_processors.active_nav_section",
|
"management.context_processors.active_nav_section",
|
||||||
"management.context_processors.news_permissions",
|
"management.context_processors.news_permissions",
|
||||||
|
"management.context_processors.feature_sections",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from django.contrib import admin
|
|||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from django.views.generic import RedirectView
|
from django.views.generic import RedirectView
|
||||||
|
|
||||||
|
from api.urls import api
|
||||||
from club.views import root
|
from club.views import root
|
||||||
|
|
||||||
from .health import healthz
|
from .health import healthz
|
||||||
@@ -25,6 +26,7 @@ urlpatterns = [
|
|||||||
path("accounts/", include("allauth.urls")),
|
path("accounts/", include("allauth.urls")),
|
||||||
path("controlpanel/", include("controlpanel.urls")),
|
path("controlpanel/", include("controlpanel.urls")),
|
||||||
path("manage/", include("management.urls")),
|
path("manage/", include("management.urls")),
|
||||||
|
path("api/v1/", api.urls),
|
||||||
# "/" resolves per tenant: a club subdomain lands on the club, the base domain
|
# "/" resolves per tenant: a club subdomain lands on the club, the base domain
|
||||||
# hands off to the control panel. This is why LOGIN_REDIRECT_URL can stay "/".
|
# hands off to the control panel. This is why LOGIN_REDIRECT_URL can stay "/".
|
||||||
path("", root, name="root"),
|
path("", root, name="root"),
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
--color-white: #fff;
|
--color-white: #fff;
|
||||||
--spacing: 0.25rem;
|
--spacing: 0.25rem;
|
||||||
--container-xs: 20rem;
|
--container-xs: 20rem;
|
||||||
|
--container-md: 28rem;
|
||||||
--container-xl: 36rem;
|
--container-xl: 36rem;
|
||||||
|
--container-2xl: 42rem;
|
||||||
--container-6xl: 72rem;
|
--container-6xl: 72rem;
|
||||||
--text-xs: 0.75rem;
|
--text-xs: 0.75rem;
|
||||||
--text-xs--line-height: calc(1 / 0.75);
|
--text-xs--line-height: calc(1 / 0.75);
|
||||||
@@ -34,8 +36,10 @@
|
|||||||
--font-weight-bold: 700;
|
--font-weight-bold: 700;
|
||||||
--tracking-wide: 0.025em;
|
--tracking-wide: 0.025em;
|
||||||
--tracking-wider: 0.05em;
|
--tracking-wider: 0.05em;
|
||||||
|
--radius-lg: 0.5rem;
|
||||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
--default-transition-duration: 150ms;
|
--default-transition-duration: 150ms;
|
||||||
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--default-font-family: var(--font-sans);
|
--default-font-family: var(--font-sans);
|
||||||
@@ -686,6 +690,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.collapse-plus {
|
||||||
|
@layer daisyui.l1.l2 {
|
||||||
|
> .collapse-title:after {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
height: 0.5rem;
|
||||||
|
width: 0.5rem;
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
transition-property: all;
|
||||||
|
transition-duration: 300ms;
|
||||||
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
top: 0.9rem;
|
||||||
|
inset-inline-end: 1.4rem;
|
||||||
|
--tw-content: "+";
|
||||||
|
content: var(--tw-content);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.dropdown {
|
.dropdown {
|
||||||
@layer daisyui.l1.l2.l3 {
|
@layer daisyui.l1.l2.l3 {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -1139,6 +1163,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.collapse-open {
|
||||||
|
@layer daisyui.l1.l2 {
|
||||||
|
grid-template-rows: max-content 1fr;
|
||||||
|
> .collapse-content {
|
||||||
|
--overflow-delay: 0.2s;
|
||||||
|
overflow: revert-layer;
|
||||||
|
content-visibility: visible;
|
||||||
|
min-height: fit-content;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
@supports not (content-visibility: visible) {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.collapse {
|
.collapse {
|
||||||
visibility: collapse;
|
visibility: collapse;
|
||||||
}
|
}
|
||||||
@@ -1207,6 +1246,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.toast {
|
||||||
|
@layer daisyui.l1.l2.l3 {
|
||||||
|
position: fixed;
|
||||||
|
inset-inline-start: auto;
|
||||||
|
inset-inline-end: calc(0.25rem * 4);
|
||||||
|
top: auto;
|
||||||
|
bottom: calc(0.25rem * 4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc(0.25rem * 2);
|
||||||
|
background-color: transparent;
|
||||||
|
translate: var(--toast-x, 0) var(--toast-y, 0);
|
||||||
|
width: max-content;
|
||||||
|
max-width: calc(100vw - 2rem);
|
||||||
|
& > * {
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
animation: toast 0.25s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.toggle {
|
.toggle {
|
||||||
@layer daisyui.l1.l2.l3 {
|
@layer daisyui.l1.l2.l3 {
|
||||||
border: var(--border) solid currentColor;
|
border: var(--border) solid currentColor;
|
||||||
@@ -1560,6 +1620,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.aura {
|
||||||
|
@layer daisyui.l1.l2.l3 {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
--aura-padding: 0.125rem;
|
||||||
|
padding: var(--aura-padding);
|
||||||
|
border-radius: calc(var(--aura-padding) + var(--aura-radius, var(--radius-box)));
|
||||||
|
animation: aura var(--tw-duration, 6s) linear infinite;
|
||||||
|
background-image: conic-gradient(from var(--aura-angle), transparent 225deg, currentColor);
|
||||||
|
&:has( > .card, > .alert) {
|
||||||
|
--aura-radius: var(--radius-box);
|
||||||
|
}
|
||||||
|
&:has( > .btn, > .input, > .select) {
|
||||||
|
--aura-radius: var(--radius-field);
|
||||||
|
}
|
||||||
|
&:has( > .checkbox, > .toggle, > .badge) {
|
||||||
|
--aura-radius: var(--radius-selector);
|
||||||
|
}
|
||||||
|
&:before, &:after {
|
||||||
|
animation: inherit;
|
||||||
|
background-color: inherit;
|
||||||
|
background-image: inherit;
|
||||||
|
border-radius: inherit;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(1 / 2 * 100%);
|
||||||
|
left: calc(1 / 2 * 100%);
|
||||||
|
z-index: 0;
|
||||||
|
display: block;
|
||||||
|
opacity: 70%;
|
||||||
|
filter: blur(0.25rem);
|
||||||
|
translate: -50% -50%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
&:after {
|
||||||
|
opacity: 30%;
|
||||||
|
filter: blur(1rem);
|
||||||
|
}
|
||||||
|
& > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.steps {
|
.steps {
|
||||||
@layer daisyui.l1.l2.l3 {
|
@layer daisyui.l1.l2.l3 {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
@@ -2125,6 +2230,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.rating {
|
||||||
|
@layer daisyui.l1.l2.l3 {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
vertical-align: middle;
|
||||||
|
--size: var(--size-selector, 0.25rem) * 6;
|
||||||
|
input {
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
border-radius: 0;
|
||||||
|
background-color: var(--color-base-content);
|
||||||
|
opacity: 20%;
|
||||||
|
width: calc(var(--size) * 1);
|
||||||
|
height: calc(var(--size));
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
animation: rating 0.25s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.rating-hidden {
|
||||||
|
width: calc(0.25rem * 2);
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
:checked, [aria-checked="true"], [aria-current="true"], :has( ~ :checked, ~ [aria-checked="true"], ~ [aria-current="true"]) {
|
||||||
|
opacity: 100%;
|
||||||
|
}
|
||||||
|
:focus-visible {
|
||||||
|
scale: 1.1;
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
transition: scale 0.2s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:active:focus {
|
||||||
|
animation: none;
|
||||||
|
scale: 1.1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@layer daisyui.l1.l2 {
|
||||||
|
--size: var(--size-selector, 0.25rem) * 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
.navbar {
|
.navbar {
|
||||||
@layer daisyui.l1.l2.l3 {
|
@layer daisyui.l1.l2.l3 {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2264,6 +2411,30 @@
|
|||||||
.sticky {
|
.sticky {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
}
|
}
|
||||||
|
.dropdown-right {
|
||||||
|
@layer daisyui.l1.l2 {
|
||||||
|
--anchor-h: right;
|
||||||
|
--anchor-v: span-bottom;
|
||||||
|
.dropdown-content {
|
||||||
|
inset-inline-start: 100%;
|
||||||
|
top: 0;
|
||||||
|
bottom: auto;
|
||||||
|
transform-origin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.dropdown-left {
|
||||||
|
@layer daisyui.l1.l2 {
|
||||||
|
--anchor-h: left;
|
||||||
|
--anchor-v: span-bottom;
|
||||||
|
.dropdown-content {
|
||||||
|
inset-inline-end: 100%;
|
||||||
|
top: 0;
|
||||||
|
bottom: auto;
|
||||||
|
transform-origin: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.dropdown-end {
|
.dropdown-end {
|
||||||
@layer daisyui.l1.l2 {
|
@layer daisyui.l1.l2 {
|
||||||
--anchor-h: span-left;
|
--anchor-h: span-left;
|
||||||
@@ -2818,6 +2989,9 @@
|
|||||||
gap: calc(0.25rem * 2);
|
gap: calc(0.25rem * 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.-mt-2 {
|
||||||
|
margin-top: calc(var(--spacing) * -2);
|
||||||
|
}
|
||||||
.mt-1 {
|
.mt-1 {
|
||||||
margin-top: var(--spacing);
|
margin-top: var(--spacing);
|
||||||
}
|
}
|
||||||
@@ -3108,6 +3282,9 @@
|
|||||||
.inline-flex {
|
.inline-flex {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
.inline-grid {
|
||||||
|
display: inline-grid;
|
||||||
|
}
|
||||||
.table {
|
.table {
|
||||||
display: table;
|
display: table;
|
||||||
}
|
}
|
||||||
@@ -3154,12 +3331,18 @@
|
|||||||
.w-full {
|
.w-full {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
.max-w-2xl {
|
||||||
|
max-width: var(--container-2xl);
|
||||||
|
}
|
||||||
.max-w-6xl {
|
.max-w-6xl {
|
||||||
max-width: var(--container-6xl);
|
max-width: var(--container-6xl);
|
||||||
}
|
}
|
||||||
.max-w-40 {
|
.max-w-40 {
|
||||||
max-width: calc(var(--spacing) * 40);
|
max-width: calc(var(--spacing) * 40);
|
||||||
}
|
}
|
||||||
|
.max-w-md {
|
||||||
|
max-width: var(--container-md);
|
||||||
|
}
|
||||||
.max-w-none {
|
.max-w-none {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
@@ -3175,18 +3358,58 @@
|
|||||||
.flex-1 {
|
.flex-1 {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
.flex-shrink {
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
.shrink {
|
.shrink {
|
||||||
flex-shrink: 1;
|
flex-shrink: 1;
|
||||||
}
|
}
|
||||||
.shrink-0 {
|
.shrink-0 {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.flex-grow {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
.grow {
|
.grow {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
.border-collapse {
|
.border-collapse {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
.transform {
|
||||||
|
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
|
||||||
|
}
|
||||||
|
.skeleton {
|
||||||
|
@layer daisyui.l1.l2.l3 {
|
||||||
|
border-radius: var(--radius-box);
|
||||||
|
background-color: var(--color-base-300);
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
transition-duration: 15s;
|
||||||
|
}
|
||||||
|
will-change: background-position;
|
||||||
|
background-image: linear-gradient( 105deg, #0000 0% 40%, var(--color-base-100) 50%, #0000 60% 100% );
|
||||||
|
background-size: 200% auto;
|
||||||
|
background-position-x: -50%;
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
animation: skeleton 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.aura-glow {
|
||||||
|
@layer daisyui.l1.l2 {
|
||||||
|
animation: none;
|
||||||
|
background-image: radial-gradient(closest-corner at center, currentColor 0%, transparent 90%);
|
||||||
|
&:before {
|
||||||
|
animation: aura-glow var(--tw-duration, 6s) ease-out infinite;
|
||||||
|
}
|
||||||
|
&:after {
|
||||||
|
animation: aura-glow-after var(--tw-duration, 6s) ease-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-pulse {
|
||||||
|
animation: var(--animate-pulse);
|
||||||
|
}
|
||||||
.link {
|
.link {
|
||||||
@layer daisyui.l1.l2.l3 {
|
@layer daisyui.l1.l2.l3 {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -3214,6 +3437,12 @@
|
|||||||
.scrollbar-gutter-stable {
|
.scrollbar-gutter-stable {
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
.list-inside {
|
||||||
|
list-style-position: inside;
|
||||||
|
}
|
||||||
|
.list-disc {
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
.grid-cols-1 {
|
.grid-cols-1 {
|
||||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -3325,6 +3554,9 @@
|
|||||||
.rounded-full {
|
.rounded-full {
|
||||||
border-radius: calc(infinity * 1px);
|
border-radius: calc(infinity * 1px);
|
||||||
}
|
}
|
||||||
|
.rounded-lg {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
.border {
|
.border {
|
||||||
border-style: var(--tw-border-style);
|
border-style: var(--tw-border-style);
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
@@ -3498,6 +3730,9 @@
|
|||||||
--btn-shadow: 0 0 0 0 oklch(0% 0 0/0);
|
--btn-shadow: 0 0 0 0 oklch(0% 0 0/0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.mask-repeat {
|
||||||
|
mask-repeat: repeat;
|
||||||
|
}
|
||||||
.object-contain {
|
.object-contain {
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
@@ -3525,6 +3760,17 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.table-sm {
|
||||||
|
@layer daisyui.l1.l2 {
|
||||||
|
:not(thead, tfoot) tr {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
:where(th, td) {
|
||||||
|
padding-inline: calc(0.25rem * 3);
|
||||||
|
padding-block: calc(0.25rem * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.px-3 {
|
.px-3 {
|
||||||
padding-inline: calc(var(--spacing) * 3);
|
padding-inline: calc(var(--spacing) * 3);
|
||||||
}
|
}
|
||||||
@@ -3546,6 +3792,9 @@
|
|||||||
.pb-2 {
|
.pb-2 {
|
||||||
padding-bottom: calc(var(--spacing) * 2);
|
padding-bottom: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
|
.pb-6 {
|
||||||
|
padding-bottom: calc(var(--spacing) * 6);
|
||||||
|
}
|
||||||
.pl-8 {
|
.pl-8 {
|
||||||
padding-left: calc(var(--spacing) * 8);
|
padding-left: calc(var(--spacing) * 8);
|
||||||
}
|
}
|
||||||
@@ -3736,11 +3985,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.line-through {
|
||||||
|
text-decoration-line: line-through;
|
||||||
|
}
|
||||||
.prose {
|
.prose {
|
||||||
& :where(a.btn:not(.btn-link)):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
& :where(a.btn:not(.btn-link)):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||||
text-decoration-line: none;
|
text-decoration-line: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.underline {
|
||||||
|
text-decoration-line: underline;
|
||||||
|
}
|
||||||
.opacity-40 {
|
.opacity-40 {
|
||||||
opacity: 40%;
|
opacity: 40%;
|
||||||
}
|
}
|
||||||
@@ -3797,6 +4052,19 @@
|
|||||||
.filter {
|
.filter {
|
||||||
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
||||||
}
|
}
|
||||||
|
.transition {
|
||||||
|
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
|
||||||
|
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||||
|
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||||
|
}
|
||||||
|
.ease-in-out {
|
||||||
|
--tw-ease: var(--ease-in-out);
|
||||||
|
transition-timing-function: var(--ease-in-out);
|
||||||
|
}
|
||||||
|
.ease-out {
|
||||||
|
--tw-ease: var(--ease-out);
|
||||||
|
transition-timing-function: var(--ease-out);
|
||||||
|
}
|
||||||
.input-lg {
|
.input-lg {
|
||||||
@layer daisyui.l1.l2 {
|
@layer daisyui.l1.l2 {
|
||||||
--in-size-mul: 12;
|
--in-size-mul: 12;
|
||||||
@@ -3973,6 +4241,11 @@
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sm\:grid-cols-3 {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
.sm\:px-6 {
|
.sm\:px-6 {
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
padding-inline: calc(var(--spacing) * 6);
|
padding-inline: calc(var(--spacing) * 6);
|
||||||
@@ -4453,6 +4726,26 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@property --tw-rotate-x {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
}
|
||||||
|
@property --tw-rotate-y {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
}
|
||||||
|
@property --tw-rotate-z {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
}
|
||||||
|
@property --tw-skew-x {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
}
|
||||||
|
@property --tw-skew-y {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
}
|
||||||
@property --tw-space-y-reverse {
|
@property --tw-space-y-reverse {
|
||||||
syntax: "*";
|
syntax: "*";
|
||||||
inherits: false;
|
inherits: false;
|
||||||
@@ -4619,9 +4912,23 @@
|
|||||||
syntax: "*";
|
syntax: "*";
|
||||||
inherits: false;
|
inherits: false;
|
||||||
}
|
}
|
||||||
|
@property --tw-ease {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
@layer properties {
|
@layer properties {
|
||||||
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
|
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
|
||||||
*, ::before, ::after, ::backdrop {
|
*, ::before, ::after, ::backdrop {
|
||||||
|
--tw-rotate-x: initial;
|
||||||
|
--tw-rotate-y: initial;
|
||||||
|
--tw-rotate-z: initial;
|
||||||
|
--tw-skew-x: initial;
|
||||||
|
--tw-skew-y: initial;
|
||||||
--tw-space-y-reverse: 0;
|
--tw-space-y-reverse: 0;
|
||||||
--tw-divide-y-reverse: 0;
|
--tw-divide-y-reverse: 0;
|
||||||
--tw-border-style: solid;
|
--tw-border-style: solid;
|
||||||
@@ -4660,6 +4967,7 @@
|
|||||||
--tw-drop-shadow-color: initial;
|
--tw-drop-shadow-color: initial;
|
||||||
--tw-drop-shadow-alpha: 100%;
|
--tw-drop-shadow-alpha: 100%;
|
||||||
--tw-drop-shadow-size: initial;
|
--tw-drop-shadow-size: initial;
|
||||||
|
--tw-ease: initial;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
wrapper.className = "relative";
|
wrapper.className = "relative";
|
||||||
|
|
||||||
const chips = document.createElement("div");
|
const chips = document.createElement("div");
|
||||||
chips.className = "flex flex-wrap gap-1 empty:hidden mb-3";
|
chips.className = "flex flex-wrap gap-1 empty:hidden mt-2";
|
||||||
|
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "text";
|
input.type = "text";
|
||||||
@@ -31,7 +31,12 @@
|
|||||||
|
|
||||||
select.parentNode.insertBefore(wrapper, select);
|
select.parentNode.insertBefore(wrapper, select);
|
||||||
if (isMultiple) {
|
if (isMultiple) {
|
||||||
wrapper.append(chips, input, list, select);
|
// Chips render below the input, not above it: above meant every pick grew
|
||||||
|
// the block ahead of the input and shoved it (and your cursor) down --
|
||||||
|
// disorienting mid-search. Below, the input stays put; only the space
|
||||||
|
// beneath it grows, and the open dropdown (absolutely positioned right
|
||||||
|
// under the input) simply overlaps the chips while it's open.
|
||||||
|
wrapper.append(input, list, chips, select);
|
||||||
} else {
|
} else {
|
||||||
wrapper.append(input, list, select);
|
wrapper.append(input, list, select);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from .models import Position, StaffAssignment, Team, TeamMembership
|
from .models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto
|
||||||
|
|
||||||
|
|
||||||
class TeamMembershipInline(admin.TabularInline):
|
class TeamMembershipInline(admin.TabularInline):
|
||||||
@@ -20,12 +20,26 @@ class StaffAssignmentInline(admin.TabularInline):
|
|||||||
raw_id_fields = ("member",)
|
raw_id_fields = ("member",)
|
||||||
|
|
||||||
|
|
||||||
|
class TeamPhotoInline(admin.TabularInline):
|
||||||
|
"""One photo per season, shown on the Team page."""
|
||||||
|
|
||||||
|
model = TeamPhoto
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Team)
|
@admin.register(Team)
|
||||||
class TeamAdmin(admin.ModelAdmin):
|
class TeamAdmin(admin.ModelAdmin):
|
||||||
list_display = ["name", "short_name", "club"]
|
list_display = ["name", "short_name", "club"]
|
||||||
list_filter = ["club"]
|
list_filter = ["club"]
|
||||||
search_fields = ["name", "short_name"]
|
search_fields = ["name", "short_name"]
|
||||||
inlines = [TeamMembershipInline, StaffAssignmentInline]
|
inlines = [TeamMembershipInline, StaffAssignmentInline, TeamPhotoInline]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TeamPhoto)
|
||||||
|
class TeamPhotoAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["team", "season"]
|
||||||
|
list_filter = ["team__club", "season"]
|
||||||
|
search_fields = ["team__name"]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Position)
|
@admin.register(Position)
|
||||||
|
|||||||
122
teams/api.py
Normal file
122
teams/api.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""Public read-only team/roster endpoints -- see api/urls.py for how this is
|
||||||
|
mounted. Roster building is a small helper (build_roster) rather than
|
||||||
|
inlined in the view so it can be unit-tested without going through Ninja's
|
||||||
|
request cycle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from itertools import groupby
|
||||||
|
|
||||||
|
from ninja import Router, Schema
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
|
||||||
|
from api.errors import require_club
|
||||||
|
from club.services.access import current_season
|
||||||
|
|
||||||
|
from .models import Team, TeamMembership, TeamPhoto
|
||||||
|
|
||||||
|
router = Router(tags=["teams"])
|
||||||
|
|
||||||
|
|
||||||
|
class TeamOut(Schema):
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
short_name: str
|
||||||
|
photo_url: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerOut(Schema):
|
||||||
|
id: uuid.UUID
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
jersey_number: int | None
|
||||||
|
is_captain: bool
|
||||||
|
is_alternate_captain: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PositionGroupOut(Schema):
|
||||||
|
position: str
|
||||||
|
players: list[PlayerOut]
|
||||||
|
|
||||||
|
|
||||||
|
class StaffMemberOut(Schema):
|
||||||
|
id: uuid.UUID
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
position: str
|
||||||
|
|
||||||
|
|
||||||
|
class RosterOut(Schema):
|
||||||
|
team: TeamOut
|
||||||
|
season: str | None
|
||||||
|
players: list[PositionGroupOut]
|
||||||
|
staff: list[StaffMemberOut]
|
||||||
|
|
||||||
|
|
||||||
|
def _to_team_out(team, request, photo=None) -> TeamOut:
|
||||||
|
photo_url = request.build_absolute_uri(photo.image.url) if photo else None
|
||||||
|
return TeamOut(id=team.pk, name=team.name, short_name=team.short_name, photo_url=photo_url)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_player_out(membership) -> PlayerOut:
|
||||||
|
return PlayerOut(
|
||||||
|
id=membership.member_id,
|
||||||
|
first_name=membership.member.first_name,
|
||||||
|
last_name=membership.member.last_name,
|
||||||
|
jersey_number=membership.jersey_number,
|
||||||
|
is_captain=membership.is_captain,
|
||||||
|
is_alternate_captain=membership.is_alternate_captain,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_roster(team, request) -> RosterOut:
|
||||||
|
"""Current season's players -- grouped by position (so a consumer can pull
|
||||||
|
just e.g. "Forward" without filtering a flat list itself), each group
|
||||||
|
sorted by jersey number, groups themselves in Position.ordering order --
|
||||||
|
and staff, for `team`. No current season -> empty roster, same "nothing
|
||||||
|
to show, not an error" handling as management.views.MembershipListView."""
|
||||||
|
season = current_season(team.club)
|
||||||
|
if season is None:
|
||||||
|
return RosterOut(team=_to_team_out(team, request), season=None, players=[], staff=[])
|
||||||
|
|
||||||
|
photo = TeamPhoto.objects.filter(team=team, season=season).first()
|
||||||
|
|
||||||
|
memberships = TeamMembership.objects.filter(team=team, season=season).select_related("member", "position").order_by("position__ordering", "position__name", "jersey_number")
|
||||||
|
assignments = team.staff_assignments.filter(season=season).select_related("member", "position").order_by("position__ordering", "position__name", "member__last_name")
|
||||||
|
|
||||||
|
# groupby only groups consecutive runs -- relies on the queryset already
|
||||||
|
# being ordered by position first, which it is.
|
||||||
|
players = [PositionGroupOut(position=position_name, players=[_to_player_out(m) for m in members]) for position_name, members in groupby(memberships, key=lambda m: m.position.name)]
|
||||||
|
staff = [
|
||||||
|
StaffMemberOut(id=assignment.member_id, first_name=assignment.member.first_name, last_name=assignment.member.last_name, position=assignment.position.name)
|
||||||
|
for assignment in assignments
|
||||||
|
]
|
||||||
|
|
||||||
|
return RosterOut(team=_to_team_out(team, request, photo=photo), season=season.name, players=players, staff=staff)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response=list[TeamOut], summary="List teams")
|
||||||
|
def list_teams(request):
|
||||||
|
club = require_club(request)
|
||||||
|
teams = list(Team.objects.filter(club=club).order_by("name"))
|
||||||
|
|
||||||
|
season = current_season(club)
|
||||||
|
photos_by_team_id = {}
|
||||||
|
if season is not None and teams:
|
||||||
|
photos_by_team_id = {photo.team_id: photo for photo in TeamPhoto.objects.filter(team__in=teams, season=season)}
|
||||||
|
|
||||||
|
return [_to_team_out(team, request, photo=photos_by_team_id.get(team.pk)) for team in teams]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{team_id}/roster/", response=RosterOut, summary="Current season's roster")
|
||||||
|
def get_roster(request, team_id: uuid.UUID):
|
||||||
|
club = require_club(request)
|
||||||
|
team = _get_team_or_404(club, team_id)
|
||||||
|
return build_roster(team, request)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_team_or_404(club, team_id):
|
||||||
|
team = Team.objects.filter(club=club, pk=team_id).first()
|
||||||
|
if team is None:
|
||||||
|
raise HttpError(404, "No such team.")
|
||||||
|
return team
|
||||||
33
teams/migrations/0007_teamphoto.py
Normal file
33
teams/migrations/0007_teamphoto.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-08-06 13:56
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import teams.models
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('club', '0018_club_sport_type'),
|
||||||
|
('teams', '0006_alter_position_ordering'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TeamPhoto',
|
||||||
|
fields=[
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||||
|
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('image', models.ImageField(upload_to=teams.models.team_photo_path, verbose_name='image')),
|
||||||
|
('season', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='team_photos', to='club.season', verbose_name='season')),
|
||||||
|
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='teams.team', verbose_name='team')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'team photo',
|
||||||
|
'verbose_name_plural': 'team photos',
|
||||||
|
'constraints': [models.UniqueConstraint(fields=('team', 'season'), name='unique_team_photo_per_season')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -22,6 +22,31 @@ class Team(ClubScopedModel):
|
|||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
def team_photo_path(instance, filename):
|
||||||
|
return f"clubs/{instance.team.club.slug}/teams/{instance.team_id}/{instance.season.name}/{filename}"
|
||||||
|
|
||||||
|
|
||||||
|
class TeamPhoto(UUIDModel):
|
||||||
|
"""One team photo per season -- a team's makeup changes every season, so
|
||||||
|
this can't be a plain field on Team. No club FK of its own: club is
|
||||||
|
already reachable via team.club, same reasoning as NewsPhoto being owned
|
||||||
|
by News rather than club-scoped itself."""
|
||||||
|
|
||||||
|
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="photos", verbose_name=_("team"))
|
||||||
|
season = models.ForeignKey(Season, on_delete=models.PROTECT, related_name="team_photos", verbose_name=_("season"))
|
||||||
|
image = models.ImageField(_("image"), upload_to=team_photo_path)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("team photo")
|
||||||
|
verbose_name_plural = _("team photos")
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=["team", "season"], name="unique_team_photo_per_season"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.team} - {self.season}"
|
||||||
|
|
||||||
|
|
||||||
class Position(ClubScopedModel):
|
class Position(ClubScopedModel):
|
||||||
name = models.CharField(_("name"), max_length=255)
|
name = models.CharField(_("name"), max_length=255)
|
||||||
short_name = models.CharField(_("short name"), max_length=255)
|
short_name = models.CharField(_("short name"), max_length=255)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from django.test import TestCase
|
|||||||
from club.models import Club, Season
|
from club.models import Club, Season
|
||||||
from members.models import Member
|
from members.models import Member
|
||||||
|
|
||||||
from .models import Position, StaffAssignment, Team, TeamMembership
|
from .models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto
|
||||||
|
|
||||||
|
|
||||||
class TeamsTestCase(TestCase):
|
class TeamsTestCase(TestCase):
|
||||||
@@ -153,3 +153,38 @@ class RosterCleanTests(TeamsTestCase):
|
|||||||
|
|
||||||
def test_staffassignment_accepts_same_club(self):
|
def test_staffassignment_accepts_same_club(self):
|
||||||
StaffAssignment(team=self.team, member=self.member, season=self.season, position=self.coach).full_clean()
|
StaffAssignment(team=self.team, member=self.member, season=self.season, position=self.coach).full_clean()
|
||||||
|
|
||||||
|
|
||||||
|
class TeamPhotoModelTests(TeamsTestCase):
|
||||||
|
def test_can_set_a_photo(self):
|
||||||
|
photo = TeamPhoto.objects.create(team=self.team, season=self.season, image="clubs/ajax-united/teams/x/26-27/pic.jpg")
|
||||||
|
|
||||||
|
self.assertEqual(str(photo), "First Team - 26-27")
|
||||||
|
self.assertEqual(list(self.team.photos.all()), [photo])
|
||||||
|
|
||||||
|
def test_only_one_photo_per_team_and_season(self):
|
||||||
|
TeamPhoto.objects.create(team=self.team, season=self.season, image="clubs/ajax-united/teams/x/26-27/pic.jpg")
|
||||||
|
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
TeamPhoto.objects.create(team=self.team, season=self.season, image="clubs/ajax-united/teams/x/26-27/pic2.jpg")
|
||||||
|
|
||||||
|
def test_the_same_team_can_have_a_photo_in_a_different_season(self):
|
||||||
|
other_season = Season.objects.create(club=self.club, start_date=datetime.date(2000, 1, 1), end_date=datetime.date(2000, 12, 31))
|
||||||
|
TeamPhoto.objects.create(team=self.team, season=self.season, image="clubs/ajax-united/teams/x/26-27/pic.jpg")
|
||||||
|
|
||||||
|
TeamPhoto.objects.create(team=self.team, season=other_season, image="clubs/ajax-united/teams/x/00-00/pic.jpg")
|
||||||
|
|
||||||
|
self.assertEqual(self.team.photos.count(), 2)
|
||||||
|
|
||||||
|
def test_season_is_protected_while_a_photo_references_it(self):
|
||||||
|
TeamPhoto.objects.create(team=self.team, season=self.season, image="clubs/ajax-united/teams/x/26-27/pic.jpg")
|
||||||
|
|
||||||
|
with self.assertRaises(ProtectedError):
|
||||||
|
self.season.delete()
|
||||||
|
|
||||||
|
def test_deleting_the_team_deletes_its_photos(self):
|
||||||
|
TeamPhoto.objects.create(team=self.team, season=self.season, image="clubs/ajax-united/teams/x/26-27/pic.jpg")
|
||||||
|
|
||||||
|
self.team.delete()
|
||||||
|
|
||||||
|
self.assertEqual(TeamPhoto.objects.count(), 0)
|
||||||
|
|||||||
@@ -127,6 +127,10 @@
|
|||||||
|
|
||||||
{% block main %}{% endblock main %}
|
{% block main %}{% endblock main %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<footer class="mx-auto w-full px-8 pb-6 text-center text-xs opacity-60">
|
||||||
|
© {% now "Y" %} Bernard Siebens · <a class="link link-hover" href="mailto:info@rosterchief.app">info@rosterchief.app</a>
|
||||||
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
{{ redirect_field }}
|
{{ redirect_field }}
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||||
<a class="btn btn-outline btn-neutral gap-2" href="/">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
<a class="btn btn-outline gap-2" href="/">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
||||||
<button class="btn btn-primary gap-2" type="submit">{% lucide "log-out" size=16 %} {% trans "Sign Out" %}</button>
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "log-out" size=16 %} {% trans "Sign Out" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
{% comment %} djlint:off {% endcomment %}
|
{% comment %} djlint:off {% endcomment %}
|
||||||
<{% if attrs.href %}a href="{{ attrs.href }}"{% else %}button{% endif %}
|
<{% if attrs.href %}a href="{{ attrs.href }}"{% else %}button{% endif %}
|
||||||
class="btn gap-2 {% if attrs.tags and 'danger' in attrs.tags %}btn-error{% elif attrs.tags and 'link' in attrs.tags %}btn-link{% elif attrs.tags and 'secondary' in attrs.tags %}btn-outline btn-neutral{% elif attrs.tags and 'outline' in attrs.tags %}btn-outline btn-primary{% else %}btn-primary{% endif %}"
|
class="btn gap-2 {% if attrs.tags and 'danger' in attrs.tags %}btn-error{% elif attrs.tags and 'link' in attrs.tags %}btn-link{% elif attrs.tags and 'secondary' in attrs.tags %}btn-outline{% elif attrs.tags and 'outline' in attrs.tags %}btn-outline btn-primary{% else %}btn-primary{% endif %}"
|
||||||
{% if attrs.form %}form="{{ attrs.form }}"{% endif %}
|
{% if attrs.form %}form="{{ attrs.form }}"{% endif %}
|
||||||
{% if attrs.id %}id="{{ attrs.id }}"{% endif %}
|
{% if attrs.id %}id="{{ attrs.id }}"{% endif %}
|
||||||
{% if attrs.name %}name="{{ attrs.name }}"{% endif %}
|
{% if attrs.name %}name="{{ attrs.name }}"{% endif %}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||||
<button class="btn btn-outline btn-neutral gap-2" type="submit" form="logout-from-stage">{% lucide "x" size=16 %} {% trans "Cancel" %}</button>
|
<button class="btn btn-outline gap-2" type="submit" form="logout-from-stage">{% lucide "x" size=16 %} {% trans "Cancel" %}</button>
|
||||||
<button class="btn btn-primary gap-2" type="submit">{% lucide "log-in" size=16 %} {% trans "Sign In" %}</button>
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "log-in" size=16 %} {% trans "Sign In" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user