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:
@@ -1,7 +1,8 @@
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import Attendance, Event, EventSeries, Location, Opponent
|
||||
from .models import Attendance, Competition, Event, EventSeries, Location, Opponent
|
||||
|
||||
|
||||
@admin.register(Opponent)
|
||||
@@ -39,8 +40,38 @@ class EventSeriesAdmin(admin.ModelAdmin):
|
||||
]
|
||||
|
||||
|
||||
class EventAdminForm(forms.ModelForm):
|
||||
"""The `competition` field is a plain CharField on Event (it just stores a
|
||||
name), but the admin should only ever offer a competition this club is
|
||||
actually allowed to use -- one whose feature flag is active for it, set
|
||||
from the control panel's Features page. A competition with no flag never
|
||||
appears at all."""
|
||||
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = [
|
||||
"title", "kind", "season", "series", "detached", "cancelled", "teams", "invited_members", "excluded_members",
|
||||
"start", "end", "gathering", "deadline", "location", "opponent",
|
||||
"competition", "external_game_id", "score_for", "score_against", "is_live",
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
club = self.instance.club if self.instance.club_id else None
|
||||
competitions = Competition.objects.filter(flag__isnull=False).select_related("flag")
|
||||
if club is not None:
|
||||
competitions = [competition for competition in competitions if competition.flag.is_active_for_club(club)]
|
||||
self.fields["competition"] = forms.ChoiceField(
|
||||
choices=[("", "---------"), *[(competition.name, competition.name) for competition in competitions]],
|
||||
required=False,
|
||||
label=self.fields["competition"].label,
|
||||
help_text=_("Only competitions whose feature flag is active for this club are offered here."),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Event)
|
||||
class EventAdmin(admin.ModelAdmin):
|
||||
form = EventAdminForm
|
||||
list_display = ["title", "kind", "start", "season", "series", "detached", "club"]
|
||||
list_filter = ["kind", "club", "teams", "detached", "cancelled"]
|
||||
search_fields = ["title"]
|
||||
@@ -53,9 +84,18 @@ class EventAdmin(admin.ModelAdmin):
|
||||
[_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}],
|
||||
[_("When"), {"fields": ["start", "end", "gathering", "deadline"]}],
|
||||
[_("Where"), {"fields": ["location", "opponent"]}],
|
||||
[_("Game"), {"fields": ["competition", "external_game_id", "score_for", "score_against", "is_live"]}],
|
||||
]
|
||||
|
||||
|
||||
@admin.register(Competition)
|
||||
class CompetitionAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "sport_type", "module", "flag"]
|
||||
list_filter = ["sport_type"]
|
||||
search_fields = ["name", "module"]
|
||||
raw_id_fields = ["flag"]
|
||||
|
||||
|
||||
@admin.register(Attendance)
|
||||
class AttendanceAdmin(admin.ModelAdmin):
|
||||
list_display = ["event", "member", "status", "showed_up"]
|
||||
|
||||
139
events/api.py
Normal file
139
events/api.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Public read-only game endpoints -- see api/urls.py for how this is
|
||||
mounted. Also owns GET /teams/{team_id}/games/: it's an Event query through
|
||||
and through, so the query logic and GameOut schema live here rather than
|
||||
being duplicated in teams/api.py.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from ninja import Router, Schema
|
||||
from ninja.errors import HttpError
|
||||
|
||||
from api.errors import require_club
|
||||
from club.services.access import current_season
|
||||
from teams.models import Team
|
||||
|
||||
from .models import Event
|
||||
|
||||
router = Router(tags=["games"])
|
||||
|
||||
DEFAULT_UPCOMING_COUNT = 10
|
||||
MAX_UPCOMING_COUNT = 50
|
||||
|
||||
#: /games/upcoming/ covers anything worth putting on a public fixture list --
|
||||
#: not just Game, but Tournament too. The other game endpoints (live,
|
||||
#: per-team) stay Game-only: is_live/score_for/score_against are genuinely
|
||||
#: game-specific (events/models.py), so a tournament wouldn't have anything
|
||||
#: meaningful to show there anyway.
|
||||
UPCOMING_KINDS = [Event.EventKind.GAME, Event.EventKind.TOURNAMENT]
|
||||
|
||||
|
||||
class LocationOut(Schema):
|
||||
name: str
|
||||
address: str
|
||||
city: str
|
||||
zip_code: str
|
||||
country: str
|
||||
is_home: bool
|
||||
|
||||
|
||||
class GameOut(Schema):
|
||||
id: uuid.UUID
|
||||
start: datetime
|
||||
location: LocationOut | None
|
||||
home_team: str | None
|
||||
away_team: str | None
|
||||
competition: str
|
||||
is_live: bool
|
||||
status: str # "upcoming" | "live" | "finished"
|
||||
home_score: int | None
|
||||
away_score: int | None
|
||||
|
||||
|
||||
def _to_game_out(event, team=None) -> GameOut:
|
||||
if team is None:
|
||||
# .first() would re-query even with teams prefetched; go through the
|
||||
# prefetch cache instead.
|
||||
related_teams = list(event.teams.all())
|
||||
team = related_teams[0] if related_teams else None
|
||||
|
||||
team_name = team.name if team is not None else None
|
||||
opponent_name = event.opponent.name if event.opponent_id else None
|
||||
|
||||
if event.is_home_game:
|
||||
home_team, away_team = team_name, opponent_name
|
||||
home_score, away_score = event.score_for, event.score_against
|
||||
else:
|
||||
home_team, away_team = opponent_name, team_name
|
||||
home_score, away_score = event.score_against, event.score_for
|
||||
|
||||
if event.is_live:
|
||||
status = "live"
|
||||
elif event.start > timezone.now():
|
||||
status = "upcoming"
|
||||
else:
|
||||
status = "finished"
|
||||
|
||||
location = None
|
||||
if event.location_id:
|
||||
location = LocationOut(name=event.location.name, address=event.location.address, city=event.location.city, zip_code=event.location.zip_code, country=str(event.location.country), is_home=event.location.is_home)
|
||||
|
||||
return GameOut(
|
||||
id=event.pk,
|
||||
start=event.start,
|
||||
location=location,
|
||||
home_team=home_team,
|
||||
away_team=away_team,
|
||||
competition=event.competition,
|
||||
is_live=event.is_live,
|
||||
status=status,
|
||||
home_score=home_score,
|
||||
away_score=away_score,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/games/upcoming/", response=list[GameOut], summary="Upcoming games")
|
||||
def list_upcoming_games(request, count: int = DEFAULT_UPCOMING_COUNT):
|
||||
"""The next `count` non-cancelled upcoming games and tournaments, club-wide."""
|
||||
club = require_club(request)
|
||||
count = max(1, min(count, MAX_UPCOMING_COUNT))
|
||||
|
||||
events = Event.objects.filter(club=club, kind__in=UPCOMING_KINDS, cancelled=False, start__gte=timezone.now()).select_related("opponent", "location").prefetch_related("teams").order_by("start")[:count]
|
||||
|
||||
return [_to_game_out(event) for event in events]
|
||||
|
||||
|
||||
@router.get("/games/live/", response=list[GameOut], summary="Live games")
|
||||
def list_live_games(request):
|
||||
club = require_club(request)
|
||||
|
||||
events = Event.objects.filter(club=club, kind=Event.EventKind.GAME, cancelled=False, is_live=True).select_related("opponent", "location").prefetch_related("teams").order_by("start")
|
||||
|
||||
return [_to_game_out(event) for event in events]
|
||||
|
||||
|
||||
@router.get("/teams/{team_id}/games/", response=list[GameOut], summary="A team's current-season games")
|
||||
def list_team_games(request, team_id: uuid.UUID):
|
||||
"""All of this team's current-season games, past and upcoming -- past ones
|
||||
include both teams' scores. Same "explicit season, else derived from
|
||||
start date" scoping management.views.EventListView already applies."""
|
||||
club = require_club(request)
|
||||
team = Team.objects.filter(club=club, pk=team_id).first()
|
||||
if team is None:
|
||||
raise HttpError(404, "No such team.")
|
||||
|
||||
season = current_season(club)
|
||||
if season is None:
|
||||
return []
|
||||
|
||||
events = (
|
||||
Event.objects.filter(club=club, teams=team, kind=Event.EventKind.GAME, cancelled=False)
|
||||
.filter(Q(season=season) | Q(season__isnull=True, start__date__gte=season.start_date, start__date__lte=season.end_date))
|
||||
.select_related("opponent", "location")
|
||||
.order_by("start")
|
||||
)
|
||||
|
||||
return [_to_game_out(event, team=team) for event in events]
|
||||
0
events/competition/__init__.py
Normal file
0
events/competition/__init__.py
Normal file
19
events/competition/base.py
Normal file
19
events/competition/base.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from typing import TypedDict
|
||||
|
||||
from ..models import Event
|
||||
|
||||
|
||||
class GameInformation(TypedDict):
|
||||
live: bool
|
||||
scoreA: int
|
||||
scoreB: int
|
||||
|
||||
|
||||
class CompetitionBaseClass:
|
||||
url: str
|
||||
|
||||
def __init__(self):
|
||||
self.url = "http://localhost"
|
||||
|
||||
def update_game_information(self, event: Event) -> None:
|
||||
raise NotImplementedError
|
||||
90
events/competition/hockey.py
Normal file
90
events/competition/hockey.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
|
||||
from ..models import Event
|
||||
from .base import CompetitionBaseClass
|
||||
|
||||
|
||||
class RBIHF(CompetitionBaseClass):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.url = "https://rbihf.be/modules/league/ajax/time.php"
|
||||
|
||||
def update_game_information(self, event: Event) -> None:
|
||||
season = "{start}{end}".format(start=event.season.start_date.strftime("%y"), end=event.season.end_date.strftime("%y"))
|
||||
|
||||
payload = {"gameNr": event.external_game_id, "season": season}
|
||||
headers = {
|
||||
"Cookie": "language=en",
|
||||
"Postman-Token": "rosterchief",
|
||||
"Host": "www.rbihf.be",
|
||||
"User-Agent": "PostmanRuntime/7.37.0",
|
||||
"Accept": "application/json",
|
||||
"Accept-Encoding": "gzip,deflate,br",
|
||||
"Connection": "keep-alive",
|
||||
"Referer": f"https://rbihf.be/game/{event.external_game_id}",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
}
|
||||
|
||||
req = requests.get(self.url, params=payload, headers=headers)
|
||||
|
||||
if req.status_code == 200:
|
||||
game_data = req.json()
|
||||
|
||||
event.is_live = game_data["live"]
|
||||
|
||||
if event.is_home_game:
|
||||
event.score_for = game_data["scoreA"]
|
||||
event.score_against = game_data["scoreB"]
|
||||
else:
|
||||
event.score_for = game_data["scoreB"]
|
||||
event.score_against = game_data["scoreA"]
|
||||
|
||||
event.save(update_fields=["is_live", "score_for", "score_against"])
|
||||
|
||||
|
||||
class CEHL(CompetitionBaseClass):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.url = "https://www.cehl.eu/ajax/"
|
||||
|
||||
def update_game_information(self, event: Event) -> None:
|
||||
season = "{start}{end}".format(start=event.season.start_date.strftime("%y"), end=event.season.end_date.strftime("%y"))
|
||||
|
||||
referer_url = urljoin("https://www.cehl.eu", f"game/{season}/{event.external_game_id}")
|
||||
timeline_url = urljoin(self.url, "timeline.php")
|
||||
score_url = urljoin(self.url, "score.php")
|
||||
|
||||
payload = {"nr": event.external_game_id, "season": season}
|
||||
headers = {
|
||||
"Cookie": "language=en",
|
||||
"Postman-Token": "rosterchief",
|
||||
"Host": "www.cehl.eu",
|
||||
"User-Agent": "PostmanRuntime/7.37.0",
|
||||
"Accept": "*/*",
|
||||
"Accept-Encoding": "gzip,deflate,br",
|
||||
"Connection": "close",
|
||||
"Referer": referer_url,
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
}
|
||||
|
||||
timeline_req = requests.get(timeline_url, params=payload, headers=headers)
|
||||
score_req = requests.get(score_url, params=payload, headers=headers)
|
||||
|
||||
if timeline_req.status_code == 200:
|
||||
event.is_live = timeline_req.json()["live"] == 1
|
||||
|
||||
if score_req.status_code == 200:
|
||||
game_data = score_req.json()
|
||||
|
||||
if event.is_home_game:
|
||||
event.score_for = game_data["scoreA"]
|
||||
event.score_against = game_data["scoreB"]
|
||||
|
||||
else:
|
||||
event.score_for = game_data["scoreB"]
|
||||
event.score_against = game_data["scoreA"]
|
||||
|
||||
if timeline_req.status_code == 200 or score_req.status_code == 200:
|
||||
event.save(update_fields=["is_live", "score_for", "score_against"])
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-04 11:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0012_alter_location_country'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('training', 'Training'), ('match', 'Match'), ('tournament', 'Tournament'), ('meeting', 'Meeting'), ('social', 'Social'), ('other', 'Other')], default='other', max_length=10, verbose_name='kind'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='eventseries',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('training', 'Training'), ('match', 'Match'), ('tournament', 'Tournament'), ('meeting', 'Meeting'), ('social', 'Social'), ('other', 'Other')], default='other', max_length=10, verbose_name='kind'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,73 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-04 11:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def rename_match_to_game(apps, schema_editor):
|
||||
Event = apps.get_model('events', 'Event')
|
||||
EventSeries = apps.get_model('events', 'EventSeries')
|
||||
Event.objects.filter(kind='match').update(kind='game')
|
||||
EventSeries.objects.filter(kind='match').update(kind='game')
|
||||
|
||||
|
||||
def rename_game_to_match(apps, schema_editor):
|
||||
Event = apps.get_model('events', 'Event')
|
||||
EventSeries = apps.get_model('events', 'EventSeries')
|
||||
Event.objects.filter(kind='game').update(kind='match')
|
||||
EventSeries.objects.filter(kind='game').update(kind='match')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0013_alter_event_kind_alter_eventseries_kind'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='competition',
|
||||
field=models.CharField(blank=True, help_text='The league, cup or competition this game is part of.', max_length=255, verbose_name='competition'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='external_game_id',
|
||||
field=models.CharField(blank=True, help_text="This game's id in an external competition data source, for automatic score fetching later.", max_length=255, verbose_name='external game ID'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='is_live',
|
||||
field=models.BooleanField(default=False, help_text='The game is currently in progress.', verbose_name='live'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='score_against',
|
||||
field=models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='score (opponent)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='score_for',
|
||||
field=models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='score (us)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='deadline',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='registration deadline'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('training', 'Training'), ('game', 'Game'), ('tournament', 'Tournament'), ('meeting', 'Meeting'), ('social', 'Social'), ('other', 'Other')], default='other', max_length=10, verbose_name='kind'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='eventseries',
|
||||
name='deadline_offset',
|
||||
field=models.DurationField(blank=True, help_text="How long before the start each occurrence's registration deadline is.", null=True, verbose_name='registration deadline offset'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='eventseries',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('training', 'Training'), ('game', 'Game'), ('tournament', 'Tournament'), ('meeting', 'Meeting'), ('social', 'Social'), ('other', 'Other')], default='other', max_length=10, verbose_name='kind'),
|
||||
),
|
||||
migrations.RunPython(rename_match_to_game, rename_game_to_match),
|
||||
]
|
||||
18
events/migrations/0015_alter_attendance_status.py
Normal file
18
events/migrations/0015_alter_attendance_status.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-04 13:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0014_event_competition_event_external_game_id_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='attendance',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('present', 'Present'), ('absent', 'Absent'), ('excused', 'Excused'), ('selected', 'Selected'), ('not_selected', 'Not selected'), ('maybe', 'Maybe'), ('no_response', 'No response')], default='no_response', max_length=20, verbose_name='status'),
|
||||
),
|
||||
]
|
||||
21
events/migrations/0016_competition.py
Normal file
21
events/migrations/0016_competition.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-04 21:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0015_alter_attendance_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Competition',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=250)),
|
||||
('module', models.CharField(max_length=250)),
|
||||
],
|
||||
),
|
||||
]
|
||||
33
events/migrations/0017_auto_20260804_2322.py
Normal file
33
events/migrations/0017_auto_20260804_2322.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-04 21:22
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def create_competiton_group(apps, schema_editor):
|
||||
Competition = apps.get_model("events", "Competition")
|
||||
|
||||
try:
|
||||
Competition.objects.create(name="RBIHF", module="events.competition.hockey")
|
||||
Competition.objects.create(name="CEHL", module="events.competition.hockey")
|
||||
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def remove_competiton_group(apps, schema_editor):
|
||||
Competition = apps.get_model("events", "Competition")
|
||||
|
||||
try:
|
||||
Competition.objects.get(name="RBIHF", module="events.competition.hockey").delete()
|
||||
Competition.objects.get(name="CEHL", module="events.competition.hockey")
|
||||
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('events', '0016_competition'),
|
||||
]
|
||||
|
||||
operations = [migrations.RunPython(create_competiton_group, remove_competiton_group)]
|
||||
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-04 21:32
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def create_flags_for_seeded_competitions(apps, schema_editor):
|
||||
"""RBIHF/CEHL (0017's data migration) predate the flag field -- give them one
|
||||
each so they're actually selectable (unflagged competitions never show up
|
||||
in the Event admin's dropdown) instead of silently invisible."""
|
||||
Competition = apps.get_model("events", "Competition")
|
||||
Flag = apps.get_model(*settings.WAFFLE_FLAG_MODEL.split("."))
|
||||
|
||||
for competition in Competition.objects.filter(flag__isnull=True):
|
||||
flag, _created = Flag.objects.get_or_create(name=competition.name)
|
||||
competition.flag = flag
|
||||
competition.save(update_fields=["flag"])
|
||||
|
||||
|
||||
def noop(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0017_auto_20260804_2322'),
|
||||
migrations.swappable_dependency(settings.WAFFLE_FLAG_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='competition',
|
||||
options={'ordering': ['name'], 'verbose_name': 'competition', 'verbose_name_plural': 'competitions'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='competition',
|
||||
name='flag',
|
||||
field=models.ForeignKey(blank=True, help_text="Which clubs this competition is offered to -- set (or leave blank to hide it everywhere) from the control panel's Features page. A competition with no flag never shows up on the Event admin's competition dropdown.", null=True, on_delete=django.db.models.deletion.PROTECT, related_name='competitions', to=settings.WAFFLE_FLAG_MODEL, verbose_name='feature flag'),
|
||||
),
|
||||
migrations.RunPython(create_flags_for_seeded_competitions, noop),
|
||||
]
|
||||
31
events/migrations/0019_competition_sport_type.py
Normal file
31
events/migrations/0019_competition_sport_type.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-04 22:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def mark_seeded_hockey_competitions(apps, schema_editor):
|
||||
"""RBIHF/CEHL (0017's data migration) predate sport_type -- they're both ice
|
||||
hockey leagues (see events/competition/hockey.py), so backfill them rather
|
||||
than leaving them at the generic "other" default."""
|
||||
Competition = apps.get_model("events", "Competition")
|
||||
Competition.objects.filter(name__in=["RBIHF", "CEHL"]).update(sport_type="ice_hockey")
|
||||
|
||||
|
||||
def noop(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0018_alter_competition_options_competition_flag'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='competition',
|
||||
name='sport_type',
|
||||
field=models.CharField(choices=[('ice_hockey', 'Ice hockey'), ('other', 'Other')], default='other', help_text='Which sport this competition is for.', max_length=20, verbose_name='sport'),
|
||||
),
|
||||
migrations.RunPython(mark_seeded_hockey_competitions, noop),
|
||||
]
|
||||
@@ -1,9 +1,10 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_countries.fields import CountryField
|
||||
|
||||
from club.models import Season
|
||||
from club.models import Club, Season
|
||||
from members.models import Member
|
||||
from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
|
||||
from teams.models import Team
|
||||
@@ -48,12 +49,12 @@ class Location(ClubScopedModel):
|
||||
|
||||
class Event(ClubScopedModel):
|
||||
class EventKind(models.TextChoices):
|
||||
TRAINING = "training", _("training")
|
||||
MATCH = "match", _("match")
|
||||
TOURNAMENT = "tournament", _("tournament")
|
||||
MEETING = "meeting", _("meeting")
|
||||
SOCIAL = "social", _("social")
|
||||
OTHER = "other", _("other")
|
||||
TRAINING = "training", _("Training")
|
||||
GAME = "game", _("Game")
|
||||
TOURNAMENT = "tournament", _("Tournament")
|
||||
MEETING = "meeting", _("Meeting")
|
||||
SOCIAL = "social", _("Social")
|
||||
OTHER = "other", _("Other")
|
||||
|
||||
series = models.ForeignKey("EventSeries", on_delete=models.CASCADE, related_name="occurrences", null=True, blank=True, verbose_name=_("series"), help_text=_("The recurring series this occurrence belongs to; blank for one-off events."))
|
||||
detached = models.BooleanField(_("detached"), default=False, help_text=_("Edited independently; excluded from series-wide updates and regeneration."))
|
||||
@@ -70,12 +71,21 @@ class Event(ClubScopedModel):
|
||||
start = models.DateTimeField(_("start"))
|
||||
end = models.DateTimeField(_("end"), blank=True, null=True)
|
||||
gathering = models.DateTimeField(_("gathering"), blank=True, null=True)
|
||||
deadline = models.DateTimeField(_("deadline"), blank=True, null=True)
|
||||
deadline = models.DateTimeField(_("registration deadline"), blank=True, null=True)
|
||||
|
||||
location = models.ForeignKey(Location, on_delete=models.SET_NULL, related_name="events", null=True, blank=True, verbose_name=_("location"))
|
||||
opponent = models.ForeignKey(Opponent, on_delete=models.SET_NULL, related_name="events", null=True, blank=True, verbose_name=_("opponent"))
|
||||
created_by = models.ForeignKey(Member, on_delete=models.SET_NULL, related_name="created_events", null=True, blank=True, verbose_name=_("created by"))
|
||||
|
||||
# Game-specific -- meaningless for other kinds, so all optional. external_game_id
|
||||
# is this game's id in an external competition/fixture data source, for a later
|
||||
# automatic score-fetcher to key off; nothing populates it yet.
|
||||
competition = models.CharField(_("competition"), max_length=255, blank=True, help_text=_("The league, cup or competition this game is part of."))
|
||||
external_game_id = models.CharField(_("external game ID"), max_length=255, blank=True, help_text=_("This game's id in an external competition data source, for automatic score fetching later."))
|
||||
score_for = models.PositiveSmallIntegerField(_("score (us)"), null=True, blank=True)
|
||||
score_against = models.PositiveSmallIntegerField(_("score (opponent)"), null=True, blank=True)
|
||||
is_live = models.BooleanField(_("live"), default=False, help_text=_("The game is currently in progress."))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("event")
|
||||
verbose_name_plural = _("events")
|
||||
@@ -87,6 +97,13 @@ class Event(ClubScopedModel):
|
||||
def clean(self):
|
||||
validate_club_scope(self, self.club_id, same_club_fields=("season", "location", "opponent"))
|
||||
|
||||
@property
|
||||
def is_home_game(self) -> bool:
|
||||
"""Whether this game is being played at the club's own ground
|
||||
(Location.is_home) -- False for anything that isn't a game, or a game
|
||||
with no location set, or one at an away/neutral location."""
|
||||
return self.kind == self.EventKind.GAME and self.location_id is not None and self.location.is_home
|
||||
|
||||
|
||||
class EventSeries(ClubScopedModel):
|
||||
"""A recurring event definition that materialises concrete Event rows."""
|
||||
@@ -96,7 +113,7 @@ class EventSeries(ClubScopedModel):
|
||||
until = models.DateTimeField(_("until"), null=True, blank=True, help_text=_("Series end: no occurrences are generated after this. Leave blank for open-ended (bounded by the rule's own COUNT/UNTIL, if any)."))
|
||||
duration = models.DurationField(_("duration"), null=True, blank=True, help_text=_("Length of each occurrence; sets each event's end."))
|
||||
gathering_offset = models.DurationField(_("gathering offset"), null=True, blank=True, help_text=_("How long before the start each occurrence's gathering time is."))
|
||||
deadline_offset = models.DurationField(_("deadline offset"), null=True, blank=True, help_text=_("How long before the start each occurrence's sign-up deadline is."))
|
||||
deadline_offset = models.DurationField(_("registration deadline offset"), null=True, blank=True, help_text=_("How long before the start each occurrence's registration deadline is."))
|
||||
excluded_dates = models.JSONField(_("excluded dates"), default=list, blank=True, help_text=_("ISO start datetimes of occurrences removed from the series (EXDATEs)."))
|
||||
generated_until = models.DateTimeField(_("generated until"), null=True, blank=True, help_text=_("Occurrences have been materialised up to this point."))
|
||||
|
||||
@@ -123,13 +140,13 @@ class EventSeries(ClubScopedModel):
|
||||
|
||||
class Attendance(UUIDModel):
|
||||
class AttendanceStatus(models.TextChoices):
|
||||
PRESENT = "present", _("present")
|
||||
ABSENT = "absent", _("absent")
|
||||
EXCUSED = "excused", _("excused")
|
||||
SELECTED = "selected", _("selected")
|
||||
NOT_SELECTED = "not_selected", _("not selected")
|
||||
MAYBE = "maybe", _("maybe")
|
||||
NO_RESPONSE = "no_response", _("no response")
|
||||
PRESENT = "present", _("Present")
|
||||
ABSENT = "absent", _("Absent")
|
||||
EXCUSED = "excused", _("Excused")
|
||||
SELECTED = "selected", _("Selected")
|
||||
NOT_SELECTED = "not_selected", _("Not selected")
|
||||
MAYBE = "maybe", _("Maybe")
|
||||
NO_RESPONSE = "no_response", _("No response")
|
||||
|
||||
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="attendances", verbose_name=_("event"))
|
||||
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="attendances", verbose_name=_("member"))
|
||||
@@ -153,3 +170,34 @@ class Attendance(UUIDModel):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.event} - {self.member}"
|
||||
|
||||
|
||||
class Competition(models.Model):
|
||||
"""A competition has a name with a specific URL to fetch data from. These are managed centrally."""
|
||||
|
||||
name = models.CharField(max_length=250)
|
||||
module = models.CharField(max_length=250)
|
||||
sport_type = models.CharField(
|
||||
_("sport"),
|
||||
max_length=20,
|
||||
choices=Club.SportType.choices,
|
||||
default=Club.SportType.OTHER,
|
||||
help_text=_("Which sport this competition is for."),
|
||||
)
|
||||
flag = models.ForeignKey(
|
||||
settings.WAFFLE_FLAG_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="competitions",
|
||||
verbose_name=_("feature flag"),
|
||||
help_text=_("Which clubs this competition is offered to -- set (or leave blank to hide it everywhere) from the control panel's Features page. A competition with no flag never shows up on the Event admin's competition dropdown."),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("competition")
|
||||
verbose_name_plural = _("competitions")
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
46
events/services/competitions.py
Normal file
46
events/services/competitions.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Refreshing a game's score/status from its competition's own data source.
|
||||
|
||||
Event.competition (free text) and Event.external_game_id (that source's id for this
|
||||
fixture) exist so a real integration has somewhere to key off later -- nothing
|
||||
fetches anything yet. This is that integration's landing spot: the button and view
|
||||
that call it already exist (management/views.py::EventFetchGameInfoView), so wiring
|
||||
up a real data source later is a matter of replacing fetch_game_info's body, not
|
||||
building the UI around it.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
|
||||
from events.models import Competition
|
||||
|
||||
|
||||
class CompetitionFetchError(Exception):
|
||||
"""A game's info could not be fetched from its competition's data source."""
|
||||
|
||||
|
||||
def fetch_game_info(event) -> bool:
|
||||
"""Refresh `event`'s score/live status from its competition's data source.
|
||||
|
||||
Gated on that competition's own feature flag being active for the event's
|
||||
club: the management form's competition dropdown deliberately shows every
|
||||
competition regardless of flag (see management.forms.EventForm), so this is
|
||||
where per-club access actually gets enforced. No flag, an inactive one, or
|
||||
a competition name that matches nothing -- there's no data source this club
|
||||
is allowed to use, so this quietly does nothing and returns False rather
|
||||
than erroring: missing access isn't a failure to report.
|
||||
|
||||
Returns whether a fetch was actually attempted.
|
||||
"""
|
||||
competition = Competition.objects.filter(name=event.competition).select_related("flag").first()
|
||||
if competition is None or competition.flag is None or not competition.flag.is_active_for_club(event.club):
|
||||
return False
|
||||
|
||||
try:
|
||||
module = importlib.import_module(competition.module)
|
||||
competition = getattr(module, competition.name)
|
||||
|
||||
competition().update_game_information(event=event)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
raise CompetitionFetchError("No competition data source is configured yet -- there's nothing to fetch from.") from error
|
||||
297
events/services/rbihf_import.py
Normal file
297
events/services/rbihf_import.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""Importing a team's fixture list from RBIHF's own website.
|
||||
|
||||
RBIHF publishes a team's schedule at ``https://www.rbihf.be/league/team/<id>`` --
|
||||
an HTML page, not an API, scraped with BeautifulSoup. This is the create/update/
|
||||
delete counterpart to ``events.services.competitions.fetch_game_info``, which
|
||||
only ever refreshes a single *existing* game's score: this is how those `Event`
|
||||
rows get created in the first place.
|
||||
|
||||
Two-step flow, mirroring ``management.bulk_import`` (the member Excel import):
|
||||
``build_plan`` is pure with respect to the outside world once it has the raw
|
||||
HTML (no further network calls), so a view can render a preview from it and
|
||||
then re-run it unchanged at confirm time -- see management/views.py.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from events.models import Event, Location, Opponent
|
||||
|
||||
TEAM_URL_RE = re.compile(r"^https://(?:www\.)?rbihf\.be/league/team/(?P<team_id>\d+)/?$")
|
||||
|
||||
REQUEST_HEADERS = {
|
||||
"Cookie": "language=en",
|
||||
"User-Agent": "Mozilla/5.0 (compatible; RosterChief/1.0)",
|
||||
"Accept": "text/html",
|
||||
}
|
||||
REQUEST_TIMEOUT_SECONDS = 15
|
||||
|
||||
|
||||
class RBIHFImportError(Exception):
|
||||
"""The page couldn't be fetched, or didn't look like an RBIHF team schedule."""
|
||||
|
||||
|
||||
def extract_team_id(url: str) -> str:
|
||||
match = TEAM_URL_RE.match(url.strip())
|
||||
if not match:
|
||||
raise RBIHFImportError("That doesn't look like an RBIHF team page -- expected something like https://www.rbihf.be/league/team/4460.")
|
||||
return match.group("team_id")
|
||||
|
||||
|
||||
def fetch_html(url: str) -> str:
|
||||
try:
|
||||
response = requests.get(url, headers=REQUEST_HEADERS, timeout=REQUEST_TIMEOUT_SECONDS)
|
||||
except requests.RequestException as error:
|
||||
raise RBIHFImportError(f"Could not reach {url}: {error}") from error
|
||||
|
||||
if response.status_code != 200:
|
||||
raise RBIHFImportError(f"{url} returned HTTP {response.status_code}.")
|
||||
|
||||
return response.text
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScrapedFixture:
|
||||
external_game_id: str
|
||||
start: datetime
|
||||
is_home: bool
|
||||
opponent_name: str
|
||||
venue_text: str
|
||||
|
||||
|
||||
def _team_id_from_href(href: str) -> str | None:
|
||||
match = re.search(r"/league/team/(\d+)", href or "")
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def parse_fixtures(html: str, team_id: str) -> tuple[str, list[ScrapedFixture]]:
|
||||
"""Returns (the scraped team's own display name, its upcoming fixtures)."""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
|
||||
team_heading = soup.find("h2")
|
||||
if team_heading is None:
|
||||
raise RBIHFImportError("Could not find a team name on this page -- is this really an RBIHF team page?")
|
||||
team_name = team_heading.get_text(strip=True)
|
||||
|
||||
games_heading = soup.find(id="games-upcoming")
|
||||
if games_heading is None:
|
||||
raise RBIHFImportError("Could not find an “Upcoming games” table on this page.")
|
||||
table = games_heading.find_next("table")
|
||||
if table is None:
|
||||
raise RBIHFImportError("Could not find an “Upcoming games” table on this page.")
|
||||
|
||||
fixtures = []
|
||||
for row in table.find_all("tr"):
|
||||
cells = row.find_all("td")
|
||||
if len(cells) < 6:
|
||||
continue # the header row (<th>s) and any stray rows
|
||||
|
||||
game_link = cells[0].find("a")
|
||||
game_id = game_link.get_text(strip=True) if game_link else cells[0].get_text(strip=True)
|
||||
if not game_id:
|
||||
continue
|
||||
|
||||
date_text = cells[1].get_text(strip=True)
|
||||
hour_text = cells[2].get_text(strip=True)
|
||||
try:
|
||||
naive_start = datetime.strptime(f"{date_text} {hour_text}", "%Y-%m-%d %H:%M")
|
||||
except ValueError:
|
||||
continue # a row that doesn't match the expected shape -- skip rather than blow up the whole import
|
||||
start = timezone.make_aware(naive_start)
|
||||
|
||||
venue_text = cells[3].get_text(strip=True)
|
||||
|
||||
home_link, visit_link = cells[4].find("a"), cells[5].find("a")
|
||||
if home_link is None or visit_link is None:
|
||||
continue
|
||||
|
||||
home_id = _team_id_from_href(home_link.get("href", ""))
|
||||
visit_id = _team_id_from_href(visit_link.get("href", ""))
|
||||
|
||||
if home_id == team_id:
|
||||
is_home, opponent_name = True, visit_link.get("title") or visit_link.get_text(strip=True)
|
||||
elif visit_id == team_id:
|
||||
is_home, opponent_name = False, home_link.get("title") or home_link.get_text(strip=True)
|
||||
else:
|
||||
continue # neither side is the team we asked about -- inconsistent row, skip it
|
||||
|
||||
fixtures.append(ScrapedFixture(external_game_id=game_id, start=start, is_home=is_home, opponent_name=opponent_name, venue_text=venue_text))
|
||||
|
||||
return team_name, fixtures
|
||||
|
||||
|
||||
def suggested_location(club, fixture: ScrapedFixture):
|
||||
if fixture.is_home:
|
||||
return Location.objects.filter(club=club, is_home=True).first()
|
||||
|
||||
venue = fixture.venue_text.strip()
|
||||
if not venue:
|
||||
return None
|
||||
return Location.objects.filter(club=club).filter(Q(city__iexact=venue) | Q(name__iexact=venue)).first()
|
||||
|
||||
|
||||
def suggested_opponent(club, fixture: ScrapedFixture):
|
||||
"""A case-insensitive name match among the club's existing Opponents, if
|
||||
any -- RBIHF's own spelling doesn't always match punctuation/casing
|
||||
already on file (e.g. "Rivals FC" vs "Rivals F.C."), and get_or_create's
|
||||
exact-string match alone would just create a near-duplicate instead of
|
||||
reusing it."""
|
||||
name = fixture.opponent_name.strip()
|
||||
if not name:
|
||||
return None
|
||||
return Opponent.objects.filter(club=club, name__iexact=name).first()
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlannedCreate:
|
||||
fixture: ScrapedFixture
|
||||
suggested_location: Location | None
|
||||
suggested_opponent: Opponent | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlannedUpdate:
|
||||
fixture: ScrapedFixture
|
||||
event: Event
|
||||
changes: dict = field(default_factory=dict) # field name -> (old, new), display-ready strings
|
||||
suggested_location: Location | None = None
|
||||
suggested_opponent: Opponent | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportPlan:
|
||||
club: object
|
||||
team: object
|
||||
scraped_team_name: str
|
||||
location_choices: object # QuerySet[Location], evaluated once for the template
|
||||
opponent_choices: object = None # QuerySet[Opponent], evaluated once for the template
|
||||
to_create: list[PlannedCreate] = field(default_factory=list)
|
||||
to_update: list[PlannedUpdate] = field(default_factory=list)
|
||||
to_delete: list[Event] = field(default_factory=list)
|
||||
unchanged_count: int = 0
|
||||
|
||||
|
||||
def _describe_changes(event, fixture: ScrapedFixture) -> dict:
|
||||
changes = {}
|
||||
if event.start != fixture.start:
|
||||
changes["start"] = (event.start, fixture.start)
|
||||
existing_opponent = event.opponent.name if event.opponent_id else ""
|
||||
if existing_opponent != fixture.opponent_name:
|
||||
changes["opponent"] = (existing_opponent, fixture.opponent_name)
|
||||
return changes
|
||||
|
||||
|
||||
def build_plan(club, team, rbihf_team_id: str, html: str) -> ImportPlan:
|
||||
"""Parses ``html`` (already fetched -- see ``fetch_html``) and diffs it
|
||||
against this club/team's existing RBIHF events. Pure with respect to the
|
||||
network: safe to call twice against the same stashed HTML (preview, then
|
||||
confirm) and get an identical result modulo whatever changed in the DB
|
||||
between the two calls."""
|
||||
scraped_team_name, fixtures = parse_fixtures(html, rbihf_team_id)
|
||||
location_choices = list(Location.objects.filter(club=club).order_by("name"))
|
||||
opponent_choices = list(Opponent.objects.filter(club=club).order_by("name"))
|
||||
|
||||
existing_by_game_id = {event.external_game_id: event for event in Event.objects.filter(club=club, teams=team, competition="RBIHF").exclude(external_game_id="").select_related("opponent", "location")}
|
||||
|
||||
plan = ImportPlan(club=club, team=team, scraped_team_name=scraped_team_name, location_choices=location_choices, opponent_choices=opponent_choices)
|
||||
|
||||
seen_game_ids = set()
|
||||
for fixture in fixtures:
|
||||
seen_game_ids.add(fixture.external_game_id)
|
||||
existing = existing_by_game_id.get(fixture.external_game_id)
|
||||
|
||||
if existing is None:
|
||||
plan.to_create.append(PlannedCreate(fixture=fixture, suggested_location=suggested_location(club, fixture), suggested_opponent=suggested_opponent(club, fixture)))
|
||||
continue
|
||||
|
||||
changes = _describe_changes(existing, fixture)
|
||||
if changes:
|
||||
# Prefer whatever location/opponent is already on the event -- a
|
||||
# previous run's manual pick -- over re-guessing, so a re-import
|
||||
# doesn't silently discard it. Only fall back to a fresh guess
|
||||
# when nothing's set yet.
|
||||
default_location = existing.location if existing.location_id else suggested_location(club, fixture)
|
||||
default_opponent = existing.opponent if existing.opponent_id else suggested_opponent(club, fixture)
|
||||
plan.to_update.append(PlannedUpdate(fixture=fixture, event=existing, changes=changes, suggested_location=default_location, suggested_opponent=default_opponent))
|
||||
else:
|
||||
plan.unchanged_count += 1
|
||||
|
||||
now = timezone.now()
|
||||
for game_id, event in existing_by_game_id.items():
|
||||
if game_id not in seen_game_ids and event.start >= now:
|
||||
plan.to_delete.append(event)
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
def apply_plan(plan: ImportPlan, locations_by_game_id: dict, opponents_by_game_id: dict | None = None) -> dict:
|
||||
"""Applies everything in ``plan``, resolving each create/update row's
|
||||
location from ``locations_by_game_id`` and opponent from
|
||||
``opponents_by_game_id`` (both external_game_id -> pk as a plain string,
|
||||
or None/""). A pk that doesn't belong to ``plan.club`` -- or isn't a real
|
||||
pk at all -- is silently ignored rather than trusted: this is user input
|
||||
straight from the confirm POST, not derived data. Compared as strings
|
||||
since the input is whatever a <select> submitted, not already a UUID.
|
||||
|
||||
Blank/missing means different things for the two: no location is a valid,
|
||||
final state (an away game with no venue on file yet), but a game always
|
||||
needs *an* opponent -- so blank there falls back to the same
|
||||
find-or-create-by-scraped-name this always did, rather than leaving it
|
||||
unset."""
|
||||
opponents_by_game_id = opponents_by_game_id or {}
|
||||
locations_by_id = {str(location.pk): location for location in plan.location_choices}
|
||||
opponents_by_id = {str(opponent.pk): opponent for opponent in plan.opponent_choices}
|
||||
|
||||
def resolve_location(game_id):
|
||||
location_id = locations_by_game_id.get(game_id)
|
||||
if not location_id:
|
||||
return None
|
||||
return locations_by_id.get(str(location_id))
|
||||
|
||||
def resolve_opponent(fixture):
|
||||
opponent_id = opponents_by_game_id.get(fixture.external_game_id)
|
||||
if opponent_id:
|
||||
opponent = opponents_by_id.get(str(opponent_id))
|
||||
if opponent is not None:
|
||||
return opponent
|
||||
opponent, _created = Opponent.objects.get_or_create(club=plan.club, name=fixture.opponent_name)
|
||||
return opponent
|
||||
|
||||
created = updated = deleted = 0
|
||||
|
||||
with transaction.atomic():
|
||||
for planned in plan.to_create:
|
||||
opponent = resolve_opponent(planned.fixture)
|
||||
event = Event.objects.create(
|
||||
club=plan.club,
|
||||
title=f"vs {opponent.name}",
|
||||
kind=Event.EventKind.GAME,
|
||||
start=planned.fixture.start,
|
||||
competition="RBIHF",
|
||||
external_game_id=planned.fixture.external_game_id,
|
||||
opponent=opponent,
|
||||
location=resolve_location(planned.fixture.external_game_id),
|
||||
)
|
||||
event.teams.add(plan.team)
|
||||
created += 1
|
||||
|
||||
for planned in plan.to_update:
|
||||
event = planned.event
|
||||
event.start = planned.fixture.start
|
||||
event.opponent = resolve_opponent(planned.fixture)
|
||||
event.location = resolve_location(planned.fixture.external_game_id)
|
||||
event.save(update_fields=["start", "opponent", "location"])
|
||||
updated += 1
|
||||
|
||||
for event in plan.to_delete:
|
||||
event.delete()
|
||||
deleted += 1
|
||||
|
||||
return {"created": created, "updated": updated, "deleted": deleted}
|
||||
378
events/tests.py
378
events/tests.py
@@ -6,12 +6,14 @@ from django.core.management import call_command
|
||||
from django.db import IntegrityError
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from waffle import get_waffle_flag_model
|
||||
|
||||
from club.models import Club, Season
|
||||
from members.models import Member
|
||||
from teams.models import Position, Team, TeamMembership
|
||||
|
||||
from .models import Attendance, Event, EventSeries, Location, Opponent
|
||||
from .admin import EventAdminForm
|
||||
from .models import Attendance, Competition, Event, EventSeries, Location, Opponent
|
||||
from .services import (
|
||||
cancel_occurrence,
|
||||
detach_occurrence,
|
||||
@@ -25,6 +27,7 @@ from .services import (
|
||||
team_attendance_rate,
|
||||
team_no_shows,
|
||||
)
|
||||
from .services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, parse_fixtures, suggested_location, suggested_opponent
|
||||
|
||||
|
||||
class EventsTestBase(TestCase):
|
||||
@@ -75,6 +78,76 @@ class EventModelTests(EventsTestBase):
|
||||
with self.assertRaises(IntegrityError):
|
||||
Attendance.objects.create(event=event, member=self.alice)
|
||||
|
||||
def test_is_home_game(self):
|
||||
home_ground = Location.objects.create(club=self.club, name="Home Ground", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
|
||||
away_ground = Location.objects.create(club=self.club, name="Away Ground", address="2 St", city="Town", zip_code="1000", country="BE")
|
||||
|
||||
home_game = self.make_event(kind=Event.EventKind.GAME, location=home_ground)
|
||||
away_game = self.make_event(kind=Event.EventKind.GAME, location=away_ground)
|
||||
game_with_no_location = self.make_event(kind=Event.EventKind.GAME)
|
||||
training_at_home_ground = self.make_event(kind=Event.EventKind.TRAINING, location=home_ground)
|
||||
|
||||
self.assertTrue(home_game.is_home_game)
|
||||
self.assertFalse(away_game.is_home_game)
|
||||
self.assertFalse(game_with_no_location.is_home_game)
|
||||
self.assertFalse(training_at_home_ground.is_home_game)
|
||||
|
||||
|
||||
class CompetitionModelTests(EventsTestBase):
|
||||
def test_sport_type_defaults_to_other(self):
|
||||
competition = Competition.objects.create(name="Local League", module="events.competition.other")
|
||||
|
||||
self.assertEqual(competition.sport_type, Club.SportType.OTHER)
|
||||
|
||||
def test_the_seeded_hockey_competitions_are_backfilled_as_ice_hockey(self):
|
||||
# Migration 0019's data migration backfills the two competitions seeded
|
||||
# by 0017 (both real ice hockey leagues) rather than leaving them at the
|
||||
# generic "other" default.
|
||||
for name in ["RBIHF", "CEHL"]:
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(Competition.objects.get(name=name).sport_type, Club.SportType.ICE_HOCKEY)
|
||||
|
||||
|
||||
class EventAdminFormCompetitionTests(EventsTestBase):
|
||||
"""`competition` is a plain CharField, but the admin should only ever offer
|
||||
competitions this club is actually allowed to use -- see events/admin.py."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
Flag = get_waffle_flag_model()
|
||||
self.active_flag = Flag.objects.create(name="active-competition")
|
||||
self.active_flag.clubs.add(self.club)
|
||||
self.inactive_flag = Flag.objects.create(name="inactive-competition")
|
||||
self.active_competition = Competition.objects.create(name="Active Cup", module="events.competition.active", flag=self.active_flag)
|
||||
self.inactive_competition = Competition.objects.create(name="Inactive Cup", module="events.competition.inactive", flag=self.inactive_flag)
|
||||
self.flagless_competition = Competition.objects.create(name="Flagless Cup", module="events.competition.flagless")
|
||||
|
||||
def test_a_flagless_competition_never_appears(self):
|
||||
form = EventAdminForm(instance=Event())
|
||||
choices = dict(form.fields["competition"].choices)
|
||||
|
||||
self.assertNotIn("Flagless Cup", choices)
|
||||
|
||||
def test_a_brand_new_event_offers_every_flagged_competition_unfiltered(self):
|
||||
# The club is unknown until the event is actually saved (see
|
||||
# EventClubScopeTests for the same "club_id is None pre-save" timing issue
|
||||
# elsewhere), so a new event can't be filtered by club yet -- it falls back
|
||||
# to showing everything with a flag rather than crashing or showing nothing.
|
||||
form = EventAdminForm(instance=Event())
|
||||
choices = dict(form.fields["competition"].choices)
|
||||
|
||||
self.assertIn("Active Cup", choices)
|
||||
self.assertIn("Inactive Cup", choices)
|
||||
|
||||
def test_an_existing_event_only_offers_competitions_active_for_its_club(self):
|
||||
event = self.make_event(kind=Event.EventKind.GAME)
|
||||
form = EventAdminForm(instance=event)
|
||||
choices = dict(form.fields["competition"].choices)
|
||||
|
||||
self.assertIn("Active Cup", choices)
|
||||
self.assertNotIn("Inactive Cup", choices)
|
||||
self.assertNotIn("Flagless Cup", choices)
|
||||
|
||||
|
||||
class EffectiveMembersTests(EventsTestBase):
|
||||
def test_union_of_team_invited_minus_excluded(self):
|
||||
@@ -517,3 +590,306 @@ class TeamAttendanceStatsTests(EventsTestBase):
|
||||
record_check_in(attendance, showed_up=True)
|
||||
|
||||
self.assertEqual(list(team_no_shows(self.team, self.season)), [])
|
||||
|
||||
|
||||
RBIHF_TEAM_ID = "4460"
|
||||
RBIHF_TEAM_NAME = "Sportoase Antwerp Phantoms"
|
||||
|
||||
|
||||
def rbihf_sample_html(rows):
|
||||
"""A trimmed stand-in for an RBIHF team page (https://www.rbihf.be/league/team/<id>)
|
||||
-- structure confirmed against the real page while designing this feature.
|
||||
``rows`` is a list of dicts: game_id, date ("YYYY-MM-DD"), hour ("HH:MM"),
|
||||
venue, home_id, home_name, visit_id, visit_name."""
|
||||
row_html = "".join(
|
||||
f'<tr><td class="game-nr"><a href="/game/{r["game_id"]}" title="Game {r["game_id"]}">{r["game_id"]}</a></td>'
|
||||
f'<td class="date">{r["date"]}</td><td class="hour">{r["hour"]}</td><td>{r["venue"]}</td>'
|
||||
f'<td><a href="/league/team/{r["home_id"]}" title="{r["home_name"]}">{r["home_name"]}</a></td>'
|
||||
f'<td><a href="/league/team/{r["visit_id"]}" title="{r["visit_name"]}">{r["visit_name"]}</a></td></tr>'
|
||||
for r in rows
|
||||
)
|
||||
return f"""<html><body>
|
||||
<div class="block"><div class="block-header"><h2>{RBIHF_TEAM_NAME}</h2></div></div>
|
||||
<div class="block"><div class="block-header"><h2 id="games-upcoming">Upcoming games</h2></div>
|
||||
<div class="block-content"><table>
|
||||
<tr><th class="game-nr">#</th><th class="date">Date</th><th class="hour">Hour</th><th>Location</th><th>Home</th><th>Visit</th></tr>
|
||||
{row_html}
|
||||
</table></div></div>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
class RBIHFParseFixturesTests(TestCase):
|
||||
"""Pure parsing, no network, no DB -- events.services.rbihf_import.parse_fixtures."""
|
||||
|
||||
def test_parses_team_name_and_fixtures(self):
|
||||
html = rbihf_sample_html(
|
||||
[
|
||||
{"game_id": "5002", "date": "2026-09-12", "hour": "12:15", "venue": "Deurne", "home_id": RBIHF_TEAM_ID, "home_name": RBIHF_TEAM_NAME, "visit_id": "4464", "visit_name": "Amsterdam Tigers"},
|
||||
{"game_id": "5010", "date": "2026-09-20", "hour": "18:30", "venue": "Antwerp Ice Rink", "home_id": "4500", "home_name": "Brussels Bears", "visit_id": RBIHF_TEAM_ID, "visit_name": RBIHF_TEAM_NAME},
|
||||
]
|
||||
)
|
||||
|
||||
team_name, fixtures = parse_fixtures(html, RBIHF_TEAM_ID)
|
||||
|
||||
self.assertEqual(team_name, RBIHF_TEAM_NAME)
|
||||
self.assertEqual(len(fixtures), 2)
|
||||
|
||||
def test_resolves_home_fixture_correctly(self):
|
||||
html = rbihf_sample_html([{"game_id": "5002", "date": "2026-09-12", "hour": "12:15", "venue": "Deurne", "home_id": RBIHF_TEAM_ID, "home_name": RBIHF_TEAM_NAME, "visit_id": "4464", "visit_name": "Amsterdam Tigers"}])
|
||||
|
||||
_team_name, fixtures = parse_fixtures(html, RBIHF_TEAM_ID)
|
||||
|
||||
self.assertTrue(fixtures[0].is_home)
|
||||
self.assertEqual(fixtures[0].opponent_name, "Amsterdam Tigers")
|
||||
self.assertEqual(fixtures[0].venue_text, "Deurne")
|
||||
self.assertEqual(fixtures[0].external_game_id, "5002")
|
||||
|
||||
def test_resolves_away_fixture_correctly(self):
|
||||
html = rbihf_sample_html([{"game_id": "5010", "date": "2026-09-20", "hour": "18:30", "venue": "Antwerp Ice Rink", "home_id": "4500", "home_name": "Brussels Bears", "visit_id": RBIHF_TEAM_ID, "visit_name": RBIHF_TEAM_NAME}])
|
||||
|
||||
_team_name, fixtures = parse_fixtures(html, RBIHF_TEAM_ID)
|
||||
|
||||
self.assertFalse(fixtures[0].is_home)
|
||||
self.assertEqual(fixtures[0].opponent_name, "Brussels Bears")
|
||||
|
||||
def test_a_row_where_neither_side_matches_is_skipped(self):
|
||||
html = rbihf_sample_html([{"game_id": "9999", "date": "2026-09-20", "hour": "18:30", "venue": "Elsewhere", "home_id": "1", "home_name": "Team One", "visit_id": "2", "visit_name": "Team Two"}])
|
||||
|
||||
_team_name, fixtures = parse_fixtures(html, RBIHF_TEAM_ID)
|
||||
|
||||
self.assertEqual(fixtures, [])
|
||||
|
||||
def test_missing_games_table_raises(self):
|
||||
with self.assertRaises(RBIHFImportError):
|
||||
parse_fixtures("<html><body><h2>Some Team</h2></body></html>", RBIHF_TEAM_ID)
|
||||
|
||||
|
||||
class RBIHFExtractTeamIdTests(TestCase):
|
||||
def test_accepts_the_documented_shape(self):
|
||||
self.assertEqual(extract_team_id("https://www.rbihf.be/league/team/4460"), "4460")
|
||||
|
||||
def test_accepts_without_www(self):
|
||||
self.assertEqual(extract_team_id("https://rbihf.be/league/team/4460"), "4460")
|
||||
|
||||
def test_rejects_a_different_host(self):
|
||||
with self.assertRaises(RBIHFImportError):
|
||||
extract_team_id("https://evil.example.com/league/team/4460")
|
||||
|
||||
def test_rejects_a_different_path(self):
|
||||
with self.assertRaises(RBIHFImportError):
|
||||
extract_team_id("https://www.rbihf.be/leagues")
|
||||
|
||||
|
||||
class RBIHFImportPlanTests(EventsTestBase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.home_location = Location.objects.create(club=self.club, name="Home Arena", address="1 St", city="Antwerp", zip_code="1000", country="BE", is_home=True)
|
||||
self.away_location = Location.objects.create(club=self.club, name="Deurne Ice Hall", address="2 St", city="Deurne", zip_code="2100", country="BE")
|
||||
|
||||
def home_fixture_row(self, game_id="5002", date="2026-09-12"):
|
||||
return {"game_id": game_id, "date": date, "hour": "12:15", "venue": "Deurne", "home_id": RBIHF_TEAM_ID, "home_name": RBIHF_TEAM_NAME, "visit_id": "4464", "visit_name": "Amsterdam Tigers"}
|
||||
|
||||
# -- suggested_location --------------------------------------------------
|
||||
|
||||
def test_suggested_location_for_a_home_fixture_is_the_clubs_home_location(self):
|
||||
_team_name, fixtures = parse_fixtures(rbihf_sample_html([self.home_fixture_row()]), RBIHF_TEAM_ID)
|
||||
|
||||
self.assertEqual(suggested_location(self.club, fixtures[0]), self.home_location)
|
||||
|
||||
def test_suggested_location_for_an_away_fixture_matches_by_city(self):
|
||||
row = {"game_id": "5010", "date": "2026-09-20", "hour": "18:30", "venue": "Deurne", "home_id": "4500", "home_name": "Brussels Bears", "visit_id": RBIHF_TEAM_ID, "visit_name": RBIHF_TEAM_NAME}
|
||||
_team_name, fixtures = parse_fixtures(rbihf_sample_html([row]), RBIHF_TEAM_ID)
|
||||
|
||||
self.assertEqual(suggested_location(self.club, fixtures[0]), self.away_location)
|
||||
|
||||
def test_suggested_location_is_none_when_nothing_matches(self):
|
||||
row = {"game_id": "5010", "date": "2026-09-20", "hour": "18:30", "venue": "Nowhere Familiar", "home_id": "4500", "home_name": "Brussels Bears", "visit_id": RBIHF_TEAM_ID, "visit_name": RBIHF_TEAM_NAME}
|
||||
_team_name, fixtures = parse_fixtures(rbihf_sample_html([row]), RBIHF_TEAM_ID)
|
||||
|
||||
self.assertIsNone(suggested_location(self.club, fixtures[0]))
|
||||
|
||||
# -- suggested_opponent ---------------------------------------------------
|
||||
|
||||
def test_suggested_opponent_matches_case_insensitively(self):
|
||||
opponent = Opponent.objects.create(club=self.club, name="AMSTERDAM TIGERS")
|
||||
_team_name, fixtures = parse_fixtures(rbihf_sample_html([self.home_fixture_row()]), RBIHF_TEAM_ID)
|
||||
|
||||
self.assertEqual(suggested_opponent(self.club, fixtures[0]), opponent)
|
||||
|
||||
def test_suggested_opponent_is_none_when_nothing_matches(self):
|
||||
_team_name, fixtures = parse_fixtures(rbihf_sample_html([self.home_fixture_row()]), RBIHF_TEAM_ID)
|
||||
|
||||
self.assertIsNone(suggested_opponent(self.club, fixtures[0]))
|
||||
|
||||
# -- build_plan ------------------------------------------------------------
|
||||
|
||||
def test_a_new_fixture_is_planned_as_a_create(self):
|
||||
html = rbihf_sample_html([self.home_fixture_row()])
|
||||
|
||||
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||
|
||||
self.assertEqual(len(plan.to_create), 1)
|
||||
self.assertEqual(plan.to_create[0].fixture.external_game_id, "5002")
|
||||
self.assertEqual(plan.to_create[0].suggested_location, self.home_location)
|
||||
|
||||
def test_an_identical_existing_fixture_is_unchanged(self):
|
||||
html = rbihf_sample_html([self.home_fixture_row()])
|
||||
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||
apply_plan(plan, {})
|
||||
|
||||
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||
|
||||
self.assertEqual(plan_again.to_create, [])
|
||||
self.assertEqual(plan_again.to_update, [])
|
||||
self.assertEqual(plan_again.unchanged_count, 1)
|
||||
|
||||
def test_a_changed_fixture_is_planned_as_an_update_with_a_diff(self):
|
||||
html = rbihf_sample_html([self.home_fixture_row()])
|
||||
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||
apply_plan(plan, {})
|
||||
|
||||
changed_row = self.home_fixture_row()
|
||||
changed_row["hour"] = "20:00" # same game id, different time
|
||||
changed_html = rbihf_sample_html([changed_row])
|
||||
|
||||
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, changed_html)
|
||||
|
||||
self.assertEqual(len(plan_again.to_update), 1)
|
||||
self.assertIn("start", plan_again.to_update[0].changes)
|
||||
|
||||
def test_a_future_fixture_no_longer_listed_is_planned_for_deletion(self):
|
||||
html = rbihf_sample_html([self.home_fixture_row()])
|
||||
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||
apply_plan(plan, {})
|
||||
|
||||
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([]))
|
||||
|
||||
self.assertEqual(len(plan_again.to_delete), 1)
|
||||
self.assertEqual(plan_again.to_delete[0].external_game_id, "5002")
|
||||
|
||||
def test_a_past_fixture_no_longer_listed_is_not_deleted(self):
|
||||
# "Upcoming games" naturally stops listing a game once it's happened --
|
||||
# that's not a signal it was cancelled.
|
||||
past_row = self.home_fixture_row(date="2020-01-01")
|
||||
html = rbihf_sample_html([past_row])
|
||||
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||
apply_plan(plan, {})
|
||||
|
||||
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([]))
|
||||
|
||||
self.assertEqual(plan_again.to_delete, [])
|
||||
|
||||
def test_a_fixture_for_a_different_team_is_not_touched(self):
|
||||
other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd")
|
||||
html = rbihf_sample_html([self.home_fixture_row()])
|
||||
plan = build_plan(self.club, other_team, RBIHF_TEAM_ID, html)
|
||||
apply_plan(plan, {})
|
||||
|
||||
# Re-running for *our* team should see no existing RBIHF events at all
|
||||
# for it -- the one that exists belongs to other_team.
|
||||
plan_for_our_team = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||
|
||||
self.assertEqual(len(plan_for_our_team.to_create), 1)
|
||||
|
||||
# -- apply_plan --------------------------------------------------------
|
||||
|
||||
def test_apply_plan_creates_events_with_the_expected_fields(self):
|
||||
html = rbihf_sample_html([self.home_fixture_row()])
|
||||
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||
|
||||
result = apply_plan(plan, {"5002": str(self.home_location.pk)})
|
||||
|
||||
self.assertEqual(result, {"created": 1, "updated": 0, "deleted": 0})
|
||||
event = Event.objects.get(club=self.club, external_game_id="5002")
|
||||
self.assertEqual(event.kind, Event.EventKind.GAME)
|
||||
self.assertEqual(event.competition, "RBIHF")
|
||||
self.assertEqual(event.opponent.name, "Amsterdam Tigers")
|
||||
self.assertEqual(event.location, self.home_location)
|
||||
self.assertIn(self.team, event.teams.all())
|
||||
self.assertTrue(event.is_home_game)
|
||||
|
||||
def test_apply_plan_ignores_a_location_id_from_another_club(self):
|
||||
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
|
||||
foreign_location = Location.objects.create(club=other_club, name="Not ours", address="x", city="x", zip_code="x", country="BE")
|
||||
html = rbihf_sample_html([self.home_fixture_row()])
|
||||
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||
|
||||
apply_plan(plan, {"5002": str(foreign_location.pk)})
|
||||
|
||||
event = Event.objects.get(club=self.club, external_game_id="5002")
|
||||
self.assertIsNone(event.location)
|
||||
|
||||
def test_apply_plan_uses_an_explicitly_chosen_opponent_instead_of_the_scraped_name(self):
|
||||
renamed = Opponent.objects.create(club=self.club, name="Amsterdam Tigers HC")
|
||||
html = rbihf_sample_html([self.home_fixture_row()])
|
||||
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||
|
||||
apply_plan(plan, {}, {"5002": str(renamed.pk)})
|
||||
|
||||
event = Event.objects.get(club=self.club, external_game_id="5002")
|
||||
self.assertEqual(event.opponent, renamed)
|
||||
self.assertEqual(Opponent.objects.filter(club=self.club, name="Amsterdam Tigers").count(), 0)
|
||||
|
||||
def test_apply_plan_ignores_an_opponent_id_from_another_club(self):
|
||||
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
|
||||
foreign_opponent = Opponent.objects.create(club=other_club, name="Not ours")
|
||||
html = rbihf_sample_html([self.home_fixture_row()])
|
||||
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||
|
||||
apply_plan(plan, {}, {"5002": str(foreign_opponent.pk)})
|
||||
|
||||
event = Event.objects.get(club=self.club, external_game_id="5002")
|
||||
# Falls back to find-or-create by the scraped name, not the foreign row.
|
||||
self.assertEqual(event.opponent.name, "Amsterdam Tigers")
|
||||
|
||||
def test_apply_plan_preserves_a_previously_chosen_opponent_on_update(self):
|
||||
renamed = Opponent.objects.create(club=self.club, name="Amsterdam Tigers HC")
|
||||
html = rbihf_sample_html([self.home_fixture_row()])
|
||||
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||
apply_plan(plan, {}, {"5002": str(renamed.pk)})
|
||||
|
||||
# Re-run with a changed start time (something else triggers the update).
|
||||
# build_plan should suggest (pre-select) the event's existing opponent
|
||||
# rather than re-guessing from the scraped name -- a real <select>
|
||||
# always resubmits whichever option is pre-selected, so that's what's
|
||||
# passed to apply_plan here too, not a blank dict.
|
||||
changed_row = self.home_fixture_row()
|
||||
changed_row["hour"] = "20:00"
|
||||
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([changed_row]))
|
||||
self.assertEqual(plan_again.to_update[0].suggested_opponent, renamed)
|
||||
apply_plan(plan_again, {}, {"5002": str(renamed.pk)})
|
||||
|
||||
event = Event.objects.get(club=self.club, external_game_id="5002")
|
||||
self.assertEqual(event.opponent, renamed)
|
||||
|
||||
def test_apply_plan_updates_only_start_opponent_and_location(self):
|
||||
html = rbihf_sample_html([self.home_fixture_row()])
|
||||
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||
apply_plan(plan, {})
|
||||
event = Event.objects.get(club=self.club, external_game_id="5002")
|
||||
event.end = self.future
|
||||
event.gathering = self.future
|
||||
event.score_for = 3
|
||||
event.save()
|
||||
|
||||
changed_row = self.home_fixture_row()
|
||||
changed_row["hour"] = "20:00"
|
||||
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([changed_row]))
|
||||
apply_plan(plan_again, {})
|
||||
|
||||
event.refresh_from_db()
|
||||
self.assertEqual(timezone.localtime(event.start).strftime("%H:%M"), "20:00")
|
||||
self.assertIsNotNone(event.end)
|
||||
self.assertIsNotNone(event.gathering)
|
||||
self.assertEqual(event.score_for, 3)
|
||||
|
||||
def test_apply_plan_deletes_only_whats_in_to_delete(self):
|
||||
html = rbihf_sample_html([self.home_fixture_row()])
|
||||
plan = build_plan(self.club, self.team, RBIHF_TEAM_ID, html)
|
||||
apply_plan(plan, {})
|
||||
|
||||
plan_again = build_plan(self.club, self.team, RBIHF_TEAM_ID, rbihf_sample_html([]))
|
||||
result = apply_plan(plan_again, {})
|
||||
|
||||
self.assertEqual(result, {"created": 0, "updated": 0, "deleted": 1})
|
||||
self.assertFalse(Event.objects.filter(club=self.club, external_game_id="5002").exists())
|
||||
|
||||
Reference in New Issue
Block a user