feat(events): recurring event series with occurrence sync

Add EventSeries (club-scoped): an RFC-5545 rrule + dtstart + duration and a
template (kind/title/location/opponent + audience M2M). Concrete Event rows
are materialised occurrences carrying a series FK plus detached/cancelled
flags; the series tracks excluded_dates (EXDATEs) and a generated_until
horizon watermark.

Recurrence service:
- occurrence_datetimes expands the rrule (via python-dateutil) up to a
  horizon, minus EXDATEs.
- generate_occurrences materialises missing rows, copies the template +
  audience (so attendance syncs through the existing signals), and is
  idempotent.
- cancel_occurrence adds an EXDATE and deletes (or soft-cancels) one
  occurrence so it isn't regenerated; detach_occurrence marks an occurrence
  as independently edited; propagate_series re-applies the template to
  non-detached future occurrences.
- extend_event_series management command rolls the horizon forward.

Register EventSeries in the admin and surface series/detached/cancelled on
the Event admin. Add python-dateutil. Full suite at 100% coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 17:51:34 +02:00
parent 866716d196
commit ca34d26a8e
11 changed files with 426 additions and 7 deletions

View File

@@ -1,7 +1,7 @@
from django.contrib import admin from django.contrib import admin
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .models import Attendance, Event, Location, Opponent from .models import Attendance, Event, EventSeries, Location, Opponent
@admin.register(Opponent) @admin.register(Opponent)
@@ -24,16 +24,31 @@ class AttendanceInline(admin.TabularInline):
raw_id_fields = ["member"] raw_id_fields = ["member"]
@admin.register(EventSeries)
class EventSeriesAdmin(admin.ModelAdmin):
list_display = ["title", "kind", "rrule", "dtstart", "generated_until", "club"]
list_filter = ["kind", "club"]
search_fields = ["title"]
autocomplete_fields = ["location", "opponent", "teams", "invited_members", "excluded_members"]
fieldsets = [
[None, {"fields": ["title", "kind"]}],
[_("Recurrence"), {"fields": ["rrule", "dtstart", "duration", "excluded_dates", "generated_until"]}],
[_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}],
[_("Where"), {"fields": ["location", "opponent"]}],
]
@admin.register(Event) @admin.register(Event)
class EventAdmin(admin.ModelAdmin): class EventAdmin(admin.ModelAdmin):
list_display = ["title", "kind", "start", "season", "club"] list_display = ["title", "kind", "start", "season", "series", "detached", "club"]
list_filter = ["kind", "club", "teams"] list_filter = ["kind", "club", "teams", "detached", "cancelled"]
search_fields = ["title"] search_fields = ["title"]
date_hierarchy = "start" date_hierarchy = "start"
autocomplete_fields = ["season", "location", "opponent", "teams", "invited_members", "excluded_members"] autocomplete_fields = ["season", "series", "location", "opponent", "teams", "invited_members", "excluded_members"]
inlines = [AttendanceInline] inlines = [AttendanceInline]
fieldsets = [ fieldsets = [
[None, {"fields": ["title", "kind", "season"]}], [None, {"fields": ["title", "kind", "season"]}],
[_("Series"), {"fields": ["series", "detached", "cancelled"]}],
[_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}], [_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}],
[_("When"), {"fields": ["start", "end", "gathering", "deadline"]}], [_("When"), {"fields": ["start", "end", "gathering", "deadline"]}],
[_("Where"), {"fields": ["location", "opponent"]}], [_("Where"), {"fields": ["location", "opponent"]}],

View File

View File

View File

@@ -0,0 +1,20 @@
from django.core.management.base import BaseCommand
from events.models import EventSeries
from events.services import generate_occurrences, horizon
class Command(BaseCommand):
help = "Materialise recurring event occurrences up to the rolling horizon."
def handle(self, *args, **options):
until = horizon()
total = 0
for series in EventSeries.objects.all():
created = generate_occurrences(series, until)
total += len(created)
if created:
self.stdout.write(f"{series}: generated {len(created)} occurrence(s).")
self.stdout.write(self.style.SUCCESS(f"Done. Generated {total} occurrence(s) across {EventSeries.objects.count()} series."))

