多くのサイクリンググループには定期的なライドがあります - 月曜朝の通勤グループ、月の第1土曜日のアドベンチャーライド、または水曜夜のソーシャルスピンなど。これまで、主催者は各イベントを手動で作成する必要がありました。新しい定期パーティー機能により、スケジュールを一度設定するだけで、Party Onbiciが自動的にインスタンスを生成します。
単発イベントの問題点
コミュニティサイクリンググループには通常、予測可能なスケジュールがあります:
- 「毎週火曜日と木曜日の午前6時30分」
- 「毎月第1日曜日」
- 「隔週土曜日の朝」
これらのイベントを手動で作成するのは面倒で、間違いが起きやすく、各回で一貫したルートや設定を維持することが難しくなります。
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
→ 毎月第1土曜日
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",
]
|
ウィジェットは一般的なパターン用の直感的なインターフェースを提供します:
- 日次 / 週次 / 月次 / 年次
- 特定の曜日
- 序数パターン(第1、第2、最終)
- カスタム間隔(2週間ごと)
- 終了条件(特定の日まで、N回後、または無期限)
インスタンスの管理
主催者はすべての生成されたインスタンスを表示し、以下のことができます:
- 個別のインスタンスを編集 - 1回分のルートや時間を変更
- インスタンスをキャンセル - 特定の日付をキャンセル済みとしてマーク
- インスタンスを再生成 - 次の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()
|
ユーザー体験
週次ライドの作成は数ステップで完了します:
- ダッシュボード → 定期パーティー → 作成に移動
- パーティーの詳細を設定(ルート、時間、難易度)
- 繰り返しパターンを設定(例:「毎週土曜日午前8時」)
- 開始日とオプションの終了日を設定
- 保存 - インスタンスが自動的に生成されます!
システムは30日先までのパーティーインスタンスを作成し、Celeryが時間の経過とともに新しいものを生成し続けます。
サイクリングコミュニティへのメリット
- 設定したら忘れる - 毎週のイベント作成は不要
- 一貫性 - 同じルート、同じ時間、同じ設定
- 柔軟性 - 個別の回を編集またはキャンセル
- 発見 - ライダーは定期的なグループライドを簡単に見つけられる
- カレンダー連携 - カレンダーアプリで定期イベントを購読
定期ライドを設定する準備はできましたか?定期パーティーを作成して、スケジューリングは私たちにお任せください!