Add billing-ending banner, events CRUD, RBIHF import, public API, team photos, and sponsors

A large batch of club-management features built up over one session:

- Club dashboard banner warning admins 1 month before billing ends
- Full Events/EventSeries CRUD (recurrence builder, occurrence lifecycle,
  per-team permissions), with match->game rename and game-specific fields
  (score, competition, live status, external game ID)
- Django-admin competition dropdown, gated per-club by feature flag
- Auto-import of RBIHF fixtures (scrape -> diff -> preview -> confirm),
  with location/opponent dropdowns suggested from existing club data
- Feature-flag-gated Shop/Forms nav sections, reusing the same flag
  machinery for the RBIHF import button
- Team roster now scoped to members active this season or next, sorted and
  grouped by position
- Club sport type (ice hockey / other), shown in the control panel's club
  subtitle
- Per-season team photo upload from the team page
- New public read-only API (Django Ninja) at /api/v1/: news, team rosters,
  upcoming/live/per-team games, and sponsors -- auto-documented via Swagger
  UI, CORS-enabled for a club's own external website
- Club sponsors: admin-only CRUD (logo, URL, active date window) plus a
  date-windowed, optionally randomized API endpoint
- Assorted fixes: NullBooleanField dropdown rendering, cross-club event
  validation timing, searchable-select chip placement, btn-neutral ->
  default button style sweep, calendar-month chart windows

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1gj3J1QPfP38XWpnpbFpy
This commit is contained in:
2026-08-06 17:36:04 +02:00
parent 6ad0d6658c
commit 98b8002a04
101 changed files with 6165 additions and 135 deletions

View File

@@ -1,7 +1,8 @@
from django import forms
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from .models import Attendance, Event, EventSeries, Location, Opponent
from .models import Attendance, Competition, Event, EventSeries, Location, Opponent
@admin.register(Opponent)
@@ -39,8 +40,38 @@ class EventSeriesAdmin(admin.ModelAdmin):
]
class EventAdminForm(forms.ModelForm):
"""The `competition` field is a plain CharField on Event (it just stores a
name), but the admin should only ever offer a competition this club is
actually allowed to use -- one whose feature flag is active for it, set
from the control panel's Features page. A competition with no flag never
appears at all."""
class Meta:
model = Event
fields = [
"title", "kind", "season", "series", "detached", "cancelled", "teams", "invited_members", "excluded_members",
"start", "end", "gathering", "deadline", "location", "opponent",
"competition", "external_game_id", "score_for", "score_against", "is_live",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
club = self.instance.club if self.instance.club_id else None
competitions = Competition.objects.filter(flag__isnull=False).select_related("flag")
if club is not None:
competitions = [competition for competition in competitions if competition.flag.is_active_for_club(club)]
self.fields["competition"] = forms.ChoiceField(
choices=[("", "---------"), *[(competition.name, competition.name) for competition in competitions]],
required=False,
label=self.fields["competition"].label,
help_text=_("Only competitions whose feature flag is active for this club are offered here."),
)
@admin.register(Event)
class EventAdmin(admin.ModelAdmin):
form = EventAdminForm
list_display = ["title", "kind", "start", "season", "series", "detached", "club"]
list_filter = ["kind", "club", "teams", "detached", "cancelled"]
search_fields = ["title"]
@@ -53,9 +84,18 @@ class EventAdmin(admin.ModelAdmin):
[_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}],
[_("When"), {"fields": ["start", "end", "gathering", "deadline"]}],
[_("Where"), {"fields": ["location", "opponent"]}],
[_("Game"), {"fields": ["competition", "external_game_id", "score_for", "score_against", "is_live"]}],
]
@admin.register(Competition)
class CompetitionAdmin(admin.ModelAdmin):
list_display = ["name", "sport_type", "module", "flag"]
list_filter = ["sport_type"]
search_fields = ["name", "module"]
raw_id_fields = ["flag"]
@admin.register(Attendance)
class AttendanceAdmin(admin.ModelAdmin):
list_display = ["event", "member", "status", "showed_up"]

139
events/api.py Normal file
View File

@@ -0,0 +1,139 @@
"""Public read-only game endpoints -- see api/urls.py for how this is
mounted. Also owns GET /teams/{team_id}/games/: it's an Event query through
and through, so the query logic and GameOut schema live here rather than
being duplicated in teams/api.py.
"""
import uuid
from datetime import datetime
from django.db.models import Q
from django.utils import timezone
from ninja import Router, Schema
from ninja.errors import HttpError
from api.errors import require_club
from club.services.access import current_season
from teams.models import Team
from .models import Event
router = Router(tags=["games"])
DEFAULT_UPCOMING_COUNT = 10
MAX_UPCOMING_COUNT = 50
#: /games/upcoming/ covers anything worth putting on a public fixture list --
#: not just Game, but Tournament too. The other game endpoints (live,
#: per-team) stay Game-only: is_live/score_for/score_against are genuinely
#: game-specific (events/models.py), so a tournament wouldn't have anything
#: meaningful to show there anyway.
UPCOMING_KINDS = [Event.EventKind.GAME, Event.EventKind.TOURNAMENT]
class LocationOut(Schema):
name: str
address: str
city: str
zip_code: str
country: str
is_home: bool
class GameOut(Schema):
id: uuid.UUID
start: datetime
location: LocationOut | None
home_team: str | None
away_team: str | None
competition: str
is_live: bool
status: str # "upcoming" | "live" | "finished"
home_score: int | None
away_score: int | None
def _to_game_out(event, team=None) -> GameOut:
if team is None:
# .first() would re-query even with teams prefetched; go through the
# prefetch cache instead.
related_teams = list(event.teams.all())
team = related_teams[0] if related_teams else None
team_name = team.name if team is not None else None
opponent_name = event.opponent.name if event.opponent_id else None
if event.is_home_game:
home_team, away_team = team_name, opponent_name
home_score, away_score = event.score_for, event.score_against
else:
home_team, away_team = opponent_name, team_name
home_score, away_score = event.score_against, event.score_for
if event.is_live:
status = "live"
elif event.start > timezone.now():
status = "upcoming"
else:
status = "finished"
location = None
if event.location_id:
location = LocationOut(name=event.location.name, address=event.location.address, city=event.location.city, zip_code=event.location.zip_code, country=str(event.location.country), is_home=event.location.is_home)
return GameOut(
id=event.pk,
start=event.start,
location=location,
home_team=home_team,
away_team=away_team,
competition=event.competition,
is_live=event.is_live,
status=status,
home_score=home_score,
away_score=away_score,
)
@router.get("/games/upcoming/", response=list[GameOut], summary="Upcoming games")
def list_upcoming_games(request, count: int = DEFAULT_UPCOMING_COUNT):
"""The next `count` non-cancelled upcoming games and tournaments, club-wide."""
club = require_club(request)
count = max(1, min(count, MAX_UPCOMING_COUNT))
events = Event.objects.filter(club=club, kind__in=UPCOMING_KINDS, cancelled=False, start__gte=timezone.now()).select_related("opponent", "location").prefetch_related("teams").order_by("start")[:count]
return [_to_game_out(event) for event in events]
@router.get("/games/live/", response=list[GameOut], summary="Live games")
def list_live_games(request):
club = require_club(request)
events = Event.objects.filter(club=club, kind=Event.EventKind.GAME, cancelled=False, is_live=True).select_related("opponent", "location").prefetch_related("teams").order_by("start")
return [_to_game_out(event) for event in events]
@router.get("/teams/{team_id}/games/", response=list[GameOut], summary="A team's current-season games")
def list_team_games(request, team_id: uuid.UUID):
"""All of this team's current-season games, past and upcoming -- past ones
include both teams' scores. Same "explicit season, else derived from
start date" scoping management.views.EventListView already applies."""
club = require_club(request)
team = Team.objects.filter(club=club, pk=team_id).first()
if team is None:
raise HttpError(404, "No such team.")
season = current_season(club)
if season is None:
return []
events = (
Event.objects.filter(club=club, teams=team, kind=Event.EventKind.GAME, cancelled=False)
.filter(Q(season=season) | Q(season__isnull=True, start__date__gte=season.start_date, start__date__lte=season.end_date))
.select_related("opponent", "location")
.order_by("start")
)
return [_to_game_out(event, team=team) for event in events]

