diff --git a/course_calendar/__init__.py b/course_calendar/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/course_calendar/admin.py b/course_calendar/admin.py new file mode 100644 index 0000000..0e56d3d --- /dev/null +++ b/course_calendar/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +# Register your models here. +# from .forms import EventForm +# +# admin.site.register(EventForm) diff --git a/course_calendar/apps.py b/course_calendar/apps.py new file mode 100644 index 0000000..53608d9 --- /dev/null +++ b/course_calendar/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CourseCalendarConfig(AppConfig): + name = 'course_calendar' diff --git a/course_calendar/forms.py b/course_calendar/forms.py new file mode 100644 index 0000000..a55d6d7 --- /dev/null +++ b/course_calendar/forms.py @@ -0,0 +1,21 @@ +from django.forms import fields as form_fields +from django.forms.widgets import ColorInput +from wagtail.admin.forms import WagtailAdminModelForm +from wagtail_color_panel.edit_handlers import NativeColorPanel + +from course_calendar.models import CalendarEvent + + +class EventForm(WagtailAdminModelForm): + class Meta: + model = CalendarEvent + fields = "__all__" + + widgets = { + "color": ColorInput(), + "start": form_fields.DateTimeInput(attrs={"type": "datetime-local"}), + "end": form_fields.DateTimeInput(attrs={"type": "datetime-local"}), + "recurrence_start_time": form_fields.TimeInput(attrs={"type": "time"}), + "recurrence_end_time": form_fields.TimeInput(attrs={"type": "time"}), + "recurrence_end_date": form_fields.DateInput(attrs={"type": "date"}), + } diff --git a/course_calendar/migrations/0001_initial.py b/course_calendar/migrations/0001_initial.py new file mode 100644 index 0000000..11f7bf1 --- /dev/null +++ b/course_calendar/migrations/0001_initial.py @@ -0,0 +1,72 @@ +# Generated by Django 6.0.3 on 2026-03-30 09:43 + +import django.db.models.deletion +import modelcluster.contrib.taggit +import wagtail.fields +import wagtail_color_panel.fields +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'), + ('wagtailimages', '0027_image_description'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CalendarEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('color', wagtail_color_panel.fields.ColorField(default='#1c398e', max_length=7)), + ('start', models.DateTimeField(blank=True, help_text='Start date and time of the singular event (ignored if recurrence is enabled)', null=True)), + ('end', models.DateTimeField(blank=True, help_text='End date and time of the singular event (ignored if recurrence is enabled)', null=True)), + ('recurrence_enabled', models.BooleanField(default=False, help_text='Enable automatic recurrence for this event')), + ('recurrence_days_of_week', models.JSONField(blank=True, default=list, help_text='Days of the week for recurrence (e.g., [0,3] for Mon & Thu)')), + ('recurrence_start_time', models.TimeField(blank=True, help_text='Start time for each occurrence', null=True)), + ('recurrence_end_time', models.TimeField(blank=True, help_text='End time for each occurrence', null=True)), + ('recurrence_repeat_until', models.DateField(blank=True, help_text='Repeat until this date (inclusive)', null=True)), + ('recurrence_endless', models.BooleanField(default=False, help_text="If enabled, recurrence will not end (ignore 'repeat until' date)")), + ('location', models.CharField(blank=True, max_length=255)), + ('max_attendees', models.PositiveIntegerField(blank=True, help_text='Maximum number of attendees. Leave blank for unlimited.', null=True)), + ('description', wagtail.fields.RichTextField(blank=True)), + ('hosts', models.ManyToManyField(help_text='Select users who will be listed as hosts of this event.', related_name='hosted_events', to=settings.AUTH_USER_MODEL)), + ('image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image')), + ], + ), + migrations.CreateModel( + name='EventOccurrence', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('start', models.DateTimeField()), + ('end', models.DateTimeField()), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='occurrences', to='course_calendar.calendarevent')), + ('signed_up_users', models.ManyToManyField(blank=True, help_text='Users who have signed up for this occurrence.', related_name='event_occurrences_signed_up', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['start'], + }, + ), + migrations.CreateModel( + name='EventTag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content_object', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tagged_items', to='course_calendar.calendarevent')), + ('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_items', to='taggit.tag')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='calendarevent', + name='tags', + field=modelcluster.contrib.taggit.ClusterTaggableManager(blank=True, help_text='A comma-separated list of tags.', through='course_calendar.EventTag', to='taggit.Tag', verbose_name='Tags'), + ), + ] diff --git a/course_calendar/migrations/__init__.py b/course_calendar/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/course_calendar/models.py b/course_calendar/models.py new file mode 100644 index 0000000..d407cb4 --- /dev/null +++ b/course_calendar/models.py @@ -0,0 +1,179 @@ +from datetime import datetime, timedelta +import logging + +from django.contrib.auth.models import User +from django.db import models +from django.forms.widgets import CheckboxSelectMultiple +from django.utils import timezone +from modelcluster.contrib.taggit import ClusterTaggableManager +from taggit.models import TaggedItemBase +from wagtail.admin.panels import FieldPanel, ObjectList +from wagtail.fields import RichTextField +from wagtail_color_panel.edit_handlers import NativeColorPanel +from wagtail_color_panel.fields import ColorField + + +class EventTag(TaggedItemBase): + content_object = models.ForeignKey( + "course_calendar.CalendarEvent", + related_name="tagged_items", + on_delete=models.CASCADE, + ) + + +class CalendarEvent(models.Model): + title = models.CharField(max_length=255) + tags = ClusterTaggableManager(through=EventTag, blank=True) + color = ColorField(default="#1c398e") + image = models.ForeignKey( + "wagtailimages.Image", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="+", + ) + + start = models.DateTimeField( + blank=True, + null=True, + help_text="Start date and time of the singular event (ignored if recurrence is enabled)", + ) + end = models.DateTimeField( + blank=True, + null=True, + help_text="End date and time of the singular event (ignored if recurrence is enabled)", + ) + + # Recurrence fields + recurrence_enabled = models.BooleanField( + default=False, help_text="Enable automatic recurrence for this event" + ) + RECURRENCE_DAYS = [ + (0, "Monday"), + (1, "Tuesday"), + (2, "Wednesday"), + (3, "Thursday"), + (4, "Friday"), + (5, "Saturday"), + (6, "Sunday"), + ] + recurrence_days_of_week = models.JSONField( + default=list, + blank=True, + help_text="Days of the week for recurrence (e.g., [0,3] for Mon & Thu)", + ) + recurrence_start_time = models.TimeField( + null=True, blank=True, help_text="Start time for each occurrence" + ) + recurrence_end_time = models.TimeField( + null=True, blank=True, help_text="End time for each occurrence" + ) + recurrence_repeat_until = models.DateField( + null=True, blank=True, help_text="Repeat until this date (inclusive)" + ) + recurrence_endless = models.BooleanField( + default=False, + help_text="If enabled, recurrence will not end (ignore 'repeat until' date)", + ) + + # TODO: use google maps here + location = models.CharField(max_length=255, blank=True) + max_attendees = models.PositiveIntegerField( + null=True, + blank=True, + help_text="Maximum number of attendees. Leave blank for unlimited.", + ) + + description = RichTextField(blank=True) + hosts = models.ManyToManyField( + User, + related_name="hosted_events", + help_text="Select users who will be listed as hosts of this event.", + ) + + def generate_occurrences(self, days_ahead=30): + """ + Generate EventOccurrence objects for this event based on recurrence settings. + For endless recurrence, generate up to days_ahead into the future. + """ + + now = timezone.now() + if not self.recurrence_enabled: + # if recurrence is not enabled, ensure there's at least one occurrence for the specified start/end + if self.occurrences.exists(): + occurrence = self.occurrences.first() + if occurrence.start != self.start or occurrence.end != self.end: + occurrence.start = self.start + occurrence.end = self.end + occurrence.save(update_fields=["start", "end"]) + else: + EventOccurrence.objects.create( + event=self, start=self.start, end=self.end + ) + return + + # Determine the date range + start_date = now.date() + if self.recurrence_endless: + end_date = start_date + timedelta(days=days_ahead) + elif self.recurrence_repeat_until: + end_date = self.recurrence_repeat_until + else: + end_date = start_date + timedelta(days=days_ahead) + + days_of_week = self.recurrence_days_of_week or [] + start_time = self.recurrence_start_time or datetime.min.time() + end_time = self.recurrence_end_time or datetime.min.time() + + for single_date in ( + start_date + timedelta(n) for n in range((end_date - start_date).days + 1) + ): + if days_of_week and single_date.weekday() not in days_of_week: + continue + start_dt = datetime.combine(single_date, start_time) + end_dt = datetime.combine(single_date, end_time) + start_dt = timezone.make_aware(start_dt) + end_dt = timezone.make_aware(end_dt) + # If an occurrence exists, update its start/end if needed, but keep signed_up_users + occurrence = self.occurrences.filter(start=start_dt, end=end_dt).first() + if occurrence: + if occurrence.start != start_dt or occurrence.end != end_dt: + occurrence.start = start_dt + occurrence.end = end_dt + occurrence.save(update_fields=["start", "end"]) + else: + EventOccurrence.objects.create(event=self, start=start_dt, end=end_dt) + + +class EventOccurrence(models.Model): + event = models.ForeignKey( + CalendarEvent, related_name="occurrences", on_delete=models.CASCADE + ) + start = models.DateTimeField() + end = models.DateTimeField() + signed_up_users = models.ManyToManyField( + User, + related_name="event_occurrences_signed_up", + blank=True, + help_text="Users who have signed up for this occurrence.", + ) + + class Meta: + ordering = ["start"] + + def __str__(self): + return f"{self.event.title} ({self.start} - {self.end})" + + @property + def attendees_count(self): + return self.signed_up_users.count() + + @property + def is_past(self): + + return self.end < timezone.now() + + def user_signed_up(self, user): + if not user.is_authenticated: + return False + return self.signed_up_users.filter(id=user.id).exists() diff --git a/course_calendar/signals.py b/course_calendar/signals.py new file mode 100644 index 0000000..759d581 --- /dev/null +++ b/course_calendar/signals.py @@ -0,0 +1,9 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from .models import CalendarEvent + + +@receiver(post_save, sender=CalendarEvent) +def generate_event_occurrences_on_save(sender, instance, created, **kwargs): + instance.generate_occurrences() diff --git a/course_calendar/templates/admin_add_event.html b/course_calendar/templates/admin_add_event.html new file mode 100644 index 0000000..22255f3 --- /dev/null +++ b/course_calendar/templates/admin_add_event.html @@ -0,0 +1,24 @@ +{% extends "wagtailadmin/base.html" %} +{% load static i18n %} + +{% block titletag %} + {% trans "Add Event" %} +{% endblock titletag %} + +{% block content %} + {{ form.media }} + {% include "wagtailadmin/shared/header.html" with title="Events" icon="calendar" %} + +