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:
2026-08-06 17:36:04 +02:00
parent 6ad0d6658c
commit 98b8002a04
101 changed files with 6165 additions and 135 deletions

13
.idea/pyLspTools.xml generated
View File

@@ -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
View File

6
api/apps.py Normal file
View 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
View 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
View 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
View 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
View 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)

View File

@@ -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>")])

View File

@@ -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
View 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]

View 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'),
),
]

View 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'],
},
),
]

View File

@@ -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

View File

@@ -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):

View File

@@ -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 /

View File

@@ -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

View File

@@ -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 %}

View File

@@ -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">

View File

@@ -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 &middot; {{ 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 %}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -27,14 +27,14 @@
{% block heading %}{{ club.name }}{% endblock heading %}
{% block subheading %}
{{ club.slug }}.rosterchief.app
{{ club.slug }}.rosterchief.app &middot; {{ 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 %}">

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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 &middot; 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 &amp; 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 &middot; 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):

View File

@@ -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
View 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]

View File

View 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

View 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"])

View File

@@ -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'),
),
]

View File

@@ -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),
]

View 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'),
),
]

View 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)),
],
),
]

View 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)]

View File

@@ -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),
]

View 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),
]

View File

@@ -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

View 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

View 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}

View File

@@ -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())

View File

@@ -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)

View File

@@ -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)

View File

@@ -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."""

View File

@@ -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."""

View 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}

View File

@@ -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>

View File

@@ -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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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" }}">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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 %}

View 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="">&mdash;</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>
&rarr;
<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="">&mdash;</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" }} &mdash; {{ 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 %}

View File

@@ -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 %}

View 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 %}

View 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">&mdash;</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 %}

View File

@@ -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>&middot;</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 %}

View File

@@ -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>

View File

@@ -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 %}

File diff suppressed because it is too large Load Diff

View File

@@ -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"),

View File

@@ -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
View 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])

View File

@@ -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" }

View File

@@ -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",
],
},
},

View File

@@ -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"),

View File

@@ -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;
}
}
}

View File

@@ -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);
}

View File

@@ -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
View 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

View 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')],
},
),
]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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">
&copy; {% now "Y" %} Bernard Siebens &middot; <a class="link link-hover" href="mailto:info@rosterchief.app">info@rosterchief.app</a>
</footer>
</main>
</div>

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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