View File

View File

@@ -0,0 +1,19 @@
from typing import TypedDict
from ..models import Event
class GameInformation(TypedDict):
live: bool
scoreA: int
scoreB: int
class CompetitionBaseClass:
url: str
def __init__(self):
self.url = "http://localhost"
def update_game_information(self, event: Event) -> None:
raise NotImplementedError

View File

@@ -0,0 +1,90 @@
from urllib.parse import urljoin
import requests
from ..models import Event
from .base import CompetitionBaseClass
class RBIHF(CompetitionBaseClass):
def __init__(self):
super().__init__()
self.url = "https://rbihf.be/modules/league/ajax/time.php"
def update_game_information(self, event: Event) -> None:
season = "{start}{end}".format(start=event.season.start_date.strftime("%y"), end=event.season.end_date.strftime("%y"))
payload = {"gameNr": event.external_game_id, "season": season}
headers = {
"Cookie": "language=en",
"Postman-Token": "rosterchief",
"Host": "www.rbihf.be",
"User-Agent": "PostmanRuntime/7.37.0",
"Accept": "application/json",
"Accept-Encoding": "gzip,deflate,br",
"Connection": "keep-alive",
"Referer": f"https://rbihf.be/game/{event.external_game_id}",
"X-Requested-With": "XMLHttpRequest",
}
req = requests.get(self.url, params=payload, headers=headers)
if req.status_code == 200:
game_data = req.json()
event.is_live = game_data["live"]
if event.is_home_game:
event.score_for = game_data["scoreA"]
event.score_against = game_data["scoreB"]
else:
event.score_for = game_data["scoreB"]
event.score_against = game_data["scoreA"]
event.save(update_fields=["is_live", "score_for", "score_against"])
class CEHL(CompetitionBaseClass):
def __init__(self):
super().__init__()
self.url = "https://www.cehl.eu/ajax/"
def update_game_information(self, event: Event) -> None:
season = "{start}{end}".format(start=event.season.start_date.strftime("%y"), end=event.season.end_date.strftime("%y"))
referer_url = urljoin("https://www.cehl.eu", f"game/{season}/{event.external_game_id}")
timeline_url = urljoin(self.url, "timeline.php")
score_url = urljoin(self.url, "score.php")
payload = {"nr": event.external_game_id, "season": season}
headers = {
"Cookie": "language=en",
"Postman-Token": "rosterchief",
"Host": "www.cehl.eu",
"User-Agent": "PostmanRuntime/7.37.0",
"Accept": "*/*",
"Accept-Encoding": "gzip,deflate,br",
"Connection": "close",
"Referer": referer_url,
"X-Requested-With": "XMLHttpRequest",
}
timeline_req = requests.get(timeline_url, params=payload, headers=headers)
score_req = requests.get(score_url, params=payload, headers=headers)
if timeline_req.status_code == 200:
event.is_live = timeline_req.json()["live"] == 1
if score_req.status_code == 200:
game_data = score_req.json()
if event.is_home_game:
event.score_for = game_data["scoreA"]
event.score_against = game_data["scoreB"]
else:
event.score_for = game_data["scoreB"]
event.score_against = game_data["scoreA"]
if timeline_req.status_code == 200 or score_req.status_code == 200:
event.save(update_fields=["is_live", "score_for", "score_against"])

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.6 on 2026-08-04 11:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0012_alter_location_country'),
]
operations = [
migrations.AlterField(
model_name='event',
name='kind',
field=models.CharField(choices=[('training', 'Training'), ('match', 'Match'), ('tournament', 'Tournament'), ('meeting', 'Meeting'), ('social', 'Social'), ('other', 'Other')], default='other', max_length=10, verbose_name='kind'),
),
migrations.AlterField(
model_name='eventseries',
name='kind',
field=models.CharField(choices=[('training', 'Training'), ('match', 'Match'), ('tournament', 'Tournament'), ('meeting', 'Meeting'), ('social', 'Social'), ('other', 'Other')], default='other', max_length=10, verbose_name='kind'),
),
]

View File

@@ -0,0 +1,73 @@
# Generated by Django 6.0.6 on 2026-08-04 11:28
from django.db import migrations, models
def rename_match_to_game(apps, schema_editor):
Event = apps.get_model('events', 'Event')
EventSeries = apps.get_model('events', 'EventSeries')
Event.objects.filter(kind='match').update(kind='game')
EventSeries.objects.filter(kind='match').update(kind='game')
def rename_game_to_match(apps, schema_editor):
Event = apps.get_model('events', 'Event')
EventSeries = apps.get_model('events', 'EventSeries')
Event.objects.filter(kind='game').update(kind='match')
EventSeries.objects.filter(kind='game').update(kind='match')
class Migration(migrations.Migration):
dependencies = [
('events', '0013_alter_event_kind_alter_eventseries_kind'),
]
operations = [
migrations.AddField(
model_name='event',
name='competition',
field=models.CharField(blank=True, help_text='The league, cup or competition this game is part of.', max_length=255, verbose_name='competition'),
),
migrations.AddField(
model_name='event',
name='external_game_id',
field=models.CharField(blank=True, help_text="This game's id in an external competition data source, for automatic score fetching later.", max_length=255, verbose_name='external game ID'),
),
migrations.AddField(
model_name='event',
name='is_live',
field=models.BooleanField(default=False, help_text='The game is currently in progress.', verbose_name='live'),
),
migrations.AddField(
model_name='event',
name='score_against',
field=models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='score (opponent)'),
),
migrations.AddField(
model_name='event',
name='score_for',
field=models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='score (us)'),
),
migrations.AlterField(
model_name='event',
name='deadline',
field=models.DateTimeField(blank=True, null=True, verbose_name='registration deadline'),
),
migrations.AlterField(
model_name='event',
name='kind',
field=models.CharField(choices=[('training', 'Training'), ('game', 'Game'), ('tournament', 'Tournament'), ('meeting', 'Meeting'), ('social', 'Social'), ('other', 'Other')], default='other', max_length=10, verbose_name='kind'),
),
migrations.AlterField(
model_name='eventseries',
name='deadline_offset',
field=models.DurationField(blank=True, help_text="How long before the start each occurrence's registration deadline is.", null=True, verbose_name='registration deadline offset'),
),
migrations.AlterField(
model_name='eventseries',
name='kind',
field=models.CharField(choices=[('training', 'Training'), ('game', 'Game'), ('tournament', 'Tournament'), ('meeting', 'Meeting'), ('social', 'Social'), ('other', 'Other')], default='other', max_length=10, verbose_name='kind'),
),
migrations.RunPython(rename_match_to_game, rename_game_to_match),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-08-04 13:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0014_event_competition_event_external_game_id_and_more'),
]
operations = [
migrations.AlterField(
model_name='attendance',
name='status',
field=models.CharField(choices=[('present', 'Present'), ('absent', 'Absent'), ('excused', 'Excused'), ('selected', 'Selected'), ('not_selected', 'Not selected'), ('maybe', 'Maybe'), ('no_response', 'No response')], default='no_response', max_length=20, verbose_name='status'),
),
]