View File

@@ -0,0 +1,57 @@
# Generated by Django 6.0.6 on 2026-07-12 15:47
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0008_alter_clubmembership_unique_together_and_more'),
('events', '0004_remove_event_team'),
('members', '0002_alter_familymembership_unique_together_and_more'),
('teams', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='event',
name='cancelled',
field=models.BooleanField(default=False, verbose_name='cancelled'),
),
migrations.AddField(
model_name='event',
name='detached',
field=models.BooleanField(default=False, help_text='Edited independently; excluded from series-wide updates and regeneration.', verbose_name='detached'),
),
migrations.CreateModel(
name='EventSeries',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('rrule', models.CharField(help_text='RFC 5545 RRULE, e.g. FREQ=WEEKLY;BYDAY=MO,WE.', max_length=255, verbose_name='recurrence rule')),
('dtstart', models.DateTimeField(verbose_name='first occurrence')),
('duration', models.DurationField(blank=True, help_text="Length of each occurrence; sets each event's end.", null=True, verbose_name='duration')),
('excluded_dates', models.JSONField(blank=True, default=list, help_text='ISO start datetimes of occurrences removed from the series (EXDATEs).', verbose_name='excluded dates')),
('generated_until', models.DateTimeField(blank=True, help_text='Occurrences have been materialised up to this point.', null=True, verbose_name='generated until')),
('kind', models.CharField(choices=[('training', 'training'), ('match', 'match'), ('tournament', 'tournament'), ('meeting', 'meeting'), ('social', 'social'), ('other', 'other')], default='other', max_length=10, verbose_name='kind')),
('title', models.CharField(max_length=255, verbose_name='title')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
('excluded_members', models.ManyToManyField(blank=True, related_name='excluded_from_event_series', to='members.member', verbose_name='excluded members')),
('invited_members', models.ManyToManyField(blank=True, related_name='invited_to_event_series', to='members.member', verbose_name='invited members')),
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='event_series', to='events.location', verbose_name='location')),
('opponent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='event_series', to='events.opponent', verbose_name='opponent')),
('teams', models.ManyToManyField(blank=True, related_name='event_series', to='teams.team', verbose_name='teams')),
],
options={
'verbose_name': 'event series',
'verbose_name_plural': 'event series',
'ordering': ['title'],
},
),
migrations.AddField(
model_name='event',
name='series',
field=models.ForeignKey(blank=True, help_text='The recurring series this occurrence belongs to; blank for one-off events.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='occurrences', to='events.eventseries', verbose_name='series'),
),
]

View File

@@ -45,6 +45,10 @@ class Event(ClubScopedModel):
SOCIAL = "social", _("social") SOCIAL = "social", _("social")
OTHER = "other", _("other") 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."))
cancelled = models.BooleanField(_("cancelled"), default=False)
teams = models.ManyToManyField(Team, related_name="scheduled_events", blank=True, verbose_name=_("teams")) teams = models.ManyToManyField(Team, related_name="scheduled_events", blank=True, verbose_name=_("teams"))
invited_members = models.ManyToManyField(Member, related_name="invited_to_events", blank=True, verbose_name=_("invited members")) invited_members = models.ManyToManyField(Member, related_name="invited_to_events", blank=True, verbose_name=_("invited members"))
excluded_members = models.ManyToManyField(Member, related_name="excluded_from_events", blank=True, verbose_name=_("excluded members")) excluded_members = models.ManyToManyField(Member, related_name="excluded_from_events", blank=True, verbose_name=_("excluded members"))
@@ -70,6 +74,33 @@ class Event(ClubScopedModel):
return self.title return self.title
class EventSeries(ClubScopedModel):
"""A recurring event definition that materialises concrete Event rows."""
rrule = models.CharField(_("recurrence rule"), max_length=255, help_text=_("RFC 5545 RRULE, e.g. FREQ=WEEKLY;BYDAY=MO,WE."))
dtstart = models.DateTimeField(_("first occurrence"))
duration = models.DurationField(_("duration"), null=True, blank=True, help_text=_("Length of each occurrence; sets each event's end."))
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."))
# Template copied onto each generated occurrence.
kind = models.CharField(_("kind"), max_length=10, choices=Event.EventKind.choices, default=Event.EventKind.OTHER)
title = models.CharField(_("title"), max_length=255)
location = models.ForeignKey(Location, on_delete=models.SET_NULL, related_name="event_series", null=True, blank=True, verbose_name=_("location"))
opponent = models.ForeignKey(Opponent, on_delete=models.SET_NULL, related_name="event_series", null=True, blank=True, verbose_name=_("opponent"))
teams = models.ManyToManyField(Team, related_name="event_series", blank=True, verbose_name=_("teams"))
invited_members = models.ManyToManyField(Member, related_name="invited_to_event_series", blank=True, verbose_name=_("invited members"))
excluded_members = models.ManyToManyField(Member, related_name="excluded_from_event_series", blank=True, verbose_name=_("excluded members"))
class Meta:
verbose_name = _("event series")
verbose_name_plural = _("event series")
ordering = ["title"]
def __str__(self):
return self.title
class Attendance(UUIDModel): class Attendance(UUIDModel):
class AttendanceStatus(models.TextChoices): class AttendanceStatus(models.TextChoices):
PRESENT = "present", _("present") PRESENT = "present", _("present")

