许多骑行团队都有定期骑行活动——周一早晨的通勤团队、每月第一个周六的探险骑行,或者周三晚间的社交骑行。到目前为止,组织者必须手动创建每个活动。通过我们新的周期性派对功能,您只需设置一次日程,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次

数据模型

RecurringParty 作为模板生成 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)

    # 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 会随着时间推移继续生成新的实例。

对骑行社区的益处

  • 设置后即可忘记 - 无需每周创建活动
  • 一致性 - 相同的路线、时间和设置
  • 灵活性 - 编辑或取消单个活动
  • 发现 - 骑行者可以轻松找到定期团骑
  • 日历集成 - 在日历应用中订阅周期性活动

准备好设置您的定期骑行了吗?创建周期性派对,让我们来处理日程安排!