View File

@@ -0,0 +1,21 @@
# Generated by Django 6.0.6 on 2026-08-04 21:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0015_alter_attendance_status'),
]
operations = [
migrations.CreateModel(
name='Competition',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=250)),
('module', models.CharField(max_length=250)),
],
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 6.0.6 on 2026-08-04 21:22
from django.db import migrations
def create_competiton_group(apps, schema_editor):
Competition = apps.get_model("events", "Competition")
try:
Competition.objects.create(name="RBIHF", module="events.competition.hockey")
Competition.objects.create(name="CEHL", module="events.competition.hockey")
except:
pass
def remove_competiton_group(apps, schema_editor):
Competition = apps.get_model("events", "Competition")
try:
Competition.objects.get(name="RBIHF", module="events.competition.hockey").delete()
Competition.objects.get(name="CEHL", module="events.competition.hockey")
except:
pass
class Migration(migrations.Migration):
dependencies = [
('events', '0016_competition'),
]
operations = [migrations.RunPython(create_competiton_group, remove_competiton_group)]

View File

@@ -0,0 +1,43 @@
# Generated by Django 6.0.6 on 2026-08-04 21:32
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
def create_flags_for_seeded_competitions(apps, schema_editor):
"""RBIHF/CEHL (0017's data migration) predate the flag field -- give them one
each so they're actually selectable (unflagged competitions never show up
in the Event admin's dropdown) instead of silently invisible."""
Competition = apps.get_model("events", "Competition")
Flag = apps.get_model(*settings.WAFFLE_FLAG_MODEL.split("."))
for competition in Competition.objects.filter(flag__isnull=True):
flag, _created = Flag.objects.get_or_create(name=competition.name)
competition.flag = flag
competition.save(update_fields=["flag"])
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('events', '0017_auto_20260804_2322'),
migrations.swappable_dependency(settings.WAFFLE_FLAG_MODEL),
]
operations = [
migrations.AlterModelOptions(
name='competition',
options={'ordering': ['name'], 'verbose_name': 'competition', 'verbose_name_plural': 'competitions'},
),
migrations.AddField(
model_name='competition',
name='flag',
field=models.ForeignKey(blank=True, help_text="Which clubs this competition is offered to -- set (or leave blank to hide it everywhere) from the control panel's Features page. A competition with no flag never shows up on the Event admin's competition dropdown.", null=True, on_delete=django.db.models.deletion.PROTECT, related_name='competitions', to=settings.WAFFLE_FLAG_MODEL, verbose_name='feature flag'),
),
migrations.RunPython(create_flags_for_seeded_competitions, noop),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 6.0.6 on 2026-08-04 22:09
from django.db import migrations, models
def mark_seeded_hockey_competitions(apps, schema_editor):
"""RBIHF/CEHL (0017's data migration) predate sport_type -- they're both ice
hockey leagues (see events/competition/hockey.py), so backfill them rather
than leaving them at the generic "other" default."""
Competition = apps.get_model("events", "Competition")
Competition.objects.filter(name__in=["RBIHF", "CEHL"]).update(sport_type="ice_hockey")
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('events', '0018_alter_competition_options_competition_flag'),
]
operations = [
migrations.AddField(
model_name='competition',
name='sport_type',
field=models.CharField(choices=[('ice_hockey', 'Ice hockey'), ('other', 'Other')], default='other', help_text='Which sport this competition is for.', max_length=20, verbose_name='sport'),
),
migrations.RunPython(mark_seeded_hockey_competitions, noop),
]

View File