View File

@@ -1,3 +1,22 @@
from .attendance import effective_members, sync_event_attendances from .attendance import effective_members, sync_event_attendances
from .recurrence import (
apply_template,
cancel_occurrence,
detach_occurrence,
generate_occurrences,
horizon,
occurrence_datetimes,
propagate_series,
)
__all__ = ["effective_members", "sync_event_attendances"] __all__ = [
"apply_template",
"cancel_occurrence",
"detach_occurrence",
"effective_members",
"generate_occurrences",
"horizon",
"occurrence_datetimes",
"propagate_series",
"sync_event_attendances",
]

View File

@@ -0,0 +1,104 @@
"""Materialise concrete Event rows from a recurring EventSeries.
Occurrences are real Event rows (so attendance attaches directly). The series
holds the RRULE, an anchor ``dtstart``, and a set of ``excluded_dates``
(EXDATEs). Rows are generated up to a rolling horizon; a single occurrence can
be cancelled (adds an EXDATE + removes the row) or detached (edited
independently, so series-wide updates skip it).
"""
from datetime import timedelta
from dateutil.rrule import rrulestr
from django.utils import timezone
from events.models import Event
HORIZON_DAYS = 90
def horizon():
return timezone.now() + timedelta(days=HORIZON_DAYS)
def occurrence_datetimes(series, until):
"""Expand the series' RRULE from its anchor up to ``until``, minus EXDATEs."""
rule = rrulestr(series.rrule, dtstart=series.dtstart)
excluded = set(series.excluded_dates)
result = []
for occurrence in rule:
if occurrence > until:
break
if occurrence.isoformat() in excluded:
continue
result.append(occurrence)
return result
def apply_template(series, event):
"""Copy the series template (and audience) onto ``event`` and save it.
Setting the audience M2M fires the sync signal, so attendance follows.
"""
event.kind = series.kind
event.title = series.title
event.location = series.location
event.opponent = series.opponent
if series.duration is not None:
event.end = event.start + series.duration
event.save()
event.teams.set(series.teams.all())
event.invited_members.set(series.invited_members.all())
event.excluded_members.set(series.excluded_members.all())
def generate_occurrences(series, until=None):
"""Materialise any missing occurrences up to ``until`` (default: horizon)."""
until = until or horizon()
existing = set(series.occurrences.values_list("start", flat=True))
created = []
for start in occurrence_datetimes(series, until):
if start in existing:
continue
event = Event(club=series.club, series=series, start=start)
apply_template(series, event)
created.append(event)
series.generated_until = until
series.save(update_fields=["generated_until"])
return created
def cancel_occurrence(event, *, hard_delete=True):
"""Drop a single occurrence and record an EXDATE so it isn't regenerated."""
series = event.series
if series is not None:
iso = event.start.isoformat()
if iso not in series.excluded_dates:
series.excluded_dates = [*series.excluded_dates, iso]
series.save(update_fields=["excluded_dates"])
if hard_delete:
event.delete()
else:
event.cancelled = True
event.save(update_fields=["cancelled"])
def detach_occurrence(event):
"""Mark an occurrence as edited independently of the series."""
event.detached = True
event.save(update_fields=["detached"])
def propagate_series(series):
"""Re-apply the series template to its non-detached future occurrences."""
now = timezone.now()
updated = []
for event in series.occurrences.filter(detached=False, start__gte=now):
apply_template(series, event)
updated.append(event)
return updated

