많은 사이클링 그룹에는 정기적인 라이드가 있습니다 - 월요일 아침 출퇴근 그룹, 매월 첫 번째 토요일 어드벤처 라이드, 또는 수요일 저녁 소셜 라이드 등. 지금까지 주최자들은 각 이벤트를 수동으로 만들어야 했습니다. 새로운 반복 파티 기능으로 일정을 한 번만 설정하면 Party Onbici가 자동으로 인스턴스를 생성합니다.

일회성 이벤트의 문제점

커뮤니티 사이클링 그룹은 일반적으로 예측 가능한 일정을 가지고 있습니다:

  • “매주 화요일과 목요일 오전 6시 30분”
  • “매월 첫 번째 일요일”
  • “격주 토요일 아침”

이러한 이벤트를 수동으로 만드는 것은 지루하고 오류가 발생하기 쉬우며, 각 회차에서 일관된 경로와 설정을 유지하기 어렵습니다.

iCalendar RRULE 소개

우리는 iCalendar RRULE 표준(RFC 5545)을 기반으로 반복 이벤트 시스템을 구축했습니다. 이것은 Google Calendar, Apple Calendar, Outlook에서 사용하는 것과 동일한 형식으로, 더 넓은 캘린더 생태계와의 호환성을 보장합니다.

반복 패턴 예시

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR
  → 매주 월요일, 수요일, 금요일

RRULE:FREQ=MONTHLY;BYDAY=1SA
  → 매월 첫 번째 토요일

RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=SU
  → 격주 일요일

RRULE:FREQ=DAILY;COUNT=10
  → 10회 동안 매일

데이터 모델

RecurringPartyParty 인스턴스를 생성하는 템플릿 역할을 합니다:

 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)

생성된 각 Party 인스턴스는 부모에 연결됩니다:

 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)

인스턴스 생성

반복 파티가 생성되거나 일정이 변경되면 다가오는 기간에 대한 인스턴스를 생성합니다:

 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

Celery Beat를 통한 자동 생성

일일 Celery 작업이 인스턴스를 최신 상태로 유지합니다:

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 Beat 스케줄은 자정에 이를 실행합니다:

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
    },
}

사용자 인터페이스

반복 파티 만들기

폼은 django-recurrence 위젯을 사용하여 RRULE 패턴을 구축합니다:

 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",
        ]

위젯은 일반적인 패턴에 대한 직관적인 인터페이스를 제공합니다:

  • 매일 / 매주 / 매월 / 매년
  • 특정 요일
  • 순서 패턴 (첫 번째, 두 번째, 마지막)
  • 사용자 지정 간격 (2주마다)
  • 종료 조건 (특정 날짜까지, N회 후, 또는 무기한)

인스턴스 관리

주최자는 생성된 모든 인스턴스를 보고 다음을 수행할 수 있습니다:

  • 개별 인스턴스 편집 - 한 회차의 경로나 시간 변경
  • 인스턴스 취소 - 특정 날짜를 취소됨으로 표시
  • 인스턴스 재생성 - 다음 30일에 대한 생성을 수동으로 트리거

특수 케이스 처리

시간대 인식

모든 날짜는 주최자가 설정한 시간대에 저장되고 보는 사람의 현지 시간으로 표시됩니다:

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

휴일 및 예외 처리

사용자는 반복 패턴에 영향을 주지 않고 개별 인스턴스를 취소할 수 있습니다:

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

취소된 인스턴스는 시각적 표시기와 함께 목록에 계속 표시되어 혼란을 방지합니다.

고아 인스턴스

반복 파티가 삭제되면 생성된 인스턴스를 선택적으로 보존할 수 있습니다:

 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()

사용자 경험

주간 라이드 만들기는 몇 단계만으로 완료됩니다:

  1. 대시보드 → 반복 파티 → 만들기로 이동
  2. 파티 세부 정보 설정 (경로, 시간, 난이도)
  3. 반복 패턴 설정 (예: “매주 토요일 오전 8시”)
  4. 시작 날짜와 선택적 종료 날짜 설정
  5. 저장 - 인스턴스가 자동으로 생성됩니다!

시스템은 30일 앞까지 파티 인스턴스를 만들고, Celery가 시간이 지남에 따라 계속 새로운 인스턴스를 생성합니다.

사이클링 커뮤니티를 위한 이점

  • 설정하고 잊어버리기 - 더 이상 매주 이벤트 만들기 필요 없음
  • 일관성 - 같은 경로, 같은 시간, 같은 설정
  • 유연성 - 개별 회차 편집 또는 취소
  • 발견 - 라이더들이 정기 그룹 라이드를 쉽게 찾을 수 있음
  • 캘린더 통합 - 캘린더 앱에서 반복 이벤트 구독

정기 라이드를 설정할 준비가 되셨나요? 반복 파티 만들기를 통해 일정 관리는 저희에게 맡기세요!