@@ -1,9 +1,10 @@
from django.conf import settings
from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from django_countries.fields import CountryField
from club.models import Season
from club.models import Club, Season
from members.models import Member
from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
from teams.models import Team
@@ -48,12 +49,12 @@ class Location(ClubScopedModel):
class Event(ClubScopedModel):
class EventKind(models.TextChoices):
TRAINING = "training", _("training")
MATCH = "match", _("match")
TOURNAMENT = "tournament", _("tournament")
MEETING = "meeting", _("meeting")
SOCIAL = "social", _("social")
OTHER = "other", _("other")
TRAINING = "training", _("Training")
GAME = "game", _("Game")
TOURNAMENT = "tournament", _("Tournament")
MEETING = "meeting", _("Meeting")
SOCIAL = "social", _("Social")
OTHER = "other", _("Other")
series = models.ForeignKey("EventSeries", on_delete=models.CASCADE, related_name="occurrences", null=True, blank=True, verbose_name=_("series"), help_text=_("The recurring series this occurrence belongs to; blank for one-off events."))
detached = models.BooleanField(_("detached"), default=False, help_text=_("Edited independently; excluded from series-wide updates and regeneration."))
@@ -70,12 +71,21 @@ class Event(ClubScopedModel):
start = models.DateTimeField(_("start"))
end = models.DateTimeField(_("end"), blank=True, null=True)
gathering = models.DateTimeField(_("gathering"), blank=True, null=True)
deadline = models.DateTimeField(_("deadline"), blank=True, null=True)
deadline = models.DateTimeField(_("registration deadline"), blank=True, null=True)
location = models.ForeignKey(Location, on_delete=models.SET_NULL, related_name="events", null=True, blank=True, verbose_name=_("location"))
opponent = models.ForeignKey(Opponent, on_delete=models.SET_NULL, related_name="events", null=True, blank=True, verbose_name=_("opponent"))
created_by = models.ForeignKey(Member, on_delete=models.SET_NULL, related_name="created_events", null=True, blank=True, verbose_name=_("created by"))
# Game-specific -- meaningless for other kinds, so all optional. external_game_id
# is this game's id in an external competition/fixture data source, for a later
# automatic score-fetcher to key off; nothing populates it yet.
competition = models.CharField(_("competition"), max_length=255, blank=True, help_text=_("The league, cup or competition this game is part of."))
external_game_id = models.CharField(_("external game ID"), max_length=255, blank=True, help_text=_("This game's id in an external competition data source, for automatic score fetching later."))
score_for = models.PositiveSmallIntegerField(_("score (us)"), null=True, blank=True)
score_against = models.PositiveSmallIntegerField(_("score (opponent)"), null=True, blank=True)
is_live = models.BooleanField(_("live"), default=False, help_text=_("The game is currently in progress."))
class Meta:
verbose_name = _("event")
verbose_name_plural = _("events")
@@ -87,6 +97,13 @@ class Event(ClubScopedModel):
def clean(self):
validate_club_scope(self, self.club_id, same_club_fields=("season", "location", "opponent"))
@property
def is_home_game(self) -> bool:
"""Whether this game is being played at the club's own ground
(Location.is_home) -- False for anything that isn't a game, or a game
with no location set, or one at an away/neutral location."""
return self.kind == self.EventKind.GAME and self.location_id is not None and self.location.is_home
class EventSeries(ClubScopedModel):
"""A recurring event definition that materialises concrete Event rows."""
@@ -96,7 +113,7 @@ class EventSeries(ClubScopedModel):
until = models.DateTimeField(_("until"), null=True, blank=True, help_text=_("Series end: no occurrences are generated after this. Leave blank for open-ended (bounded by the rule's own COUNT/UNTIL, if any)."))
duration = models.DurationField(_("duration"), null=True, blank=True, help_text=_("Length of each occurrence; sets each event's end."))
gathering_offset = models.DurationField(_("gathering offset"), null=True, blank=True, help_text=_("How long before the start each occurrence's gathering time is."))
deadline_offset = models.DurationField(_("deadline offset"), null=True, blank=True, help_text=_("How long before the start each occurrence's sign-up deadline is."))
deadline_offset = models.DurationField(_("registration deadline offset"), null=True, blank=True, help_text=_("How long before the start each occurrence's registration deadline is."))
excluded_dates = models.JSONField(_("excluded dates"), default=list, blank=True, help_text=_("ISO start datetimes of occurrences removed from the series (EXDATEs)."))
generated_until = models.DateTimeField(_("generated until"), null=True, blank=True, help_text=_("Occurrences have been materialised up to this point."))
@@ -123,13 +140,13 @@ class EventSeries(ClubScopedModel):
class Attendance(UUIDModel):
class AttendanceStatus(models.TextChoices):
PRESENT = "present", _("present")
ABSENT = "absent", _("absent")
EXCUSED = "excused", _("excused")
SELECTED = "selected", _("selected")
NOT_SELECTED = "not_selected", _("not selected")
MAYBE = "maybe", _("maybe")
NO_RESPONSE = "no_response", _("no response")
PRESENT = "present", _("Present")
ABSENT = "absent", _("Absent")
EXCUSED = "excused", _("Excused")
SELECTED = "selected", _("Selected")
NOT_SELECTED = "not_selected", _("Not selected")
MAYBE = "maybe", _("Maybe")
NO_RESPONSE = "no_response", _("No response")
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="attendances", verbose_name=_("event"))
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="attendances", verbose_name=_("member"))
@@ -153,3 +170,34 @@ class Attendance(UUIDModel):
def __str__(self):
return f"{self.event} - {self.member}"
class Competition(models.Model):
"""A competition has a name with a specific URL to fetch data from. These are managed centrally."""
name = models.CharField(max_length=250)
module = models.CharField(max_length=250)
sport_type = models.CharField(
_("sport"),
max_length=20,
choices=Club.SportType.choices,
default=Club.SportType.OTHER,
help_text=_("Which sport this competition is for."),
)
flag = models.ForeignKey(
settings.WAFFLE_FLAG_MODEL,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="competitions",
verbose_name=_("feature flag"),
help_text=_("Which clubs this competition is offered to -- set (or leave blank to hide it everywhere) from the control panel's Features page. A competition with no flag never shows up on the Event admin's competition dropdown."),
)
class Meta:
verbose_name = _("competition")
verbose_name_plural = _("competitions")
ordering = ["name"]
def __str__(self):
return self.name

View File

@@ -0,0 +1,46 @@
"""Refreshing a game's score/status from its competition's own data source.
Event.competition (free text) and Event.external_game_id (that source's id for this
fixture) exist so a real integration has somewhere to key off later -- nothing
fetches anything yet. This is that integration's landing spot: the button and view
that call it already exist (management/views.py::EventFetchGameInfoView), so wiring
up a real data source later is a matter of replacing fetch_game_info's body, not
building the UI around it.
"""
import importlib
from events.models import Competition
class CompetitionFetchError(Exception):
"""A game's info could not be fetched from its competition's data source."""
def fetch_game_info(event) -> bool:
"""Refresh `event`'s score/live status from its competition's data source.
Gated on that competition's own feature flag being active for the event's
club: the management form's competition dropdown deliberately shows every
competition regardless of flag (see management.forms.EventForm), so this is
where per-club access actually gets enforced. No flag, an inactive one, or
a competition name that matches nothing -- there's no data source this club
is allowed to use, so this quietly does nothing and returns False rather
than erroring: missing access isn't a failure to report.
Returns whether a fetch was actually attempted.
"""
competition = Competition.objects.filter(name=event.competition).select_related("flag").first()
if competition is None or competition.flag is None or not competition.flag.is_active_for_club(event.club):
return False
try:
module = importlib.import_module(competition.module)
competition = getattr(module, competition.name)
competition().update_game_information(event=event)
return True
except Exception as error:
raise CompetitionFetchError("No competition data source is configured yet -- there's nothing to fetch from.") from error

View File