View File

@@ -1,5 +1,7 @@
from datetime import timedelta from datetime import timedelta
from io import StringIO
from django.core.management import call_command
from django.db import IntegrityError from django.db import IntegrityError
from django.test import TestCase from django.test import TestCase
from django.utils import timezone from django.utils import timezone
@@ -8,8 +10,15 @@ from club.models import Club, Season
from members.models import Member from members.models import Member
from teams.models import Position, Team, TeamMembership from teams.models import Position, Team, TeamMembership
from .models import Attendance, Event, Location, Opponent from .models import Attendance, Event, EventSeries, Location, Opponent
from .services import effective_members from .services import (
cancel_occurrence,
detach_occurrence,
effective_members,
generate_occurrences,
occurrence_datetimes,
propagate_series,
)
class EventsTestBase(TestCase): class EventsTestBase(TestCase):
@@ -159,3 +168,143 @@ class RosterChangeSyncTests(EventsTestBase):
TeamMembership.objects.get(team=self.team, member=self.alice).delete() TeamMembership.objects.get(team=self.team, member=self.alice).delete()
self.assertTrue(past.attendances.filter(member=self.alice).exists()) self.assertTrue(past.attendances.filter(member=self.alice).exists())
class RecurrenceTestBase(EventsTestBase):
def setUp(self):
super().setUp()
self.anchor = (timezone.now() + timedelta(days=1)).replace(microsecond=0)
def make_series(self, **kwargs):
kwargs.setdefault("club", self.club)
kwargs.setdefault("title", "Weekly Training")
kwargs.setdefault("kind", Event.EventKind.TRAINING)
kwargs.setdefault("rrule", "FREQ=WEEKLY;COUNT=4")
kwargs.setdefault("dtstart", self.anchor)
kwargs.setdefault("duration", timedelta(hours=2))
series = EventSeries.objects.create(**kwargs)
series.teams.set([self.team])
return series
class OccurrenceExpansionTests(RecurrenceTestBase):
def test_str(self):
self.assertEqual(str(self.make_series()), "Weekly Training")
def test_weekly_expansion(self):
series = self.make_series()
dts = occurrence_datetimes(series, self.anchor + timedelta(days=30))
self.assertEqual(dts[0], self.anchor)
self.assertEqual(dts[1], self.anchor + timedelta(weeks=1))
self.assertEqual(len(dts), 4)
def test_until_bounds_expansion(self):
series = self.make_series()
dts = occurrence_datetimes(series, self.anchor + timedelta(days=10))
self.assertEqual(len(dts), 2)
def test_excluded_dates_are_skipped(self):
series = self.make_series()
skipped = self.anchor + timedelta(weeks=1)
series.excluded_dates = [skipped.isoformat()]
series.save()
dts = occurrence_datetimes(series, self.anchor + timedelta(days=30))
self.assertNotIn(skipped, dts)
self.assertEqual(len(dts), 3)
class GenerateOccurrencesTests(RecurrenceTestBase):
def test_materialises_occurrences_with_template_and_attendance(self):
series = self.make_series()
created = generate_occurrences(series, self.anchor + timedelta(days=30))
self.assertEqual(len(created), 4)
first = series.occurrences.order_by("start").first()
self.assertEqual(first.start, self.anchor)
self.assertEqual(first.end, self.anchor + timedelta(hours=2))
self.assertEqual(first.title, "Weekly Training")
self.assertEqual(first.kind, Event.EventKind.TRAINING)
# Audience copied from the series, so attendance follows the roster.
self.assertEqual(set(first.attendances.values_list("member_id", flat=True)), {self.alice.id, self.bob.id})
series.refresh_from_db()
self.assertIsNotNone(series.generated_until)
def test_generation_is_idempotent(self):
series = self.make_series()
until = self.anchor + timedelta(days=30)
generate_occurrences(series, until)
generate_occurrences(series, until)
self.assertEqual(series.occurrences.count(), 4)
class SingleOccurrenceTests(RecurrenceTestBase):
def test_cancel_deletes_and_prevents_regeneration(self):
series = self.make_series()
until = self.anchor + timedelta(days=30)
generate_occurrences(series, until)
target = series.occurrences.order_by("start")[1]
target_start = target.start
cancel_occurrence(target)
self.assertFalse(series.occurrences.filter(start=target_start).exists())
generate_occurrences(series, until)
self.assertFalse(series.occurrences.filter(start=target_start).exists())
self.assertEqual(series.occurrences.count(), 3)
def test_cancel_soft_marks_cancelled(self):
series = self.make_series()
generate_occurrences(series, self.anchor + timedelta(days=30))
target = series.occurrences.order_by("start").first()
cancel_occurrence(target, hard_delete=False)
target.refresh_from_db()
self.assertTrue(target.cancelled)
self.assertIn(target.start.isoformat(), series.excluded_dates)
def test_detached_occurrence_is_left_untouched_by_propagation(self):
series = self.make_series()
generate_occurrences(series, self.anchor + timedelta(days=30))
detached = series.occurrences.order_by("start").first()
detach_occurrence(detached)
series.title = "Renamed"
series.save()
propagate_series(series)
detached.refresh_from_db()
self.assertEqual(detached.title, "Weekly Training")
other = series.occurrences.exclude(pk=detached.pk).order_by("start").first()
self.assertEqual(other.title, "Renamed")
def test_propagation_updates_audience_and_attendance(self):
series = self.make_series()
generate_occurrences(series, self.anchor + timedelta(days=30))
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
series.invited_members.set([carol])
propagate_series(series)
event = series.occurrences.order_by("start").first()
self.assertIn(carol.id, set(event.attendances.values_list("member_id", flat=True)))
class ExtendSeriesCommandTests(RecurrenceTestBase):
def test_command_generates_occurrences(self):
series = self.make_series()
out = StringIO()
call_command("extend_event_series", stdout=out)
self.assertEqual(series.occurrences.count(), 4)
self.assertIn("Done.", out.getvalue())

