Wiele grup rowerowych ma regularne przejazdy - poniedziałkowa poranna ekipa dojazdowa, przygodowy przejazd w pierwszą sobotę miesiąca lub środowy wieczorny przejazd towarzyski. Do tej pory organizatorzy musieli ręcznie tworzyć każde wydarzenie. Dzięki naszej nowej funkcji Powtarzających się Imprez możesz ustawić harmonogram raz i pozwolić Party Onbici automatycznie generować instancje.

Problem z jednorazowymi wydarzeniami

Społecznościowe grupy rowerowe zazwyczaj mają przewidywalne harmonogramy:

  • “Co wtorek i czwartek o 6:30”
  • “Pierwsza niedziela każdego miesiąca”
  • “Co drugą sobotę rano”

Ręczne tworzenie tych wydarzeń jest żmudne, podatne na błędy i utrudnia utrzymanie spójnych tras i ustawień między wystąpieniami.

Przedstawiamy iCalendar RRULE

Zbudowaliśmy nasz system powtarzających się wydarzeń na standardzie iCalendar RRULE (RFC 5545). To ten sam format używany przez Google Calendar, Apple Calendar i Outlook - zapewniający kompatybilność z szerszym ekosystemem kalendarzy.

Przykładowe wzorce powtarzania

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR
  → W każdy poniedziałek, środę i piątek

RRULE:FREQ=MONTHLY;BYDAY=1SA
  → Pierwsza sobota każdego miesiąca

RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=SU
  → Co drugą niedzielę

RRULE:FREQ=DAILY;COUNT=10
  → Codziennie przez 10 wystąpień

Model danych

RecurringParty służy jako szablon generujący instancje Party:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class RecurringParty(models.Model):
    name = models.CharField(max_length=255)

    # Konfiguracja harmonogramu
    start_date = models.DateField()
    end_date = models.DateField(null=True, blank=True)
    max_occurrences = models.PositiveIntegerField(null=True, blank=True)
    recurrence = RecurrenceField()  # wzorzec RRULE

    # Pola szablonu (kopiowane do każdej instancji)
    departure_time = models.TimeField()
    arrival_time = models.TimeField()
    origin = PointField()
    destination = PointField()
    route = models.JSONField()
    difficulty = models.CharField(choices=DIFFICULTY_CHOICES)
    # ... inne atrybuty imprezy

    is_active = models.BooleanField(default=True)

Każda wygenerowana instancja Party łączy się ze swoim rodzicem:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Party(models.Model):
    # Istniejące pola...

    # Wsparcie dla powtarzającej się imprezy
    scheduled_date = models.DateField(null=True, blank=True)
    recurring_parent = models.ForeignKey(
        RecurringParty,
        on_delete=models.SET_NULL,
        null=True,
        related_name="instances"
    )
    is_cancelled = models.BooleanField(default=False)

Generowanie instancji

Gdy powtarzająca się impreza jest tworzona lub harmonogram się zmienia, generujemy instancje na nadchodzący okres:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def generate_instances(self, lookahead_days: int = 30) -> list[Party]:
    """Generuj instancje imprezy dla nadchodzących wystąpień."""
    occurrences = self.recurrence.between(
        datetime.now().date(),
        datetime.now().date() + timedelta(days=lookahead_days),
    )

    created = []
    for date in occurrences:
        # Pomiń jeśli instancja już istnieje
        if self.instances.filter(scheduled_date=date).exists():
            continue

        # Utwórz nową instancję imprezy z szablonu
        party = Party.objects.create(
            name=f"{self.name} - {date.strftime('%B %d')}",
            scheduled_date=date,
            recurring_parent=self,
            departure_time=self.departure_time,
            arrival_time=self.arrival_time,
            origin=self.origin,
            destination=self.destination,
            route=self.route,
            # ... skopiuj inne pola szablonu
        )
        created.append(party)

    return created

Automatyczne generowanie z Celery Beat

Codzienne zadanie Celery utrzymuje instancje aktualne:

1
2
3
4
5
6
7
8
9
@shared_task
def generate_recurring_party_instances():
    """Generuj instancje imprezy dla wszystkich aktywnych powtarzających się imprez."""
    for recurring_party in RecurringParty.objects.filter(is_active=True):
        created = recurring_party.generate_instances(lookahead_days=30)
        if created:
            logger.info(
                f"Wygenerowano {len(created)} instancji dla {recurring_party.name}"
            )