@@ -0,0 +1,297 @@
"""Importing a team's fixture list from RBIHF's own website.
RBIHF publishes a team's schedule at ``https://www.rbihf.be/league/team/<id>`` --
an HTML page, not an API, scraped with BeautifulSoup. This is the create/update/
delete counterpart to ``events.services.competitions.fetch_game_info``, which
only ever refreshes a single *existing* game's score: this is how those `Event`
rows get created in the first place.
Two-step flow, mirroring ``management.bulk_import`` (the member Excel import):
``build_plan`` is pure with respect to the outside world once it has the raw
HTML (no further network calls), so a view can render a preview from it and
then re-run it unchanged at confirm time -- see management/views.py.
"""
import re
from dataclasses import dataclass, field
from datetime import datetime
import requests
from bs4 import BeautifulSoup
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from events.models import Event, Location, Opponent
TEAM_URL_RE = re.compile(r"^https://(?:www\.)?rbihf\.be/league/team/(?P<team_id>\d+)/?$")
REQUEST_HEADERS = {
"Cookie": "language=en",
"User-Agent": "Mozilla/5.0 (compatible; RosterChief/1.0)",
"Accept": "text/html",
}
REQUEST_TIMEOUT_SECONDS = 15
class RBIHFImportError(Exception):
"""The page couldn't be fetched, or didn't look like an RBIHF team schedule."""
def extract_team_id(url: str) -> str:
match = TEAM_URL_RE.match(url.strip())
if not match:
raise RBIHFImportError("That doesn't look like an RBIHF team page -- expected something like https://www.rbihf.be/league/team/4460.")
return match.group("team_id")
def fetch_html(url: str) -> str:
try:
response = requests.get(url, headers=REQUEST_HEADERS, timeout=REQUEST_TIMEOUT_SECONDS)
except requests.RequestException as error:
raise RBIHFImportError(f"Could not reach {url}: {error}") from error
if response.status_code != 200:
raise RBIHFImportError(f"{url} returned HTTP {response.status_code}.")
return response.text
@dataclass
class ScrapedFixture:
external_game_id: str
start: datetime
is_home: bool
opponent_name: str
venue_text: str
def _team_id_from_href(href: str) -> str | None:
match = re.search(r"/league/team/(\d+)", href or "")
return match.group(1) if match else None
def parse_fixtures(html: str, team_id: str) -> tuple[str, list[ScrapedFixture]]:
"""Returns (the scraped team's own display name, its upcoming fixtures)."""
soup = BeautifulSoup(html, "html.parser")
team_heading = soup.find("h2")
if team_heading is None:
raise RBIHFImportError("Could not find a team name on this page -- is this really an RBIHF team page?")
team_name = team_heading.get_text(strip=True)
games_heading = soup.find(id="games-upcoming")
if games_heading is None:
raise RBIHFImportError("Could not find an “Upcoming games” table on this page.")
table = games_heading.find_next("table")
if table is None:
raise RBIHFImportError("Could not find an “Upcoming games” table on this page.")
fixtures = []
for row in table.find_all("tr"):
cells = row.find_all("td")
if len(cells) < 6:
continue # the header row (<th>s) and any stray rows
game_link = cells[0].find("a")
game_id = game_link.get_text(strip=True) if game_link else cells[0].get_text(strip=True)
if not game_id:
continue
date_text = cells[1].get_text(strip=True)
hour_text = cells[2].get_text(strip=True)
try:
naive_start = datetime.strptime(f"{date_text} {hour_text}", "%Y-%m-%d %H:%M")
except ValueError:
continue # a row that doesn't match the expected shape -- skip rather than blow up the whole import
start = timezone.make_aware(naive_start)
venue_text = cells[3].get_text(strip=True)
home_link, visit_link = cells[4].find("a"), cells[5].find("a")
if home_link is None or visit_link is None:
continue
home_id = _team_id_from_href(home_link.get("href", ""))
visit_id = _team_id_from_href(visit_link.get("href", ""))
if home_id == team_id:
is_home, opponent_name = True, visit_link.get("title") or visit_link.get_text(strip=True)
elif visit_id == team_id:
is_home, opponent_name = False, home_link.get("title") or home_link.get_text(strip=True)
else:
continue # neither side is the team we asked about -- inconsistent row, skip it
fixtures.append(ScrapedFixture(external_game_id=game_id, start=start, is_home=is_home, opponent_name=opponent_name, venue_text=venue_text))
return team_name, fixtures
def suggested_location(club, fixture: ScrapedFixture):
if fixture.is_home:
return Location.objects.filter(club=club, is_home=True).first()
venue = fixture.venue_text.strip()
if not venue:
return None
return Location.objects.filter(club=club).filter(Q(city__iexact=venue) | Q(name__iexact=venue)).first()
def suggested_opponent(club, fixture: ScrapedFixture):
"""A case-insensitive name match among the club's existing Opponents, if
any -- RBIHF's own spelling doesn't always match punctuation/casing
already on file (e.g. "Rivals FC" vs "Rivals F.C."), and get_or_create's
exact-string match alone would just create a near-duplicate instead of
reusing it."""
name = fixture.opponent_name.strip()
if not name:
return None
return Opponent.objects.filter(club=club, name__iexact=name).first()
@dataclass
class PlannedCreate:
fixture: ScrapedFixture
suggested_location: Location | None
suggested_opponent: Opponent | None = None
@dataclass
class PlannedUpdate:
fixture: ScrapedFixture
event: Event
changes: dict = field(default_factory=dict) # field name -> (old, new), display-ready strings
suggested_location: Location | None = None
suggested_opponent: Opponent | None = None
@dataclass
class ImportPlan:
club: object
team: object
scraped_team_name: str
location_choices: object # QuerySet[Location], evaluated once for the template
opponent_choices: object = None # QuerySet[Opponent], evaluated once for the template
to_create: list[PlannedCreate] = field(default_factory=list)
to_update: list[PlannedUpdate] = field(default_factory=list)
to_delete: list[Event] = field(default_factory=list)
unchanged_count: int = 0
def _describe_changes(event, fixture: ScrapedFixture) -> dict:
changes = {}
if event.start != fixture.start:
changes["start"] = (event.start, fixture.start)
existing_opponent = event.opponent.name if event.opponent_id else ""
if existing_opponent != fixture.opponent_name:
changes["opponent"] = (existing_opponent, fixture.opponent_name)
return changes
def build_plan(club, team, rbihf_team_id: str, html: str) -> ImportPlan:
"""Parses ``html`` (already fetched -- see ``fetch_html``) and diffs it
against this club/team's existing RBIHF events. Pure with respect to the
network: safe to call twice against the same stashed HTML (preview, then
confirm) and get an identical result modulo whatever changed in the DB
between the two calls."""
scraped_team_name, fixtures = parse_fixtures(html, rbihf_team_id)
location_choices = list(Location.objects.filter(club=club).order_by("name"))
opponent_choices = list(Opponent.objects.filter(club=club).order_by("name"))
existing_by_game_id = {event.external_game_id: event for event in Event.objects.filter(club=club, teams=team, competition="RBIHF").exclude(external_game_id="").select_related("opponent", "location")}
plan = ImportPlan(club=club, team=team, scraped_team_name=scraped_team_name, location_choices=location_choices, opponent_choices=opponent_choices)
seen_game_ids = set()
for fixture in fixtures:
seen_game_ids.add(fixture.external_game_id)
existing = existing_by_game_id.get(fixture.external_game_id)
if existing is None:
plan.to_create.append(PlannedCreate(fixture=fixture, suggested_location=suggested_location(club, fixture), suggested_opponent=suggested_opponent(club, fixture)))
continue
changes = _describe_changes(existing, fixture)
if changes:
# Prefer whatever location/opponent is already on the event -- a
# previous run's manual pick -- over re-guessing, so a re-import
# doesn't silently discard it. Only fall back to a fresh guess
# when nothing's set yet.
default_location = existing.location if existing.location_id else suggested_location(club, fixture)
default_opponent = existing.opponent if existing.opponent_id else suggested_opponent(club, fixture)
plan.to_update.append(PlannedUpdate(fixture=fixture, event=existing, changes=changes, suggested_location=default_location, suggested_opponent=default_opponent))
else:
plan.unchanged_count += 1
now = timezone.now()
for game_id, event in existing_by_game_id.items():
if game_id not in seen_game_ids and event.start >= now:
plan.to_delete.append(event)
return plan
def apply_plan(plan: ImportPlan, locations_by_game_id: dict, opponents_by_game_id: dict | None = None) -> dict:
"""Applies everything in ``plan``, resolving each create/update row's
location from ``locations_by_game_id`` and opponent from
``opponents_by_game_id`` (both external_game_id -> pk as a plain string,
or None/""). A pk that doesn't belong to ``plan.club`` -- or isn't a real
pk at all -- is silently ignored rather than trusted: this is user input
straight from the confirm POST, not derived data. Compared as strings
since the input is whatever a <select> submitted, not already a UUID.
Blank/missing means different things for the two: no location is a valid,
final state (an away game with no venue on file yet), but a game always
needs *an* opponent -- so blank there falls back to the same
find-or-create-by-scraped-name this always did, rather than leaving it
unset."""
opponents_by_game_id = opponents_by_game_id or {}
locations_by_id = {str(location.pk): location for location in plan.location_choices}
opponents_by_id = {str(opponent.pk): opponent for opponent in plan.opponent_choices}
def resolve_location(game_id):
location_id = locations_by_game_id.get(game_id)
if not location_id:
return None
return locations_by_id.get(str(location_id))
def resolve_opponent(fixture):
opponent_id = opponents_by_game_id.get(fixture.external_game_id)
if opponent_id:
opponent = opponents_by_id.get(str(opponent_id))
if opponent is not None:
return opponent
opponent, _created = Opponent.objects.get_or_create(club=plan.club, name=fixture.opponent_name)
return opponent
created = updated = deleted = 0
with transaction.atomic():
for planned in plan.to_create:
opponent = resolve_opponent(planned.fixture)
event = Event.objects.create(
club=plan.club,
title=f"vs {opponent.name}",
kind=Event.EventKind.GAME,
start=planned.fixture.start,
competition="RBIHF",
external_game_id=planned.fixture.external_game_id,
opponent=opponent,
location=resolve_location(planned.fixture.external_game_id),
)
event.teams.add(plan.team)
created += 1
for planned in plan.to_update:
event = planned.event
event.start = planned.fixture.start
event.opponent = resolve_opponent(planned.fixture)
event.location = resolve_location(planned.fixture.external_game_id)
event.save(update_fields=["start", "opponent", "location"])
updated += 1
for event in plan.to_delete:
event.delete()
deleted += 1
return {"created": created, "updated": updated, "deleted": deleted}

