Monella pyöräilyporukalla on säännöllisiä ajoja: maanantaiaamun työmatkaporukka, kuukauden ensimmäisen lauantain seikkailuajo tai keskiviikkoillan rento yhteislenkki. Tähän asti järjestäjien on pitänyt luoda jokainen tapahtuma käsin. Uuden Toistuvat ryhmäajot -ominaisuuden ansiosta voit määrittää aikataulun kerran ja antaa Party Onbicin luoda tapahtumakerrat automaattisesti.

Kertaluonteisten tapahtumien ongelma

Yhteisöllisillä pyöräilyporukoilla on yleensä ennustettavat aikataulut:

  • ”Joka tiistai ja torstai klo 6.30”
  • ”Joka kuukauden ensimmäinen sunnuntai”
  • ”Joka toinen lauantaiaamu”

Näiden tapahtumien luominen käsin on työlästä ja virhealtista, ja sen vuoksi on vaikea pitää reitit ja asetukset yhdenmukaisina kerrasta toiseen.

iCalendarin RRULE astuu kuvaan

Rakensimme toistuvien tapahtumien järjestelmämme iCalendarin RRULE-standardin (RFC 5545) varaan. Samaa muotoa käyttävät Google Calendar, Apple Calendar ja Outlook, joten yhteensopivuus laajemman kalenteriekosysteemin kanssa on taattu.

Esimerkkejä toistumismalleista

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR
  → Joka maanantai, keskiviikko ja perjantai

RRULE:FREQ=MONTHLY;BYDAY=1SA
  → Joka kuukauden ensimmäinen lauantai

RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=SU
  → Joka toinen sunnuntai

RRULE:FREQ=DAILY;COUNT=10
  → Päivittäin 10 kertaa

Tietomalli

RecurringParty toimii pohjana, josta luodaan Party-tapahtumakertoja:

 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)

    # Schedule configuration
    start_date = models.DateField()
    end_date = models.DateField(null=True, blank=True)
    max_occurrences = models.PositiveIntegerField(null=True, blank=True)
    recurrence = RecurrenceField()  # RRULE pattern

    # Template fields (copied to each instance)
    departure_time = models.TimeField()
    arrival_time = models.TimeField()
    origin = PointField()
    destination = PointField()
    route = models.JSONField()
    difficulty = models.CharField(choices=DIFFICULTY_CHOICES)
    # ... other party attributes

    is_active = models.BooleanField(default=True)

Jokainen luotu Party-tapahtumakerta viittaa takaisin emoonsa:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Party(models.Model):
    # Existing fields...

    # Recurring party support
    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)

Tapahtumakertojen luominen

Kun toistuva ryhmäajo luodaan tai sen aikataulu muuttuu, luomme tapahtumakerrat tulevalle jaksolle:

 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]:
    """Generate party instances for upcoming occurrences."""
    occurrences = self.recurrence.between(
        datetime.now().date(),
        datetime.now().date() + timedelta(days=lookahead_days),
    )

    created = []
    for date in occurrences:
        # Skip if instance already exists
        if self.instances.filter(scheduled_date=date).exists():
            continue

        # Create new party instance from template
        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,
            # ... copy other template fields
        )
        created.append(party)

    return created

Automaattinen luonti Celery Beatilla

Päivittäinen Celery-tehtävä pitää tapahtumakerrat ajan tasalla:

1
2
3
4
5
6
7
8
9
@shared_task
def generate_recurring_party_instances():
    """Generate party instances for all active recurring parties."""
    for recurring_party in RecurringParty.objects.filter(is_active=True):
        created = recurring_party.generate_instances(lookahead_days=30)
        if created:
            logger.info(
                f"Generated {len(created)} instances for {recurring_party.name}"
            )

Celery Beatin aikataulu ajaa tehtävän keskiyöllä:

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),  # Daily at midnight
    },
}

Käyttöliittymä

Toistuvan ryhmäajon luominen

Lomake rakentaa RRULE-mallit django-recurrence-widgetillä:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class RecurringPartyForm(forms.ModelForm):
    recurrence = RecurrenceField(
        label="Recurrence pattern",
        help_text="Set up when this party should repeat.",
        required=True,
    )

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

Widget tarjoaa helppokäyttöisen käyttöliittymän yleisimpiin malleihin:

  • Päivittäin / viikoittain / kuukausittain / vuosittain
  • Tietyt viikonpäivät
  • Järjestysnumeroon perustuvat mallit (ensimmäinen, toinen, viimeinen)
  • Mukautetut välit (joka toinen viikko)
  • Päättymisehdot (tiettyyn päivään asti, N kerran jälkeen tai ei koskaan)

Tapahtumakertojen hallinta

Järjestäjät näkevät kaikki luodut tapahtumakerrat ja voivat:

  • Muokata yksittäisiä kertoja: vaihtaa reitin tai ajan yhdelle kerralle
  • Peruuttaa kertoja: merkitä tietyt päivät peruutetuiksi
  • Luoda kerrat uudelleen: käynnistää luonnin käsin seuraaville 30 päivälle

Erikoistapausten käsittely

Aikavyöhykkeet

Kaikki päivämäärät tallennetaan järjestäjän määrittämällä aikavyöhykkeellä ja näytetään katsojan paikallisessa ajassa:

1
2
3
# Generate instances using timezone-aware dates
local_tz = get_current_timezone()
today = timezone.now().astimezone(local_tz).date()

Pyhäpäivät ja poikkeukset

Käyttäjät voivat peruuttaa yksittäisiä kertoja ilman, että toistumismalli muuttuu:

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

Peruutetut kerrat näkyvät edelleen listassa visuaalisella merkinnällä, mikä ehkäisee sekaannuksia.

Orvoiksi jäävät tapahtumakerrat

Jos toistuva ryhmäajo poistetaan, sen luodut tapahtumakerrat voidaan halutessa säilyttää:

 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 future instances
        recurring_party.instances.filter(
            scheduled_date__gte=date.today(),
            deleted_at__isnull=True,
        ).update(deleted_at=timezone.now())

    recurring_party.delete()

Käyttökokemus

Viikoittaisen ajon luominen vaatii vain muutaman vaiheen:

  1. Siirry kohtaan Hallintapaneeli → Toistuvat ryhmäajot → Luo
  2. Määritä ryhmäajon tiedot (reitti, ajat, vaativuus)
  3. Määritä toistumismalli (esim. ”Joka lauantai klo 8”)
  4. Aseta aloituspäivä ja halutessasi päättymispäivä
  5. Tallenna, niin tapahtumakerrat luodaan automaattisesti!

Järjestelmä luo ryhmäajokerrat 30 päivää etukäteen, ja Celery luo uusia sitä mukaa kuin aika kuluu.

Hyödyt pyöräily-yhteisöille

  • Määritä kerran ja unohda: tapahtumia ei tarvitse enää luoda joka viikko
  • Johdonmukaisuus: sama reitti, sama aika, samat asetukset
  • Joustavuus: muokkaa tai peruuta yksittäisiä kertoja
  • Löydettävyys: ajajat löytävät säännölliset ryhmäajot helposti
  • Kalenteri-integraatio: tilaa toistuvat tapahtumat kalenterisovellukseesi

Valmis perustamaan säännöllisen ajosi? Luo toistuva ryhmäajo ja anna meidän hoitaa aikataulutus!