Harmonogram Celery Beat uruchamia to o północy:

1
2
3
4
5
6
CELERY_BEAT_SCHEDULE = {
    "generate-recurring-party-instances": {
        "task": "party_onbici.apps.party.tasks.generate_recurring_party_instances",
        "schedule": crontab(hour=0, minute=0),  # Codziennie o północy
    },
}

Interfejs użytkownika

Tworzenie powtarzającej się imprezy

Formularz używa widgetu django-recurrence do budowania wzorców RRULE:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class RecurringPartyForm(forms.ModelForm):
    recurrence = RecurrenceField(
        label="Wzorzec powtarzania",
        help_text="Ustaw kiedy ta impreza powinna się powtarzać.",
        required=True,
    )

    class Meta:
        model = RecurringParty
        fields = [
            "name", "start_date", "end_date", "recurrence",
            "departure_time", "arrival_time",
            "origin", "destination", "description",
            "difficulty", "gender", "privacy",
        ]

Widget zapewnia intuicyjny interfejs dla typowych wzorców:

  • Codziennie / Tygodniowo / Miesięcznie / Rocznie
  • Określone dni tygodnia
  • Wzorce porządkowe (pierwszy, drugi, ostatni)
  • Niestandardowe interwały (co 2 tygodnie)
  • Warunki zakończenia (do daty, po N wystąpieniach lub nigdy)

Zarządzanie instancjami

Organizatorzy mogą przeglądać wszystkie wygenerowane instancje i:

  • Edytować pojedyncze instancje - Zmienić trasę lub czas dla jednego wystąpienia
  • Anulować instancje - Oznaczyć określone daty jako anulowane
  • Regenerować instancje - Ręcznie wywołać generowanie na następne 30 dni

Obsługa przypadków brzegowych

Świadomość strefy czasowej

Wszystkie daty są przechowywane w skonfigurowanej strefie czasowej organizatora i wyświetlane w lokalnym czasie widza:

1
2
3
# Generuj instancje używając dat świadomych strefy czasowej
local_tz = get_current_timezone()
today = timezone.now().astimezone(local_tz).date()

Obsługa świąt i wyjątków

Użytkownicy mogą anulować pojedyncze instancje bez wpływu na wzorzec powtarzania:

1
2
3
party = recurring_party.instances.get(scheduled_date=holiday_date)
party.is_cancelled = True
party.save()

Anulowane instancje nadal pojawiają się na liście z wizualnym wskaźnikiem, zapobiegając nieporozumieniom.

Osierocone instancje

Jeśli powtarzająca się impreza zostanie usunięta, wygenerowane instancje mogą być opcjonalnie zachowane:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def form_valid(self, form):
    delete_instances = request.POST.get("delete_instances") == "true"

    if delete_instances:
        # Soft-delete przyszłych instancji
        recurring_party.instances.filter(
            scheduled_date__gte=date.today(),
            deleted_at__isnull=True,
        ).update(deleted_at=timezone.now())

    recurring_party.delete()

Doświadczenie użytkownika

Tworzenie tygodniowego przejazdu wymaga tylko kilku kroków:

  1. Przejdź do Panel → Powtarzające się imprezy → Utwórz
  2. Ustaw szczegóły imprezy (trasa, czasy, trudność)
  3. Skonfiguruj wzorzec powtarzania (np. “Co sobotę o 8 rano”)
  4. Ustaw datę rozpoczęcia i opcjonalną datę zakończenia
  5. Zapisz - instancje są generowane automatycznie!

System tworzy instancje imprez z 30-dniowym wyprzedzeniem, a Celery generuje nowe w miarę upływu czasu.

Korzyści dla społeczności rowerowych

  • Ustaw i zapomnij - Koniec z cotygodniowym tworzeniem wydarzeń
  • Spójność - Ta sama trasa, ten sam czas, te same ustawienia
  • Elastyczność - Edytuj lub anuluj pojedyncze wystąpienia
  • Odkrywanie - Rowerzyści mogą łatwo znaleźć regularne przejazdy grupowe
  • Integracja z kalendarzem - Subskrybuj powtarzające się wydarzenia w aplikacji kalendarza

Gotowy na ustawienie regularnego przejazdu? Utwórz powtarzającą się imprezę i pozwól nam zająć się planowaniem!