Many cycling groups have regular rides - the Monday morning commute crew, the first Saturday of the month adventure ride, or the Wednesday evening social spin. Until now, organizers had to manually create each event. With our new Recurring Parties feature, you can set up a schedule once and let Party Onbici generate instances automatically.
The Problem with One-Off Events
Community cycling groups typically have predictable schedules:
- “Every Tuesday and Thursday at 6:30 AM”
- “First Sunday of each month”
- “Every other Saturday morning”
Manually creating these events is tedious, error-prone, and makes it hard to maintain consistent routes and settings across occurrences.
Enter iCalendar RRULE
We built our recurring events system on the iCalendar RRULE standard (RFC 5545). This is the same format used by Google Calendar, Apple Calendar, and Outlook - ensuring compatibility with the wider calendar ecosystem.
Example Recurrence Patterns
1
2
3
4
5
6
7
8
9
10
11
| RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR
→ Every Monday, Wednesday, and Friday
RRULE:FREQ=MONTHLY;BYDAY=1SA
→ First Saturday of every month
RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=SU
→ Every other Sunday
RRULE:FREQ=DAILY;COUNT=10
→ Daily for 10 occurrences
|
The Data Model
A RecurringParty serves as a template that generates Party instances:
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)
|
Each generated Party instance links back to its parent:
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)
|
Instance Generation
When a recurring party is created or the schedule changes, we generate instances for the upcoming period:
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
|
Automatic Generation with Celery Beat
A daily Celery task keeps instances up-to-date:
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}"
)
|
The Celery Beat schedule runs this at midnight:
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
},
}
|
User Interface
Creating a Recurring Party
The form uses the django-recurrence widget for building RRULE patterns:
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",
]
|
The widget provides an intuitive interface for common patterns:
- Daily / Weekly / Monthly / Yearly
- Specific days of the week
- Ordinal patterns (first, second, last)
- Custom intervals (every 2 weeks)
- End conditions (until date, after N occurrences, or never)
Managing Instances
Organizers can view all generated instances and:
- Edit individual instances - Change route or time for one occurrence
- Cancel instances - Mark specific dates as cancelled
- Regenerate instances - Manually trigger generation for the next 30 days
Handling Edge Cases
Time Zone Awareness
All dates are stored in the organizer’s configured timezone and displayed in the viewer’s local time:
1
2
3
| # Generate instances using timezone-aware dates
local_tz = get_current_timezone()
today = timezone.now().astimezone(local_tz).date()
|
Holiday and Exception Handling
Users can cancel individual instances without affecting the recurring pattern:
1
2
3
| party = recurring_party.instances.get(scheduled_date=holiday_date)
party.is_cancelled = True
party.save()
|
Cancelled instances still appear in the list with a visual indicator, preventing confusion.
Orphaned Instances
If a recurring party is deleted, generated instances can optionally be preserved:
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()
|
The User Experience
Creating a weekly ride takes just a few steps:
- Go to Dashboard → Recurring Parties → Create
- Set your party details (route, times, difficulty)
- Configure the recurrence pattern (e.g., “Every Saturday at 8 AM”)
- Set the start date and optional end date
- Save - instances are generated automatically!
The system creates party instances 30 days ahead, and Celery keeps generating new ones as time passes.
Benefits for Cycling Communities
- Set it and forget it - No more weekly event creation
- Consistency - Same route, same time, same settings
- Flexibility - Edit or cancel individual occurrences
- Discovery - Riders can easily find regular group rides
- Calendar integration - Subscribe to recurring events in your calendar app
Ready to set up your regular ride? Create a recurring party and let us handle the scheduling!