Muitos grupos de ciclismo têm passeios regulares - a turma do trajeto de segunda de manhã, o passeio de aventura do primeiro sábado do mês, ou a pedalada social de quarta à noite. Até agora, os organizadores tinham que criar manualmente cada evento. Com nossa nova funcionalidade de Festas Recorrentes, você pode configurar um horário uma vez e deixar o Party Onbici gerar as instâncias automaticamente.

O Problema com Eventos Únicos

Os grupos de ciclismo comunitários tipicamente têm horários previsíveis:

  • “Toda terça e quinta às 6:30 da manhã”
  • “O primeiro domingo de cada mês”
  • “A cada dois sábados de manhã”

Criar esses eventos manualmente é tedioso, propenso a erros e dificulta manter rotas e configurações consistentes entre as ocorrências.

Apresentamos o iCalendar RRULE

Construímos nosso sistema de eventos recorrentes sobre o padrão iCalendar RRULE (RFC 5545). Este é o mesmo formato usado pelo Google Calendar, Apple Calendar e Outlook - garantindo compatibilidade com o ecossistema de calendários mais amplo.

Exemplos de Padrões de Recorrência

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR
  → Toda segunda, quarta e sexta

RRULE:FREQ=MONTHLY;BYDAY=1SA
  → O primeiro sábado de cada mês

RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=SU
  → A cada dois domingos

RRULE:FREQ=DAILY;COUNT=10
  → Diariamente por 10 ocorrências

O Modelo de Dados

Um RecurringParty serve como template que gera instâncias de 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)

Cada instância de Party gerada vincula de volta ao seu pai:

 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)

Geração de Instâncias

Quando uma festa recorrente é criada ou o horário muda, geramos instâncias para o período seguinte:

 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

Geração Automática com Celery Beat

Uma tarefa Celery diária mantém as instâncias atualizadas:

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}"
            )

O agendamento do Celery Beat executa isso à meia-noite:

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

Interface do Usuário

Criando uma Festa Recorrente

O formulário usa o widget django-recurrence para construir padrões 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",
        ]

O widget fornece uma interface intuitiva para padrões comuns:

  • Diário / Semanal / Mensal / Anual
  • Dias específicos da semana
  • Padrões ordinais (primeiro, segundo, último)
  • Intervalos personalizados (a cada 2 semanas)
  • Condições de fim (até uma data, após N ocorrências, ou nunca)

Gerenciando Instâncias

Os organizadores podem ver todas as instâncias geradas e:

  • Editar instâncias individuais - Alterar rota ou horário para uma ocorrência
  • Cancelar instâncias - Marcar datas específicas como canceladas
  • Regenerar instâncias - Acionar manualmente a geração para os próximos 30 dias

Tratamento de Casos Especiais

Consciência de Fuso Horário

Todas as datas são armazenadas no fuso horário configurado do organizador e exibidas no horário local do visualizador:

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

Tratamento de Feriados e Exceções

Os usuários podem cancelar instâncias individuais sem afetar o padrão recorrente:

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

As instâncias canceladas ainda aparecem na lista com um indicador visual, evitando confusão.

Instâncias Órfãs

Se uma festa recorrente é excluída, as instâncias geradas podem opcionalmente ser preservadas:

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

A Experiência do Usuário

Criar um passeio semanal leva apenas alguns passos:

  1. Vá em Painel de Controle → Festas Recorrentes → Criar
  2. Defina os detalhes da sua festa (rota, horários, dificuldade)
  3. Configure o padrão de recorrência (ex., “Todo sábado às 8h”)
  4. Defina a data de início e a data de fim opcional
  5. Salve - as instâncias são geradas automaticamente!

O sistema cria instâncias de festas com 30 dias de antecedência, e o Celery continua gerando novas conforme o tempo passa.

Benefícios para as Comunidades de Ciclismo

  • Configure e esqueça - Chega de criação semanal de eventos
  • Consistência - Mesma rota, mesmo horário, mesmas configurações
  • Flexibilidade - Edite ou cancele ocorrências individuais
  • Descoberta - Os ciclistas podem facilmente encontrar passeios de grupo regulares
  • Integração de calendário - Inscreva-se em eventos recorrentes no seu app de calendário

Pronto para configurar seu passeio regular? Crie uma festa recorrente e deixe a gente cuidar do agendamento!