View File

@@ -7,6 +7,7 @@ dependencies = [
"django>=6.0.6", "django>=6.0.6",
"django-phonenumber-field[phonenumbers]>=8.4.0", "django-phonenumber-field[phonenumbers]>=8.4.0",
"pillow>=12.3.0", "pillow>=12.3.0",
"python-dateutil>=2.9.0.post0",
"python-decouple>=3.8", "python-decouple>=3.8",
] ]

23
uv.lock generated
View File

@@ -20,6 +20,7 @@ dependencies = [
{ name = "django" }, { name = "django" },
{ name = "django-phonenumber-field", extra = ["phonenumbers"] }, { name = "django-phonenumber-field", extra = ["phonenumbers"] },
{ name = "pillow" }, { name = "pillow" },
{ name = "python-dateutil" },
{ name = "python-decouple" }, { name = "python-decouple" },
] ]
@@ -35,6 +36,7 @@ requires-dist = [
{ name = "django", specifier = ">=6.0.6" }, { name = "django", specifier = ">=6.0.6" },
{ name = "django-phonenumber-field", extras = ["phonenumbers"], specifier = ">=8.4.0" }, { name = "django-phonenumber-field", extras = ["phonenumbers"], specifier = ">=8.4.0" },
{ name = "pillow", specifier = ">=12.3.0" }, { name = "pillow", specifier = ">=12.3.0" },
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
{ name = "python-decouple", specifier = ">=3.8" }, { name = "python-decouple", specifier = ">=3.8" },
] ]
@@ -185,6 +187,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
] ]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]] [[package]]
name = "python-decouple" name = "python-decouple"
version = "3.8" version = "3.8"
@@ -219,6 +233,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" },
] ]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]] [[package]]
name = "sqlparse" name = "sqlparse"
version = "0.5.5" version = "0.5.5"