View File

@@ -6,12 +6,14 @@ from django.core.management import call_command
from django.db import IntegrityError
from django.test import TestCase
from django.utils import timezone
from waffle import get_waffle_flag_model
from club.models import Club, Season
from members.models import Member
from teams.models import Position, Team, TeamMembership
from .models import Attendance, Event, EventSeries, Location, Opponent
from .admin import EventAdminForm
from .models import Attendance, Competition, Event, EventSeries, Location, Opponent
from .services import (
cancel_occurrence,
detach_occurrence,
@@ -25,6 +27,7 @@ from .services import (
team_attendance_rate,
team_no_shows,
)
from .services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, parse_fixtures, suggested_location, suggested_opponent
class EventsTestBase(TestCase):
@@ -75,6 +78,76 @@ class EventModelTests(EventsTestBase):
with self.assertRaises(IntegrityError):
Attendance.objects.create(event=event, member=self.alice)
def test_is_home_game(self):
home_ground = Location.objects.create(club=self.club, name="Home Ground", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
away_ground = Location.objects.create(club=self.club, name="Away Ground", address="2 St", city="Town", zip_code="1000", country="BE")
home_game = self.make_event(kind=Event.EventKind.GAME, location=home_ground)
away_game = self.make_event(kind=Event.EventKind.GAME, location=away_ground)
game_with_no_location = self.make_event(kind=Event.EventKind.GAME)
training_at_home_ground = self.make_event(kind=Event.EventKind.TRAINING, location=home_ground)
self.assertTrue(home_game.is_home_game)
self.assertFalse(away_game.is_home_game)
self.assertFalse(game_with_no_location.is_home_game)
self.assertFalse(training_at_home_ground.is_home_game)
class CompetitionModelTests(EventsTestBase):
def test_sport_type_defaults_to_other(self):
competition = Competition.objects.create(name="Local League", module="events.competition.other")
self.assertEqual(competition.sport_type, Club.SportType.OTHER)
def test_the_seeded_hockey_competitions_are_backfilled_as_ice_hockey(self):
# Migration 0019's data migration backfills the two competitions seeded
# by 0017 (both real ice hockey leagues) rather than leaving them at the
# generic "other" default.
for name in ["RBIHF", "CEHL"]:
with self.subTest(name=name):
self.assertEqual(Competition.objects.get(name=name).sport_type, Club.SportType.ICE_HOCKEY)
class EventAdminFormCompetitionTests(EventsTestBase):
"""`competition` is a plain CharField, but the admin should only ever offer
competitions this club is actually allowed to use -- see events/admin.py."""
def setUp(self):
super().setUp()
Flag = get_waffle_flag_model()
self.active_flag = Flag.objects.create(name="active-competition")
self.active_flag.clubs.add(self.club)
self.inactive_flag = Flag.objects.create(name="inactive-competition")
self.active_competition = Competition.objects.create(name="Active Cup", module="events.competition.active", flag=self.active_flag)
self.inactive_competition = Competition.objects.create(name="Inactive Cup", module="events.competition.inactive", flag=self.inactive_flag)
self.flagless_competition = Competition.objects.create(name="Flagless Cup", module="events.competition.flagless")
def test_a_flagless_competition_never_appears(self):
form = EventAdminForm(instance=Event())
choices = dict(form.fields["competition"].choices)
self.assertNotIn("Flagless Cup", choices)
def test_a_brand_new_event_offers_every_flagged_competition_unfiltered(self):
# The club is unknown until the event is actually saved (see
# EventClubScopeTests for the same "club_id is None pre-save" timing issue
# elsewhere), so a new event can't be filtered by club yet -- it falls back
# to showing everything with a flag rather than crashing or showing nothing.
form = EventAdminForm(instance=Event())
choices = dict(form.fields["competition"].choices)
self.assertIn("Active Cup", choices)
self.assertIn("Inactive Cup", choices)
def test_an_existing_event_only_offers_competitions_active_for_its_club(self):
event = self.make_event(kind=Event.EventKind.GAME)
form = EventAdminForm(instance=event)
choices = dict(form.fields["competition"].choices)
self.assertIn("Active Cup", choices)
self.assertNotIn("Inactive Cup", choices)
self.assertNotIn("Flagless Cup", choices)
class EffectiveMembersTests(EventsTestBase):
def test_union_of_team_invited_minus_excluded(self):
@@ -517,3 +590,306 @@ class TeamAttendanceStatsTests(EventsTestBase):
record_check_in(attendance, showed_up=True)
self.assertEqual(list(team_no_shows(self.team, self.season)), [])
RBIHF_TEAM_ID = "4460"
RBIHF_TEAM_NAME = "Sportoase Antwerp Phantoms"
def rbihf_sample_html(rows):
"""A trimmed stand-in for an RBIHF team page (https://www.rbihf.be/league/team/<id>)
-- structure confirmed against the real page while designing this feature.
``rows`` is a list of dicts: game_id, date ("YYYY-MM-DD"), hour ("HH:MM"),
venue, home_id, home_name, visit_id, visit_name."""
row_html = "".join(
f'<tr><td class="game-nr"><a href="/game/{r["game_id"]}" title="Game {r["game_id"]}">{r["game_id"]}</a></td>'
f'<td class="date">{r["date"]}</td><td class="hour">{r["hour"]}</td><td>{r["venue"]}</td>'
f'<td><a href="/league/team/{r["home_id"]}" title="{r["home_name"]}">{r["home_name"]}</a></td>'
f'<td><a href="/league/team/{r["visit_id"]}" title="{r["visit_name"]}">{r["visit_name"]}</a></td></tr>'
for r in rows
)
return f"""<html><body>
<div class="block"><div class="block-header"><h2>{RBIHF_TEAM_NAME}</h2></div></div>
<div class="block"><div class="block-header"><h2 id="games-upcoming">Upcoming games</h2></div>
<div class="block-content"><table>
<tr><th class="game-nr">#</th><th class="date">Date</th><th class="hour">Hour</th><th>Location</th><th>Home</th><th>Visit</th></tr>
{row_html}
</table></div></div>
</body></html>"""
class RBIHFParseFixturesTests(TestCase):
"""Pure parsing, no network, no DB -- events.services.rbihf_import.parse_fixtures."""
def test_parses_team_name_and_fixtures(self):
html = rbihf_sample_html(
[
{"game_id": "5002", "date": "2026-09-12", "hour": "12:15", "venue": "Deurne", "home_id": RBIHF_TEAM_ID, "home_name": RBIHF_TEAM_NAME, "visit_id": "4464", "visit_name": "Amsterdam Tigers"},
{"game_id": "5010", "date": "2026-09-20", "hour": "18:30", "venue": "Antwerp Ice Rink", "home_id": "4500", "home_name": "Brussels Bears", "visit_id": RBIHF_TEAM_ID, "visit_name": RBIHF_TEAM_NAME},
]
)
team_name, fixtures = parse_fixtures(html, RBIHF_TEAM_ID)
self.assertEqual(team_name, RBIHF_TEAM_NAME)
self.assertEqual(len(fixtures), 2)
def test_resolves_home_fixture_correctly(self):
html = rbihf_sample_html([{"game_id": "5002", "date": "2026-09-12", "hour": "12:15", "venue": "Deurne", "home_id": RBIHF_TEAM_ID, "home_name": RBIHF_TEAM_NAME, "visit_id": "4464", "visit_name": "Amsterdam Tigers"}])
_team_name, fixtures = parse_fixtures(html, RBIHF_TEAM_ID)
self.assertTrue(fixtures[0].is_home)
self.assertEqual(fixtures[0].opponent_name, "Amsterdam Tigers")
self.assertEqual(fixtures[0].venue_text, "Deurne")
self.assertEqual(fixtures[0].external_game_id, "5002")
def test_resolves_away_fixture_correctly(self):
html = rbihf_sample_html([{"game_id": "5010", "date": "2026-09-20", "hour": "18:30", "venue": "Antwerp Ice Rink", "home_id": "4500", "home_name": "Brussels Bears", "visit_id": RBIHF_TEAM_ID, "visit_name": RBIHF_TEAM_NAME}])
_team_name, fixtures = parse_fixtures(html, RBIHF_TEAM_ID)
self.assertFalse(fixtures[0].is_home)
self.assertEqual(fixtures[0].opponent_name, "Brussels Bears")
def test_a_row_where_neither_side_matches_is_skipped(self):
html = rbihf_sample_html([{"game_id": "9999", "date": "2026-09-20", "hour": "18:30", "venue": "Elsewhere", "home_id": "1", "home_name": "Team One", "visit_id": "2", "visit_name": "Team Two"}])
_team_name, fixtures = parse_fixtures(html, RBIHF_TEAM_ID)
self.assertEqual(fixtures, [])
def test_missing_games_table_raises(self):
with self.assertRaises(RBIHFImportError):
parse_fixtures("<html><body><h2>Some Team</h2></body></html>", RBIHF_TEAM_ID)
class RBIHFExtractTeamIdTests(TestCase):
def test_accepts_the_documented_shape(self):
self.assertEqual(extract_team_id("https://www.rbihf.be/league/team/4460"), "4460")
def test_accepts_without_www(self):
self.assertEqual(extract_team_id("https://rbihf.be/league/team/4460"), "4460")
def test_rejects_a_different_host(self):
with self.assertRaises(RBIHFImportError):
extract_team_id("https://evil.example.com/league/team/4460")
def test_rejects_a_different_path(self):
with self.assertRaises(RBIHFImportError):
extract_team_id("https://www.rbihf.be/leagues")
class RBIHFImportPlanTests(EventsTestBase):
def setUp(self):
super().setUp()
self.home_location = Location.objects.create(club=self.club, name="Home Arena", address="1 St", city="Antwerp", zip_code="1000", country="BE", is_home=True)
self.away_location = Location.objects.create(club=self.club, name="Deurne Ice Hall", address="2 St", city="Deurne", zip_code="2100", country="BE")
def home_fixture_row(self, game_id="5002", date="2026-09-12"):
return {"game_id": game_id, "date": date, "hour": "12:15", "venue": "Deurne", "home_id": RBIHF_TEAM_ID, "home_name": RBIHF_TEAM_NAME, "visit_id": "4464", "visit_name": "Amsterdam Tigers"}
# -- suggested_location --------------------------------------------------
def test_suggested_location_for_a_home_fixture_is_the_clubs_home_location(self):
_team_name, fixtures = parse_fixtures(rbihf_sample_html([self.home_fixture_row()]), RBIHF_TEAM_ID)
self.assertEqual(suggested_location(self.club, fixtures[0]), self.home_location)
def test_suggested_location_for_an_away_fixture_matches_by_city(self):
row = {"game_id": "5010", "date": "2026-09-20", "hour": "18:30", "venue": "Deurne", "home_id": "4500", "home_name": "Brussels Bears", "visit_id": RBIHF_TEAM_ID, "visit_name": RBIHF_TEAM_NAME}
_team_name, fixtures = parse_fixtures(rbihf_sample_html([row]), RBIHF_TEAM_ID)
self.assertEqual(suggested_location(self.club, fixtures[0]), self.away_location)
def test_suggested_location_is_none_when_nothing_matches(self):
row = {"game_id": "5010", "date": "2026-09-20", "hour": "18:30", "venue": "Nowhere Familiar", "home_id": "4500", "home_name": "Brussels Bears", "visit_id": RBIHF_TEAM_ID, "visit_name": RBIHF_TEAM_NAME}
_team_name, fixtures = parse_fixtures(rbihf_sample_html([row]), RBIHF_TEAM_ID)
self.assertIsNone(suggested_location(self.club, fixtures[0]))
# -- suggested_opponent ---------------------------------------------------
def test_suggested_opponent_matches_case_insensitively(self):
opponent = Opponent.objects.create(club=self.club, name="AMSTERDAM TIGERS")
_team_name, fixtures = parse_fixtures(rbihf_sample_html([self.home_fixture_row()]), RBIHF_TEAM_ID)
self.assertEqual(suggested_opponent(self.club, fixtures[0]), opponent)
def test_suggested_opponent_is_none_when_nothing_matches(self):
_team_name, fixtures = parse_fixtures(rbihf_sample_html([self.home_fixture_row()]), RBIHF_TEAM_ID)
self.assertIsNone(suggested_opponent(self.club, fixtures[0]))
# -- build_plan ------------------------------------------------------------
def test_a_new_fixture_is_planned_as_a_create(self):
html = rbihf_sample_html([self.home_fixture_row()])
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
self.assertEqual(len(plan.to_create), 1)
self.assertEqual(plan.to_create[0].fixture.external_game_id, "5002")
self.assertEqual(plan.to_create[0].suggested_location, self.home_location)
def test_an_identical_existing_fixture_is_unchanged(self):
html = rbihf_sample_html([self.home_fixture_row()])
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
apply_plan(plan, {})
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
self.assertEqual(plan_again.to_create, [])
self.assertEqual(plan_again.to_update, [])
self.assertEqual(plan_again.unchanged_count, 1)
def test_a_changed_fixture_is_planned_as_an_update_with_a_diff(self):
html = rbihf_sample_html([self.home_fixture_row()])
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
apply_plan(plan, {})
changed_row = self.home_fixture_row()
changed_row["hour"] = "20:00" # same game id, different time
changed_html = rbihf_sample_html([changed_row])
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, changed_html)
self.assertEqual(len(plan_again.to_update), 1)
self.assertIn("start", plan_again.to_update[0].changes)
def test_a_future_fixture_no_longer_listed_is_planned_for_deletion(self):
html = rbihf_sample_html([self.home_fixture_row()])
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
apply_plan(plan, {})
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([]))
self.assertEqual(len(plan_again.to_delete), 1)
self.assertEqual(plan_again.to_delete[0].external_game_id, "5002")
def test_a_past_fixture_no_longer_listed_is_not_deleted(self):
# "Upcoming games" naturally stops listing a game once it's happened --
# that's not a signal it was cancelled.
past_row = self.home_fixture_row(date="2020-01-01")
html = rbihf_sample_html([past_row])
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
apply_plan(plan, {})
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([]))
self.assertEqual(plan_again.to_delete, [])
def test_a_fixture_for_a_different_team_is_not_touched(self):
other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd")
html = rbihf_sample_html([self.home_fixture_row()])
plan = build_plan(self.club, other_team, RBIHF_TEAM_ID, html)
apply_plan(plan, {})
# Re-running for *our* team should see no existing RBIHF events at all
# for it -- the one that exists belongs to other_team.
plan_for_our_team = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
self.assertEqual(len(plan_for_our_team.to_create), 1)
# -- apply_plan --------------------------------------------------------
def test_apply_plan_creates_events_with_the_expected_fields(self):
html = rbihf_sample_html([self.home_fixture_row()])
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
result = apply_plan(plan, {"5002": str(self.home_location.pk)})
self.assertEqual(result, {"created": 1, "updated": 0, "deleted": 0})
event = Event.objects.get(club=self.club, external_game_id="5002")
self.assertEqual(event.kind, Event.EventKind.GAME)
self.assertEqual(event.competition, "RBIHF")
self.assertEqual(event.opponent.name, "Amsterdam Tigers")
self.assertEqual(event.location, self.home_location)
self.assertIn(self.team, event.teams.all())
self.assertTrue(event.is_home_game)
def test_apply_plan_ignores_a_location_id_from_another_club(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
foreign_location = Location.objects.create(club=other_club, name="Not ours", address="x", city="x", zip_code="x", country="BE")
html = rbihf_sample_html([self.home_fixture_row()])
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
apply_plan(plan, {"5002": str(foreign_location.pk)})
event = Event.objects.get(club=self.club, external_game_id="5002")
self.assertIsNone(event.location)
def test_apply_plan_uses_an_explicitly_chosen_opponent_instead_of_the_scraped_name(self):
renamed = Opponent.objects.create(club=self.club, name="Amsterdam Tigers HC")
html = rbihf_sample_html([self.home_fixture_row()])
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
apply_plan(plan, {}, {"5002": str(renamed.pk)})
event = Event.objects.get(club=self.club, external_game_id="5002")
self.assertEqual(event.opponent, renamed)
self.assertEqual(Opponent.objects.filter(club=self.club, name="Amsterdam Tigers").count(), 0)
def test_apply_plan_ignores_an_opponent_id_from_another_club(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
foreign_opponent = Opponent.objects.create(club=other_club, name="Not ours")
html = rbihf_sample_html([self.home_fixture_row()])
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
apply_plan(plan, {}, {"5002": str(foreign_opponent.pk)})
event = Event.objects.get(club=self.club, external_game_id="5002")
# Falls back to find-or-create by the scraped name, not the foreign row.
self.assertEqual(event.opponent.name, "Amsterdam Tigers")
def test_apply_plan_preserves_a_previously_chosen_opponent_on_update(self):
renamed = Opponent.objects.create(club=self.club, name="Amsterdam Tigers HC")
html = rbihf_sample_html([self.home_fixture_row()])
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
apply_plan(plan, {}, {"5002": str(renamed.pk)})
# Re-run with a changed start time (something else triggers the update).
# build_plan should suggest (pre-select) the event's existing opponent
# rather than re-guessing from the scraped name -- a real <select>
# always resubmits whichever option is pre-selected, so that's what's
# passed to apply_plan here too, not a blank dict.
changed_row = self.home_fixture_row()
changed_row["hour"] = "20:00"
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([changed_row]))
self.assertEqual(plan_again.to_update[0].suggested_opponent, renamed)
apply_plan(plan_again, {}, {"5002": str(renamed.pk)})
event = Event.objects.get(club=self.club, external_game_id="5002")
self.assertEqual(event.opponent, renamed)
def test_apply_plan_updates_only_start_opponent_and_location(self):
html = rbihf_sample_html([self.home_fixture_row()])
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
apply_plan(plan, {})
event = Event.objects.get(club=self.club, external_game_id="5002")
event.end = self.future
event.gathering = self.future
event.score_for = 3
event.save()
changed_row = self.home_fixture_row()
changed_row["hour"] = "20:00"
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([changed_row]))
apply_plan(plan_again, {})
event.refresh_from_db()
self.assertEqual(timezone.localtime(event.start).strftime("%H:%M"), "20:00")
self.assertIsNotNone(event.end)
self.assertIsNotNone(event.gathering)
self.assertEqual(event.score_for, 3)
def test_apply_plan_deletes_only_whats_in_to_delete(self):
html = rbihf_sample_html([self.home_fixture_row()])
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
apply_plan(plan, {})
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([]))
result = apply_plan(plan_again, {})
self.assertEqual(result, {"created": 0, "updated": 0, "deleted": 1})
self.assertFalse(Event.objects.filter(club=self.club, external_game_id="5002").exists())