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"?>
|
||||
<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">
|
||||
<option name="enabled" value="true" />
|
||||
</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"')
|
||||
|
||||
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">')
|
||||
|
||||
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):
|
||||
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"') :]
|
||||
|
||||
self.assertIn("<svg", cancel[: cancel.index("</a>")])
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
from django.contrib import admin
|
||||
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)
|
||||
class ClubAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "slug"]
|
||||
list_display = ["name", "slug", "sport_type"]
|
||||
list_filter = ["sport_type"]
|
||||
search_fields = ["name", "slug"]
|
||||
prepopulated_fields = {"slug": ["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)
|
||||
class SeasonAdmin(admin.ModelAdmin):
|
||||
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.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
|
||||
|
||||
@@ -36,6 +37,27 @@ class ClubAdminRequiredMixin(ClubStaffRequiredMixin):
|
||||
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):
|
||||
"""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.
|
||||
@@ -51,6 +73,23 @@ class TeamManagerRequiredMixin(ClubStaffRequiredMixin):
|
||||
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):
|
||||
"""ADMIN, or anyone with a current-season *management*-position
|
||||
StaffAssignment on any team -- unlike ``TeamManagerRequiredMixin``, the
|
||||
|
||||
@@ -2,6 +2,7 @@ import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import FileExtensionValidator, MaxValueValidator, MinValueValidator, RegexValidator
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
@@ -30,6 +31,13 @@ def club_logo_path(instance: Club, filename: str) -> str:
|
||||
|
||||
|
||||
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)
|
||||
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."),
|
||||
)
|
||||
|
||||
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."))
|
||||
|
||||
season_start = models.DateField(
|
||||
@@ -143,6 +159,39 @@ class Club(UUIDModel):
|
||||
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):
|
||||
start_date = models.DateField(_("start 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 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 StatusChoices(models.TextChoices):
|
||||
|
||||
@@ -20,7 +20,7 @@ from events.models import Event
|
||||
from members.models import Family, FamilyMembership, Member
|
||||
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 (
|
||||
COACH_MANAGER,
|
||||
can_edit_event,
|
||||
@@ -464,6 +464,56 @@ class SeasonGetCurrentTests(TestCase):
|
||||
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):
|
||||
"""Every registered model across all apps must have a working admin: load
|
||||
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 Meta:
|
||||
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.")}
|
||||
# Deliberately a text input, not <input type="color">: a colour picker cannot
|
||||
# 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">
|
||||
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Billing</h2>
|
||||
<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 %}
|
||||
</button>
|
||||
{% 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
|
||||
</button>
|
||||
{% endif %}
|
||||
@@ -87,7 +87,7 @@
|
||||
{% if not due.payments.all %}
|
||||
<form class="inline" method="post" action="{% url 'controlpanel:due_waive' due.pk %}">
|
||||
{% 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>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<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 class="overflow-x-auto">
|
||||
<table class="table">
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</td>
|
||||
@@ -122,7 +122,7 @@
|
||||
</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>
|
||||
</tr>
|
||||
{% empty %}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</form>
|
||||
<div class="modal-action">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</form>
|
||||
<div class="modal-action">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
</td>
|
||||
<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-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>
|
||||
</tr>
|
||||
{% empty %}
|
||||
|
||||
@@ -27,14 +27,14 @@
|
||||
{% block heading %}{{ club.name }}{% endblock heading %}
|
||||
|
||||
{% block subheading %}
|
||||
{{ club.slug }}.rosterchief.app
|
||||
{{ club.slug }}.rosterchief.app · {{ club.get_sport_type_display }}
|
||||
{% if club.is_archived %}
|
||||
<span class="badge badge-warning badge-sm ml-2">Archived</span>
|
||||
{% endif %}
|
||||
{% endblock subheading %}
|
||||
|
||||
{% 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>
|
||||
{% if club.is_archived %}
|
||||
<form method="post" action="{% url 'controlpanel:club_restore' club.pk %}">
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
</div>
|
||||
{% 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.slug %}
|
||||
{% form_field form.sport_type %}
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
@@ -36,7 +37,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
{% block actions %}
|
||||
{% 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 %}
|
||||
<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 %}
|
||||
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} New club</a>
|
||||
{% endblock actions %}
|
||||
@@ -24,7 +24,7 @@
|
||||
class="input input-bordered w-full max-w-xs">
|
||||
</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 %}
|
||||
<a class="btn btn-primary gap-2" href="{% url "controlpanel:club_list" %}">{% lucide "x" size=16 %} Clear filter</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
<td>{{ flag.clubs.count }}</td>
|
||||
<td class="max-w-xs truncate opacity-70">{{ flag.note|default:"-" }}</td>
|
||||
<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>
|
||||
</tr>
|
||||
{% empty %}
|
||||
|
||||
@@ -54,6 +54,15 @@
|
||||
>{{ field.value|default:"" }}</textarea>
|
||||
|
||||
{% 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
|
||||
class="select w-full {% if size_modifier %}select-{{ size_modifier }}{% endif %} {% if field.errors %}select-error text-error{% endif %}"
|
||||
name="{{ field.html_name }}"
|
||||
@@ -67,7 +76,7 @@
|
||||
<option selected disabled>{{ field.label|capfirst }}</option>
|
||||
{% 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_label }}
|
||||
</option>
|
||||
@@ -77,7 +86,7 @@
|
||||
<option selected disabled>{{ field.label|capfirst }}</option>
|
||||
{% 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_label }}
|
||||
</option>
|
||||
|
||||
@@ -116,8 +116,16 @@ class ClubManagementTests(ControlPanelTestBase):
|
||||
def test_dashboard_lists_clubs(self):
|
||||
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):
|
||||
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")
|
||||
self.assertEqual(club.slug, "new-club")
|
||||
@@ -126,12 +134,21 @@ class ClubManagementTests(ControlPanelTestBase):
|
||||
def test_update_club(self):
|
||||
self.client.post(
|
||||
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.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):
|
||||
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, "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):
|
||||
self.client.post(reverse("controlpanel:club_archive", args=[self.club.pk]))
|
||||
self.club.refresh_from_db()
|
||||
@@ -489,6 +514,17 @@ class FeatureViewTests(ControlPanelTestBase):
|
||||
self.assertContains(response, "shop")
|
||||
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):
|
||||
# Reachable only through a modal on the features page: there is no standalone
|
||||
# template to render on a GET.
|
||||
@@ -824,16 +860,20 @@ class FlagAdoptionTests(TestCase):
|
||||
self.club = Club.objects.create(name="Ajax United")
|
||||
|
||||
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.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):
|
||||
# `everyone` beats club targeting, so the club count would be a lie.
|
||||
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):
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
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)
|
||||
@@ -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)
|
||||
class EventAdmin(admin.ModelAdmin):
|
||||
form = EventAdminForm
|
||||
list_display = ["title", "kind", "start", "season", "series", "detached", "club"]
|
||||
list_filter = ["kind", "club", "teams", "detached", "cancelled"]
|
||||
search_fields = ["title"]
|
||||
@@ -53,9 +84,18 @@ class EventAdmin(admin.ModelAdmin):
|
||||
[_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}],
|
||||
[_("When"), {"fields": ["start", "end", "gathering", "deadline"]}],
|
||||
[_("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)
|
||||
class AttendanceAdmin(admin.ModelAdmin):
|
||||
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.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_countries.fields import CountryField
|
||||
|
||||
from club.models import Season
|
||||
from club.models import Club, Season
|
||||
from members.models import Member
|
||||
from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
|
||||
from teams.models import Team
|
||||
@@ -48,12 +49,12 @@ class Location(ClubScopedModel):
|
||||
|
||||
class Event(ClubScopedModel):
|
||||
class EventKind(models.TextChoices):
|
||||
TRAINING = "training", _("training")
|
||||
MATCH = "match", _("match")
|
||||
TOURNAMENT = "tournament", _("tournament")
|
||||
MEETING = "meeting", _("meeting")
|
||||
SOCIAL = "social", _("social")
|
||||
OTHER = "other", _("other")
|
||||
TRAINING = "training", _("Training")
|
||||
GAME = "game", _("Game")
|
||||
TOURNAMENT = "tournament", _("Tournament")
|
||||
MEETING = "meeting", _("Meeting")
|
||||
SOCIAL = "social", _("Social")
|
||||
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."))
|
||||
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"))
|
||||
end = models.DateTimeField(_("end"), 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"))
|
||||
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"))
|
||||
|
||||
# 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:
|
||||
verbose_name = _("event")
|
||||
verbose_name_plural = _("events")
|
||||
@@ -87,6 +97,13 @@ class Event(ClubScopedModel):
|
||||
def clean(self):
|
||||
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):
|
||||
"""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)."))
|
||||
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."))
|
||||
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)."))
|
||||
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 AttendanceStatus(models.TextChoices):
|
||||
PRESENT = "present", _("present")
|
||||
ABSENT = "absent", _("absent")
|
||||
EXCUSED = "excused", _("excused")
|
||||
SELECTED = "selected", _("selected")
|
||||
NOT_SELECTED = "not_selected", _("not selected")
|
||||
MAYBE = "maybe", _("maybe")
|
||||
NO_RESPONSE = "no_response", _("no response")
|
||||
PRESENT = "present", _("Present")
|
||||
ABSENT = "absent", _("Absent")
|
||||
EXCUSED = "excused", _("Excused")
|
||||
SELECTED = "selected", _("Selected")
|
||||
NOT_SELECTED = "not_selected", _("Not selected")
|
||||
MAYBE = "maybe", _("Maybe")
|
||||
NO_RESPONSE = "no_response", _("No response")
|
||||
|
||||
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"))
|
||||
@@ -153,3 +170,34 @@ class Attendance(UUIDModel):
|
||||
|
||||
def __str__(self):
|
||||
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.test import TestCase
|
||||
from django.utils import timezone
|
||||
from waffle import get_waffle_flag_model
|
||||
|
||||
from club.models import Club, Season
|
||||
from members.models import Member
|
||||
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 (
|
||||
cancel_occurrence,
|
||||
detach_occurrence,
|
||||
@@ -25,6 +27,7 @@ from .services import (
|
||||
team_attendance_rate,
|
||||
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):
|
||||
@@ -75,6 +78,76 @@ class EventModelTests(EventsTestBase):
|
||||
with self.assertRaises(IntegrityError):
|
||||
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):
|
||||
def test_union_of_team_invited_minus_excluded(self):
|
||||
@@ -517,3 +590,306 @@ class TeamAttendanceStatsTests(EventsTestBase):
|
||||
record_check_in(attendance, showed_up=True)
|
||||
|
||||
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:
|
||||
"""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_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"))
|
||||
|
||||
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):
|
||||
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``).
|
||||
"""
|
||||
|
||||
from waffle import flag_is_active
|
||||
|
||||
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 --
|
||||
@@ -54,6 +56,8 @@ _NAV_SECTIONS = {
|
||||
"team_staff_add": "team_list",
|
||||
"team_staff_update": "team_list",
|
||||
"team_staff_remove": "team_list",
|
||||
"team_photo_set": "team_list",
|
||||
"team_photo_delete": "team_list",
|
||||
"news_list": "news_list",
|
||||
"news_create": "news_list",
|
||||
"news_detail": "news_list",
|
||||
@@ -65,7 +69,20 @@ _NAV_SECTIONS = {
|
||||
"news_photo_set_main": "news_list",
|
||||
"news_photo_delete": "news_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_create": "location_list",
|
||||
"location_update": "location_list",
|
||||
@@ -74,6 +91,10 @@ _NAV_SECTIONS = {
|
||||
"opponent_create": "opponent_list",
|
||||
"opponent_update": "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",
|
||||
"order_list": "order_list",
|
||||
"discount_list": "discount_list",
|
||||
@@ -124,6 +145,23 @@ def management_link(request):
|
||||
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):
|
||||
"""Whether the "New news item" action should show -- viewing the News
|
||||
section itself is open to any staff, same as Roster/Staff."""
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from django import forms
|
||||
@@ -5,12 +6,16 @@ from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from club.models import ClubMembership, ClubRole, FeePayment
|
||||
from events.models import Location, Opponent
|
||||
from club.models import ClubMembership, ClubRole, FeePayment, Season, Sponsor
|
||||
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.services.family import find_member_by_email
|
||||
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()
|
||||
|
||||
@@ -28,6 +33,16 @@ class TeamForm(forms.ModelForm):
|
||||
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):
|
||||
"""Add/edit one roster entry -- team and season come from the view (the URL
|
||||
already identifies both), never from the form itself."""
|
||||
@@ -41,7 +56,13 @@ class TeamMembershipForm(forms.ModelForm):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.team = team
|
||||
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:
|
||||
# Already on this team's roster this season -- offering them again
|
||||
# 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):
|
||||
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:
|
||||
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)
|
||||
@@ -117,6 +142,206 @@ class OpponentForm(forms.ModelForm):
|
||||
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):
|
||||
"""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 %}
|
||||
<div class="flex justify-end gap-1">
|
||||
{% 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" %}
|
||||
</button>
|
||||
{% 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' %}">
|
||||
{% lucide "user-x" size=14 %} {% trans "Remove" %}
|
||||
</button>
|
||||
|
||||
@@ -30,19 +30,25 @@
|
||||
|
||||
<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_series_list' %}menu-active{% endif %}" href="{% url 'management:event_series_list' %}">{% lucide "repeat" size=16 %} {% trans "Event series" %}</a></li>
|
||||
{% 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 == 'opponent_list' %}menu-active{% endif %}" href="{% url 'management:opponent_list' %}">{% lucide "swords" size=16 %} {% trans "Opponents" %}</a></li>
|
||||
{% endif %}
|
||||
|
||||
{% 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><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 == '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>
|
||||
{% endif %}
|
||||
|
||||
{% if is_club_admin and forms_enabled %}
|
||||
<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>
|
||||
{% 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 %}
|
||||
{% trans "Add parent" as add_parent_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 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_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_child_modal').showModal()">{% lucide "baby" size=16 %} {{ add_child_label }}</button>
|
||||
{% endif %}
|
||||
{% endblock actions %}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -14,6 +14,19 @@
|
||||
</div>
|
||||
{% 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 %}
|
||||
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
|
||||
@@ -82,7 +95,7 @@
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<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 class="overflow-x-auto">
|
||||
<table class="table">
|
||||
@@ -91,7 +104,8 @@
|
||||
<tr>
|
||||
<td class="whitespace-nowrap">{{ event.start|date:"D j M, H:i" }}</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>
|
||||
{% empty %}
|
||||
<tr>
|
||||
@@ -108,7 +122,7 @@
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<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 class="overflow-x-auto">
|
||||
<table class="table">
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<td>{{ location.country }}</td>
|
||||
<td class="text-right">
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
{% block actions %}
|
||||
{% 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 %}
|
||||
{% endblock actions %}
|
||||
|
||||
@@ -114,8 +114,8 @@
|
||||
</h2>
|
||||
{% if is_club_admin %}
|
||||
<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-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_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_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>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -140,7 +140,7 @@
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
{% if not family_groups %}
|
||||
<p class="text-sm opacity-60">{% trans "Not part of a family." %}</p>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
{% endif %}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% block heading %}{% trans "Mass upload members" %}{% endblock heading %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
{% block panel %}
|
||||
@@ -30,7 +30,7 @@
|
||||
{% endfor %}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
</div>
|
||||
|
||||
<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 %}
|
||||
<form method="post" action="{% url 'management:member_import_confirm' %}">
|
||||
{% 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>
|
||||
{% 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-neutral 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:family_create' %}">{% lucide "users" size=16 %} {% trans "Add family" %}</a>
|
||||
<a class="btn btn-outline gap-2" href="{% url 'management:member_create' %}">{% lucide "user-plus" size=16 %} {% trans "Add member" %}</a>
|
||||
{% endif %}
|
||||
{% endblock actions %}
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
<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">
|
||||
</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 %}
|
||||
<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 %}
|
||||
</form>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<div class="flex flex-col gap-1">
|
||||
{% for fm in member.family_memberships_display %}
|
||||
<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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -73,7 +73,7 @@
|
||||
<td class="text-right">
|
||||
{% if is_club_admin %}
|
||||
<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>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block subheading %}{% if current_season %}{% blocktrans %}Fee status for season{% endblocktrans %}{% endif %}{% endblock subheading %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
{% block panel %}
|
||||
@@ -93,8 +93,8 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<button class="btn btn-outline btn-neutral 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>
|
||||
<button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button>
|
||||
<a class="btn gap-2" href="{% url 'management:membership_list' %}">{% lucide "x" size=16 %} {% trans "Reset" %}</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
<td class="text-right">
|
||||
{% if membership.record_payment_form %}
|
||||
<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" %}
|
||||
</button>
|
||||
<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 %}
|
||||
{% 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>
|
||||
{% endif %}
|
||||
{% if can_publish %}
|
||||
@@ -46,7 +46,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="card-title text-base">{% trans "Photos" %}</h2>
|
||||
{% 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 %}
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
{% if not photo.is_main %}
|
||||
<form method="post" action="{% url 'management:news_photo_set_main' news_item.pk photo.pk %}">
|
||||
{% 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>
|
||||
{% 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>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{% block actions %}
|
||||
{% 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 %}
|
||||
{% endblock actions %}
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<td class="text-right">
|
||||
{% if news_item.can_edit %}
|
||||
<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>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<td>{{ opponent.name }}</td>
|
||||
<td class="text-right">
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{% block actions %}
|
||||
{% 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 %}
|
||||
{% endblock actions %}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{% 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 %}
|
||||
</td>
|
||||
</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 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 %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% 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>
|
||||
{% endfor %}
|
||||
</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>
|
||||
</form>
|
||||
|
||||
@@ -28,6 +34,23 @@
|
||||
<span>{% trans "This club has no seasons yet, so there's no roster or staff to show." %}</span>
|
||||
</div>
|
||||
{% 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="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">
|
||||
@@ -126,7 +149,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="card-title text-base">{% trans "Roster" %}</h2>
|
||||
{% 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 %}
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
@@ -153,7 +176,7 @@
|
||||
<td class="text-right">
|
||||
{% if can_manage %}
|
||||
<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>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -175,7 +198,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="card-title text-base">{% trans "Staff" %}</h2>
|
||||
{% 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 %}
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
@@ -195,7 +218,7 @@
|
||||
<td class="text-right">
|
||||
{% if can_manage %}
|
||||
<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>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -212,6 +235,36 @@
|
||||
</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 %}
|
||||
{% trans "Add player" as add_player_label %}
|
||||
{% url 'management:team_roster_add' team.pk selected_season.pk as add_player_url %}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{% block actions %}
|
||||
{% 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 %}
|
||||
{% endblock actions %}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<td class="text-right">
|
||||
{% if is_club_admin %}
|
||||
<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>
|
||||
</div>
|
||||
{% 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: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>/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
|
||||
path("news/", views.NewsListView.as_view(), name="news_list"),
|
||||
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"),
|
||||
# Calendar
|
||||
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/new/", views.LocationCreateView.as_view(), name="location_create"),
|
||||
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/<uuid:pk>/edit/", views.OpponentUpdateView.as_view(), name="opponent_update"),
|
||||
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)
|
||||
path("shop/products/", views.ProductListView.as_view(), name="product_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.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View
|
||||
|
||||
from club.mixins import ClubAdminRequiredMixin, ClubStaffRequiredMixin, ManagementPositionRequiredMixin, NewsAuthorRequiredMixin, NewsEditRequiredMixin, NewsPublisherRequiredMixin, TeamManagerRequiredMixin
|
||||
from club.models import ClubMembership, ClubRole, Season
|
||||
from billing.models import RENEWAL_LEAD_DAYS, Due
|
||||
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.fees import mark_as_paid, record_payment, remaining_balance
|
||||
from controlpanel.messages import notify
|
||||
from controlpanel.mixins import RedirectOnInvalidMixin
|
||||
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.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 Submission
|
||||
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 news.models import News, NewsPhoto
|
||||
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 .forms import (
|
||||
@@ -33,6 +47,8 @@ from .forms import (
|
||||
AttachToFamilyForm,
|
||||
ClubMembershipForm,
|
||||
ClubRoleAssignForm,
|
||||
EventForm,
|
||||
EventSeriesForm,
|
||||
FamilyCreateForm,
|
||||
GrantLoginForm,
|
||||
LocationForm,
|
||||
@@ -43,12 +59,16 @@ from .forms import (
|
||||
NewsPublishForm,
|
||||
OpponentForm,
|
||||
PositionForm,
|
||||
RBIHFImportForm,
|
||||
RecordFeePaymentForm,
|
||||
SponsorForm,
|
||||
StaffAssignmentForm,
|
||||
TeamForm,
|
||||
TeamMembershipForm,
|
||||
TeamPhotoForm,
|
||||
)
|
||||
from .pdf import PDFExportError, membership_list_pdf
|
||||
from .recurrence_ui import describe_rrule
|
||||
|
||||
|
||||
class HomeView(ClubStaffRequiredMixin, TemplateView):
|
||||
@@ -56,18 +76,32 @@ class HomeView(ClubStaffRequiredMixin, TemplateView):
|
||||
club_attention/club_charts/club_statistics are the exact functions
|
||||
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
|
||||
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"
|
||||
|
||||
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(
|
||||
attention=club_attention(club),
|
||||
charts=club_charts(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],
|
||||
billing_ends_at=billing_ends_at,
|
||||
billing_auto_renews=subscription.auto_renew if subscription else False,
|
||||
today=timezone.localdate(),
|
||||
**kwargs,
|
||||
)
|
||||
@@ -774,6 +808,7 @@ class TeamDetailView(ClubStaffRequiredMixin, DetailView):
|
||||
top_attenders, bottom_attenders = [], []
|
||||
missed_practices = Member.objects.none()
|
||||
no_shows = []
|
||||
team_photo = TeamPhoto.objects.filter(team=team, season=season).first() if season is not None else 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"))
|
||||
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,
|
||||
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,
|
||||
team_photo=team_photo,
|
||||
team_photo_form=TeamPhotoForm(instance=team_photo) if can_manage and season else None,
|
||||
attendance_rate=attendance_rate,
|
||||
top_attenders=top_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}")
|
||||
|
||||
|
||||
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
|
||||
# granted or taken away) -------------------------------------------------------------
|
||||
|
||||
@@ -1411,19 +1498,440 @@ class NewsPhotoDeleteView(NewsEditRequiredMixin, View):
|
||||
return redirect("management:news_detail", pk=news_item.pk)
|
||||
|
||||
|
||||
class EventListView(ClubStaffRequiredMixin, StubListMixin, ListView):
|
||||
page_title = _("Events")
|
||||
def scoped_to_managed_teams(queryset, user, club):
|
||||
"""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):
|
||||
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):
|
||||
page_title = _("Event series")
|
||||
def get_form_kwargs(self):
|
||||
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):
|
||||
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):
|
||||
template_name = "management/location_list.html"
|
||||
@@ -1537,42 +2045,107 @@ class OpponentDeleteView(ManagementPositionRequiredMixin, View):
|
||||
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")
|
||||
|
||||
def get_queryset(self):
|
||||
return Product.objects.filter(club=self.request.club)
|
||||
|
||||
|
||||
class OrderListView(ClubAdminRequiredMixin, StubListMixin, ListView):
|
||||
class OrderListView(FeatureRequiredMixin, StubListMixin, ListView):
|
||||
feature_flag = "shop"
|
||||
page_title = _("Orders")
|
||||
|
||||
def get_queryset(self):
|
||||
return Order.objects.filter(club=self.request.club)
|
||||
|
||||
|
||||
class DiscountListView(ClubAdminRequiredMixin, StubListMixin, ListView):
|
||||
class DiscountListView(FeatureRequiredMixin, StubListMixin, ListView):
|
||||
feature_flag = "shop"
|
||||
page_title = _("Discounts")
|
||||
|
||||
def get_queryset(self):
|
||||
return Discount.objects.filter(club=self.request.club)
|
||||
|
||||
|
||||
class InvoiceListView(ClubAdminRequiredMixin, StubListMixin, ListView):
|
||||
class InvoiceListView(FeatureRequiredMixin, StubListMixin, ListView):
|
||||
feature_flag = "shop"
|
||||
page_title = _("Invoices")
|
||||
|
||||
def get_queryset(self):
|
||||
return Invoice.objects.filter(club=self.request.club)
|
||||
|
||||
|
||||
class FormListView(ClubAdminRequiredMixin, StubListMixin, ListView):
|
||||
class FormListView(FeatureRequiredMixin, StubListMixin, ListView):
|
||||
feature_flag = "formbuilder"
|
||||
page_title = _("Forms")
|
||||
|
||||
def get_queryset(self):
|
||||
return FormBuilderForm.objects.filter(club=self.request.club)
|
||||
|
||||
|
||||
class SubmissionListView(ClubAdminRequiredMixin, StubListMixin, ListView):
|
||||
class SubmissionListView(FeatureRequiredMixin, StubListMixin, ListView):
|
||||
feature_flag = "formbuilder"
|
||||
page_title = _("Submissions")
|
||||
|
||||
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"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"beautifulsoup4>=4.15.0",
|
||||
"dj-database-url>=3.1.2",
|
||||
"django>=6.0.6",
|
||||
"django-allauth[mfa]>=65.18.0",
|
||||
"django-countries>=9.0.0",
|
||||
"django-lucide",
|
||||
"django-ninja>=1.6.2",
|
||||
"django-phonenumber-field[phonenumbers]>=8.4.0",
|
||||
"django-redis>=7.0.0",
|
||||
"django-storages[s3]>=1.14.6",
|
||||
@@ -18,6 +20,7 @@ dependencies = [
|
||||
"psycopg[binary]>=3.3.4",
|
||||
"python-dateutil>=2.9.0.post0",
|
||||
"python-decouple>=3.8",
|
||||
"requests>=2.34.2",
|
||||
"weasyprint>=69.0",
|
||||
"whitenoise>=6.12.0",
|
||||
]
|
||||
@@ -58,7 +61,7 @@ ignore = [
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
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]
|
||||
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
|
||||
# staff managing every club) and not the future parent/player app.
|
||||
"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:
|
||||
@@ -100,6 +102,9 @@ MIDDLEWARE = [
|
||||
"club.tenancy.ClubTenantMiddleware",
|
||||
# After tenancy: it decides club-vs-platform from request.club, which was just resolved.
|
||||
"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.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
@@ -193,6 +198,7 @@ TEMPLATES = [
|
||||
"management.context_processors.management_link",
|
||||
"management.context_processors.active_nav_section",
|
||||
"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.views.generic import RedirectView
|
||||
|
||||
from api.urls import api
|
||||
from club.views import root
|
||||
|
||||
from .health import healthz
|
||||
@@ -25,6 +26,7 @@ urlpatterns = [
|
||||
path("accounts/", include("allauth.urls")),
|
||||
path("controlpanel/", include("controlpanel.urls")),
|
||||
path("manage/", include("management.urls")),
|
||||
path("api/v1/", api.urls),
|
||||
# "/" 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 "/".
|
||||
path("", root, name="root"),
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
--color-white: #fff;
|
||||
--spacing: 0.25rem;
|
||||
--container-xs: 20rem;
|
||||
--container-md: 28rem;
|
||||
--container-xl: 36rem;
|
||||
--container-2xl: 42rem;
|
||||
--container-6xl: 72rem;
|
||||
--text-xs: 0.75rem;
|
||||
--text-xs--line-height: calc(1 / 0.75);
|
||||
@@ -34,8 +36,10 @@
|
||||
--font-weight-bold: 700;
|
||||
--tracking-wide: 0.025em;
|
||||
--tracking-wider: 0.05em;
|
||||
--radius-lg: 0.5rem;
|
||||
--ease-out: cubic-bezier(0, 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-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--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 {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
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 {
|
||||
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 {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
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 {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
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 {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
display: flex;
|
||||
@@ -2264,6 +2411,30 @@
|
||||
.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 {
|
||||
@layer daisyui.l1.l2 {
|
||||
--anchor-h: span-left;
|
||||
@@ -2818,6 +2989,9 @@
|
||||
gap: calc(0.25rem * 2);
|
||||
}
|
||||
}
|
||||
.-mt-2 {
|
||||
margin-top: calc(var(--spacing) * -2);
|
||||
}
|
||||
.mt-1 {
|
||||
margin-top: var(--spacing);
|
||||
}
|
||||
@@ -3108,6 +3282,9 @@
|
||||
.inline-flex {
|
||||
display: inline-flex;
|
||||
}
|
||||
.inline-grid {
|
||||
display: inline-grid;
|
||||
}
|
||||
.table {
|
||||
display: table;
|
||||
}
|
||||
@@ -3154,12 +3331,18 @@
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
.max-w-2xl {
|
||||
max-width: var(--container-2xl);
|
||||
}
|
||||
.max-w-6xl {
|
||||
max-width: var(--container-6xl);
|
||||
}
|
||||
.max-w-40 {
|
||||
max-width: calc(var(--spacing) * 40);
|
||||
}
|
||||
.max-w-md {
|
||||
max-width: var(--container-md);
|
||||
}
|
||||
.max-w-none {
|
||||
max-width: none;
|
||||
}
|
||||
@@ -3175,18 +3358,58 @@
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
.flex-shrink {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
.shrink {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
.shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.flex-grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.border-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 {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
cursor: pointer;
|
||||
@@ -3214,6 +3437,12 @@
|
||||
.scrollbar-gutter-stable {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.list-inside {
|
||||
list-style-position: inside;
|
||||
}
|
||||
.list-disc {
|
||||
list-style-type: disc;
|
||||
}
|
||||
.grid-cols-1 {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
@@ -3325,6 +3554,9 @@
|
||||
.rounded-full {
|
||||
border-radius: calc(infinity * 1px);
|
||||
}
|
||||
.rounded-lg {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.border {
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 1px;
|
||||
@@ -3498,6 +3730,9 @@
|
||||
--btn-shadow: 0 0 0 0 oklch(0% 0 0/0);
|
||||
}
|
||||
}
|
||||
.mask-repeat {
|
||||
mask-repeat: repeat;
|
||||
}
|
||||
.object-contain {
|
||||
object-fit: contain;
|
||||
}
|
||||
@@ -3525,6 +3760,17 @@
|
||||
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 {
|
||||
padding-inline: calc(var(--spacing) * 3);
|
||||
}
|
||||
@@ -3546,6 +3792,9 @@
|
||||
.pb-2 {
|
||||
padding-bottom: calc(var(--spacing) * 2);
|
||||
}
|
||||
.pb-6 {
|
||||
padding-bottom: calc(var(--spacing) * 6);
|
||||
}
|
||||
.pl-8 {
|
||||
padding-left: calc(var(--spacing) * 8);
|
||||
}
|
||||
@@ -3736,11 +3985,17 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.line-through {
|
||||
text-decoration-line: line-through;
|
||||
}
|
||||
.prose {
|
||||
& :where(a.btn:not(.btn-link)):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||
text-decoration-line: none;
|
||||
}
|
||||
}
|
||||
.underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.opacity-40 {
|
||||
opacity: 40%;
|
||||
}
|
||||
@@ -3797,6 +4052,19 @@
|
||||
.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,);
|
||||
}
|
||||
.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 {
|
||||
@layer daisyui.l1.l2 {
|
||||
--in-size-mul: 12;
|
||||
@@ -3973,6 +4241,11 @@
|
||||
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 {
|
||||
@media (width >= 40rem) {
|
||||
padding-inline: calc(var(--spacing) * 6);
|
||||
@@ -4453,6 +4726,26 @@
|
||||
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 {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
@@ -4619,9 +4912,23 @@
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-ease {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@layer properties {
|
||||
@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 {
|
||||
--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-divide-y-reverse: 0;
|
||||
--tw-border-style: solid;
|
||||
@@ -4660,6 +4967,7 @@
|
||||
--tw-drop-shadow-color: initial;
|
||||
--tw-drop-shadow-alpha: 100%;
|
||||
--tw-drop-shadow-size: initial;
|
||||
--tw-ease: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
wrapper.className = "relative";
|
||||
|
||||
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");
|
||||
input.type = "text";
|
||||
@@ -31,7 +31,12 @@
|
||||
|
||||
select.parentNode.insertBefore(wrapper, select);
|
||||
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 {
|
||||
wrapper.append(input, list, select);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.contrib import admin
|
||||
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):
|
||||
@@ -20,12 +20,26 @@ class StaffAssignmentInline(admin.TabularInline):
|
||||
raw_id_fields = ("member",)
|
||||
|
||||
|
||||
class TeamPhotoInline(admin.TabularInline):
|
||||
"""One photo per season, shown on the Team page."""
|
||||
|
||||
model = TeamPhoto
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(Team)
|
||||
class TeamAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "short_name", "club"]
|
||||
list_filter = ["club"]
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
name = models.CharField(_("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 members.models import Member
|
||||
|
||||
from .models import Position, StaffAssignment, Team, TeamMembership
|
||||
from .models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto
|
||||
|
||||
|
||||
class TeamsTestCase(TestCase):
|
||||
@@ -153,3 +153,38 @@ class RosterCleanTests(TeamsTestCase):
|
||||
|
||||
def test_staffassignment_accepts_same_club(self):
|
||||
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 %}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
{{ redirect_field }}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{% endcomment %}
|
||||
{% comment %} djlint:off {% endcomment %}
|
||||
<{% 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.id %}id="{{ attrs.id }}"{% endif %}
|
||||
{% if attrs.name %}name="{{ attrs.name }}"{% endif %}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user