diff --git a/.idea/pyLspTools.xml b/.idea/pyLspTools.xml index 0a5598d..f39483f 100644 --- a/.idea/pyLspTools.xml +++ b/.idea/pyLspTools.xml @@ -1,5 +1,18 @@ + + + diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/apps.py b/api/apps.py new file mode 100644 index 0000000..e1d9dc1 --- /dev/null +++ b/api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + name = "api" + verbose_name = "Public API" diff --git a/api/errors.py b/api/errors.py new file mode 100644 index 0000000..ddd37bc --- /dev/null +++ b/api/errors.py @@ -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 diff --git a/api/middleware.py b/api/middleware.py new file mode 100644 index 0000000..34ef327 --- /dev/null +++ b/api/middleware.py @@ -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 diff --git a/api/tests.py b/api/tests.py new file mode 100644 index 0000000..5d37276 --- /dev/null +++ b/api/tests.py @@ -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"]) diff --git a/api/urls.py b/api/urls.py new file mode 100644 index 0000000..f6fc8f6 --- /dev/null +++ b/api/urls.py @@ -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) diff --git a/authentication/tests.py b/authentication/tests.py index 2635530..a3b62d7 100644 --- a/authentication/tests.py +++ b/authentication/tests.py @@ -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, ' {% if not subscription %} - {% endif %} @@ -87,7 +87,7 @@ {% if not due.payments.all %}
{% csrf_token %} - +
{% endif %} {% endif %} diff --git a/controlpanel/templates/controlpanel/_club_features_card.html b/controlpanel/templates/controlpanel/_club_features_card.html index 8a88fca..e335918 100644 --- a/controlpanel/templates/controlpanel/_club_features_card.html +++ b/controlpanel/templates/controlpanel/_club_features_card.html @@ -8,7 +8,7 @@
diff --git a/controlpanel/templates/controlpanel/_club_health_table.html b/controlpanel/templates/controlpanel/_club_health_table.html index dc34d63..3613f14 100644 --- a/controlpanel/templates/controlpanel/_club_health_table.html +++ b/controlpanel/templates/controlpanel/_club_health_table.html @@ -43,7 +43,7 @@
{{ club.name }} -
{{ club.slug }}.rosterchief.app
+
{{ club.slug }}.rosterchief.app · {{ club.get_sport_type_display }}
@@ -122,7 +122,7 @@ {% empty %} diff --git a/controlpanel/templates/controlpanel/_confirm_modal.html b/controlpanel/templates/controlpanel/_confirm_modal.html index 544d12b..3b89361 100644 --- a/controlpanel/templates/controlpanel/_confirm_modal.html +++ b/controlpanel/templates/controlpanel/_confirm_modal.html @@ -18,7 +18,7 @@ diff --git a/controlpanel/templates/controlpanel/_modal_form.html b/controlpanel/templates/controlpanel/_modal_form.html index 760a469..9b545a1 100644 --- a/controlpanel/templates/controlpanel/_modal_form.html +++ b/controlpanel/templates/controlpanel/_modal_form.html @@ -19,7 +19,7 @@ diff --git a/controlpanel/templates/controlpanel/billing.html b/controlpanel/templates/controlpanel/billing.html index f9496b0..2487280 100644 --- a/controlpanel/templates/controlpanel/billing.html +++ b/controlpanel/templates/controlpanel/billing.html @@ -53,7 +53,7 @@ {% empty %} diff --git a/controlpanel/templates/controlpanel/club_detail.html b/controlpanel/templates/controlpanel/club_detail.html index 5054799..384699d 100644 --- a/controlpanel/templates/controlpanel/club_detail.html +++ b/controlpanel/templates/controlpanel/club_detail.html @@ -27,14 +27,14 @@ {% block heading %}{{ club.name }}{% endblock heading %} {% block subheading %} - {{ club.slug }}.rosterchief.app + {{ club.slug }}.rosterchief.app · {{ club.get_sport_type_display }} {% if club.is_archived %} Archived {% endif %} {% endblock subheading %} {% block actions %} - {% lucide "pencil" size=16 %} Edit + {% lucide "pencil" size=16 %} Edit{% lucide "external-link" size=16 %} Open {% if club.is_archived %} diff --git a/controlpanel/templates/controlpanel/club_form.html b/controlpanel/templates/controlpanel/club_form.html index b4531d4..6aa9878 100644 --- a/controlpanel/templates/controlpanel/club_form.html +++ b/controlpanel/templates/controlpanel/club_form.html @@ -15,9 +15,10 @@ {% endfor %} -
+
{% form_field form.name %} {% form_field form.slug %} + {% form_field form.sport_type %}
@@ -36,7 +37,7 @@
diff --git a/controlpanel/templates/controlpanel/club_list.html b/controlpanel/templates/controlpanel/club_list.html index e736d6a..a815aa4 100644 --- a/controlpanel/templates/controlpanel/club_list.html +++ b/controlpanel/templates/controlpanel/club_list.html @@ -5,9 +5,9 @@ {% block actions %} {% if show_archived %} - {% lucide "archive-x" size=16 %} Hide archived clubs + {% lucide "archive-x" size=16 %} Hide archived clubs {% else %} - {% lucide "archive" size=16 %} Show archived clubs + {% lucide "archive" size=16 %} Show archived clubs {% endif %} {% lucide "plus" size=16 %} New club {% endblock actions %} @@ -24,7 +24,7 @@ class="input input-bordered w-full max-w-xs"> - + {% if search %} {% lucide "x" size=16 %} Clear filter {% endif %} diff --git a/controlpanel/templates/controlpanel/features.html b/controlpanel/templates/controlpanel/features.html index 15c3209..64fa957 100644 --- a/controlpanel/templates/controlpanel/features.html +++ b/controlpanel/templates/controlpanel/features.html @@ -93,7 +93,7 @@ {% empty %} diff --git a/controlpanel/templates/templatetags/field.html b/controlpanel/templates/templatetags/field.html index b2bec9c..db9360b 100644 --- a/controlpanel/templates/templatetags/field.html +++ b/controlpanel/templates/templatetags/field.html @@ -54,6 +54,15 @@ >{{ field.value|default:"" }} {% 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 ' + f'' + f'' + f'' + for r in rows + ) + return f""" +

{RBIHF_TEAM_NAME}

+

Upcoming games

+
- {% lucide "pencil" size=14 %} Edit + {% lucide "pencil" size=14 %} Edit
- +
{{ flag.clubs.count }} {{ flag.note|default:"-" }} - +
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
{r["game_id"]}{r["date"]}{r["hour"]}{r["venue"]}{r["home_name"]}{r["visit_name"]}
+ + {row_html} +
#DateHourLocationHomeVisit
+ """ + + +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("

Some Team

", 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 + {% trans "Keep a record of it (marks it cancelled instead of deleting it)" %} + + {% else %} +

{% trans "This cannot be undone." %}

+ {% endif %} + + + + + + {% endif %} + + {% if has_attendance_rows %} + + + + + {% endif %} +{% endblock extra_body %} diff --git a/management/templates/management/event_form.html b/management/templates/management/event_form.html new file mode 100644 index 0000000..063303a --- /dev/null +++ b/management/templates/management/event_form.html @@ -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 %} +
+
+
+ {% csrf_token %} + + {% for error in form.non_field_errors %} +
+ {{ error }} +
+ {% endfor %} + +
+ {% form_field form.title %} + {% form_field form.kind %} +
+ +
+

{% trans "Audience" %}

+
+ {% form_field form.teams %} + {% form_field form.invited_members %} + {% form_field form.excluded_members %} +
+ +
+

{% trans "Where" %}

+
+ {% form_field form.location %} +
+ {% form_field form.opponent %} +
+
+ +
+

{% trans "When" %}

+
+ {% form_field form.start %} + {% form_field form.end %} + {% form_field form.gathering %} + {% form_field form.deadline %} +
+ +
+
+

{% trans "Game" %}

+
+ {% form_field form.competition %} + {% form_field form.external_game_id %} +
+ {% 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 %} +
+ {% form_field form.score_for %} + {% form_field form.score_against %} + {% form_field form.is_live %} +
+ {% endif %} +
+ +
+ {% lucide "arrow-left" size=16 %} {% trans "Cancel" %} + +
+
+
+
+{% endblock panel %} + +{% block extra_body %} + + +{% endblock extra_body %} diff --git a/management/templates/management/event_list.html b/management/templates/management/event_list.html new file mode 100644 index 0000000..9a338f3 --- /dev/null +++ b/management/templates/management/event_list.html @@ -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 %} + {% lucide "download" size=16 %} {% trans "Import from RBIHF" %} + {% endif %} + {% if can_create %} + {% lucide "repeat" size=16 %} {% trans "New series" %} + {% lucide "plus" size=16 %} {% trans "New event" %} + {% endif %} +{% endblock actions %} + +{% block panel %} +
+
+ + + + +
+
+ +
+
+
+ + + + + + + + + + + + + {% for event in events %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Title" %}{% trans "Kind" %}{% trans "When" %}{% trans "Teams" %}{% trans "Location" %}
+ {{ event.title }} + {% if event.series_id %}{% lucide "repeat" size=12 %}{% endif %} + {{ event.get_kind_display }}{{ event.start|date:"j M Y H:i" }}{% for team in event.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}{{ event.location.name|default:"—" }} + {% if event.can_manage %} +
+ {% lucide "pencil" size=14 %} {% trans "Edit" %} + +
+ {% endif %} +
{% trans "No events." %}
+
+
+
+ + {% comment %} Dialogs live outside the table: may only contain elements. {% endcomment %} + {% for event in events %} + {% if event.can_manage %} + + + + + {% endif %} + {% endfor %} +{% endblock panel %} diff --git a/management/templates/management/event_series_detail.html b/management/templates/management/event_series_detail.html new file mode 100644 index 0000000..1e048c6 --- /dev/null +++ b/management/templates/management/event_series_detail.html @@ -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 %} + {% lucide "pencil" size=16 %} {% trans "Edit" %} +
+ {% csrf_token %} + +
+ + + {% endif %} +{% endblock actions %} + +{% block panel %} +
+
+
+

{% lucide "repeat" size=18 %} {% trans "Recurrence" %}

+
+
+
{% trans "Pattern" %}
+
{{ recurrence_summary }}
+
+
+
{% trans "First occurrence" %}
+
{{ series.dtstart|date:"j M Y H:i" }}
+
+
+
{% trans "Repeats until" %}
+
{{ series.until|date:"j M Y H:i"|default:"—" }}
+
+
+
{% trans "Generated up to" %}
+
{{ series.generated_until|date:"j M Y"|default:"—" }}
+
+
+
+
+ +
+
+

{% lucide "info" size=18 %} {% trans "Template" %}

+
+
+
{% trans "Location" %}
+
{{ series.location|default:"—" }}
+
+
+
{% trans "Opponent" %}
+
{{ series.opponent|default:"—" }}
+
+
+
{% trans "Teams" %}
+
{% for team in series.teams.all %}{{ team.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}
+
+
+
+
+
+ +
+
+

{% lucide "calendar" size=18 %} {% trans "Occurrences" %}

+
+ + + + + + + + + {% for occurrence in occurrences %} + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "When" %}{% trans "Status" %}
{{ occurrence.start|date:"j M Y H:i" }} + {% if occurrence.cancelled %} + {% lucide "ban" size=12 %} {% trans "Cancelled" %} + {% elif occurrence.detached %} + {% lucide "unlink" size=12 %} {% trans "Detached" %} + {% elif occurrence.is_past %} + {% trans "Past" %} + {% else %} + {% trans "Upcoming" %} + {% endif %} +
{% trans "No occurrences generated yet." %}
+
+
+
+{% 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 %} diff --git a/management/templates/management/event_series_form.html b/management/templates/management/event_series_form.html new file mode 100644 index 0000000..60e9234 --- /dev/null +++ b/management/templates/management/event_series_form.html @@ -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 %} +
+
+
+ {% csrf_token %} + + {% for error in form.non_field_errors %} +
+ {{ error }} +
+ {% endfor %} + +
+ {% form_field form.title %} + {% form_field form.kind %} +
+ +
+

{% trans "Repeats" %}

+
+ {% form_field form.frequency %} + {% form_field form.interval %} + {% form_field form.dtstart %} + {% form_field form.until %} +
+ {% 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 %} +
+ {% trans "Advanced: raw recurrence rule" %} +
+ {% form_field form.advanced_rrule %} +
+
+ +
+

{% trans "Audience" %}

+
+ {% form_field form.teams %} + {% form_field form.invited_members %} + {% form_field form.excluded_members %} +
+ +
+

{% trans "Where" %}

+
+ {% form_field form.location %} + {% form_field form.opponent %} +
+ +
+

{% trans "Timing" %}

+
+ {% form_field form.duration_hours %} + {% form_field form.duration_minutes %} + {% form_field form.gathering_minutes_before %} + {% form_field form.deadline_minutes_before %} +
+ +
+ {% lucide "arrow-left" size=16 %} {% trans "Cancel" %} + +
+
+
+
+{% endblock panel %} + +{% block extra_body %} + +{% endblock extra_body %} diff --git a/management/templates/management/family_detail.html b/management/templates/management/family_detail.html index 0e8f06b..ff3bec1 100644 --- a/management/templates/management/family_detail.html +++ b/management/templates/management/family_detail.html @@ -7,8 +7,8 @@ {% if is_club_admin %} {% trans "Add parent" as add_parent_label %} {% trans "Add child" as add_child_label %} - - + + {% endif %} {% endblock actions %} diff --git a/management/templates/management/family_form.html b/management/templates/management/family_form.html index 4c00599..c6eb451 100644 --- a/management/templates/management/family_form.html +++ b/management/templates/management/family_form.html @@ -34,7 +34,7 @@
- {% lucide "arrow-left" size=16 %} {% trans "Cancel" %} + {% lucide "arrow-left" size=16 %} {% trans "Cancel" %}
diff --git a/management/templates/management/home.html b/management/templates/management/home.html index c90073c..4e249bc 100644 --- a/management/templates/management/home.html +++ b/management/templates/management/home.html @@ -14,6 +14,19 @@ {% endif %} + {% if is_club_admin and billing_ends_at %} +
+ {% lucide "calendar-clock" size=20 %} + + {% 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 %} + +
+ {% 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 @@
@@ -91,7 +104,8 @@ - + + {% empty %} @@ -108,7 +122,7 @@
{{ event.start|date:"D j M, H:i" }} {{ event.title }}{{ event.get_kind_display|capfirst }}{{ event.get_kind_display }}{% for team in event.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}
diff --git a/management/templates/management/location_form.html b/management/templates/management/location_form.html index ad6c126..9398291 100644 --- a/management/templates/management/location_form.html +++ b/management/templates/management/location_form.html @@ -22,7 +22,7 @@ diff --git a/management/templates/management/location_list.html b/management/templates/management/location_list.html index 88fc40a..b768f7d 100644 --- a/management/templates/management/location_list.html +++ b/management/templates/management/location_list.html @@ -33,7 +33,7 @@ diff --git a/management/templates/management/member_detail.html b/management/templates/management/member_detail.html index 07698a9..5f0dee0 100644 --- a/management/templates/management/member_detail.html +++ b/management/templates/management/member_detail.html @@ -6,7 +6,7 @@ {% block actions %} {% if is_club_admin %} - {% lucide "pencil" size=16 %} {% trans "Edit" %} + {% lucide "pencil" size=16 %} {% trans "Edit" %} {% endif %} {% endblock actions %} @@ -114,8 +114,8 @@ {% if is_club_admin %}
- - + +
{% endif %} @@ -140,7 +140,7 @@

{% lucide "users" size=18 %} {% trans "Family" %}

- +
{% if not family_groups %}

{% trans "Not part of a family." %}

diff --git a/management/templates/management/member_form.html b/management/templates/management/member_form.html index ff8d828..4a6f484 100644 --- a/management/templates/management/member_form.html +++ b/management/templates/management/member_form.html @@ -45,7 +45,7 @@ {% endif %} diff --git a/management/templates/management/member_import.html b/management/templates/management/member_import.html index e6798fc..ab92361 100644 --- a/management/templates/management/member_import.html +++ b/management/templates/management/member_import.html @@ -4,7 +4,7 @@ {% block heading %}{% trans "Mass upload members" %}{% endblock heading %} {% block actions %} - {% lucide "download" size=16 %} {% trans "Download template" %} + {% lucide "download" size=16 %} {% trans "Download template" %} {% endblock actions %} {% block panel %} @@ -30,7 +30,7 @@ {% endfor %} diff --git a/management/templates/management/member_import_preview.html b/management/templates/management/member_import_preview.html index 11fe6dc..5875072 100644 --- a/management/templates/management/member_import_preview.html +++ b/management/templates/management/member_import_preview.html @@ -55,7 +55,7 @@
- {% lucide "arrow-left" size=16 %} {% trans "Back" %} + {% lucide "arrow-left" size=16 %} {% trans "Back" %} {% if valid_count %}
{% csrf_token %} diff --git a/management/templates/management/member_list.html b/management/templates/management/member_list.html index e6444a6..cd1e9f4 100644 --- a/management/templates/management/member_list.html +++ b/management/templates/management/member_list.html @@ -7,8 +7,8 @@ {% lucide "download" size=16 %} {% trans "Download upload template" %} {% if is_club_admin %} {% lucide "upload" size=16 %} {% trans "Mass upload" %} - {% lucide "users" size=16 %} {% trans "Add family" %} - {% lucide "user-plus" size=16 %} {% trans "Add member" %} + {% lucide "users" size=16 %} {% trans "Add family" %} + {% lucide "user-plus" size=16 %} {% trans "Add member" %} {% endif %} {% endblock actions %} @@ -18,9 +18,9 @@ {% lucide "search" size=16 %} - + {% if search %} - {% lucide "x" size=16 %} {% trans "Clear filter" %} + {% lucide "x" size=16 %} {% trans "Clear filter" %} {% endif %} @@ -49,7 +49,7 @@ @@ -73,7 +73,7 @@
diff --git a/management/templates/management/position_form.html b/management/templates/management/position_form.html index 8e3670d..709c405 100644 --- a/management/templates/management/position_form.html +++ b/management/templates/management/position_form.html @@ -29,7 +29,7 @@ diff --git a/management/templates/management/position_list.html b/management/templates/management/position_list.html index d57913a..99ec28e 100644 --- a/management/templates/management/position_list.html +++ b/management/templates/management/position_list.html @@ -5,7 +5,7 @@ {% block actions %} {% if is_club_admin %} - {% lucide "plus" size=16 %} {% trans "New position" %} + {% lucide "plus" size=16 %} {% trans "New position" %} {% endif %} {% endblock actions %} @@ -42,7 +42,7 @@ diff --git a/management/templates/management/rbihf_import_form.html b/management/templates/management/rbihf_import_form.html new file mode 100644 index 0000000..df39c57 --- /dev/null +++ b/management/templates/management/rbihf_import_form.html @@ -0,0 +1,35 @@ +{% extends "management/base.html" %} +{% load lucide ui i18n %} + +{% block heading %}{% trans "Import from RBIHF" %}{% endblock heading %} + +{% block panel %} +
+
+

+ {% 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 %} +

+ +
+ {% csrf_token %} + + {% for error in form.non_field_errors %} +
+ {{ error }} +
+ {% endfor %} + + {% for field in form %} + {% form_field field %} + {% endfor %} + +
+ {% lucide "arrow-left" size=16 %} {% trans "Cancel" %} + +
+ +
+
+{% endblock panel %} diff --git a/management/templates/management/rbihf_import_preview.html b/management/templates/management/rbihf_import_preview.html new file mode 100644 index 0000000..f6c7cf4 --- /dev/null +++ b/management/templates/management/rbihf_import_preview.html @@ -0,0 +1,152 @@ +{% extends "management/base.html" %} +{% load lucide i18n %} + +{% block heading %}{% trans "Review import" %}{% endblock heading %} + +{% block panel %} +
+ {% lucide "info" size=16 %} + {% 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 %} +
+ + + {% csrf_token %} + +
+
+

{% lucide "plus" size=18 %} {% blocktrans count counter=plan.to_create|length %}{{ counter }} game to create{% plural %}{{ counter }} games to create{% endblocktrans %}

+ + {% if plan.to_create %} +
+
{{ location.country }} {% if is_club_admin %} {% endif %} diff --git a/management/templates/management/membership_list.html b/management/templates/management/membership_list.html index de2c81c..9a39a88 100644 --- a/management/templates/management/membership_list.html +++ b/management/templates/management/membership_list.html @@ -5,7 +5,7 @@ {% block subheading %}{% if current_season %}{% blocktrans %}Fee status for season{% endblocktrans %}{% endif %}{% endblock subheading %} {% block actions %} - {% lucide "file-down" size=16 %} {% trans "Export to PDF" %} + {% lucide "file-down" size=16 %} {% trans "Export to PDF" %} {% endblock actions %} {% block panel %} @@ -93,8 +93,8 @@ {% endfor %} - - {% lucide "x" size=16 %} {% trans "Reset" %} + + {% lucide "x" size=16 %} {% trans "Reset" %} @@ -173,7 +173,7 @@ {% if membership.record_payment_form %}
- {% endif %} {% if can_publish %} @@ -46,7 +46,7 @@

{% trans "Photos" %}

{% if can_edit %} - + {% endif %}
@@ -62,7 +62,7 @@ {% if not photo.is_main %}
{% csrf_token %} - +
{% endif %} diff --git a/management/templates/management/news_form.html b/management/templates/management/news_form.html index 2e69760..9065068 100644 --- a/management/templates/management/news_form.html +++ b/management/templates/management/news_form.html @@ -26,7 +26,7 @@
diff --git a/management/templates/management/news_list.html b/management/templates/management/news_list.html index 4264348..75e8a81 100644 --- a/management/templates/management/news_list.html +++ b/management/templates/management/news_list.html @@ -5,7 +5,7 @@ {% block actions %} {% if can_add_news %} - {% lucide "plus" size=16 %} {% trans "New news item" %} + {% lucide "plus" size=16 %} {% trans "New news item" %} {% endif %} {% endblock actions %} @@ -47,7 +47,7 @@
{% if news_item.can_edit %} {% endif %} diff --git a/management/templates/management/opponent_form.html b/management/templates/management/opponent_form.html index 15ee75c..215f36a 100644 --- a/management/templates/management/opponent_form.html +++ b/management/templates/management/opponent_form.html @@ -22,7 +22,7 @@ diff --git a/management/templates/management/opponent_list.html b/management/templates/management/opponent_list.html index c70e943..e1480d2 100644 --- a/management/templates/management/opponent_list.html +++ b/management/templates/management/opponent_list.html @@ -30,7 +30,7 @@ {{ opponent.name }} {% if is_club_admin %} - {% lucide "pencil" size=14 %} {% trans "Edit" %} + {% lucide "pencil" size=14 %} {% trans "Edit" %} {% endif %}
+ + + + + + + + + + {% for planned in plan.to_create %} + + + + + + + {% endfor %} + +
{% trans "#" %}{% trans "Start" %}{% trans "Opponent" %}{% trans "Location" %}
{{ planned.fixture.external_game_id }}{{ planned.fixture.start|date:"Y-m-d H:i" }} +
+ + {% blocktrans with opponent=planned.fixture.opponent_name %}RBIHF: {{ opponent }}{% endblocktrans %} +
+
+
+ + {% blocktrans with venue=planned.fixture.venue_text %}RBIHF: {{ venue }}{% endblocktrans %} +
+
+
+ {% endif %} +
+ + +
+
+

{% lucide "refresh-cw" size=18 %} {% blocktrans count counter=plan.to_update|length %}{{ counter }} game to update{% plural %}{{ counter }} games to update{% endblocktrans %}

+ + {% if plan.to_update %} +
+ + + + + + + + + + + {% for planned in plan.to_update %} + + + + + + + {% endfor %} + +
{% trans "#" %}{% trans "Changes" %}{% trans "Opponent" %}{% trans "Location" %}
{{ planned.fixture.external_game_id }} + {% for field, change in planned.changes.items %} +
+ {{ field }}: + {{ change.0|default:"—" }} + → + {{ change.1 }} +
+ {% endfor %} +
+
+ + {% blocktrans with opponent=planned.fixture.opponent_name %}RBIHF: {{ opponent }}{% endblocktrans %} +
+
+
+ + {% blocktrans with venue=planned.fixture.venue_text %}RBIHF: {{ venue }}{% endblocktrans %} +
+
+
+ {% endif %} +
+
+ +
+
+

{% lucide "trash-2" size=18 %} {% blocktrans count counter=plan.to_delete|length %}{{ counter }} game to delete{% plural %}{{ counter }} games to delete{% endblocktrans %}

+

{% trans "No longer listed on the RBIHF page — only upcoming games are ever removed this way, past results are left alone." %}

+ + {% if plan.to_delete %} +
    + {% for event in plan.to_delete %} +
  • {{ event.start|date:"Y-m-d H:i" }} — {{ event }}
  • + {% endfor %} +
+ {% endif %} +
+
+ + {% if plan.unchanged_count %} +

{% blocktrans count counter=plan.unchanged_count %}{{ counter }} game is already up to date.{% plural %}{{ counter }} games are already up to date.{% endblocktrans %}

+ {% endif %} + +
+ {% lucide "arrow-left" size=16 %} {% trans "Back" %} + {% if plan.to_create or plan.to_update or plan.to_delete %} + + {% endif %} +
+ +{% endblock panel %} diff --git a/management/templates/management/role_list.html b/management/templates/management/role_list.html index 7a0d20b..88092e3 100644 --- a/management/templates/management/role_list.html +++ b/management/templates/management/role_list.html @@ -12,7 +12,7 @@ {% block heading %}{% trans "Roles" %}{% endblock heading %} {% block actions %} - + {% endblock actions %} {% block panel %} diff --git a/management/templates/management/sponsor_form.html b/management/templates/management/sponsor_form.html new file mode 100644 index 0000000..cb1d4e7 --- /dev/null +++ b/management/templates/management/sponsor_form.html @@ -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 %} +
+
+
+ {% csrf_token %} + + {% for error in form.non_field_errors %} +
+ {{ error }} +
+ {% endfor %} + +
+ {% for field in form %} + {% form_field field %} + {% endfor %} +
+ +
+ {% lucide "arrow-left" size=16 %} {% trans "Cancel" %} + +
+
+
+
+{% endblock panel %} diff --git a/management/templates/management/sponsor_list.html b/management/templates/management/sponsor_list.html new file mode 100644 index 0000000..9940fc3 --- /dev/null +++ b/management/templates/management/sponsor_list.html @@ -0,0 +1,68 @@ +{% extends "management/base.html" %} +{% load i18n lucide ui %} + +{% block heading %}{% trans "Sponsors" %}{% endblock heading %} + +{% block actions %} + {% lucide "plus" size=16 %} {% trans "New sponsor" %} +{% endblock actions %} + +{% block panel %} +
+
+
+ + + + + + + + + + + + + {% for sponsor in sponsors %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Name" %}{% trans "URL" %}{% trans "Start date" %}{% trans "End date" %}
+ {% if sponsor.logo %} + + {% endif %} + {{ sponsor.name }} + {% if sponsor.url %} + {{ sponsor.url }} + {% else %} + + {% endif %} + {{ sponsor.start_date|date:"Y-m-d" }}{{ sponsor.end_date|date:"Y-m-d"|default:"—" }} +
+ {% lucide "pencil" size=14 %} {% trans "Edit" %} + +
+
{% trans "No sponsors yet." %}
+
+
+
+ + {% 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 %} diff --git a/management/templates/management/team_detail.html b/management/templates/management/team_detail.html index c08fce7..496dd6e 100644 --- a/management/templates/management/team_detail.html +++ b/management/templates/management/team_detail.html @@ -2,11 +2,17 @@ {% load i18n lucide static ui %} {% block heading %}{{ team.name }}{% endblock heading %} -{% block subheading %}{{ team.short_name }}{% endblock subheading %} +{% block subheading %} + + {{ team.short_name }} + · + {% lucide "bookmark" size=14 %} {{ team.pk }} + +{% endblock subheading %} {% block actions %} {% if is_club_admin %} - {% lucide "pencil" size=16 %} {% trans "Edit" %} + {% lucide "pencil" size=16 %} {% trans "Edit" %} {% endif %} {% endblock actions %} @@ -18,7 +24,7 @@ {% endfor %} - + @@ -28,6 +34,23 @@ {% trans "This club has no seasons yet, so there's no roster or staff to show." %} {% 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 %} +
@@ -126,7 +149,7 @@

{% trans "Roster" %}

{% if can_manage %} - + {% endif %}
@@ -153,7 +176,7 @@ {% if can_manage %}
- +
{% endif %} @@ -175,7 +198,7 @@

{% trans "Staff" %}

{% if can_manage %} - + {% endif %}
@@ -195,7 +218,7 @@ {% if can_manage %}
- +
{% endif %} @@ -212,6 +235,36 @@
+
+
+
+

{% trans "Team photo" %}

+ {% if can_manage %} +
+ {% trans "Upload photo" as upload_photo_label %} + {% trans "Replace photo" as replace_photo_label %} + + {% if team_photo %} + + {% endif %} +
+ {% endif %} +
+ + {% if team_photo %} + {{ team }} + {% else %} +

{% trans "No photo uploaded for this season yet." %}

+ {% endif %} +
+
+ {% if can_manage %} {% trans "Add player" as add_player_label %} {% url 'management:team_roster_add' team.pk selected_season.pk as add_player_url %} diff --git a/management/templates/management/team_form.html b/management/templates/management/team_form.html index c3fde72..c25cc3c 100644 --- a/management/templates/management/team_form.html +++ b/management/templates/management/team_form.html @@ -22,7 +22,7 @@
diff --git a/management/templates/management/team_list.html b/management/templates/management/team_list.html index 2a718b3..703d49e 100644 --- a/management/templates/management/team_list.html +++ b/management/templates/management/team_list.html @@ -5,7 +5,7 @@ {% block actions %} {% if is_club_admin %} - {% lucide "plus" size=16 %} {% trans "New team" %} + {% lucide "plus" size=16 %} {% trans "New team" %} {% endif %} {% endblock actions %} @@ -33,7 +33,7 @@ {% if is_club_admin %} {% endif %} diff --git a/management/tests.py b/management/tests.py index df62651..493cbc1 100644 --- a/management/tests.py +++ b/management/tests.py @@ -8,19 +8,26 @@ from unittest import mock import openpyxl from allauth.mfa.models import Authenticator from django.contrib.auth import get_user_model +from django.core.cache import cache from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase, override_settings from django.urls import NoReverseMatch, reverse from django.utils import timezone +from waffle import get_waffle_flag_model -from club.models import Club, ClubMembership, ClubRole, FeePayment, Season -from events.models import Attendance, Event, Location, Opponent +from billing.models import Tier, TierPrice +from billing.services.dues import subscribe +from club.models import Club, ClubMembership, ClubRole, FeePayment, Season, Sponsor +from events.models import Attendance, Competition, Event, EventSeries, Location, Opponent +from events.services.rbihf_import import RBIHFImportError +from events.services.recurrence import detach_occurrence, generate_occurrences from management.bulk_import import TEMPLATE_COLUMNS from management.pdf import PDFExportError, render_pdf +from management.recurrence_ui import build_rrule, describe_rrule, parse_rrule from members.models import Family, FamilyMembership, Member from news.models import News, NewsPhoto from shop.models import Order -from teams.models import Position, StaffAssignment, Team, TeamMembership +from teams.models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto User = get_user_model() @@ -380,6 +387,35 @@ class TeamRosterStaffTests(ManagementTestBase): self.assertTrue(TeamMembership.objects.filter(team=self.team, season=self.season, member=self.player).exists()) + def test_the_add_player_dropdown_excludes_a_member_with_no_active_membership(self): + lapsed_player = Member.objects.create(first_name="Lex", last_name="Lapsed") + ClubMembership.objects.create(club=self.club, member=lapsed_player, season=self.season, status=ClubMembership.StatusChoices.LAPSED) + self.client.force_login(self.admin_user) + + response = self.club_get("team_detail", self.team.pk) + + self.assertNotContains(response, "Lex Lapsed") + + def test_the_add_player_dropdown_includes_a_member_active_only_next_season(self): + next_season = Season.objects.create(club=self.club, start_date=self.season.end_date + datetime.timedelta(days=1), end_date=self.season.end_date + datetime.timedelta(days=300)) + upcoming_player = Member.objects.create(first_name="Uma", last_name="Upcoming") + ClubMembership.objects.create(club=self.club, member=upcoming_player, season=next_season, status=ClubMembership.StatusChoices.ACTIVE) + self.client.force_login(self.admin_user) + + response = self.club_get("team_detail", self.team.pk) + + self.assertContains(response, "Uma Upcoming") + + def test_the_add_player_dropdown_excludes_a_member_active_only_in_a_past_season(self): + past_season = Season.objects.create(club=self.club, start_date=datetime.date(2020, 1, 1), end_date=datetime.date(2020, 12, 31)) + past_player = Member.objects.create(first_name="Pip", last_name="Past") + ClubMembership.objects.create(club=self.club, member=past_player, season=past_season, status=ClubMembership.StatusChoices.ACTIVE) + self.client.force_login(self.admin_user) + + response = self.club_get("team_detail", self.team.pk) + + self.assertNotContains(response, "Pip Past") + def test_adding_the_same_member_twice_fails_with_a_form_error_not_a_500(self): self.client.force_login(self.admin_user) TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position) @@ -454,6 +490,18 @@ class TeamRosterStaffTests(ManagementTestBase): self.assertTrue(StaffAssignment.objects.filter(team=self.team, season=self.season, member=physio).exists()) + def test_the_assign_staff_dropdown_excludes_a_member_active_only_in_a_past_season(self): + # Same eligibility rule as the "Add player" dropdown -- see + # test_the_add_player_dropdown_excludes_a_member_active_only_in_a_past_season. + past_season = Season.objects.create(club=self.club, start_date=datetime.date(2020, 1, 1), end_date=datetime.date(2020, 12, 31)) + past_member = Member.objects.create(first_name="Sam", last_name="Stale") + ClubMembership.objects.create(club=self.club, member=past_member, season=past_season, status=ClubMembership.StatusChoices.ACTIVE) + self.client.force_login(self.admin_user) + + response = self.club_get("team_detail", self.team.pk) + + self.assertNotContains(response, "Sam Stale") + def test_a_different_teams_coach_cannot_assign_staff(self): physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True) physio = Member.objects.create(first_name="Pat", last_name="Physio") @@ -472,6 +520,108 @@ class TeamRosterStaffTests(ManagementTestBase): self.assertFalse(StaffAssignment.objects.filter(pk=assignment.pk).exists()) +ONE_PIXEL_PNG = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" + + +def make_image_file(name="photo.png"): + return SimpleUploadedFile(name, ONE_PIXEL_PNG, content_type="image/png") + + +class TeamPhotoTests(ManagementTestBase): + """One photo per (team, season), uploaded from the team page -- see + management.views.TeamPhotoSetView/TeamPhotoDeleteView.""" + + def setUp(self): + super().setUp() + self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + self.other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd") + self.coach_position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) + + def make_team_coach(self, team, email="coach-photo@example.com"): + coach_user = User.objects.create_user(email=email, password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=self.coach_position) + return coach_user + + def make_plain_staff(self, email="physio-photo@example.com"): + staff_user = User.objects.create_user(email=email, password="pw-secret-123") + staff_member = Member.objects.create(user=staff_user, first_name="Pat", last_name="Physio") + position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True, management_position=False) + StaffAssignment.objects.create(team=self.team, member=staff_member, season=self.season, position=position) + return staff_user + + def test_uploading_creates_a_photo(self): + self.client.force_login(self.admin_user) + + response = self.club_post("team_photo_set", {"image": make_image_file()}, self.team.pk, self.season.pk) + + self.assertRedirects(response, f"{reverse('management:team_detail', args=[self.team.pk])}?season={self.season.pk}") + self.assertEqual(TeamPhoto.objects.filter(team=self.team, season=self.season).count(), 1) + + def test_uploading_again_replaces_it_in_place(self): + self.client.force_login(self.admin_user) + self.club_post("team_photo_set", {"image": make_image_file("first.png")}, self.team.pk, self.season.pk) + first = TeamPhoto.objects.get(team=self.team, season=self.season) + + self.club_post("team_photo_set", {"image": make_image_file("second.png")}, self.team.pk, self.season.pk) + + self.assertEqual(TeamPhoto.objects.filter(team=self.team, season=self.season).count(), 1) + second = TeamPhoto.objects.get(team=self.team, season=self.season) + self.assertEqual(first.pk, second.pk) + self.assertIn("second", second.image.name) + + def test_a_different_teams_coach_cannot_upload(self): + self.client.force_login(self.make_team_coach(self.other_team)) + + response = self.club_post("team_photo_set", {"image": make_image_file()}, self.team.pk, self.season.pk) + + self.assertEqual(response.status_code, 403) + self.assertFalse(TeamPhoto.objects.filter(team=self.team).exists()) + + def test_this_teams_coach_can_upload(self): + self.client.force_login(self.make_team_coach(self.team)) + + response = self.club_post("team_photo_set", {"image": make_image_file()}, self.team.pk, self.season.pk) + + self.assertRedirects(response, f"{reverse('management:team_detail', args=[self.team.pk])}?season={self.season.pk}") + self.assertTrue(TeamPhoto.objects.filter(team=self.team, season=self.season).exists()) + + def test_deleting_removes_the_photo(self): + TeamPhoto.objects.create(team=self.team, season=self.season, image=make_image_file()) + self.client.force_login(self.admin_user) + + response = self.club_post("team_photo_delete", {}, self.team.pk, self.season.pk) + + self.assertRedirects(response, f"{reverse('management:team_detail', args=[self.team.pk])}?season={self.season.pk}") + self.assertFalse(TeamPhoto.objects.filter(team=self.team, season=self.season).exists()) + + def test_the_team_page_shows_the_photo_when_set(self): + TeamPhoto.objects.create(team=self.team, season=self.season, image=make_image_file()) + self.client.force_login(self.admin_user) + + response = self.club_get("team_detail", self.team.pk) + + self.assertContains(response, "Replace photo") + self.assertNotContains(response, "No photo uploaded") + + def test_the_team_page_shows_a_placeholder_when_not_set(self): + self.client.force_login(self.admin_user) + + response = self.club_get("team_detail", self.team.pk) + + self.assertContains(response, "No photo uploaded") + self.assertContains(response, "Upload photo") + + def test_upload_ui_is_hidden_from_a_plain_staff_member(self): + TeamPhoto.objects.create(team=self.team, season=self.season, image=make_image_file()) + self.client.force_login(self.make_plain_staff()) + + response = self.club_get("team_detail", self.team.pk) + + self.assertNotContains(response, "Replace photo") + self.assertNotContains(response, "Upload photo") + + class PositionManagementTests(ManagementTestBase): def setUp(self): super().setUp() @@ -1784,7 +1934,7 @@ class HomeViewTests(ManagementTestBase): now = timezone.now() past = Event.objects.create(club=self.club, kind=Event.EventKind.TRAINING, title="Past training", start=now - datetime.timedelta(days=1)) soon = Event.objects.create(club=self.club, kind=Event.EventKind.TRAINING, title="Sooner training", start=now + datetime.timedelta(days=1)) - later = Event.objects.create(club=self.club, kind=Event.EventKind.MATCH, title="Later match", start=now + datetime.timedelta(days=5)) + later = Event.objects.create(club=self.club, kind=Event.EventKind.GAME, title="Later game", start=now + datetime.timedelta(days=5)) self.client.force_login(self.admin_user) response = self.club_get("home") @@ -2487,3 +2637,965 @@ class LocationOpponentManagementTests(ManagementTestBase): self.assertEqual(response.status_code, 403) self.assertTrue(Opponent.objects.filter(pk=opponent.pk).exists()) + + +class SponsorManagementTests(ManagementTestBase): + """Full CRUD for Sponsor -- admin-only, unlike Location/Opponent which any + management position can maintain (see club.mixins.ClubAdminRequiredMixin + and management.views.Sponsor*View).""" + + def setUp(self): + super().setUp() + self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + + def make_coach_manager(self, email="coach-sponsor@example.com"): + coach_user = User.objects.create_user(email=email, password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=self.team, member=coach_member, season=self.season, position=position) + return coach_user + + def sponsor_data(self, **overrides): + data = {"name": "Acme Corp", "url": "https://acme.example.com", "start_date": "2026-01-01", "end_date": ""} + data.update(overrides) + return data + + def test_an_admin_can_view_the_sponsor_list(self): + Sponsor.objects.create(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1)) + self.client.force_login(self.admin_user) + + response = self.club_get("sponsor_list") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Acme Corp") + + def test_a_management_position_cannot_view_the_sponsor_list(self): + self.client.force_login(self.make_coach_manager()) + + self.assertEqual(self.club_get("sponsor_list").status_code, 403) + + def test_plain_staff_cannot_view_the_sponsor_list(self): + self.client.force_login(self.make_plain_staff()) + + self.assertEqual(self.club_get("sponsor_list").status_code, 403) + + def test_an_admin_can_create_a_sponsor_with_a_logo(self): + self.client.force_login(self.admin_user) + + response = self.club_post("sponsor_create", self.sponsor_data(logo=make_image_file())) + + self.assertRedirects(response, reverse("management:sponsor_list")) + sponsor = Sponsor.objects.get(club=self.club, name="Acme Corp") + self.assertTrue(sponsor.logo) + + def test_a_management_position_cannot_create_a_sponsor(self): + self.client.force_login(self.make_coach_manager()) + + response = self.club_post("sponsor_create", self.sponsor_data()) + + self.assertEqual(response.status_code, 403) + self.assertFalse(Sponsor.objects.filter(club=self.club, name="Acme Corp").exists()) + + def test_an_end_date_before_the_start_date_is_a_form_error_not_a_500(self): + self.client.force_login(self.admin_user) + + response = self.club_post("sponsor_create", self.sponsor_data(start_date="2026-06-01", end_date="2026-01-01")) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "before the start date") + self.assertFalse(Sponsor.objects.filter(club=self.club).exists()) + + def test_an_admin_can_edit_a_sponsor(self): + sponsor = Sponsor.objects.create(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1)) + self.client.force_login(self.admin_user) + + response = self.club_post("sponsor_update", self.sponsor_data(name="Acme Corp Renamed"), sponsor.pk) + + self.assertRedirects(response, reverse("management:sponsor_list")) + sponsor.refresh_from_db() + self.assertEqual(sponsor.name, "Acme Corp Renamed") + + def test_plain_staff_cannot_edit_a_sponsor(self): + sponsor = Sponsor.objects.create(club=self.club, name="Acme Corp", start_date=datetime.date(2026, 1, 1)) + self.client.force_login(self.make_plain_staff()) + + response = self.club_post("sponsor_update", self.sponsor_data(), sponsor.pk) + + self.assertEqual(response.status_code, 403) + + def test_an_admin_can_delete_a_sponsor(self): + sponsor = Sponsor.objects.create(club=self.club, name="Doomed Corp", start_date=datetime.date(2026, 1, 1)) + self.client.force_login(self.admin_user) + + response = self.club_post("sponsor_delete", {}, sponsor.pk) + + self.assertRedirects(response, reverse("management:sponsor_list")) + self.assertFalse(Sponsor.objects.filter(pk=sponsor.pk).exists()) + + def test_plain_staff_cannot_delete_a_sponsor(self): + sponsor = Sponsor.objects.create(club=self.club, name="Safe Corp", start_date=datetime.date(2026, 1, 1)) + self.client.force_login(self.make_plain_staff()) + + response = self.club_post("sponsor_delete", {}, sponsor.pk) + + self.assertEqual(response.status_code, 403) + self.assertTrue(Sponsor.objects.filter(pk=sponsor.pk).exists()) + + def test_nav_only_shows_sponsors_for_an_admin(self): + self.client.force_login(self.admin_user) + self.assertContains(self.club_get("home"), "Sponsors") + + self.client.force_login(self.make_coach_manager()) + self.assertNotContains(self.club_get("home"), "Sponsors") + + def make_plain_staff(self, email="physio-sponsor@example.com"): + staff_user = User.objects.create_user(email=email, password="pw-secret-123") + staff_member = Member.objects.create(user=staff_user, first_name="Pat", last_name="Physio") + position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True, management_position=False) + StaffAssignment.objects.create(team=self.team, member=staff_member, season=self.season, position=position) + return staff_user + + +class BillingEndingBannerTests(ManagementTestBase): + """The club dashboard's "billing is about to stop" warning -- see + management.views.HomeView and management/templates/management/home.html. + Admin-only, and only within RENEWAL_LEAD_DAYS of the current period ending.""" + + def setUp(self): + super().setUp() + self.tier = Tier.objects.create(name="Standard") + TierPrice.objects.create(tier=self.tier, active_from=self.season.start_date - datetime.timedelta(days=1200), amount=Decimal("500.00")) + + def test_admin_sees_the_banner_when_the_period_ends_soon(self): + subscribe(self.club, self.tier, start=timezone.localdate() - datetime.timedelta(days=350), auto_renew=False) + self.client.force_login(self.admin_user) + + response = self.club_get("home") + + self.assertContains(response, "billing is about to stop") + + def test_admin_does_not_see_the_banner_when_the_period_is_not_ending_soon(self): + subscribe(self.club, self.tier, start=timezone.localdate()) + self.client.force_login(self.admin_user) + + response = self.club_get("home") + + self.assertNotContains(response, "billing is about to stop") + + def test_a_non_admin_manager_never_sees_the_banner(self): + subscribe(self.club, self.tier, start=timezone.localdate() - datetime.timedelta(days=350), auto_renew=False) + team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + position = Position.objects.create(club=self.club, name="Coach", short_name="C", staff_position=True, management_position=True) + coach_user = User.objects.create_user(email="coach-banner@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + self.client.force_login(coach_user) + + response = self.club_get("home") + + self.assertNotContains(response, "billing is about to stop") + + def test_a_club_with_no_subscription_shows_no_banner(self): + self.client.force_login(self.admin_user) + + response = self.club_get("home") + + self.assertNotContains(response, "billing is about to stop") + self.assertNotContains(response, "will renew automatically") + + def test_an_auto_renewing_club_gets_a_reassuring_banner_instead(self): + subscribe(self.club, self.tier, start=timezone.localdate() - datetime.timedelta(days=350), auto_renew=True) + self.client.force_login(self.admin_user) + + response = self.club_get("home") + + self.assertContains(response, "will renew automatically") + self.assertNotContains(response, "billing is about to stop") + + +class RecurrenceUiTests(TestCase): + """management.recurrence_ui's friendly builder <-> raw RRULE round-trip.""" + + def test_weekly_round_trips(self): + rrule = build_rrule("weekly", 2, ["WE", "MO"]) + + self.assertEqual(rrule, "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE") + self.assertEqual(parse_rrule(rrule), {"frequency": "weekly", "interval": 2, "weekdays": ["MO", "WE"]}) + self.assertEqual(str(describe_rrule(rrule)), "Every 2 weeks on Mon, Wed") + + def test_monthly_round_trips(self): + rrule = build_rrule("monthly", 1) + + self.assertEqual(rrule, "FREQ=MONTHLY;INTERVAL=1") + self.assertEqual(parse_rrule(rrule), {"frequency": "monthly", "interval": 1, "weekdays": []}) + self.assertEqual(str(describe_rrule(rrule)), "Every month") + + def test_an_unrecognised_rrule_falls_back_to_the_raw_string(self): + self.assertIsNone(parse_rrule("FREQ=DAILY;COUNT=5")) + self.assertEqual(describe_rrule("FREQ=DAILY;COUNT=5"), "FREQ=DAILY;COUNT=5") + + +class EventManagementTests(ManagementTestBase): + """Event CRUD -- permissions are scoped per-team (like roster/staff), not + club-wide, since an event's teams field is M2M: a manager of at least one + of an event's current teams can edit it, see club.mixins.EventManagerRequiredMixin.""" + + def setUp(self): + super().setUp() + self.own_team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + self.other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd") + self.coach_position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) + + def make_coach(self, team, email="coach-events@example.com"): + coach_user = User.objects.create_user(email=email, password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=self.coach_position) + return coach_user + + def make_plain_staff(self, email="physio-events@example.com"): + staff_user = User.objects.create_user(email=email, password="pw-secret-123") + staff_member = Member.objects.create(user=staff_user, first_name="Pat", last_name="Physio") + position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True, management_position=False) + StaffAssignment.objects.create(team=self.own_team, member=staff_member, season=self.season, position=position) + return staff_user + + def event_data(self, **overrides): + data = { + "title": "Training", + "kind": "training", + "teams": [str(self.own_team.pk)], + "invited_members": [], + "excluded_members": [], + "location": "", + "opponent": "", + "start": "2026-09-01T18:00", + "end": "", + "gathering": "", + "deadline": "", + } + data.update(overrides) + return data + + def test_a_team_manager_can_create_an_event_for_their_own_team(self): + self.client.force_login(self.make_coach(self.own_team)) + + response = self.club_post("event_create", self.event_data()) + + event = Event.objects.get(title="Training") + self.assertRedirects(response, reverse("management:event_detail", args=[event.pk])) + self.assertIn(self.own_team, event.teams.all()) + + def test_creating_an_event_with_a_same_club_location_does_not_raise_a_cross_club_error(self): + # Regression: Event.clean() rejects a location from another club by + # comparing against self.club_id, which was still None on a brand-new + # instance at validation time (club is only auto-assigned in save(), + # which runs after full_clean()) -- so a same-club location falsely + # failed as "must belong to the same club". See EventCreateView.get_form_kwargs. + location = Location.objects.create(club=self.club, name="Home Ground", address="1 St", city="Town", zip_code="1000", country="BE") + self.client.force_login(self.make_coach(self.own_team)) + + response = self.club_post("event_create", self.event_data(location=str(location.pk))) + + event = Event.objects.get(title="Training") + self.assertRedirects(response, reverse("management:event_detail", args=[event.pk])) + self.assertEqual(event.location, location) + + def test_the_new_event_forms_competition_dropdown_shows_every_competition_regardless_of_flag(self): + # Unlike the Django-admin form, this dropdown isn't filtered by whether the + # competition's flag is active for the club -- see management.forms.EventForm + # and events.services.competitions.fetch_game_info (which is where that + # per-club gate actually lives). + Competition.objects.create(name="Active Cup", module="events.competition.active") + Competition.objects.create(name="Inactive Cup", module="events.competition.inactive") + self.client.force_login(self.make_coach(self.own_team)) + + response = self.club_get("event_create") + + self.assertContains(response, "Active Cup") + self.assertContains(response, "Inactive Cup") + + def test_creating_a_game_with_a_competition_selected(self): + Competition.objects.create(name="Regional Cup", module="events.competition.regional") + self.client.force_login(self.make_coach(self.own_team)) + + response = self.club_post("event_create", self.event_data(kind="game", competition="Regional Cup")) + + event = Event.objects.get(title="Training") + self.assertRedirects(response, reverse("management:event_detail", args=[event.pk])) + self.assertEqual(event.competition, "Regional Cup") + + def test_a_team_manager_cannot_create_an_event_for_a_team_they_dont_manage(self): + self.client.force_login(self.make_coach(self.own_team)) + + self.club_post("event_create", self.event_data(teams=[str(self.other_team.pk)])) + + self.assertFalse(Event.objects.filter(title="Training").exists()) + + def test_a_plain_staff_member_gets_403_creating_an_event(self): + self.client.force_login(self.make_plain_staff()) + + response = self.club_post("event_create", self.event_data()) + + self.assertEqual(response.status_code, 403) + + def test_a_plain_staff_member_can_still_view_the_event_list(self): + self.client.force_login(self.make_plain_staff()) + + self.assertEqual(self.club_get("event_list").status_code, 200) + + def test_the_list_shows_edit_and_delete_only_for_events_the_manager_manages(self): + own_event = Event.objects.create(club=self.club, title="My event", start=timezone.now() + datetime.timedelta(days=1)) + own_event.teams.add(self.own_team) + other_event = Event.objects.create(club=self.club, title="Other event", start=timezone.now() + datetime.timedelta(days=2)) + other_event.teams.add(self.other_team) + self.client.force_login(self.make_coach(self.own_team)) + + response = self.club_get("event_list") + + # The Edit link goes to the detail page, not straight to the edit form (same + # convention as Teams/News), so what actually distinguishes a manageable row + # is the delete action being present. + self.assertContains(response, reverse("management:event_delete", args=[own_event.pk])) + self.assertNotContains(response, reverse("management:event_delete", args=[other_event.pk])) + + def test_a_manager_only_sees_events_for_teams_they_manage(self): + own_event = Event.objects.create(club=self.club, title="My event", start=timezone.now() + datetime.timedelta(days=1)) + own_event.teams.add(self.own_team) + other_event = Event.objects.create(club=self.club, title="Other event", start=timezone.now() + datetime.timedelta(days=2)) + other_event.teams.add(self.other_team) + Event.objects.create(club=self.club, title="AGM", start=timezone.now() + datetime.timedelta(days=3)) + self.client.force_login(self.make_coach(self.own_team)) + + response = self.club_get("event_list") + + self.assertContains(response, "My event") + self.assertNotContains(response, "Other event") + self.assertContains(response, "AGM") # team-less events stay visible to everyone + + def test_an_admin_sees_every_event_regardless_of_team(self): + own_event = Event.objects.create(club=self.club, title="My event", start=timezone.now() + datetime.timedelta(days=1)) + own_event.teams.add(self.own_team) + other_event = Event.objects.create(club=self.club, title="Other event", start=timezone.now() + datetime.timedelta(days=2)) + other_event.teams.add(self.other_team) + self.client.force_login(self.admin_user) + + response = self.club_get("event_list") + + self.assertContains(response, "My event") + self.assertContains(response, "Other event") + + def test_a_manager_cannot_open_another_teams_event_by_url(self): + other_event = Event.objects.create(club=self.club, title="Other event", start=timezone.now() + datetime.timedelta(days=2)) + other_event.teams.add(self.other_team) + self.client.force_login(self.make_coach(self.own_team)) + + response = self.club_get("event_detail", other_event.pk) + + self.assertEqual(response.status_code, 404) + + def test_the_dashboard_only_shows_upcoming_events_for_managed_teams(self): + own_event = Event.objects.create(club=self.club, title="My event", start=timezone.now() + datetime.timedelta(days=1)) + own_event.teams.add(self.own_team) + other_event = Event.objects.create(club=self.club, title="Other event", start=timezone.now() + datetime.timedelta(days=2)) + other_event.teams.add(self.other_team) + self.client.force_login(self.make_coach(self.own_team)) + + response = self.club_get("home") + + self.assertContains(response, "My event") + self.assertNotContains(response, "Other event") + + def test_a_team_less_event_is_refused_for_a_non_admin(self): + self.client.force_login(self.make_coach(self.own_team)) + + self.club_post("event_create", self.event_data(teams=[])) + + self.assertFalse(Event.objects.filter(title="Training").exists()) + + def test_an_admin_can_create_a_team_less_event(self): + self.client.force_login(self.admin_user) + + self.club_post("event_create", self.event_data(teams=[])) + + self.assertTrue(Event.objects.filter(title="Training").exists()) + + def test_a_manager_of_one_team_cannot_edit_an_event_for_another_team(self): + event = Event.objects.create(club=self.club, title="Other's event", start=timezone.now() + datetime.timedelta(days=1)) + event.teams.add(self.other_team) + self.client.force_login(self.make_coach(self.own_team)) + + response = self.club_get("event_update", event.pk) + + self.assertEqual(response.status_code, 403) + + def test_a_manager_can_edit_their_own_teams_event(self): + event = Event.objects.create(club=self.club, title="My event", start=timezone.now() + datetime.timedelta(days=1)) + event.teams.add(self.own_team) + self.client.force_login(self.make_coach(self.own_team)) + + self.club_post("event_update", self.event_data(title="Renamed"), event.pk) + + event.refresh_from_db() + self.assertEqual(event.title, "Renamed") + + def test_deleting_a_one_off_event_deletes_it(self): + event = Event.objects.create(club=self.club, title="Gone", start=timezone.now() + datetime.timedelta(days=1)) + event.teams.add(self.own_team) + self.client.force_login(self.make_coach(self.own_team)) + + self.club_post("event_delete", {}, event.pk) + + self.assertFalse(Event.objects.filter(pk=event.pk).exists()) + + def test_creating_a_game_records_competition_and_external_id_but_not_score(self): + # Score/live status don't exist yet for a game that's only just being + # scheduled -- the add form doesn't even offer those fields (see + # test_the_add_form_has_no_score_or_live_fields below), so posting them + # here has no effect. + Competition.objects.create(name="Regional Cup", module="events.competition.regional") + self.client.force_login(self.make_coach(self.own_team)) + + self.club_post("event_create", self.event_data(kind="game", competition="Regional Cup", external_game_id="ext-42", score_for="3", score_against="1", is_live="on")) + + game = Event.objects.get(title="Training") + self.assertEqual(game.kind, Event.EventKind.GAME) + self.assertEqual(game.competition, "Regional Cup") + self.assertEqual(game.external_game_id, "ext-42") + self.assertIsNone(game.score_for) + self.assertFalse(game.is_live) + + def test_the_add_form_has_no_score_or_live_fields(self): + self.client.force_login(self.make_coach(self.own_team)) + + response = self.club_get("event_create") + + self.assertNotContains(response, 'name="score_for"') + self.assertNotContains(response, 'name="is_live"') + self.assertContains(response, 'name="competition"') + + def test_editing_a_game_can_record_its_score_and_live_status(self): + Competition.objects.create(name="Regional Cup", module="events.competition.regional") + game = Event.objects.create(club=self.club, title="Cup game", kind=Event.EventKind.GAME, start=timezone.now() + datetime.timedelta(days=1)) + game.teams.add(self.own_team) + self.client.force_login(self.make_coach(self.own_team)) + + response = self.club_get("event_update", game.pk) + self.assertContains(response, 'name="score_for"') + self.assertContains(response, 'name="is_live"') + + self.club_post("event_update", self.event_data(kind="game", competition="Regional Cup", external_game_id="ext-42", score_for="3", score_against="1", is_live="on"), game.pk) + + game.refresh_from_db() + self.assertEqual(game.score_for, 3) + self.assertEqual(game.score_against, 1) + self.assertTrue(game.is_live) + + def test_the_game_kind_choice_is_no_longer_called_match(self): + self.assertNotIn("match", dict(Event.EventKind.choices)) + self.assertEqual(dict(Event.EventKind.choices)["game"], "Game") + + +class EventSeriesManagementTests(ManagementTestBase): + """EventSeries CRUD + occurrence lifecycle actions (cancel/detach/stop).""" + + def setUp(self): + super().setUp() + self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + self.other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd") + self.coach_position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) + + def make_coach(self, team, email="coach-series@example.com"): + coach_user = User.objects.create_user(email=email, password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=self.coach_position) + return coach_user + + def series_data(self, **overrides): + data = { + "title": "Weekly training", + "kind": "training", + "dtstart": "2026-09-01T18:00", + "until": "", + "teams": [str(self.team.pk)], + "invited_members": [], + "excluded_members": [], + "location": "", + "opponent": "", + "frequency": "weekly", + "interval": "1", + "weekdays": ["MO", "WE"], + "duration_hours": "1", + "duration_minutes": "0", + "gathering_minutes_before": "", + "deadline_minutes_before": "", + "advanced_rrule": "", + } + data.update(overrides) + return data + + def create_series(self): + self.club_post("event_series_create", self.series_data()) + return EventSeries.objects.get(title="Weekly training") + + def test_creating_a_series_with_a_same_club_location_does_not_raise_a_cross_club_error(self): + # Same regression as EventManagementTests' equivalent -- EventSeries.clean() + # has the same self.club_id-is-still-None-at-validation-time problem. + location = Location.objects.create(club=self.club, name="Home Ground", address="1 St", city="Town", zip_code="1000", country="BE") + self.client.force_login(self.make_coach(self.team)) + + response = self.club_post("event_series_create", self.series_data(location=str(location.pk))) + + series = EventSeries.objects.get(title="Weekly training") + self.assertRedirects(response, reverse("management:event_series_detail", args=[series.pk])) + self.assertEqual(series.location, location) + + def test_creating_a_series_generates_occurrences_immediately(self): + self.client.force_login(self.make_coach(self.team)) + + series = self.create_series() + + self.assertTrue(series.occurrences.exists()) + + def test_editing_a_series_propagates_to_future_occurrences_but_not_a_detached_one(self): + self.client.force_login(self.make_coach(self.team)) + series = self.create_series() + detached = series.occurrences.filter(start__gte=timezone.now()).order_by("start").first() + other = series.occurrences.exclude(pk=detached.pk).filter(start__gte=timezone.now()).first() + detach_occurrence(detached) + + self.club_post("event_series_update", self.series_data(title="Renamed training"), series.pk) + + detached.refresh_from_db() + other.refresh_from_db() + self.assertEqual(detached.title, "Weekly training") + self.assertEqual(other.title, "Renamed training") + + def test_cancelling_an_occurrence_records_an_excluded_date_not_a_raw_delete(self): + self.client.force_login(self.make_coach(self.team)) + series = self.create_series() + occurrence = series.occurrences.order_by("start").first() + start_iso = occurrence.start.isoformat() + + self.club_post("event_delete", {}, occurrence.pk) + + self.assertFalse(Event.objects.filter(pk=occurrence.pk).exists()) + series.refresh_from_db() + self.assertIn(start_iso, series.excluded_dates) + + def test_cancelling_with_keep_record_marks_it_cancelled_instead_of_deleting(self): + self.client.force_login(self.make_coach(self.team)) + series = self.create_series() + occurrence = series.occurrences.order_by("start").first() + + self.club_post("event_delete", {"keep_record": "on"}, occurrence.pk) + + occurrence.refresh_from_db() + self.assertTrue(occurrence.cancelled) + + def test_stop_repeating_prevents_new_occurrences_without_touching_existing_ones(self): + self.client.force_login(self.make_coach(self.team)) + series = self.create_series() + count_before = series.occurrences.count() + + self.club_post("event_series_stop", {}, series.pk) + series.refresh_from_db() + generate_occurrences(series) + + self.assertEqual(series.occurrences.count(), count_before) + + def test_a_manager_of_a_different_team_cannot_edit_the_series(self): + self.client.force_login(self.make_coach(self.team)) + series = self.create_series() + self.client.force_login(self.make_coach(self.other_team, email="other-coach@example.com")) + + response = self.club_get("event_series_update", series.pk) + + self.assertEqual(response.status_code, 403) + + def test_a_manager_of_a_different_team_cannot_view_the_series_either(self): + self.client.force_login(self.make_coach(self.team)) + series = self.create_series() + self.client.force_login(self.make_coach(self.other_team, email="other-coach2@example.com")) + + response = self.club_get("event_series_detail", series.pk) + + self.assertEqual(response.status_code, 404) + + def test_deleting_a_series_deletes_its_occurrences(self): + self.client.force_login(self.make_coach(self.team)) + series = self.create_series() + occurrence_ids = list(series.occurrences.values_list("pk", flat=True)) + + self.club_post("event_series_delete", {}, series.pk) + + self.assertFalse(EventSeries.objects.filter(pk=series.pk).exists()) + self.assertFalse(Event.objects.filter(pk__in=occurrence_ids).exists()) + + +class EventDetailDisplayTests(ManagementTestBase): + """The event detail page's RSVP breakdown/modal and the game "fetch info" + stub -- see management.views.EventDetailView/EventFetchGameInfoView and + events.services.competitions.fetch_game_info.""" + + def setUp(self): + super().setUp() + self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + self.position = Position.objects.create(club=self.club, name="Forward", short_name="FW") + self.player = Member.objects.create(first_name="Peter", last_name="Player") + ClubMembership.objects.create(club=self.club, member=self.player, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.position) + self.client.force_login(self.admin_user) + + def make_event(self, **kwargs): + kwargs.setdefault("title", "Training") + kwargs.setdefault("kind", Event.EventKind.TRAINING) + kwargs.setdefault("start", timezone.now() + datetime.timedelta(days=1)) + event = Event.objects.create(club=self.club, **kwargs) + event.teams.add(self.team) + return event + + def test_the_rsvp_breakdown_shows_every_status_even_at_zero(self): + event = self.make_event() + + response = self.club_get("event_detail", event.pk) + + for label in ["Present", "Absent", "Excused", "Selected", "Not selected", "Maybe", "No response"]: + self.assertContains(response, label) + + def test_the_rsvp_modal_shows_who_responded_and_their_note(self): + event = self.make_event() + Attendance.objects.filter(event=event, member=self.player).update(status=Attendance.AttendanceStatus.PRESENT, note="Bringing the kit bag") + + response = self.club_get("event_detail", event.pk) + + self.assertContains(response, "Peter Player") + self.assertContains(response, "Bringing the kit bag") + + def test_the_rsvp_modal_groups_responses_into_collapsible_status_sections(self): + event = self.make_event() + Attendance.objects.filter(event=event, member=self.player).update(status=Attendance.AttendanceStatus.PRESENT) + + response = self.club_get("event_detail", event.pk) + + self.assertContains(response, " +

Sportoase Antwerp Phantoms

+

Upcoming games

+
+ + + + + + + + + +
#DateHourLocationHomeVisit
50022026-09-1212:15DeurneSportoase Antwerp PhantomsAmsterdam Tigers
+""" + + +class RBIHFImportViewTests(ManagementTestBase): + """The Events page's "Import from RBIHF" button/flow -- admin-only, and + only when the "RBIHF" waffle Flag is active for the club, same gating + machinery as FeatureGatedSectionsTests above. The scrape/diff/apply logic + itself (events.services.rbihf_import) has its own offline tests in + events/tests.py; these exercise the views end to end via a mocked + fetch_html, never touching the network.""" + + def setUp(self): + super().setUp() + cache.clear() + self.addCleanup(cache.clear) + self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + + def activate_flag(self): + # "RBIHF" is already seeded (migration 0018 links it to the Competition + # row of the same name) -- get_or_create, not create. + flag, _created = get_waffle_flag_model().objects.get_or_create(name="RBIHF") + flag.clubs.add(self.club) + + def make_coach(self, email="coach-rbihf@example.com"): + coach_user = User.objects.create_user(email=email, password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + ClubMembership.objects.create(club=self.club, member=coach_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=self.team, member=coach_member, season=self.season, position=position) + return coach_user + + def test_button_only_shows_for_admin_with_the_flag_active(self): + self.activate_flag() + self.client.force_login(self.admin_user) + + response = self.club_get("event_list") + + self.assertContains(response, "Import from RBIHF") + + def test_button_hidden_when_the_flag_is_not_active(self): + self.client.force_login(self.admin_user) + + response = self.club_get("event_list") + + self.assertNotContains(response, "Import from RBIHF") + + def test_button_hidden_from_a_non_admin_even_with_the_flag_active(self): + self.activate_flag() + self.client.force_login(self.make_coach()) + + response = self.club_get("event_list") + + self.assertNotContains(response, "Import from RBIHF") + + def test_the_import_views_404_when_the_flag_is_not_active(self): + self.client.force_login(self.admin_user) + + self.assertEqual(self.club_get("rbihf_import").status_code, 404) + self.assertEqual(self.club_post("rbihf_import_confirm", {}).status_code, 404) + + def test_a_non_admin_gets_403_when_the_flag_is_active(self): + self.activate_flag() + self.client.force_login(self.make_coach()) + + self.assertEqual(self.club_get("rbihf_import").status_code, 403) + + @mock.patch("management.views.fetch_html", return_value=RBIHF_SAMPLE_HTML) + def test_submitting_the_form_shows_a_preview(self, mock_fetch): + self.activate_flag() + self.client.force_login(self.admin_user) + + response = self.club_post("rbihf_import", {"url": "https://www.rbihf.be/league/team/4460", "team": str(self.team.pk)}) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Sportoase Antwerp Phantoms") + self.assertContains(response, "5002") + self.assertContains(response, 'name="opponent_5002"') + self.assertContains(response, 'name="location_5002"') + mock_fetch.assert_called_once_with("https://www.rbihf.be/league/team/4460") + + @mock.patch("management.views.fetch_html", return_value=RBIHF_SAMPLE_HTML) + def test_confirming_creates_the_event(self, mock_fetch): + self.activate_flag() + self.client.force_login(self.admin_user) + self.club_post("rbihf_import", {"url": "https://www.rbihf.be/league/team/4460", "team": str(self.team.pk)}) + + response = self.club_post("rbihf_import_confirm", {}) + + self.assertRedirects(response, reverse("management:event_list")) + event = Event.objects.get(club=self.club, external_game_id="5002") + self.assertEqual(event.opponent.name, "Amsterdam Tigers") + self.assertIn(self.team, event.teams.all()) + + @mock.patch("management.views.fetch_html", return_value=RBIHF_SAMPLE_HTML) + def test_confirming_respects_the_chosen_location(self, mock_fetch): + self.activate_flag() + location = Location.objects.create(club=self.club, name="Deurne Ice Hall", address="1 St", city="Deurne", zip_code="2100", country="BE") + self.client.force_login(self.admin_user) + self.club_post("rbihf_import", {"url": "https://www.rbihf.be/league/team/4460", "team": str(self.team.pk)}) + + self.club_post("rbihf_import_confirm", {"location_5002": str(location.pk)}) + + event = Event.objects.get(club=self.club, external_game_id="5002") + self.assertEqual(event.location, location) + + @mock.patch("management.views.fetch_html", return_value=RBIHF_SAMPLE_HTML) + def test_confirming_respects_the_chosen_opponent(self, mock_fetch): + self.activate_flag() + renamed = Opponent.objects.create(club=self.club, name="Amsterdam Tigers HC") + self.client.force_login(self.admin_user) + self.club_post("rbihf_import", {"url": "https://www.rbihf.be/league/team/4460", "team": str(self.team.pk)}) + + self.club_post("rbihf_import_confirm", {"opponent_5002": str(renamed.pk)}) + + event = Event.objects.get(club=self.club, external_game_id="5002") + self.assertEqual(event.opponent, renamed) + self.assertFalse(Opponent.objects.filter(club=self.club, name="Amsterdam Tigers").exists()) + + @mock.patch("management.views.fetch_html", return_value=RBIHF_SAMPLE_HTML) + def test_a_blank_opponent_choice_falls_back_to_the_scraped_name(self, mock_fetch): + self.activate_flag() + self.client.force_login(self.admin_user) + self.club_post("rbihf_import", {"url": "https://www.rbihf.be/league/team/4460", "team": str(self.team.pk)}) + + self.club_post("rbihf_import_confirm", {}) + + event = Event.objects.get(club=self.club, external_game_id="5002") + self.assertEqual(event.opponent.name, "Amsterdam Tigers") + + def test_confirming_with_nothing_stashed_redirects_with_a_notice(self): + self.activate_flag() + self.client.force_login(self.admin_user) + + response = self.club_post("rbihf_import_confirm", {}) + + self.assertRedirects(response, reverse("management:rbihf_import")) + + def test_a_non_rbihf_url_is_a_form_error_not_a_500(self): + self.activate_flag() + self.client.force_login(self.admin_user) + + response = self.club_post("rbihf_import", {"url": "https://evil.example.com/x", "team": str(self.team.pk)}) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "RBIHF team page") + + @mock.patch("management.views.fetch_html", side_effect=RBIHFImportError("Could not reach the page.")) + def test_a_fetch_failure_is_a_form_error_not_a_500(self, mock_fetch): + self.activate_flag() + self.client.force_login(self.admin_user) + + response = self.club_post("rbihf_import", {"url": "https://www.rbihf.be/league/team/4460", "team": str(self.team.pk)}) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Could not reach the page.") + + @mock.patch("management.views.fetch_html", return_value=RBIHF_SAMPLE_HTML) + def test_re_running_the_same_import_shows_it_as_unchanged(self, mock_fetch): + self.activate_flag() + self.client.force_login(self.admin_user) + self.club_post("rbihf_import", {"url": "https://www.rbihf.be/league/team/4460", "team": str(self.team.pk)}) + self.club_post("rbihf_import_confirm", {}) + + response = self.club_post("rbihf_import", {"url": "https://www.rbihf.be/league/team/4460", "team": str(self.team.pk)}) + + self.assertContains(response, "already up to date") + self.assertEqual(Event.objects.filter(club=self.club, external_game_id="5002").count(), 1) diff --git a/management/urls.py b/management/urls.py index 217f3bf..208c95f 100644 --- a/management/urls.py +++ b/management/urls.py @@ -47,6 +47,8 @@ urlpatterns = [ path("teams//staff//add/", views.TeamStaffAddView.as_view(), name="team_staff_add"), path("teams//staff//edit/", views.TeamStaffUpdateView.as_view(), name="team_staff_update"), path("teams//staff//remove/", views.TeamStaffRemoveView.as_view(), name="team_staff_remove"), + path("teams//photo//set/", views.TeamPhotoSetView.as_view(), name="team_photo_set"), + path("teams//photo//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//photos//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//", views.EventDetailView.as_view(), name="event_detail"), + path("events//edit/", views.EventUpdateView.as_view(), name="event_update"), + path("events//delete/", views.EventDeleteView.as_view(), name="event_delete"), + path("events//detach/", views.EventDetachView.as_view(), name="event_detach"), + path("events//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//", views.EventSeriesDetailView.as_view(), name="event_series_detail"), + path("event-series//edit/", views.EventSeriesUpdateView.as_view(), name="event_series_update"), + path("event-series//delete/", views.EventSeriesDeleteView.as_view(), name="event_series_delete"), + path("event-series//stop/", views.EventSeriesStopView.as_view(), name="event_series_stop"), + path("event-series//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//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//edit/", views.OpponentUpdateView.as_view(), name="opponent_update"), path("opponents//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//edit/", views.SponsorUpdateView.as_view(), name="sponsor_update"), + path("sponsors//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"), diff --git a/management/views.py b/management/views.py index 69012fd..6da6707 100644 --- a/management/views.py +++ b/management/views.py @@ -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): diff --git a/news/api.py b/news/api.py new file mode 100644 index 0000000..b4e84b5 --- /dev/null +++ b/news/api.py @@ -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]) diff --git a/pyproject.toml b/pyproject.toml index 35a205e..28f933b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/rosterchief/settings.py b/rosterchief/settings.py index b2f84b8..cd354bd 100644 --- a/rosterchief/settings.py +++ b/rosterchief/settings.py @@ -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", ], }, }, diff --git a/rosterchief/urls.py b/rosterchief/urls.py index 47d6748..b82a9a9 100644 --- a/rosterchief/urls.py +++ b/rosterchief/urls.py @@ -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"), diff --git a/static/css/app.css b/static/css/app.css index 0a1fdc1..15978ea 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -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; } } } diff --git a/static/js/searchable-select.js b/static/js/searchable-select.js index a08fb8f..2ff0695 100644 --- a/static/js/searchable-select.js +++ b/static/js/searchable-select.js @@ -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); } diff --git a/teams/admin.py b/teams/admin.py index c2deb32..d570912 100644 --- a/teams/admin.py +++ b/teams/admin.py @@ -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) diff --git a/teams/api.py b/teams/api.py new file mode 100644 index 0000000..6853ea7 --- /dev/null +++ b/teams/api.py @@ -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 diff --git a/teams/migrations/0007_teamphoto.py b/teams/migrations/0007_teamphoto.py new file mode 100644 index 0000000..0c2ae03 --- /dev/null +++ b/teams/migrations/0007_teamphoto.py @@ -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')], + }, + ), + ] diff --git a/teams/models.py b/teams/models.py index bdeb154..8a2e5cc 100644 --- a/teams/models.py +++ b/teams/models.py @@ -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) diff --git a/teams/tests.py b/teams/tests.py index a16100f..08f4bdb 100644 --- a/teams/tests.py +++ b/teams/tests.py @@ -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) diff --git a/templates/_base.html b/templates/_base.html index 0d416a9..abbe5bc 100644 --- a/templates/_base.html +++ b/templates/_base.html @@ -127,6 +127,10 @@ {% block main %}{% endblock main %}
+ +
diff --git a/templates/account/logout.html b/templates/account/logout.html index 2fb25e6..0deb535 100644 --- a/templates/account/logout.html +++ b/templates/account/logout.html @@ -26,7 +26,7 @@ {{ redirect_field }}
- {% lucide "arrow-left" size=16 %} {% trans "Cancel" %} + {% lucide "arrow-left" size=16 %} {% trans "Cancel" %}
diff --git a/templates/allauth/elements/button.html b/templates/allauth/elements/button.html index 5c648a6..088b43d 100644 --- a/templates/allauth/elements/button.html +++ b/templates/allauth/elements/button.html @@ -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 %} diff --git a/templates/mfa/authenticate.html b/templates/mfa/authenticate.html index f12c199..925302c 100644 --- a/templates/mfa/authenticate.html +++ b/templates/mfa/authenticate.html @@ -33,7 +33,7 @@
- +
diff --git a/uv.lock b/uv.lock index 199a9a1..087f760 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.14" +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + [[package]] name = "asgiref" version = "3.11.1" @@ -11,6 +20,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + [[package]] name = "boto3" version = "1.43.47" @@ -78,6 +100,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" }, ] +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + [[package]] name = "cffi" version = "2.1.0" @@ -137,6 +168,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -324,6 +390,19 @@ name = "django-lucide" version = "1.3.1" source = { git = "https://github.com/bsiebens/lucide#d2cdc609c30ad09701c9d044427adef5cec87500" } +[[package]] +name = "django-ninja" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/7c/3307e17b872f545c88314b2737a22f965785dfb5a120d739b0131d0492c3/django_ninja-1.6.2.tar.gz", hash = "sha256:d56ae5aa4791068ef4ac9a66cfdf2fc11f507413ded35abb79c51d0d52ad6412", size = 3685599, upload-time = "2026-03-18T20:06:47.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/0c/25f72060a39632fbd2d90e9c8b6052a09cd45b0598fc06c0758d313f0052/django_ninja-1.6.2-py3-none-any.whl", hash = "sha256:20095f5900bada22ea00cf1a58af50bdb285b2354c61a9d9b47d0dc89ac462d6", size = 2374994, upload-time = "2026-03-18T20:06:45.676Z" }, +] + [[package]] name = "django-phonenumber-field" version = "8.4.0" @@ -448,6 +527,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, ] +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + [[package]] name = "jmespath" version = "1.1.0" @@ -581,6 +669,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + [[package]] name = "pydyf" version = "0.12.1" @@ -641,16 +785,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/0a/c2345ebf1ebe70840ce3f6c6ee612f8fa749cfbd1b03069c53bf0c62aaad/redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743", size = 502406, upload-time = "2026-06-23T14:52:36.137Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "rosterchief" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "beautifulsoup4" }, { name = "dj-database-url" }, { name = "django" }, { name = "django-allauth", extra = ["mfa"] }, { name = "django-countries" }, { name = "django-lucide" }, + { name = "django-ninja" }, { name = "django-phonenumber-field", extra = ["phonenumbers"] }, { name = "django-redis" }, { name = "django-storages", extra = ["s3"] }, @@ -661,6 +822,7 @@ dependencies = [ { name = "psycopg", extra = ["binary"] }, { name = "python-dateutil" }, { name = "python-decouple" }, + { name = "requests" }, { name = "weasyprint" }, { name = "whitenoise" }, ] @@ -674,11 +836,13 @@ dev = [ [package.metadata] requires-dist = [ + { name = "beautifulsoup4", specifier = ">=4.15.0" }, { name = "dj-database-url", specifier = ">=3.1.2" }, { name = "django", specifier = ">=6.0.6" }, { name = "django-allauth", extras = ["mfa"], specifier = ">=65.18.0" }, { name = "django-countries", specifier = ">=9.0.0" }, { name = "django-lucide", git = "https://github.com/bsiebens/lucide" }, + { name = "django-ninja", specifier = ">=1.6.2" }, { name = "django-phonenumber-field", extras = ["phonenumbers"], specifier = ">=8.4.0" }, { name = "django-redis", specifier = ">=7.0.0" }, { name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" }, @@ -689,6 +853,7 @@ requires-dist = [ { name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" }, { name = "python-dateutil", specifier = ">=2.9.0.post0" }, { name = "python-decouple", specifier = ">=3.8" }, + { name = "requests", specifier = ">=2.34.2" }, { name = "weasyprint", specifier = ">=69.0" }, { name = "whitenoise", specifier = ">=6.12.0" }, ] @@ -746,6 +911,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "soupsieve" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/38/e12680bbe6b4f8f3d17adcaf38d26850aa756c85cf4a80e79fc12a018fe8/soupsieve-2.9.1.tar.gz", hash = "sha256:c33e6605bbc71dd628b00c632d58ae607c22bade247e52553928f83bbb75b4ba", size = 122261, upload-time = "2026-07-21T16:57:17.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/2c/437fe806897c2d6cfdc3ee43a18da8bf8e568530a4ae9bac781541ca9896/soupsieve-2.9.1-py3-none-any.whl", hash = "sha256:4f4477399246b7a0c720a88ca2454b11cd6bb9ae4c9d170140786e916776c14c", size = 37404, upload-time = "2026-07-21T16:57:16.421Z" }, +] + [[package]] name = "sqlparse" version = "0.5.5" @@ -788,6 +962,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "tzdata" version = "2026.2"