Muchos grupos ciclistas tienen paseos regulares: el grupo del lunes por la mañana para ir al trabajo, el paseo de aventura del primer sábado del mes, o la salida social del miércoles por la noche. Hasta ahora, los organizadores tenían que crear manualmente cada evento. Con nuestra nueva función de Fiestas Recurrentes, puedes configurar un horario una vez y dejar que Party Onbici genere las instancias automáticamente.

El Problema con los Eventos Únicos

Los grupos ciclistas comunitarios típicamente tienen horarios predecibles:

  • “Todos los martes y jueves a las 6:30 AM”
  • “El primer domingo de cada mes”
  • “Cada dos sábados por la mañana”

Crear estos eventos manualmente es tedioso, propenso a errores y dificulta mantener rutas y configuraciones consistentes entre ocurrencias.

Presentamos iCalendar RRULE

Construimos nuestro sistema de eventos recurrentes sobre el estándar iCalendar RRULE (RFC 5545). Este es el mismo formato usado por Google Calendar, Apple Calendar y Outlook, asegurando compatibilidad con el ecosistema de calendarios más amplio.

Ejemplos de Patrones de Recurrencia

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR
  → Todos los lunes, miércoles y viernes

RRULE:FREQ=MONTHLY;BYDAY=1SA
  → El primer sábado de cada mes

RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=SU
  → Cada dos domingos

RRULE:FREQ=DAILY;COUNT=10
  → Diariamente durante 10 ocurrencias

El Modelo de Datos

Un RecurringParty sirve como plantilla que genera instancias 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 instancia de Party generada enlaza de vuelta a su padre:

 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)

Generación de Instancias

Cuando se crea una fiesta recurrente o cambia el horario, generamos instancias para el período próximo:

 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

Generación Automática con Celery Beat

Una tarea diaria de Celery mantiene las instancias actualizadas:

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

El horario de Celery Beat ejecuta esto a medianoche:

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

Interfaz de Usuario

Creando una Fiesta Recurrente

El formulario usa el widget django-recurrence para construir patrones 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",
        ]

El widget proporciona una interfaz intuitiva para patrones comunes:

  • Diario / Semanal / Mensual / Anual
  • Días específicos de la semana
  • Patrones ordinales (primero, segundo, último)
  • Intervalos personalizados (cada 2 semanas)
  • Condiciones de fin (hasta una fecha, después de N ocurrencias, o nunca)

Gestionando Instancias

Los organizadores pueden ver todas las instancias generadas y:

  • Editar instancias individuales - Cambiar ruta u horario para una ocurrencia
  • Cancelar instancias - Marcar fechas específicas como canceladas
  • Regenerar instancias - Activar manualmente la generación para los próximos 30 días

Manejo de Casos Especiales

Conciencia de Zona Horaria

Todas las fechas se almacenan en la zona horaria configurada del organizador y se muestran en la hora local del espectador:

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

Manejo de Festivos y Excepciones

Los usuarios pueden cancelar instancias individuales sin afectar el patrón recurrente:

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

Las instancias canceladas aún aparecen en la lista con un indicador visual, evitando confusión.

Instancias Huérfanas

Si se elimina una fiesta recurrente, las instancias generadas pueden preservarse opcionalmente:

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

La Experiencia del Usuario

Crear un paseo semanal toma solo unos pasos:

  1. Ve a Panel de Control → Fiestas Recurrentes → Crear
  2. Configura los detalles de tu fiesta (ruta, horarios, dificultad)
  3. Configura el patrón de recurrencia (ej., “Todos los sábados a las 8 AM”)
  4. Establece la fecha de inicio y fecha de fin opcional
  5. Guarda - ¡las instancias se generan automáticamente!

El sistema crea instancias de fiestas con 30 días de anticipación, y Celery sigue generando nuevas a medida que pasa el tiempo.

Beneficios para las Comunidades Ciclistas

  • Configúralo y olvídalo - No más creación semanal de eventos
  • Consistencia - Misma ruta, mismo horario, mismas configuraciones
  • Flexibilidad - Edita o cancela ocurrencias individuales
  • Descubrimiento - Los ciclistas pueden encontrar fácilmente paseos grupales regulares
  • Integración de calendario - Suscríbete a eventos recurrentes en tu aplicación de calendario

¿Listo para configurar tu paseo regular? Crea una fiesta recurrente y ¡déjanos manejar la programación!