diff --git a/backend/officehoursqueue/settings/base.py b/backend/officehoursqueue/settings/base.py index 77f232d8..98f69ca4 100644 --- a/backend/officehoursqueue/settings/base.py +++ b/backend/officehoursqueue/settings/base.py @@ -50,7 +50,8 @@ "email_tools.apps.EmailToolsConfig", "accounts.apps.AccountsConfig", "ohq.apps.OhqConfig", - "schedule", + "ohq_schedule.apps.OHQScheduleConfig", + "schedule" ] MIDDLEWARE = [ diff --git a/backend/ohq/migrations/0022_booking.py b/backend/ohq/migrations/0022_booking.py new file mode 100644 index 00000000..7c4c288f --- /dev/null +++ b/backend/ohq/migrations/0022_booking.py @@ -0,0 +1,53 @@ +# Generated by Django 5.0.3 on 2025-04-12 03:39 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq", "0021_queue_question_timer_enabled_and_more"), + ("ohq_schedule", "0015_event_bookable_event_interval_event_location_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Booking", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("start", models.DateTimeField(db_index=True, verbose_name="start")), + ("end", models.DateTimeField(db_index=True, verbose_name="end")), + ( + "occurrence", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="bookings", + to="ohq_schedule.occurrence", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "booking", + "verbose_name_plural": "bookings", + "ordering": ["start"], + "index_together": {("start", "end")}, + }, + ), + ] diff --git a/backend/ohq/migrations/0023_migrate_schedule_data.py b/backend/ohq/migrations/0023_migrate_schedule_data.py new file mode 100644 index 00000000..705cb6fb --- /dev/null +++ b/backend/ohq/migrations/0023_migrate_schedule_data.py @@ -0,0 +1,108 @@ +# Generated by Django 5.0.3 on 2025-04-13 15:24 + +from django.db import migrations + +def migrate_schedule_data(apps, schema_editor): + OldCalendarRelation = apps.get_model('schedule', 'CalendarRelation') + OldCalendar = apps.get_model('schedule', 'Calendar') + OldEvent = apps.get_model('schedule', 'Event') + OldOccurrence = apps.get_model('schedule', 'Occurrence') + OldRule = apps.get_model('schedule', 'Rule') + OldEventRelation = apps.get_model('schedule', 'EventRelation') + + NewCalendarRelation = apps.get_model('ohq_schedule', 'CalendarRelation') + NewCalendar = apps.get_model('ohq_schedule', 'Calendar') + NewEvent = apps.get_model('ohq_schedule', 'Event') + NewOccurrence = apps.get_model('ohq_schedule', 'Occurrence') + NewRule = apps.get_model('ohq_schedule', 'Rule') + NewEventRelation = apps.get_model('ohq_schedule', 'EventRelation') + + for old_calendar_relation in OldCalendarRelation.objects.all(): + NewCalendarRelation.objects.create( + id=old_calendar_relation.id, + calendar_id=old_calendar_relation.calendar_id, + content_type_id=old_calendar_relation.content_type_id, + object_id=old_calendar_relation.object_id, + distinction=old_calendar_relation.distinction, + inheritable=old_calendar_relation.inheritable + ) + + for old_calendar in OldCalendar.objects.all(): + NewCalendar.objects.create( + id=old_calendar.id, + name=old_calendar.name, + slug=old_calendar.slug + ) + + for old_rule in OldRule.objects.all(): + NewRule.objects.create( + id=old_rule.id, + name=old_rule.name, + description=old_rule.description, + frequency=old_rule.frequency, + params=old_rule.params + ) + + for old_event in OldEvent.objects.all(): + NewEvent.objects.create( + id=old_event.id, + start=old_event.start, + end=old_event.end, + title=old_event.title, + description=old_event.description, + calendar_id=old_event.calendar_id, + rule_id=old_event.rule_id, + end_recurring_period=old_event.end_recurring_period, + location=old_event.location if hasattr(old_event, 'location') else '', + bookable=old_event.bookable if hasattr(old_event, 'bookable') else False, + interval=old_event.interval if hasattr(old_event, 'interval') else None + ) + + for old_occurrence in OldOccurrence.objects.all(): + NewOccurrence.objects.create( + id=old_occurrence.id, + event_id=old_occurrence.event_id, + title=old_occurrence.title, + description=old_occurrence.description, + start=old_occurrence.start, + end=old_occurrence.end, + cancelled=old_occurrence.cancelled, + original_start=old_occurrence.original_start, + original_end=old_occurrence.original_end, + location=old_occurrence.location if hasattr(old_occurrence, 'location') else '', + interval=old_occurrence.interval if hasattr(old_occurrence, 'interval') else None + ) + + for old_relation in OldEventRelation.objects.all(): + NewEventRelation.objects.create( + id=old_relation.id, + event_id=old_relation.event_id, + content_type_id=old_relation.content_type_id, + object_id=old_relation.object_id, + distinction=old_relation.distinction + ) + +def reverse_migrate_schedule_data(apps, schema_editor): + NewCalendarRelation = apps.get_model('ohq_schedule', 'CalendarRelation') + NewCalendar = apps.get_model('ohq_schedule', 'Calendar') + NewEvent = apps.get_model('ohq_schedule', 'Event') + NewOccurrence = apps.get_model('ohq_schedule', 'Occurrence') + NewRule = apps.get_model('ohq_schedule', 'Rule') + NewEventRelation = apps.get_model('ohq_schedule', 'EventRelation') + + NewCalendarRelation.objects.all().delete() + NewCalendar.objects.all().delete() + NewEvent.objects.all().delete() + NewOccurrence.objects.all().delete() + NewRule.objects.all().delete() + NewEventRelation.objects.all().delete() + +class Migration(migrations.Migration): + dependencies = [ + ('ohq', '0022_booking'), + ('schedule', '0001_initial'), + ('ohq_schedule', '0015_event_bookable_event_interval_event_location_and_more'), + ] + operations = [ + migrations.RunPython(migrate_schedule_data, reverse_migrate_schedule_data), + ] diff --git a/backend/ohq/models.py b/backend/ohq/models.py index 31b59e9a..2f6100f3 100644 --- a/backend/ohq/models.py +++ b/backend/ohq/models.py @@ -4,7 +4,9 @@ from django.dispatch import receiver from email_tools.emails import send_email from phonenumber_field.modelfields import PhoneNumberField - +from ohq_schedule.models import Event, Occurrence +from schedule.models import Event as OldEvent +from schedule.models import Occurrence as OldOccurrence User = settings.AUTH_USER_MODEL @@ -447,3 +449,71 @@ class Meta: def __str__(self): return f"{self.user}: {self.metric}" + +class Booking(models.Model): + """ + Booking within an occurrence. + Bookings can only be created with start times of 5-minute intervals. + """ + + occurrence = models.ForeignKey(Occurrence, on_delete=models.CASCADE, related_name="bookings") + user = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True) + start = models.DateTimeField("start", db_index=True) + end = models.DateTimeField("end", db_index=True) + + class Meta: + verbose_name = ("booking") + verbose_name_plural = ("bookings") + ordering = ["start"] + index_together = (("start", "end"),) + + def clean(self): + if self.start >= self.end: + raise ValidationError('Start time must be before end time.') + + if self.start.minute % 5 != 0: + raise ValidationError('Start time must be on a 5-minute interval (e.g., :00, :05, :10, :15, etc).') + + if self.start < self.occurrence.start or self.end > self.occurrence.end: + raise ValidationError('Booking times must be within the occurrence\'s start and end times.') + + duration = self.end - self.start + duration_minutes = duration.total_seconds() / 60 + + if duration_minutes != self.occurrence.interval: + raise ValidationError(f'Booking duration must be {self.occurrence.interval} minutes.') + + overlapping_bookings = Booking.objects.filter( + occurrence=self.occurrence, + start__lt=self.end, + end__gt=self.start + ).exclude(id=self.id) + if overlapping_bookings.exists(): + raise ValidationError('Booking times cannot overlap with existing bookings.') + + super().clean() + + def save(self, *args, **kwargs): + self.clean() + super().save(*args, **kwargs) + + def __str__(self): + start_str = self.start.strftime("%Y-%m-%d %H:%M:%S") + end_str = self.end.strftime("%Y-%m-%d %H:%M:%S") + return f"{start_str} to {end_str}" + +Event.add_to_class('location', models.CharField(max_length=255, blank=True)) +Occurrence.add_to_class('location', models.CharField(max_length=255, blank=True)) +Occurrence.add_to_class('interval', models.IntegerField(blank=True, null=True)) + +def new_occurrence_init(self, *args, **kwargs): + super(Occurrence, self).__init__(*args, **kwargs) + event = kwargs.get("event", None) + if not self.title and event: + self.title = event.title + if not self.description and event: + self.description = event.description + if not self.location and event: + self.location = event.location + +Occurrence.__init__ = new_occurrence_init \ No newline at end of file diff --git a/backend/ohq/permissions.py b/backend/ohq/permissions.py index 58a1cce5..cdfc4f37 100644 --- a/backend/ohq/permissions.py +++ b/backend/ohq/permissions.py @@ -1,9 +1,12 @@ from django.db.models import Q from rest_framework import permissions -from schedule.models import Event, EventRelation, Occurrence - -from ohq.models import Course, Membership, Question - +from ohq_schedule.models import Event, EventRelation, Occurrence +from ohq.models import ( + Course, + Membership, + Question, + Booking, +) # Hierarchy of permissions is usually: # Professor > Head TA > TA > Student > User diff --git a/backend/ohq/serializers.py b/backend/ohq/serializers.py index 1ae87c58..08292b74 100644 --- a/backend/ohq/serializers.py +++ b/backend/ohq/serializers.py @@ -7,8 +7,7 @@ from phonenumber_field.serializerfields import PhoneNumberField from rest_framework import serializers from rest_live.signals import save_handler -from schedule.models import Calendar, Event, EventRelation, EventRelationManager, Rule -from schedule.models.events import Occurrence +from ohq_schedule.models import Calendar, Event, Occurrence, EventRelation, EventRelationManager, Rule from ohq.models import ( Announcement, diff --git a/backend/ohq/views.py b/backend/ohq/views.py index 51bde2af..2793c778 100644 --- a/backend/ohq/views.py +++ b/backend/ohq/views.py @@ -32,7 +32,7 @@ from rest_framework.settings import api_settings from rest_framework.views import APIView from rest_live.mixins import RealtimeMixin -from schedule.models import Event, EventRelationManager, Occurrence +from ohq_schedule.models import Event, EventRelationManager, Occurrence from http import HTTPStatus from ohq.filters import CourseStatisticFilter, QuestionSearchFilter, QueueStatisticFilter diff --git a/backend/ohq_schedule/__init__.py b/backend/ohq_schedule/__init__.py new file mode 100644 index 00000000..32b83f46 --- /dev/null +++ b/backend/ohq_schedule/__init__.py @@ -0,0 +1,4 @@ +import django + +if django.VERSION < (3, 2): + default_app_config = "ohq_schedule.apps.OHQScheduleConfig" diff --git a/backend/ohq_schedule/admin.py b/backend/ohq_schedule/admin.py new file mode 100644 index 00000000..44d0a4ab --- /dev/null +++ b/backend/ohq_schedule/admin.py @@ -0,0 +1,80 @@ +from django.contrib import admin + +from ohq_schedule.forms import EventAdminForm +from ohq_schedule.models import ( + Calendar, + CalendarRelation, + Event, + EventRelation, + Occurrence, + Rule, +) + +@admin.register(Calendar) +class CalendarAdmin(admin.ModelAdmin): + list_display = ("name", "slug") + prepopulated_fields = {"slug": ("name",)} + search_fields = ["name"] + fieldsets = ((None, {"fields": [("name", "slug")]}),) + + +@admin.register(CalendarRelation) +class CalendarRelationAdmin(admin.ModelAdmin): + list_display = ("calendar", "content_object") + list_filter = ("inheritable",) + fieldsets = ( + ( + None, + { + "fields": [ + "calendar", + ("content_type", "object_id", "distinction"), + "inheritable", + ] + }, + ), + ) + + +@admin.register(EventRelation) +class EventRelationAdmin(admin.ModelAdmin): + list_display = ("event", "content_object", "distinction") + fieldsets = ( + (None, {"fields": ["event", ("content_type", "object_id", "distinction")]}), + ) + + +@admin.register(Event) +class EventAdmin(admin.ModelAdmin): + list_display = ("title", "start", "end") + list_filter = ("start",) + ordering = ("-start",) + date_hierarchy = "start" + search_fields = ("title", "description") + fieldsets = ( + ( + None, + { + "fields": [ + ("title", "color_event"), + ("description",), + ("start", "end"), + ("creator", "calendar"), + ("rule", "end_recurring_period"), + "location", + ("interval", "bookable"), + ] + }, + ), + ) + form = EventAdminForm + + +admin.site.register(Occurrence, admin.ModelAdmin) + + +@admin.register(Rule) +class RuleAdmin(admin.ModelAdmin): + list_display = ("name",) + list_filter = ("frequency",) + search_fields = ("name", "description") diff --git a/backend/ohq_schedule/apps.py b/backend/ohq_schedule/apps.py new file mode 100644 index 00000000..7703c191 --- /dev/null +++ b/backend/ohq_schedule/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class OHQScheduleConfig(AppConfig): + name = "ohq_schedule" + verbose_name = _("ohq_schedules") + default_auto_field = "django.db.models.AutoField" diff --git a/backend/ohq_schedule/feeds/__init__.py b/backend/ohq_schedule/feeds/__init__.py new file mode 100644 index 00000000..bdc317a4 --- /dev/null +++ b/backend/ohq_schedule/feeds/__init__.py @@ -0,0 +1,69 @@ +import itertools + +from django.conf import settings +from django.contrib.syndication.views import Feed, FeedDoesNotExist +from django.utils import timezone + +from ohq_schedule.feeds.ical import ICalendarFeed +from ohq_schedule.models import Calendar + + +class UpcomingEventsFeed(Feed): + feed_id = "upcoming" + + def feed_title(self, obj): + return "Upcoming Events for %s" % obj.name + + def get_object(self, request, calendar_id): + return Calendar.objects.get(pk=calendar_id) + + def link(self, obj): + if not obj: + raise FeedDoesNotExist + return obj.get_absolute_url() + + def items(self, obj): + return itertools.islice( + obj.occurrences_after(timezone.now()), + getattr(settings, "FEED_LIST_LENGTH", 10), + ) + + def item_id(self, item): + return str(item.id) + + def item_title(self, item): + return item.event.title + + def item_authors(self, item): + if item.event.creator is None: + return [{"name": ""}] + return [{"name": item.event.creator.username}] + + def item_updated(self, item): + return item.event.created_on + + def item_content(self, item): + return "{} \n {}".format(item.event.title, item.event.description) + + +class CalendarICalendar(ICalendarFeed): + def items(self): + cal_id = self.args[1] + cal = Calendar.objects.get(pk=cal_id) + + return cal.events.all() + + def item_uid(self, item): + return str(item.id) + + def item_start(self, item): + return item.start + + def item_end(self, item): + return item.end + + def item_summary(self, item): + return item.title + + def item_created(self, item): + return item.created_on diff --git a/backend/ohq_schedule/feeds/ical.py b/backend/ohq_schedule/feeds/ical.py new file mode 100644 index 00000000..ea681afd --- /dev/null +++ b/backend/ohq_schedule/feeds/ical.py @@ -0,0 +1,61 @@ +import icalendar +from django.http import HttpResponse + +EVENT_ITEMS = ( + ("uid", "uid"), + ("dtstart", "start"), + ("dtend", "end"), + ("summary", "summary"), + ("location", "location"), + ("last_modified", "last_modified"), + ("created", "created"), +) + + +class ICalendarFeed: + def __call__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + + cal = icalendar.Calendar() + cal.add("prodid", "-// django-scheduler //") + cal.add("version", "2.0") + + for item in list(self.items()): + event = icalendar.Event() + + for vkey, key in EVENT_ITEMS: + value = getattr(self, "item_" + key)(item) + if value: + event.add(vkey, value) + + cal.add_component(event) + + response = HttpResponse(cal.to_ical()) + response["Content-Type"] = "text/calendar" + + return response + + def items(self): + return [] + + def item_uid(self, item): + pass + + def item_start(self, item): + pass + + def item_end(self, item): + pass + + def item_summary(self, item): + return str(item) + + def item_location(self, item): + pass + + def item_last_modified(self, item): + pass + + def item_created(self, item): + pass diff --git a/backend/ohq_schedule/forms.py b/backend/ohq_schedule/forms.py new file mode 100644 index 00000000..e55c2c09 --- /dev/null +++ b/backend/ohq_schedule/forms.py @@ -0,0 +1,48 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ + +from ohq_schedule.models import Event, Occurrence +from ohq_schedule.widgets import ColorInput + + +class SpanForm(forms.ModelForm): + start = forms.SplitDateTimeField(label=_("start")) + end = forms.SplitDateTimeField( + label=_("end"), help_text=_("The end time must be later than start time.") + ) + + def clean(self): + if "end" in self.cleaned_data and "start" in self.cleaned_data: + if self.cleaned_data["end"] <= self.cleaned_data["start"]: + raise forms.ValidationError( + _("The end time must be later than start time.") + ) + return self.cleaned_data + + +class EventForm(SpanForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + end_recurring_period = forms.DateTimeField( + label=_("End recurring period"), + help_text=_("This date is ignored for one time only events."), + required=False, + ) + + class Meta: + model = Event + exclude = ("creator", "created_on", "calendar") + + +class OccurrenceForm(SpanForm): + class Meta: + model = Occurrence + exclude = ("original_start", "original_end", "event", "cancelled") + + +class EventAdminForm(forms.ModelForm): + class Meta: + exclude = [] + model = Event + widgets = {"color_event": ColorInput} diff --git a/backend/ohq_schedule/migrations/0001_initial.py b/backend/ohq_schedule/migrations/0001_initial.py new file mode 100644 index 00000000..528b2b11 --- /dev/null +++ b/backend/ohq_schedule/migrations/0001_initial.py @@ -0,0 +1,286 @@ +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("contenttypes", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Calendar", + fields=[ + ( + "id", + models.AutoField( + primary_key=True, + serialize=False, + verbose_name="ID", + auto_created=True, + ), + ), + ("name", models.CharField(max_length=200, verbose_name="name")), + ("slug", models.SlugField(max_length=200, verbose_name="slug")), + ], + options={"verbose_name_plural": "calendar", "verbose_name": "calendar"}, + bases=(models.Model,), + ), + migrations.CreateModel( + name="CalendarRelation", + fields=[ + ( + "id", + models.AutoField( + primary_key=True, + serialize=False, + verbose_name="ID", + auto_created=True, + ), + ), + ("object_id", models.IntegerField()), + ( + "distinction", + models.CharField( + null=True, max_length=20, verbose_name="distinction" + ), + ), + ( + "inheritable", + models.BooleanField(default=True, verbose_name="inheritable"), + ), + ( + "calendar", + models.ForeignKey( + to="ohq_schedule.Calendar", + verbose_name="calendar", + on_delete=models.CASCADE, + ), + ), + ( + "content_type", + models.ForeignKey( + to="contenttypes.ContentType", on_delete=models.CASCADE + ), + ), + ], + options={ + "verbose_name_plural": "calendar relations", + "verbose_name": "calendar relation", + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name="Event", + fields=[ + ( + "id", + models.AutoField( + primary_key=True, + serialize=False, + verbose_name="ID", + auto_created=True, + ), + ), + ("start", models.DateTimeField(verbose_name="start")), + ( + "end", + models.DateTimeField( + help_text="The end time must be later than the start time.", + verbose_name="end", + ), + ), + ("title", models.CharField(max_length=255, verbose_name="title")), + ( + "description", + models.TextField(blank=True, null=True, verbose_name="description"), + ), + ( + "created_on", + models.DateTimeField(auto_now_add=True, verbose_name="created on"), + ), + ( + "updated_on", + models.DateTimeField(auto_now=True, verbose_name="updated on"), + ), + ( + "end_recurring_period", + models.DateTimeField( + blank=True, + null=True, + help_text="This date is ignored for one time only events.", + verbose_name="end recurring period", + ), + ), + ( + "calendar", + models.ForeignKey( + blank=True, + null=True, + to="ohq_schedule.Calendar", + verbose_name="calendar", + on_delete=models.CASCADE, + ), + ), + ( + "creator", + models.ForeignKey( + blank=True, + null=True, + related_name="creator", + verbose_name="creator", + to=settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + ), + ), + ], + options={"verbose_name_plural": "events", "verbose_name": "event"}, + bases=(models.Model,), + ), + migrations.CreateModel( + name="EventRelation", + fields=[ + ( + "id", + models.AutoField( + primary_key=True, + serialize=False, + verbose_name="ID", + auto_created=True, + ), + ), + ("object_id", models.IntegerField()), + ( + "distinction", + models.CharField( + null=True, max_length=20, verbose_name="distinction" + ), + ), + ( + "content_type", + models.ForeignKey( + to="contenttypes.ContentType", on_delete=models.CASCADE + ), + ), + ( + "event", + models.ForeignKey( + to="ohq_schedule.Event", + verbose_name="event", + on_delete=models.CASCADE, + ), + ), + ], + options={ + "verbose_name_plural": "event relations", + "verbose_name": "event relation", + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name="Occurrence", + fields=[ + ( + "id", + models.AutoField( + primary_key=True, + serialize=False, + verbose_name="ID", + auto_created=True, + ), + ), + ( + "title", + models.CharField( + blank=True, null=True, max_length=255, verbose_name="title" + ), + ), + ( + "description", + models.TextField(blank=True, null=True, verbose_name="description"), + ), + ("start", models.DateTimeField(verbose_name="start")), + ("end", models.DateTimeField(verbose_name="end")), + ( + "cancelled", + models.BooleanField(default=False, verbose_name="cancelled"), + ), + ("original_start", models.DateTimeField(verbose_name="original start")), + ("original_end", models.DateTimeField(verbose_name="original end")), + ( + "created_on", + models.DateTimeField(auto_now_add=True, verbose_name="created on"), + ), + ( + "updated_on", + models.DateTimeField(auto_now=True, verbose_name="updated on"), + ), + ( + "event", + models.ForeignKey( + to="ohq_schedule.Event", + verbose_name="event", + on_delete=models.CASCADE, + ), + ), + ], + options={ + "verbose_name_plural": "occurrences", + "verbose_name": "occurrence", + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name="Rule", + fields=[ + ( + "id", + models.AutoField( + primary_key=True, + serialize=False, + verbose_name="ID", + auto_created=True, + ), + ), + ("name", models.CharField(max_length=32, verbose_name="name")), + ("description", models.TextField(verbose_name="description")), + ( + "frequency", + models.CharField( + max_length=10, + choices=[ + ("YEARLY", "Yearly"), + ("MONTHLY", "Monthly"), + ("WEEKLY", "Weekly"), + ("DAILY", "Daily"), + ("HOURLY", "Hourly"), + ("MINUTELY", "Minutely"), + ("SECONDLY", "Secondly"), + ], + verbose_name="frequency", + ), + ), + ( + "params", + models.TextField(blank=True, null=True, verbose_name="params"), + ), + ], + options={"verbose_name_plural": "rules", "verbose_name": "rule"}, + bases=(models.Model,), + ), + migrations.AddField( + model_name="event", + name="rule", + field=models.ForeignKey( + blank=True, + null=True, + to="ohq_schedule.Rule", + verbose_name="rule", + help_text="Select '----' for a one time only event.", + on_delete=models.CASCADE, + ), + preserve_default=True, + ), + ] diff --git a/backend/ohq_schedule/migrations/0002_event_color_event.py b/backend/ohq_schedule/migrations/0002_event_color_event.py new file mode 100644 index 00000000..de6d0977 --- /dev/null +++ b/backend/ohq_schedule/migrations/0002_event_color_event.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("ohq_schedule", "0001_initial")] + + operations = [ + migrations.AddField( + model_name="event", + name="color_event", + field=models.CharField( + verbose_name="Color event", blank=True, max_length=10, null=True + ), + ) + ] diff --git a/backend/ohq_schedule/migrations/0003_auto_20160715_0028.py b/backend/ohq_schedule/migrations/0003_auto_20160715_0028.py new file mode 100644 index 00000000..826954ed --- /dev/null +++ b/backend/ohq_schedule/migrations/0003_auto_20160715_0028.py @@ -0,0 +1,48 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("ohq_schedule", "0002_event_color_event")] + + operations = [ + migrations.AlterField( + model_name="event", + name="end", + field=models.DateTimeField( + help_text="The end time must be later than the start time.", + verbose_name="end", + db_index=True, + ), + ), + migrations.AlterField( + model_name="event", + name="end_recurring_period", + field=models.DateTimeField( + help_text="This date is ignored for one time only events.", + null=True, + verbose_name="end recurring period", + db_index=True, + blank=True, + ), + ), + migrations.AlterField( + model_name="event", + name="start", + field=models.DateTimeField(verbose_name="start", db_index=True), + ), + migrations.AlterField( + model_name="occurrence", + name="end", + field=models.DateTimeField(verbose_name="end", db_index=True), + ), + migrations.AlterField( + model_name="occurrence", + name="start", + field=models.DateTimeField(verbose_name="start", db_index=True), + ), + migrations.AlterIndexTogether(name="event", index_together={("start", "end")}), + migrations.AlterIndexTogether( + name="occurrence", index_together={("start", "end")} + ), + ] diff --git a/backend/ohq_schedule/migrations/0004_text_fields_not_null.py b/backend/ohq_schedule/migrations/0004_text_fields_not_null.py new file mode 100644 index 00000000..751fbdff --- /dev/null +++ b/backend/ohq_schedule/migrations/0004_text_fields_not_null.py @@ -0,0 +1,59 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("ohq_schedule", "0003_auto_20160715_0028")] + + operations = [ + migrations.AlterField( + model_name="calendarrelation", + name="distinction", + field=models.CharField( + default="", max_length=20, verbose_name="distinction" + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="event", + name="color_event", + field=models.CharField( + blank=True, default="", max_length=10, verbose_name="Color event" + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="event", + name="description", + field=models.TextField(blank=True, default="", verbose_name="description"), + preserve_default=False, + ), + migrations.AlterField( + model_name="eventrelation", + name="distinction", + field=models.CharField( + default="", max_length=20, verbose_name="distinction" + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="occurrence", + name="description", + field=models.TextField(blank=True, default="", verbose_name="description"), + preserve_default=False, + ), + migrations.AlterField( + model_name="occurrence", + name="title", + field=models.CharField( + blank=True, default="", max_length=255, verbose_name="title" + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="rule", + name="params", + field=models.TextField(blank=True, default="", verbose_name="params"), + preserve_default=False, + ), + ] diff --git a/backend/ohq_schedule/migrations/0005_verbose_name_plural_for_calendar.py b/backend/ohq_schedule/migrations/0005_verbose_name_plural_for_calendar.py new file mode 100644 index 00000000..f6c495e3 --- /dev/null +++ b/backend/ohq_schedule/migrations/0005_verbose_name_plural_for_calendar.py @@ -0,0 +1,13 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [("ohq_schedule", "0004_text_fields_not_null")] + + operations = [ + migrations.AlterModelOptions( + name="calendar", + options={"verbose_name": "calendar", "verbose_name_plural": "calendars"}, + ) + ] diff --git a/backend/ohq_schedule/migrations/0006_update_text_fields_empty_string.py b/backend/ohq_schedule/migrations/0006_update_text_fields_empty_string.py new file mode 100644 index 00000000..8570c9e1 --- /dev/null +++ b/backend/ohq_schedule/migrations/0006_update_text_fields_empty_string.py @@ -0,0 +1,36 @@ +from django.db import migrations + + +def forwards(apps, schema_editor): + model_fields = [ + ("CalendarRelation", ["distinction"]), + ("Event", ["color_event", "description"]), + ("EventRelation", ["distinction"]), + ("Occurrence", ["description", "title"]), + ("Rule", ["params"]), + ] + for model_name, fields in model_fields: + model_class = apps.get_model("ohq_schedule", model_name) + for field_name in fields: + model_class.objects.filter(**{field_name: None}).update(**{field_name: ""}) + + +def reverse(apps, schema_editor): + model_fields = [ + ("CalendarRelation", ["distinction"]), + ("Event", ["color_event", "description"]), + ("EventRelation", ["distinction"]), + ("Occurrence", ["description", "title"]), + ("Rule", ["params"]), + ] + for model_name, fields in model_fields: + model_class = apps.get_model("ohq_schedule", model_name) + for field_name in fields: + model_class.objects.filter(**{field_name: ""}).update(**{field_name: None}) + + +class Migration(migrations.Migration): + + dependencies = [("ohq_schedule", "0003_auto_20160715_0028")] + + operations = [migrations.RunPython(forwards, reverse, elidable=True)] diff --git a/backend/ohq_schedule/migrations/0007_merge_text_fields.py b/backend/ohq_schedule/migrations/0007_merge_text_fields.py new file mode 100644 index 00000000..ea140741 --- /dev/null +++ b/backend/ohq_schedule/migrations/0007_merge_text_fields.py @@ -0,0 +1,11 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq_schedule", "0006_update_text_fields_empty_string"), + ("ohq_schedule", "0005_verbose_name_plural_for_calendar"), + ] + + operations = [] diff --git a/backend/ohq_schedule/migrations/0008_gfk_index.py b/backend/ohq_schedule/migrations/0008_gfk_index.py new file mode 100644 index 00000000..1fbc1c38 --- /dev/null +++ b/backend/ohq_schedule/migrations/0008_gfk_index.py @@ -0,0 +1,33 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("ohq_schedule", "0007_merge_text_fields"), + ] + + operations = [ + migrations.AlterField( + model_name="calendarrelation", + name="object_id", + field=models.IntegerField(db_index=True), + ), + migrations.AlterField( + model_name="eventrelation", + name="object_id", + field=models.IntegerField(db_index=True), + ), + migrations.AlterIndexTogether( + name="calendarrelation", index_together={("content_type", "object_id")} + ), + migrations.AlterIndexTogether( + name="eventrelation", index_together={("content_type", "object_id")} + ), + migrations.AlterField( + model_name="calendar", + name="slug", + field=models.SlugField(verbose_name="slug", max_length=200, unique=True), + ), + ] diff --git a/backend/ohq_schedule/migrations/0009_merge_20180108_2303.py b/backend/ohq_schedule/migrations/0009_merge_20180108_2303.py new file mode 100644 index 00000000..9fcdf8a3 --- /dev/null +++ b/backend/ohq_schedule/migrations/0009_merge_20180108_2303.py @@ -0,0 +1,10 @@ +# Generated by Django 2.0 on 2018-01-09 05:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [("ohq_schedule", "0008_gfk_index")] + + operations = [] diff --git a/backend/ohq_schedule/migrations/0010_events_set_missing_calendar.py b/backend/ohq_schedule/migrations/0010_events_set_missing_calendar.py new file mode 100644 index 00000000..1d8d9eef --- /dev/null +++ b/backend/ohq_schedule/migrations/0010_events_set_missing_calendar.py @@ -0,0 +1,22 @@ +from django.db import migrations + + +def forwards(apps, schema_editor): + Calendar = apps.get_model("ohq_schedule", "Calendar") + Event = apps.get_model("ohq_schedule", "Event") + events_qs = Event.objects.filter(calendar=None) + # Only create the default Calendar object if events need it. + if events_qs.exists(): + calendar, _created = Calendar.objects.get_or_create( + name="default", defaults={"slug": "default"} + ) + events_qs.update(calendar=calendar) + + +class Migration(migrations.Migration): + + dependencies = [("ohq_schedule", "0009_merge_20180108_2303")] + + operations = [ + migrations.RunPython(forwards, migrations.RunPython.noop, elidable=True) + ] diff --git a/backend/ohq_schedule/migrations/0011_event_calendar_not_null.py b/backend/ohq_schedule/migrations/0011_event_calendar_not_null.py new file mode 100644 index 00000000..0253da68 --- /dev/null +++ b/backend/ohq_schedule/migrations/0011_event_calendar_not_null.py @@ -0,0 +1,19 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("ohq_schedule", "0010_events_set_missing_calendar")] + + operations = [ + migrations.AlterField( + model_name="event", + name="calendar", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="ohq_schedule.Calendar", + verbose_name="calendar", + ), + ) + ] diff --git a/backend/ohq_schedule/migrations/0012_auto_20191025_1852.py b/backend/ohq_schedule/migrations/0012_auto_20191025_1852.py new file mode 100644 index 00000000..6eb97769 --- /dev/null +++ b/backend/ohq_schedule/migrations/0012_auto_20191025_1852.py @@ -0,0 +1,37 @@ +# Generated by Django 2.2.6 on 2019-10-25 18:52 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("ohq_schedule", "0011_event_calendar_not_null")] + + operations = [ + migrations.AlterField( + model_name="event", + name="creator", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="creator", + to=settings.AUTH_USER_MODEL, + verbose_name="creator", + ), + ), + migrations.AlterField( + model_name="event", + name="rule", + field=models.ForeignKey( + blank=True, + help_text="Select '----' for a one time only event.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="ohq_schedule.Rule", + verbose_name="rule", + ), + ), + ] diff --git a/backend/ohq_schedule/migrations/0013_auto_20210502_2303.py b/backend/ohq_schedule/migrations/0013_auto_20210502_2303.py new file mode 100644 index 00000000..92f4915a --- /dev/null +++ b/backend/ohq_schedule/migrations/0013_auto_20210502_2303.py @@ -0,0 +1,55 @@ +# Generated by Django 3.2 on 2021-05-02 23:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq_schedule", "0012_auto_20191025_1852"), + ] + + operations = [ + migrations.AlterField( + model_name="calendar", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="calendarrelation", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="event", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="eventrelation", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="occurrence", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="rule", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ] diff --git a/backend/ohq_schedule/migrations/0014_use_autofields_for_pk.py b/backend/ohq_schedule/migrations/0014_use_autofields_for_pk.py new file mode 100644 index 00000000..83b5b664 --- /dev/null +++ b/backend/ohq_schedule/migrations/0014_use_autofields_for_pk.py @@ -0,0 +1,53 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq_schedule", "0013_auto_20210502_2303"), + ] + + operations = [ + migrations.AlterField( + model_name="calendar", + name="id", + field=models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="calendarrelation", + name="id", + field=models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="event", + name="id", + field=models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="eventrelation", + name="id", + field=models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="occurrence", + name="id", + field=models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="rule", + name="id", + field=models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ] diff --git a/backend/ohq_schedule/migrations/0015_event_bookable_event_interval_event_location_and_more.py b/backend/ohq_schedule/migrations/0015_event_bookable_event_interval_event_location_and_more.py new file mode 100644 index 00000000..92660fe1 --- /dev/null +++ b/backend/ohq_schedule/migrations/0015_event_bookable_event_interval_event_location_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.0.3 on 2025-04-12 02:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq_schedule", "0014_use_autofields_for_pk"), + ] + + operations = [ + migrations.AddField( + model_name="event", + name="bookable", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="event", + name="interval", + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="event", + name="location", + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name="occurrence", + name="bookable", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="occurrence", + name="interval", + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="occurrence", + name="location", + field=models.CharField(blank=True, max_length=255), + ), + ] diff --git a/backend/ohq_schedule/migrations/0016_alter_calendarrelation_content_type_and_more.py b/backend/ohq_schedule/migrations/0016_alter_calendarrelation_content_type_and_more.py new file mode 100644 index 00000000..8e152fb7 --- /dev/null +++ b/backend/ohq_schedule/migrations/0016_alter_calendarrelation_content_type_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 5.0.3 on 2025-04-13 04:41 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("ohq_schedule", "0015_event_bookable_event_interval_event_location_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name="calendarrelation", + name="content_type", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="ohq_calendarrelation_set", + to="contenttypes.contenttype", + ), + ), + migrations.AlterField( + model_name="event", + name="creator", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="ohq_creator", + to=settings.AUTH_USER_MODEL, + verbose_name="creator", + ), + ), + migrations.AlterField( + model_name="eventrelation", + name="content_type", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="ohq_eventrelation_set", + to="contenttypes.contenttype", + ), + ), + ] diff --git a/backend/ohq_schedule/migrations/__init__.py b/backend/ohq_schedule/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/ohq_schedule/models/__init__.py b/backend/ohq_schedule/models/__init__.py new file mode 100644 index 00000000..d7528f10 --- /dev/null +++ b/backend/ohq_schedule/models/__init__.py @@ -0,0 +1,3 @@ +from ohq_schedule.models.calendars import Calendar, CalendarRelation # noqa +from ohq_schedule.models.events import * # noqa +from ohq_schedule.models.rules import * # noqa diff --git a/backend/ohq_schedule/models/calendars.py b/backend/ohq_schedule/models/calendars.py new file mode 100644 index 00000000..8781b375 --- /dev/null +++ b/backend/ohq_schedule/models/calendars.py @@ -0,0 +1,237 @@ +from django.contrib.contenttypes import fields +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.db.models import Q +from django.template.defaultfilters import slugify +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from ohq_schedule.settings import USE_FULLCALENDAR +from ohq_schedule.utils import EventListManager + + +class CalendarManager(models.Manager): + """ + >>> user1 = User(username='tony') + >>> user1.save() + """ + + def get_calendar_for_object(self, obj, distinction=""): + """ + This function gets a calendar for an object. It should only return one + calendar. If the object has more than one calendar related to it (or + more than one related to it under a distinction if a distinction is + defined) an AssertionError will be raised. If none are returned it will + raise a DoesNotExistError. + + >>> user = User.objects.get(username='tony') + >>> try: + ... Calendar.objects.get_calendar_for_object(user) + ... except Calendar.DoesNotExist: + ... print("failed") + ... + failed + + Now if we add a calendar it should return the calendar + + >>> calendar = Calendar(name='My Cal') + >>> calendar.save() + >>> calendar.create_relation(user) + >>> Calendar.objects.get_calendar_for_object(user) + + + Now if we add one more calendar it should raise an AssertionError + because there is more than one related to it. + + If you would like to get more than one calendar for an object you should + use get_calendars_for_object (see below). + >>> calendar = Calendar(name='My 2nd Cal') + >>> calendar.save() + >>> calendar.create_relation(user) + >>> try: + ... Calendar.objects.get_calendar_for_object(user) + ... except AssertionError: + ... print("failed") + ... + failed + """ + calendar_list = self.get_calendars_for_object(obj, distinction) + if len(calendar_list) == 0: + raise Calendar.DoesNotExist("Calendar does not exist.") + elif len(calendar_list) > 1: + raise AssertionError("More than one calendars were found.") + else: + return calendar_list[0] + + def get_or_create_calendar_for_object(self, obj, distinction="", name=None): + """ + >>> user = User(username="jeremy") + >>> user.save() + >>> calendar = Calendar.objects.get_or_create_calendar_for_object(user, name = "Jeremy's Calendar") + >>> calendar.name + "Jeremy's Calendar" + """ + try: + return self.get_calendar_for_object(obj, distinction) + except Calendar.DoesNotExist: + if name is None: + calendar = self.model(name=str(obj)) + else: + calendar = self.model(name=name) + calendar.slug = slugify(calendar.name) + calendar.save() + calendar.create_relation(obj, distinction) + return calendar + + def get_calendars_for_object(self, obj, distinction=""): + """ + This function allows you to get calendars for a specific object + + If distinction is set it will filter out any relation that doesnt have + that distinction. + """ + ct = ContentType.objects.get_for_model(obj) + if distinction: + dist_q = Q(calendarrelation__distinction=distinction) + else: + dist_q = Q() + return self.filter( + dist_q, + calendarrelation__content_type=ct, + calendarrelation__object_id=obj.id, + ) + + +class Calendar(models.Model): + """ + This is for grouping events so that batch relations can be made to all + events. An example would be a project calendar. + + name: the name of the calendar + events: all the events contained within the calendar. + >>> calendar = Calendar(name = 'Test Calendar') + >>> calendar.save() + >>> data = { + ... 'title': 'Recent Event', + ... 'start': datetime.datetime(2008, 1, 5, 0, 0), + ... 'end': datetime.datetime(2008, 1, 10, 0, 0) + ... } + >>> event = Event(**data) + >>> event.save() + >>> calendar.events.add(event) + >>> data = { + ... 'title': 'Upcoming Event', + ... 'start': datetime.datetime(2008, 1, 1, 0, 0), + ... 'end': datetime.datetime(2008, 1, 4, 0, 0) + ... } + >>> event = Event(**data) + >>> event.save() + >>> calendar.events.add(event) + >>> data = { + ... 'title': 'Current Event', + ... 'start': datetime.datetime(2008, 1, 3), + ... 'end': datetime.datetime(2008, 1, 6) + ... } + >>> event = Event(**data) + >>> event.save() + >>> calendar.events.add(event) + """ + + name = models.CharField(_("name"), max_length=200) + slug = models.SlugField(_("slug"), max_length=200, unique=True) + objects = CalendarManager() + + class Meta: + verbose_name = _("calendar") + verbose_name_plural = _("calendars") + + def __str__(self): + return self.name + + @property + def events(self): + return self.event_set + + def create_relation(self, obj, distinction="", inheritable=True): + """ + Creates a CalendarRelation between self and obj. + + if Inheritable is set to true this relation will cascade to all events + related to this calendar. + """ + CalendarRelation.objects.create_relation(self, obj, distinction, inheritable) + + def get_recent(self, amount=5): + """ + This shortcut function allows you to get events that have started + recently. + + amount is the amount of events you want in the queryset. The default is + 5. + """ + return self.events.order_by("-start").filter(start__lt=timezone.now())[:amount] + + def occurrences_after(self, date=None): + return EventListManager(self.events.all()).occurrences_after(date) + + def get_absolute_url(self): + if USE_FULLCALENDAR: + return reverse("fullcalendar", kwargs={"calendar_slug": self.slug}) + return reverse("calendar_home", kwargs={"calendar_slug": self.slug}) + + +class CalendarRelationManager(models.Manager): + def create_relation( + self, calendar, content_object, distinction="", inheritable=True + ): + """ + Creates a relation between calendar and content_object. + See CalendarRelation for help on distinction and inheritable + """ + return CalendarRelation.objects.create( + calendar=calendar, distinction=distinction, content_object=content_object + ) + + +class CalendarRelation(models.Model): + """ + This is for relating data to a Calendar, and possible all of the events for + that calendar, there is also a distinction, so that the same type or kind of + data can be related in different ways. A good example would be, if you have + calendars that are only visible by certain users, you could create a + relation between calendars and users, with the distinction of 'visibility', + or 'ownership'. If inheritable is set to true, all the events for this + calendar will inherit this relation. + + calendar: a foreign key relation to a Calendar object. + content_type: a foreign key relation to ContentType of the generic object + object_id: the id of the generic object + content_object: the generic foreign key to the generic object + distinction: a string representing a distinction of the relation, User could + have a 'veiwer' relation and an 'owner' relation for example. + inheritable: a boolean that decides if events of the calendar should also + inherit this relation + + DISCLAIMER: while this model is a nice out of the box feature to have, it + may not scale well. If you use this, keep that in mind. + """ + + calendar = models.ForeignKey( + Calendar, on_delete=models.CASCADE, verbose_name=_("calendar") + ) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name="ohq_calendarrelation_set") + object_id = models.IntegerField(db_index=True) + content_object = fields.GenericForeignKey("content_type", "object_id") + distinction = models.CharField(_("distinction"), max_length=20) + inheritable = models.BooleanField(_("inheritable"), default=True) + + objects = CalendarRelationManager() + + class Meta: + verbose_name = _("calendar relation") + verbose_name_plural = _("calendar relations") + index_together = [("content_type", "object_id")] + + def __str__(self): + return "{} - {}".format(self.calendar, self.content_object) diff --git a/backend/ohq_schedule/models/events.py b/backend/ohq_schedule/models/events.py new file mode 100644 index 00000000..f4bed974 --- /dev/null +++ b/backend/ohq_schedule/models/events.py @@ -0,0 +1,759 @@ +import datetime + +import pytz +from dateutil import rrule +from django.conf import settings as django_settings +from django.contrib.contenttypes import fields +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import Q +from django.template.defaultfilters import date +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext, gettext_lazy as _ + +from ohq_schedule.models.calendars import Calendar +from ohq_schedule.models.rules import Rule +from ohq_schedule.utils import OccurrenceReplacer + +freq_dict_order = { + "YEARLY": 0, + "MONTHLY": 1, + "WEEKLY": 2, + "DAILY": 3, + "HOURLY": 4, + "MINUTELY": 5, + "SECONDLY": 6, +} +param_dict_order = { + "byyearday": 1, + "bymonth": 1, + "bymonthday": 2, + "byweekno": 2, + "byweekday": 3, + "byhour": 4, + "byminute": 5, + "bysecond": 6, +} + + +class EventManager(models.Manager): + def get_for_object(self, content_object, distinction="", inherit=True): + return EventRelation.objects.get_events_for_object( + content_object, distinction, inherit + ) + + +class Event(models.Model): + """ + This model stores meta data for a date. You can relate this data to many + other models. + """ + + start = models.DateTimeField(_("start"), db_index=True) + end = models.DateTimeField( + _("end"), + db_index=True, + help_text=_("The end time must be later than the start time."), + ) + title = models.CharField(_("title"), max_length=255) + description = models.TextField(_("description"), blank=True) + creator = models.ForeignKey( + django_settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name=_("creator"), + related_name="ohq_creator", + ) + created_on = models.DateTimeField(_("created on"), auto_now_add=True) + updated_on = models.DateTimeField(_("updated on"), auto_now=True) + rule = models.ForeignKey( + Rule, + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name=_("rule"), + help_text=_("Select '----' for a one time only event."), + ) + end_recurring_period = models.DateTimeField( + _("end recurring period"), + null=True, + blank=True, + db_index=True, + help_text=_("This date is ignored for one time only events."), + ) + calendar = models.ForeignKey( + Calendar, on_delete=models.CASCADE, verbose_name=_("calendar") + ) + color_event = models.CharField(_("Color event"), blank=True, max_length=10) + location = models.CharField(max_length=255, blank=True) + interval = models.IntegerField(blank=True, null=True) + bookable = models.BooleanField(default=False) + objects = EventManager() + + class Meta: + verbose_name = _("event") + verbose_name_plural = _("events") + index_together = (("start", "end"),) + + def __str__(self): + return gettext("%(title)s: %(start)s - %(end)s") % { + "title": self.title, + "start": date(self.start, django_settings.DATE_FORMAT), + "end": date(self.end, django_settings.DATE_FORMAT), + } + + def clean(self): + super().clean() + if self.interval is not None and not self.bookable: + raise ValidationError({'interval': _('Interval can only be set when bookable is True')}) + if self.bookable and self.interval is None: + raise ValidationError({'bookable': _('Bookable events must have an interval set')}) + + def save(self, *args, **kwargs): + if self.pk is not None: + original_event = Event.objects.get(pk=self.pk) + + if original_event.interval != self.interval or original_event.bookable != self.bookable: + self.clean() + super().save(*args, **kwargs) + self.occurrence_set.all().update( + interval=self.interval, + bookable=self.bookable + ) + return + self.clean() + super().save(*args, **kwargs) + + @property + def seconds(self): + return (self.end - self.start).total_seconds() + + @property + def minutes(self): + return float(self.seconds) / 60 + + @property + def hours(self): + return float(self.seconds) / 3600 + + def get_absolute_url(self): + return reverse("event", args=[self.id]) + + def get_occurrences(self, start, end, clear_prefetch=True): + """ + >>> rule = Rule(frequency = "MONTHLY", name = "Monthly") + >>> rule.save() + >>> event = Event(rule=rule, start=datetime.datetime(2008,1,1,tzinfo=pytz.utc), end=datetime.datetime(2008,1,2)) + >>> event.rule + + >>> occurrences = event.get_occurrences(datetime.datetime(2008,1,24), datetime.datetime(2008,3,2)) + >>> ["%s to %s" %(o.start, o.end) for o in occurrences] + ['2008-02-01 00:00:00+00:00 to 2008-02-02 00:00:00+00:00', '2008-03-01 00:00:00+00:00 to 2008-03-02 00:00:00+00:00'] + + Ensure that if an event has no rule, that it appears only once. + + >>> event = Event(start=datetime.datetime(2008,1,1,8,0), end=datetime.datetime(2008,1,1,9,0)) + >>> occurrences = event.get_occurrences(datetime.datetime(2008,1,24), datetime.datetime(2008,3,2)) + >>> ["%s to %s" %(o.start, o.end) for o in occurrences] + [] + """ + + # Explanation of clear_prefetch: + # + # Periods, and their subclasses like Week, call + # prefetch_related('occurrence_set') on all events in their + # purview. This reduces the database queries they make from + # len()+1 to 2. However, having a cached occurrence_set on the + # Event model instance can sometimes cause Events to have a + # different view of the state of occurrences than the Period + # managing them. + # + # E.g., if you create an unsaved occurrence, move it to a + # different time [which saves the event], keep a reference to + # the moved occurrence, & refetch all occurrences from the + # Period without clearing the prefetch cache, you'll end up + # with two Occurrences for the same event but different moved + # states. It's a complicated scenario, but can happen. (See + # tests/test_occurrence.py#test_moved_occurrences, which caught + # this bug in the first place.) + # + # To prevent this, we clear the select_related cache by default + # before we call an event's get_occurrences, but allow Period + # to override this cache clear since it already fetches all + # occurrence_sets via prefetch_related in its get_occurrences. + if clear_prefetch: + self.refresh_from_db() + + persisted_occurrences = self.occurrence_set.all() + occ_replacer = OccurrenceReplacer(persisted_occurrences) + occurrences = self._get_occurrence_list(start, end) + final_occurrences = [] + for occ in occurrences: + # replace occurrences with their persisted counterparts + if occ_replacer.has_occurrence(occ): + p_occ = occ_replacer.get_occurrence(occ) + # ...but only if they are within this period + if p_occ.start < end and p_occ.end >= start: + final_occurrences.append(p_occ) + else: + final_occurrences.append(occ) + # then add persisted occurrences which originated outside of this period but now + # fall within it + final_occurrences += occ_replacer.get_additional_occurrences(start, end) + return final_occurrences + + def get_rrule_object(self, tzinfo): + if self.rule is None: + return + params = self._event_params() + frequency = self.rule.rrule_frequency() + if timezone.is_naive(self.start): + dtstart = self.start + else: + dtstart = self.start.astimezone(tzinfo).replace(tzinfo=None) + + if self.end_recurring_period is None: + until = None + elif timezone.is_naive(self.end_recurring_period): + until = self.end_recurring_period + else: + until = self.end_recurring_period.astimezone(tzinfo).replace(tzinfo=None) + + return rrule.rrule(frequency, dtstart=dtstart, until=until, **params) + + def _create_occurrence(self, start, end=None): + if end is None: + end = start + (self.end - self.start) + return Occurrence( + event=self, start=start, end=end, original_start=start, original_end=end, interval=self.interval, bookable=self.bookable + ) + + def get_occurrence(self, date): + use_naive = timezone.is_naive(date) + tzinfo = datetime.timezone.utc + if timezone.is_naive(date): + date = timezone.make_aware(date, tzinfo) + if date.tzinfo: + tzinfo = date.tzinfo + rule = self.get_rrule_object(tzinfo) + if rule: + next_occurrence = rule.after( + date.astimezone(tzinfo).replace(tzinfo=None), inc=True + ) + next_occurrence = pytz.timezone(str(tzinfo)).localize(next_occurrence) + else: + next_occurrence = self.start + if next_occurrence == date: + try: + return Occurrence.objects.get(event=self, original_start=date) + except Occurrence.DoesNotExist: + if use_naive: + next_occurrence = timezone.make_naive(next_occurrence, tzinfo) + return self._create_occurrence(next_occurrence) + + def _get_occurrence_list(self, start, end): + """ + Returns a list of occurrences that fall completely or partially inside + the timespan defined by start (inclusive) and end (exclusive) + """ + if self.rule is not None: + duration = self.end - self.start + use_naive = timezone.is_naive(start) + + # Use the timezone from the start date + tzinfo = datetime.timezone.utc + if start.tzinfo: + tzinfo = start.tzinfo + + # Limit timespan to recurring period + occurrences = [] + if self.end_recurring_period and self.end_recurring_period < end: + end = self.end_recurring_period + + start_rule = self.get_rrule_object(tzinfo) + start = start.replace(tzinfo=None) + if timezone.is_aware(end): + end = end.astimezone(tzinfo).replace(tzinfo=None) + + o_starts = [] + + # Occurrences that start before the timespan but ends inside or after timespan + closest_start = start_rule.before(start, inc=False) + if closest_start is not None and closest_start + duration > start: + o_starts.append(closest_start) + + # Occurrences starts that happen inside timespan (end-inclusive) + occs = start_rule.between(start, end, inc=True) + # The occurrence that start on the end of the timespan is potentially + # included above, lets remove if thats the case. + if len(occs) > 0: + if occs[-1] == end: + occs.pop() + # Add the occurrences found inside timespan + o_starts.extend(occs) + + # Create the Occurrence objects for the found start dates + for o_start in o_starts: + o_start = pytz.timezone(str(tzinfo)).localize(o_start) + if use_naive: + o_start = timezone.make_naive(o_start, tzinfo) + o_end = o_start + duration + occurrence = self._create_occurrence(o_start, o_end) + if occurrence not in occurrences: + occurrences.append(occurrence) + return occurrences + else: + # check if event is in the period + if self.start < end and self.end > start: + return [self._create_occurrence(self.start)] + else: + return [] + + def _occurrences_after_generator(self, after=None): + """ + returns a generator that produces unpresisted occurrences after the + datetime ``after``. (Optionally) This generator will return up to + ``max_occurrences`` occurrences or has reached ``self.end_recurring_period``, whichever is smallest. + """ + + tzinfo = datetime.timezone.utc + if after is None: + after = timezone.now() + elif not timezone.is_naive(after): + tzinfo = after.tzinfo + rule = self.get_rrule_object(tzinfo) + if rule is None: + if self.end > after: + yield self._create_occurrence(self.start, self.end) + return + date_iter = iter(rule) + difference = self.end - self.start + loop_counter = 0 + for o_start in date_iter: + o_start = pytz.timezone(str(tzinfo)).localize(o_start) + o_end = o_start + difference + if o_end > after: + yield self._create_occurrence(o_start, o_end) + + loop_counter += 1 + + def occurrences_after(self, after=None, max_occurrences=None): + """ + returns a generator that produces occurrences after the datetime + ``after``. Includes all of the persisted Occurrences. (Optionally) This generator will return up to + ``max_occurrences`` occurrences or has reached ``self.end_recurring_period``, whichever is smallest. + """ + if after is None: + after = timezone.now() + occ_replacer = OccurrenceReplacer(self.occurrence_set.all()) + generator = self._occurrences_after_generator(after) + trickies = list( + self.occurrence_set.filter( + original_start__lte=after, start__gte=after + ).order_by("start") + ) + for index, nxt in enumerate(generator): + if max_occurrences and index > max_occurrences - 1: + break + if len(trickies) > 0 and (nxt is None or nxt.start > trickies[0].start): + yield trickies.pop(0) + yield occ_replacer.get_occurrence(nxt) + + @property + def event_start_params(self): + start = self.start + params = { + "byyearday": start.timetuple().tm_yday, + "bymonth": start.month, + "bymonthday": start.day, + "byweekno": start.isocalendar()[1], + "byweekday": start.weekday(), + "byhour": start.hour, + "byminute": start.minute, + "bysecond": start.second, + } + return params + + @property + def event_rule_params(self): + return self.rule.get_params() + + def _event_params(self): + freq_order = freq_dict_order[self.rule.frequency] + rule_params = self.event_rule_params + start_params = self.event_start_params + event_params = {} + + if len(rule_params) == 0: + return event_params + + for param in rule_params: + # start date influences rule params + if ( + param in param_dict_order + and param_dict_order[param] > freq_order + and param in start_params + ): + sp = start_params[param] + if sp == rule_params[param] or ( + hasattr(rule_params[param], "__iter__") and sp in rule_params[param] + ): + event_params[param] = [sp] + else: + event_params[param] = rule_params[param] + else: + event_params[param] = rule_params[param] + + return event_params + + @property + def event_params(self): + event_params = self._event_params() + start = self.effective_start + empty = False + if not start: + empty = True + elif self.end_recurring_period and start > self.end_recurring_period: + empty = True + return event_params, empty + + @property + def effective_start(self): + if self.pk and self.end_recurring_period: + occ_generator = self._occurrences_after_generator(self.start) + try: + return next(occ_generator).start + except StopIteration: + pass + elif self.pk: + return self.start + return None + + @property + def effective_end(self): + if self.pk and self.end_recurring_period: + params, empty = self.event_params + if empty or not self.effective_start: + return None + elif self.end_recurring_period: + occ = None + occ_generator = self._occurrences_after_generator(self.start) + for occ in occ_generator: + pass + return occ.end + elif self.pk: + return datetime.datetime.max + return None + + +class EventRelationManager(models.Manager): + """ + >>> import datetime + >>> EventRelation.objects.all().delete() + >>> CalendarRelation.objects.all().delete() + >>> data = { + ... 'title': 'Test1', + ... 'start': datetime.datetime(2008, 1, 1), + ... 'end': datetime.datetime(2008, 1, 11) + ... } + >>> Event.objects.all().delete() + >>> event1 = Event(**data) + >>> event1.save() + >>> data['title'] = 'Test2' + >>> event2 = Event(**data) + >>> event2.save() + >>> user1 = User(username='alice') + >>> user1.save() + >>> user2 = User(username='bob') + >>> user2.save() + >>> event1.create_relation(user1, 'owner') + >>> event1.create_relation(user2, 'viewer') + >>> event2.create_relation(user1, 'viewer') + """ + + # Currently not supported + # Multiple level reverse lookups of generic relations appears to be + # unsupported in Django, which makes sense. + # + # def get_objects_for_event(self, event, model, distinction=None): + # ''' + # returns a queryset full of instances of model, if it has an EventRelation + # with event, and distinction + # >>> event = Event.objects.get(title='Test1') + # >>> EventRelation.objects.get_objects_for_event(event, User, 'owner') + # [] + # >>> EventRelation.objects.get_objects_for_event(event, User) + # [, ] + # ''' + # if distinction: + # dist_q = Q(eventrelation__distinction = distinction) + # else: + # dist_q = Q() + # ct = ContentType.objects.get_for_model(model) + # return model.objects.filter( + # dist_q, + # eventrelation__content_type = ct, + # eventrelation__event = event + # ) + + def get_events_for_object(self, content_object, distinction="", inherit=True): + """ + returns a queryset full of events, that relate to the object through, the + distinction + + If inherit is false it will not consider the calendars that the events + belong to. If inherit is true it will inherit all of the relations and + distinctions that any calendar that it belongs to has, as long as the + relation has inheritable set to True. (See Calendar) + + >>> event = Event.objects.get(title='Test1') + >>> user = User.objects.get(username = 'alice') + >>> EventRelation.objects.get_events_for_object(user, 'owner', inherit=False) + [] + + If a distinction is not declared it will not vet the relations based on + distinction. + >>> EventRelation.objects.get_events_for_object(user, inherit=False) + [, ] + + Now if there is a Calendar + >>> calendar = Calendar(name = 'MyProject') + >>> calendar.save() + + And an event that belongs to that calendar + >>> event = Event.objects.get(title='Test2') + >>> calendar.events.add(event) + + If we relate this calendar to some object with inheritable set to true, + that relation will be inherited + >>> user = User.objects.get(username='bob') + >>> cr = calendar.create_relation(user, 'viewer', True) + >>> EventRelation.objects.get_events_for_object(user, 'viewer') + [, ] + """ + ct = ContentType.objects.get_for_model(type(content_object)) + if distinction: + dist_q = Q(eventrelation__distinction=distinction) + cal_dist_q = Q(calendar__calendarrelation__distinction=distinction) + else: + dist_q = Q() + cal_dist_q = Q() + if inherit: + inherit_q = Q( + cal_dist_q, + calendar__calendarrelation__content_type=ct, + calendar__calendarrelation__object_id=content_object.id, + calendar__calendarrelation__inheritable=True, + ) + else: + inherit_q = Q() + event_q = Q( + dist_q, + eventrelation__content_type=ct, + eventrelation__object_id=content_object.id, + ) + return Event.objects.filter(inherit_q | event_q) + + def create_relation(self, event, content_object, distinction=""): + """ + Creates a relation between event and content_object. + See EventRelation for help on distinction. + """ + return EventRelation.objects.create( + event=event, distinction=distinction, content_object=content_object + ) + + +class EventRelation(models.Model): + """ + This is for relating data to an Event, there is also a distinction, so that + data can be related in different ways. A good example would be, if you have + events that are only visible by certain users, you could create a relation + between events and users, with the distinction of 'visibility', or + 'ownership'. + + event: a foreign key relation to an Event model. + content_type: a foreign key relation to ContentType of the generic object + object_id: the id of the generic object + content_object: the generic foreign key to the generic object + distinction: a string representing a distinction of the relation, User could + have a 'viewer' relation and an 'owner' relation for example. + + DISCLAIMER: while this model is a nice out of the box feature to have, it + may not scale well. If you use this keep that in mind. + """ + + event = models.ForeignKey(Event, on_delete=models.CASCADE, verbose_name=_("event")) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name="ohq_eventrelation_set") + object_id = models.IntegerField(db_index=True) + content_object = fields.GenericForeignKey("content_type", "object_id") + distinction = models.CharField(_("distinction"), max_length=20) + + objects = EventRelationManager() + + class Meta: + verbose_name = _("event relation") + verbose_name_plural = _("event relations") + index_together = [("content_type", "object_id")] + + def __str__(self): + return "{}({})-{}".format( + self.event.title, self.distinction, self.content_object + ) + + +class Occurrence(models.Model): + event = models.ForeignKey(Event, on_delete=models.CASCADE, verbose_name=_("event")) + title = models.CharField(_("title"), max_length=255, blank=True) + description = models.TextField(_("description"), blank=True) + start = models.DateTimeField(_("start"), db_index=True) + end = models.DateTimeField(_("end"), db_index=True) + cancelled = models.BooleanField(_("cancelled"), default=False) + original_start = models.DateTimeField(_("original start")) + original_end = models.DateTimeField(_("original end")) + created_on = models.DateTimeField(_("created on"), auto_now_add=True) + updated_on = models.DateTimeField(_("updated on"), auto_now=True) + location = models.CharField(max_length=255, blank=True) + interval = models.IntegerField(blank=True, null=True) + bookable = models.BooleanField(default=False) + + class Meta: + verbose_name = _("occurrence") + verbose_name_plural = _("occurrences") + index_together = (("start", "end"),) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + event = kwargs.get("event", None) + if not self.title and event: + self.title = event.title + if not self.description and event: + self.description = event.description + if not self.location and event: + self.location = event.location + if not self.interval and event: + self.interval = event.interval + if not self.bookable and event: + self.bookable = event.bookable + + def clean(self): + super().clean() + if self.interval is not None and not self.bookable: + raise ValidationError({'interval': _('Interval can only be set when bookable is True')}) + if self.bookable and self.interval is None: + raise ValidationError({'bookable': _('Bookable occurrences must have an interval set')}) + + def save(self, *args, **kwargs): + self.clean() + super().save(*args, **kwargs) + + def moved(self): + return self.original_start != self.start or self.original_end != self.end + + moved = property(moved) + + def move(self, new_start, new_end): + self.start = new_start + self.end = new_end + self.save() + + def cancel(self): + self.cancelled = True + self.save() + + def uncancel(self): + self.cancelled = False + self.save() + + @property + def seconds(self): + return (self.end - self.start).total_seconds() + + @property + def minutes(self): + return float(self.seconds) / 60 + + @property + def hours(self): + return float(self.seconds) / 3600 + + def get_absolute_url(self): + if self.pk is not None: + return reverse( + "occurrence", + kwargs={"occurrence_id": self.pk, "event_id": self.event_id}, + ) + return reverse( + "occurrence_by_date", + kwargs={ + "event_id": self.event_id, + "year": self.start.year, + "month": self.start.month, + "day": self.start.day, + "hour": self.start.hour, + "minute": self.start.minute, + "second": self.start.second, + }, + ) + + def get_cancel_url(self): + if self.pk is not None: + return reverse( + "cancel_occurrence", + kwargs={"occurrence_id": self.pk, "event_id": self.event_id}, + ) + return reverse( + "cancel_occurrence_by_date", + kwargs={ + "event_id": self.event_id, + "year": self.start.year, + "month": self.start.month, + "day": self.start.day, + "hour": self.start.hour, + "minute": self.start.minute, + "second": self.start.second, + }, + ) + + def get_edit_url(self): + if self.pk is not None: + return reverse( + "edit_occurrence", + kwargs={"occurrence_id": self.pk, "event_id": self.event_id}, + ) + return reverse( + "edit_occurrence_by_date", + kwargs={ + "event_id": self.event_id, + "year": self.start.year, + "month": self.start.month, + "day": self.start.day, + "hour": self.start.hour, + "minute": self.start.minute, + "second": self.start.second, + }, + ) + + def __str__(self): + return gettext("%(start)s to %(end)s") % { + "start": date(self.start, django_settings.DATE_FORMAT), + "end": date(self.end, django_settings.DATE_FORMAT), + } + + def __lt__(self, other): + return self.end < other.end + + def __hash__(self): + if not self.pk: + raise TypeError("Model instances without primary key value are unhashable") + return hash(self.pk) + + def __eq__(self, other): + return ( + isinstance(other, Occurrence) + and self.original_start == other.original_start + and self.original_end == other.original_end + ) diff --git a/backend/ohq_schedule/models/rules.py b/backend/ohq_schedule/models/rules.py new file mode 100644 index 00000000..dc09b542 --- /dev/null +++ b/backend/ohq_schedule/models/rules.py @@ -0,0 +1,126 @@ +from dateutil.rrule import ( + DAILY, + FR, + HOURLY, + MINUTELY, + MO, + MONTHLY, + SA, + SECONDLY, + SU, + TH, + TU, + WE, + WEEKLY, + YEARLY, +) +from django.db import models +from django.utils.translation import gettext_lazy as _ + +freqs = ( + ("YEARLY", _("Yearly")), + ("MONTHLY", _("Monthly")), + ("WEEKLY", _("Weekly")), + ("DAILY", _("Daily")), + ("HOURLY", _("Hourly")), + ("MINUTELY", _("Minutely")), + ("SECONDLY", _("Secondly")), +) + + +class Rule(models.Model): + """ + This defines a rule by which an event will recur. This is defined by the + rrule in the dateutil documentation. + + * name - the human friendly name of this kind of recursion. + * description - a short description describing this type of recursion. + * frequency - the base recurrence period + * param - extra params required to define this type of recursion. The params + should follow this format: + + param = [rruleparam:value;]* + rruleparam = see list below + value = int[,int]* + + The options are: (documentation for these can be found at + https://dateutil.readthedocs.io/en/stable/rrule.html#module-dateutil.rrule + ** count + ** bysetpos + ** bymonth + ** bymonthday + ** byyearday + ** byweekno + ** byweekday + ** byhour + ** byminute + ** bysecond + ** byeaster + """ + + name = models.CharField(_("name"), max_length=32) + description = models.TextField(_("description")) + frequency = models.CharField(_("frequency"), choices=freqs, max_length=10) + params = models.TextField(_("params"), blank=True) + + _week_days = {"MO": MO, "TU": TU, "WE": WE, "TH": TH, "FR": FR, "SA": SA, "SU": SU} + + class Meta: + verbose_name = _("rule") + verbose_name_plural = _("rules") + + def rrule_frequency(self): + compatibility_dict = { + "DAILY": DAILY, + "MONTHLY": MONTHLY, + "WEEKLY": WEEKLY, + "YEARLY": YEARLY, + "HOURLY": HOURLY, + "MINUTELY": MINUTELY, + "SECONDLY": SECONDLY, + } + return compatibility_dict[self.frequency] + + def _weekday_or_number(self, param): + """ + Receives a rrule parameter value, returns a upper case version + of the value if its a weekday or an integer if its a number + """ + try: + return int(param) + except (TypeError, ValueError): + uparam = str(param).upper() + if uparam in Rule._week_days: + return Rule._week_days[uparam] + + def get_params(self): + """ + >>> rule = Rule(params = "count:1;bysecond:1;byminute:1,2,4,5") + >>> rule.get_params() + {'count': 1, 'byminute': [1, 2, 4, 5], 'bysecond': 1} + """ + params = self.params.split(";") + param_dict = [] + for param in params: + param = param.split(":") + if len(param) != 2: + continue + + param = ( + str(param[0]).lower(), + [ + x + for x in [self._weekday_or_number(v) for v in param[1].split(",")] + if x is not None + ], + ) + + if len(param[1]) == 1: + param_value = self._weekday_or_number(param[1][0]) + param = (param[0], param_value) + param_dict.append(param) + return dict(param_dict) + + def __str__(self): + """Human readable string for Rule""" + return "Rule {} params {}".format(self.name, self.params) diff --git a/backend/ohq_schedule/periods.py b/backend/ohq_schedule/periods.py new file mode 100644 index 00000000..af186b7d --- /dev/null +++ b/backend/ohq_schedule/periods.py @@ -0,0 +1,494 @@ +import calendar as standardlib_calendar +import datetime + +import pytz +from django.conf import settings +from django.db.models.query import prefetch_related_objects +from django.template.defaultfilters import date as date_filter +from django.utils import timezone +from django.utils.dates import WEEKDAYS, WEEKDAYS_ABBR +from django.utils.translation import gettext + +from ohq_schedule.models import Occurrence +from ohq_schedule.settings import SHOW_CANCELLED_OCCURRENCES + +weekday_names = [] +weekday_abbrs = [] + +if settings.FIRST_DAY_OF_WEEK == 1: + # The calendar week starts on Monday + for i in range(7): + weekday_names.append(WEEKDAYS[i]) + weekday_abbrs.append(WEEKDAYS_ABBR[i]) +else: + # The calendar week starts on Sunday, not Monday + weekday_names.append(WEEKDAYS[6]) + weekday_abbrs.append(WEEKDAYS_ABBR[6]) + for i in range(6): + weekday_names.append(WEEKDAYS[i]) + weekday_abbrs.append(WEEKDAYS_ABBR[i]) + + +class Period: + """ + This class represents a period of time. It can return a set of occurrences + based on its events, and its time period (start and end). + """ + + def __init__( + self, + events, + start, + end, + parent_persisted_occurrences=None, + occurrence_pool=None, + tzinfo=pytz.utc, + sorting_options=None, + ): + + self.utc_start = self._normalize_timezone_to_utc(start, tzinfo) + + self.utc_end = self._normalize_timezone_to_utc(end, tzinfo) + + self.events = events + self.tzinfo = self._get_tzinfo(tzinfo) + self.occurrence_pool = occurrence_pool + if parent_persisted_occurrences is not None: + self._persisted_occurrences = parent_persisted_occurrences + self.sorting_options = sorting_options or {} + + def _normalize_timezone_to_utc(self, point_in_time, tzinfo): + if point_in_time.tzinfo is not None: + return point_in_time.astimezone(pytz.utc) + if tzinfo is not None: + return pytz.timezone(str(tzinfo)).localize(point_in_time) + if settings.USE_TZ: + return pytz.utc.localize(point_in_time) + else: + if timezone.is_aware(point_in_time): + return timezone.make_naive(point_in_time, pytz.utc) + else: + return point_in_time + + def __eq__(self, period): + return ( + self.utc_start == period.utc_start + and self.utc_end == period.utc_end + and self.events == period.events + ) + + def _get_tzinfo(self, tzinfo): + return tzinfo if settings.USE_TZ else None + + def _get_sorted_occurrences(self): + occurrences = [] + if hasattr(self, "occurrence_pool") and self.occurrence_pool is not None: + for occurrence in self.occurrence_pool: + if ( + occurrence.start <= self.utc_end + and occurrence.end >= self.utc_start + ): + occurrences.append(occurrence) + else: + prefetch_related_objects(self.events, "occurrence_set") + for event in self.events: + event_occurrences = event.get_occurrences( + self.start, self.end, clear_prefetch=False + ) + occurrences += event_occurrences + return sorted(occurrences, **self.sorting_options) + + def cached_get_sorted_occurrences(self): + if hasattr(self, "_occurrences"): + return self._occurrences + occs = self._get_sorted_occurrences() + self._occurrences = occs + return occs + + occurrences = property(cached_get_sorted_occurrences) + + def get_persisted_occurrences(self): + if hasattr(self, "_persisted_occurrences"): + return self._persisted_occurrences + else: + self._persisted_occurrences = Occurrence.objects.filter( + event__in=self.events + ) + return self._persisted_occurrences + + def classify_occurrence(self, occurrence): + if occurrence.cancelled and not SHOW_CANCELLED_OCCURRENCES: + return + if occurrence.start > self.end or occurrence.end < self.start: + return None + started = False + ended = False + if self.utc_start <= occurrence.start < self.utc_end: + started = True + if self.utc_start <= occurrence.end < self.utc_end: + ended = True + if started and ended: + return {"occurrence": occurrence, "class": 1} + elif started: + return {"occurrence": occurrence, "class": 0} + elif ended: + return {"occurrence": occurrence, "class": 3} + # it existed during this period but it didn't begin or end within it + # so it must have just continued + return {"occurrence": occurrence, "class": 2} + + def get_occurrence_partials(self): + occurrence_dicts = [] + for occurrence in self.occurrences: + occurrence = self.classify_occurrence(occurrence) + if occurrence: + occurrence_dicts.append(occurrence) + return occurrence_dicts + + def get_occurrences(self): + return self.occurrences + + def has_occurrences(self): + return any(self.classify_occurrence(o) for o in self.occurrences) + + def get_time_slot(self, start, end): + if start >= self.start and end <= self.end: + return Period(self.events, start, end, tzinfo=self.tzinfo) + return Period([], start, end, tzinfo=self.tzinfo) + + def create_sub_period(self, cls, start=None, tzinfo=None): + if tzinfo is None: + tzinfo = self.tzinfo + start = start or self.start + return cls( + self.events, + start, + self.get_persisted_occurrences(), + self.occurrences, + tzinfo, + ) + + def get_periods(self, cls, tzinfo=None): + if tzinfo is None: + tzinfo = self.tzinfo + period = self.create_sub_period(cls) + while period.start < self.end: + yield self.create_sub_period(cls, period.start, tzinfo) + period = next(period) + + @property + def start(self): + if self.tzinfo is not None: + return self.utc_start.astimezone(self.tzinfo) + return self.utc_start.replace(tzinfo=None) + + @property + def end(self): + if self.tzinfo is not None: + return self.utc_end.astimezone(self.tzinfo) + return self.utc_end.replace(tzinfo=None) + + +class Year(Period): + def __init__( + self, events, date=None, parent_persisted_occurrences=None, tzinfo=pytz.utc + ): + self.tzinfo = self._get_tzinfo(tzinfo) + if date is None: + date = timezone.now() + start, end = self._get_year_range(date) + super().__init__( + events, start, end, parent_persisted_occurrences, tzinfo=tzinfo + ) + + def get_months(self): + return self.get_periods(Month) + + def next_year(self): + return Year(self.events, self.end, tzinfo=self.tzinfo) + + next = __next__ = next_year + + def prev_year(self): + start = datetime.datetime(self.start.year - 1, self.start.month, self.start.day) + return Year(self.events, start, tzinfo=self.tzinfo) + + prev = prev_year + + def _get_year_range(self, year): + # If tzinfo is not none get the local start of the year and convert it to utc. + naive_start = datetime.datetime( + year.year, datetime.datetime.min.month, datetime.datetime.min.day + ) + naive_end = datetime.datetime( + year.year + 1, datetime.datetime.min.month, datetime.datetime.min.day + ) + + start = naive_start + end = naive_end + if self.tzinfo is not None: + local_start = pytz.timezone(str(self.tzinfo)).localize(naive_start) + local_end = pytz.timezone(str(self.tzinfo)).localize(naive_end) + start = local_start.astimezone(pytz.utc) + end = local_end.astimezone(pytz.utc) + + return start, end + + def __str__(self): + return self.start.year + + +class Month(Period): + """ + The month period has functions for retrieving the week periods within this period + and day periods within the date. + """ + + def __init__( + self, + events, + date=None, + parent_persisted_occurrences=None, + occurrence_pool=None, + tzinfo=pytz.utc, + ): + self.tzinfo = self._get_tzinfo(tzinfo) + if date is None: + date = timezone.now() + start, end = self._get_month_range(date) + super().__init__( + events, + start, + end, + parent_persisted_occurrences, + occurrence_pool, + tzinfo=tzinfo, + ) + + def get_weeks(self): + return self.get_periods(Week) + + def get_days(self): + return self.get_periods(Day) + + def get_day(self, daynumber): + date = self.start + if daynumber > 1: + date += datetime.timedelta(days=daynumber - 1) + return self.create_sub_period(Day, date) + + def next_month(self): + return Month(self.events, self.end, tzinfo=self.tzinfo) + + next = __next__ = next_month + + def prev_month(self): + start = (self.start - datetime.timedelta(days=1)).replace( + day=1, tzinfo=self.tzinfo + ) + return Month(self.events, start, tzinfo=self.tzinfo) + + prev = prev_month + + def current_year(self): + return Year(self.events, self.start, tzinfo=self.tzinfo) + + def prev_year(self): + start = datetime.datetime.min.replace( + year=self.start.year - 1, tzinfo=self.tzinfo + ) + return Year(self.events, start, tzinfo=self.tzinfo) + + def next_year(self): + start = datetime.datetime.min.replace( + year=self.start.year + 1, tzinfo=self.tzinfo + ) + return Year(self.events, start, tzinfo=self.tzinfo) + + def _get_month_range(self, month): + year = month.year + month = month.month + # If tzinfo is not none get the local start of the month and convert it to utc. + naive_start = datetime.datetime.min.replace(year=year, month=month) + if month == 12: + naive_end = datetime.datetime.min.replace(month=1, year=year + 1, day=1) + else: + naive_end = datetime.datetime.min.replace(month=month + 1, year=year, day=1) + + start = naive_start + end = naive_end + if self.tzinfo is not None: + local_start = pytz.timezone(str(self.tzinfo)).localize(naive_start) + local_end = pytz.timezone(str(self.tzinfo)).localize(naive_end) + start = local_start.astimezone(pytz.utc) + end = local_end.astimezone(pytz.utc) + + return start, end + + def __str__(self): + return self.name() + + def name(self): + return standardlib_calendar.month_name[self.start.month] + + def year(self): + return self.start.year + + +class Week(Period): + """ + The Week period that has functions for retrieving Day periods within it + """ + + def __init__( + self, + events, + date=None, + parent_persisted_occurrences=None, + occurrence_pool=None, + tzinfo=pytz.utc, + ): + self.tzinfo = self._get_tzinfo(tzinfo) + if date is None: + date = timezone.now() + start, end = self._get_week_range(date) + super().__init__( + events, + start, + end, + parent_persisted_occurrences, + occurrence_pool, + tzinfo=tzinfo, + ) + + def prev_week(self): + return Week( + self.events, self.start - datetime.timedelta(days=7), tzinfo=self.tzinfo + ) + + prev = prev_week + + def next_week(self): + return Week(self.events, self.end, tzinfo=self.tzinfo) + + next = __next__ = next_week + + def current_month(self): + return Month(self.events, self.start, tzinfo=self.tzinfo) + + def current_year(self): + return Year(self.events, self.start, tzinfo=self.tzinfo) + + def get_days(self): + return self.get_periods(Day) + + def _get_week_range(self, week): + if isinstance(week, datetime.datetime): + week = week.date() + # Adjust the start datetime to midnight of the week datetime + naive_start = datetime.datetime.combine(week, datetime.time.min) + # Adjust the start datetime to Monday or Sunday of the current week + if settings.FIRST_DAY_OF_WEEK == 1: + # The week begins on Monday + sub_days = naive_start.isoweekday() - 1 + else: + # The week begins on Sunday + sub_days = naive_start.isoweekday() + if sub_days == 7: + sub_days = 0 + if sub_days > 0: + naive_start = naive_start - datetime.timedelta(days=sub_days) + naive_end = naive_start + datetime.timedelta(days=7) + + if self.tzinfo is not None: + local_start = pytz.timezone(str(self.tzinfo)).localize(naive_start) + local_end = pytz.timezone(str(self.tzinfo)).localize(naive_end) + start = local_start.astimezone(pytz.utc) + end = local_end.astimezone(pytz.utc) + else: + start = naive_start + end = naive_end + + return start, end + + def __str__(self): + date_format = "l, %s" % settings.DATE_FORMAT + return gettext("Week: %(start)s-%(end)s") % { + "start": date_filter(self.start, date_format), + "end": date_filter(self.end, date_format), + } + + +class Day(Period): + def __init__( + self, + events, + date=None, + parent_persisted_occurrences=None, + occurrence_pool=None, + tzinfo=pytz.utc, + ): + self.tzinfo = self._get_tzinfo(tzinfo) + if date is None: + date = timezone.now() + start, end = self._get_day_range(date) + super().__init__( + events, + start, + end, + parent_persisted_occurrences, + occurrence_pool, + tzinfo=tzinfo, + ) + + def _get_day_range(self, date): + + # localize the date before we typecast to naive dates + if self.tzinfo is not None and timezone.is_aware(date): + date = date.astimezone(self.tzinfo) + + if isinstance(date, datetime.datetime): + date = date.date() + + naive_start = datetime.datetime.combine(date, datetime.time.min) + naive_end = datetime.datetime.combine( + date + datetime.timedelta(days=1), datetime.time.min + ) + if self.tzinfo is not None: + local_start = pytz.timezone(str(self.tzinfo)).localize(naive_start) + local_end = pytz.timezone(str(self.tzinfo)).localize(naive_end) + start = local_start.astimezone(pytz.utc) + end = local_end.astimezone(pytz.utc) + else: + start = naive_start + end = naive_end + + return start, end + + def __str__(self): + date_format = "l, %s" % settings.DATE_FORMAT + return gettext("Day: %(start)s-%(end)s") % { + "start": date_filter(self.start, date_format), + "end": date_filter(self.end, date_format), + } + + def prev_day(self): + return Day( + self.events, self.start - datetime.timedelta(days=1), tzinfo=self.tzinfo + ) + + prev = prev_day + + def next_day(self): + return Day(self.events, self.end, tzinfo=self.tzinfo) + + next = __next__ = next_day + + def current_year(self): + return Year(self.events, self.start, tzinfo=self.tzinfo) + + def current_month(self): + return Month(self.events, self.start, tzinfo=self.tzinfo) + + def current_week(self): + return Week(self.events, self.start, tzinfo=self.tzinfo) diff --git a/backend/ohq_schedule/settings.py b/backend/ohq_schedule/settings.py new file mode 100644 index 00000000..9dbe19e5 --- /dev/null +++ b/backend/ohq_schedule/settings.py @@ -0,0 +1,66 @@ +from django.conf import settings + +# whether to display cancelled occurrences +# (if they are displayed then they have a css class "cancelled") +# this controls behaviour of Period.classify_occurrence method +SHOW_CANCELLED_OCCURRENCES = getattr(settings, "SHOW_CANCELLED_OCCURRENCES", False) + +# Callable used to check if a user has edit permissions to event +# (and occurrence). Used by check_edit_permission decorator +# if ob==None we check permission to add occurrence +CHECK_EVENT_PERM_FUNC = getattr(settings, "CHECK_EVENT_PERM_FUNC", None) + +if not CHECK_EVENT_PERM_FUNC: + + def check_event_permission(ob, user): + return user.is_authenticated + + CHECK_EVENT_PERM_FUNC = check_event_permission + +# Callable used to check if a user has edit permissions to occurrence +CHECK_OCCURRENCE_PERM_FUNC = getattr(settings, "CHECK_OCCURRENCE_PERM_FUNC", None) + +if not CHECK_OCCURRENCE_PERM_FUNC: + + def check_occurrence_permission(ob, user): + return CHECK_EVENT_PERM_FUNC(ob.event, user) + + CHECK_OCCURRENCE_PERM_FUNC = check_occurrence_permission + +CALENDAR_VIEW_PERM = getattr(settings, "CALENDAR_VIEW_PERM", False) + +# Callable used to check if a user has edit permissions to calendar +CHECK_CALENDAR_PERM_FUNC = getattr(settings, "CHECK_CALENDAR_PERM_FUNC", None) + +if not CHECK_CALENDAR_PERM_FUNC: + + def check_calendar_permission(ob, user): + return user.is_authenticated + + CHECK_CALENDAR_PERM_FUNC = check_calendar_permission + +CALENDAR_VIEW_PERM = getattr(settings, "CALENDAR_VIEW_PERM", False) + +# Callable used to customize the event list given for a calendar and user +# (e.g. all events on that calendar, those events plus another calendar's events, +# or the events filtered based on user permissions) +# Imports have to be placed within the function body to avoid circular imports +GET_EVENTS_FUNC = getattr(settings, "GET_EVENTS_FUNC", None) +if not GET_EVENTS_FUNC: + + def get_events(request, calendar): + return calendar.event_set.prefetch_related("occurrence_set", "rule") + + GET_EVENTS_FUNC = get_events + +# URL to redirect to to after an occurrence is canceled +OCCURRENCE_CANCEL_REDIRECT = getattr(settings, "OCCURRENCE_CANCEL_REDIRECT", None) + +SCHEDULER_PREVNEXT_LIMIT_SECONDS = getattr( + settings, "SCHEDULER_PREVNEXT_LIMIT_SECONDS", 62208000 +) # two years + +USE_FULLCALENDAR = getattr(settings, "USE_FULLCALENDAR", False) + +# This name is used when a new event is created through selecting in fullcalendar +EVENT_NAME_PLACEHOLDER = getattr(settings, "EVENT_NAME_PLACEHOLDER", "Event Name") diff --git a/backend/ohq_schedule/templatetags/__init__.py b/backend/ohq_schedule/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/ohq_schedule/templatetags/scheduletags.py b/backend/ohq_schedule/templatetags/scheduletags.py new file mode 100644 index 00000000..6548098b --- /dev/null +++ b/backend/ohq_schedule/templatetags/scheduletags.py @@ -0,0 +1,279 @@ +import datetime +from urllib.parse import urlencode + +from django import template +from django.conf import settings +from django.template.loader import get_template +from django.urls import reverse +from django.utils import timezone +from django.utils.dateformat import format +from django.utils.html import escape + +from ohq_schedule.models import Calendar +from ohq_schedule.periods import weekday_abbrs, weekday_names +from ohq_schedule.settings import ( + CHECK_CALENDAR_PERM_FUNC, + CHECK_EVENT_PERM_FUNC, + SCHEDULER_PREVNEXT_LIMIT_SECONDS, +) + +register = template.Library() + + +@register.inclusion_tag("schedule/_month_table.html", takes_context=True) +def month_table(context, calendar, month, size="regular", shift=None): + if shift: + if shift == -1: + month = month.prev() + if shift == 1: + month = next(month) + if size == "small": + context["day_names"] = weekday_abbrs + else: + context["day_names"] = weekday_names + context["calendar"] = calendar + context["month"] = month + context["size"] = size + return context + + +@register.inclusion_tag("schedule/_day_cell.html", takes_context=True) +def day_cell(context, calendar, day, month, size="regular"): + context.update({"calendar": calendar, "day": day, "month": month, "size": size}) + return context + + +@register.inclusion_tag("schedule/_daily_table.html", takes_context=True) +def daily_table(context, day, start=8, end=20, increment=30): + """ + Display a nice table with occurrences and action buttons. + Arguments: + start - hour at which the day starts + end - hour at which the day ends + increment - size of a time slot (in minutes) + """ + user = context["request"].user + addable = CHECK_EVENT_PERM_FUNC(None, user) + if "calendar" in context: + addable = addable and CHECK_CALENDAR_PERM_FUNC(context["calendar"], user) + context["addable"] = addable + day_part = day.get_time_slot( + day.start + datetime.timedelta(hours=start), + day.start + datetime.timedelta(hours=end), + ) + # get slots to display on the left + slots = _cook_slots(day_part, increment) + context["slots"] = slots + return context + + +@register.inclusion_tag("schedule/_event_title.html", takes_context=True) +def title(context, occurrence): + context.update({"occurrence": occurrence}) + return context + + +@register.inclusion_tag("schedule/_event_options.html", takes_context=True) +def options(context, occurrence): + context.update( + {"occurrence": occurrence, "MEDIA_URL": getattr(settings, "MEDIA_URL")} + ) + context["view_occurrence"] = occurrence.get_absolute_url() + user = context["request"].user + if CHECK_EVENT_PERM_FUNC(occurrence.event, user) and CHECK_CALENDAR_PERM_FUNC( + occurrence.event.calendar, user + ): + context["edit_occurrence"] = occurrence.get_edit_url() + context["cancel_occurrence"] = occurrence.get_cancel_url() + context["delete_event"] = reverse("delete_event", args=(occurrence.event.id,)) + context["edit_event"] = reverse( + "edit_event", args=(occurrence.event.calendar.slug, occurrence.event.id) + ) + else: + context["edit_event"] = context["delete_event"] = "" + return context + + +@register.inclusion_tag("schedule/_create_event_options.html", takes_context=True) +def create_event_url(context, calendar, slot): + context.update({"calendar": calendar, "MEDIA_URL": getattr(settings, "MEDIA_URL")}) + lookup_context = {"calendar_slug": calendar.slug} + context["create_event_url"] = "{}{}".format( + reverse("calendar_create_event", kwargs=lookup_context), + querystring_for_date(slot), + ) + return context + + +class CalendarNode(template.Node): + def __init__(self, content_object, distinction, context_var, create=False): + self.content_object = template.Variable(content_object) + self.distinction = distinction + self.context_var = context_var + + def render(self, context): + Calendar.objects.get_calendar_for_object( + self.content_object.resolve(context), self.distinction + ) + context[self.context_var] = Calendar.objects.get_calendar_for_object( + self.content_object.resolve(context), self.distinction + ) + return "" + + +@register.tag +def get_calendar(parser, token): + contents = token.split_contents() + if len(contents) == 4: + _, content_object, _, context_var = contents + distinction = None + elif len(contents) == 5: + _, content_object, distinction, _, context_var = token.split_contents() + else: + raise template.TemplateSyntaxError( + "%r tag follows form %r as " + % (token.contents.split()[0], token.contents.split()[0]) + ) + return CalendarNode(content_object, distinction, context_var) + + +class CreateCalendarNode(template.Node): + def __init__(self, content_object, distinction, context_var, name): + self.content_object = template.Variable(content_object) + self.distinction = distinction + self.context_var = context_var + self.name = name + + def render(self, context): + context[self.context_var] = Calendar.objects.get_or_create_calendar_for_object( + self.content_object.resolve(context), self.distinction, name=self.name + ) + return "" + + +@register.tag +def get_or_create_calendar(parser, token): + contents = token.split_contents() + if len(contents) > 2: + obj = contents[1] + if "by" in contents: + by_index = contents.index("by") + distinction = contents[by_index + 1] + else: + distinction = None + if "named" in contents: + named_index = contents.index("named") + name = contents[named_index + 1] + if name[0] == name[-1]: + name = name[1:-1] + else: + name = None + if "as" in contents: + as_index = contents.index("as") + context_var = contents[as_index + 1] + else: + raise template.TemplateSyntaxError( + "%r tag requires an a context variable: %r [named ] [by ] as " + % (token.split_contents()[0], token.split_contents()[0]) + ) + else: + raise template.TemplateSyntaxError( + "%r tag follows form %r [named ] [by ] as " + % (token.split_contents()[0], token.split_contents()[0]) + ) + return CreateCalendarNode(obj, distinction, context_var, name) + + +@register.simple_tag +def querystring_for_date(date, num=6): + qs_parts = [ + ("year", date.year), + ("month", date.month), + ("day", date.day), + ("hour", date.hour), + ("minute", date.minute), + ("second", date.second), + ] + query_string = "?" + urlencode(qs_parts[:num]) + # For compatibility with older Django versions, escape the + # output. Starting with Django 1.9, simple_tags are automatically + # passed through conditional_escape(). See: + # https://docs.djangoproject.com/en/1.9/releases/1.9/#simple-tag-now-wraps-tag-output-in-conditional-escape + return escape(query_string) + + +@register.simple_tag +def prev_url(target, calendar, period): + now = timezone.now() + delta = now - period.prev().start + slug = calendar.slug + if delta.total_seconds() > SCHEDULER_PREVNEXT_LIMIT_SECONDS: + return "" + context = { + "url": "{}{}".format( + reverse(target, kwargs={"calendar_slug": slug}), + querystring_for_date(period.prev().start), + ) + } + return get_template("schedule/_prev.html").render(context) + + +@register.simple_tag +def next_url(target, calendar, period): + now = timezone.now() + slug = calendar.slug + + delta = period.next().start - now + if delta.total_seconds() > SCHEDULER_PREVNEXT_LIMIT_SECONDS: + return "" + + context = { + "url": "{}{}".format( + reverse(target, kwargs={"calendar_slug": slug}), + querystring_for_date(period.next().start), + ) + } + return get_template("schedule/_next.html").render(context) + + +@register.inclusion_tag("schedule/_prevnext.html") +def prevnext(target, calendar, period, fmt=None): + if fmt is None: + fmt = settings.DATE_FORMAT + context = { + "calendar": calendar, + "period": period, + "period_name": format(period.start, fmt), + "target": target, + } + return context + + +@register.inclusion_tag("schedule/_detail.html") +def detail(occurrence): + context = {"occurrence": occurrence} + return context + + +def _cook_slots(period, increment): + """ + Prepare slots to be displayed on the left hand side + calculate dimensions (in px) for each slot. + Arguments: + period - time period for the whole series + increment - slot size in minutes + """ + tdiff = datetime.timedelta(minutes=increment) + num = int((period.end - period.start).total_seconds()) // int(tdiff.total_seconds()) + s = period.start + slots = [] + for i in range(num): + sl = period.get_time_slot(s, s + tdiff) + slots.append(sl) + s = s + tdiff + return slots + + +@register.simple_tag +def hash_occurrence(occ): + return "{}_{}".format(occ.start.strftime("%Y%m%d%H%M%S"), occ.event.id) diff --git a/backend/ohq_schedule/utils.py b/backend/ohq_schedule/utils.py new file mode 100644 index 00000000..93d0abb4 --- /dev/null +++ b/backend/ohq_schedule/utils.py @@ -0,0 +1,243 @@ +import heapq +from functools import wraps + +from django.conf import settings +from django.http import HttpResponseNotFound, HttpResponseRedirect +from django.utils import timezone + +from ohq_schedule.settings import ( + CALENDAR_VIEW_PERM, + CHECK_CALENDAR_PERM_FUNC, + CHECK_EVENT_PERM_FUNC, + CHECK_OCCURRENCE_PERM_FUNC, +) + + +class EventListManager: + """ + This class is responsible for doing functions on a list of events. It is + used to when one has a list of events and wants to access the occurrences + from these events in as a group + """ + + def __init__(self, events): + self.events = events + + def occurrences_after(self, after=None): + """ + It is often useful to know what the next occurrence is given a list of + events. This function produces a generator that yields the + the most recent occurrence after the date ``after`` from any of the + events in ``self.events`` + """ + from ohq_schedule.models import Occurrence + + if after is None: + after = timezone.now() + occ_replacer = OccurrenceReplacer( + Occurrence.objects.filter(event__in=self.events) + ) + generators = [ + event._occurrences_after_generator(after) for event in self.events + ] + occurrences = [] + + for generator in generators: + try: + heapq.heappush(occurrences, (next(generator), generator)) + except StopIteration: + pass + + while occurrences: + generator = occurrences[0][1] + + try: + next_occurrence = heapq.heapreplace( + occurrences, (next(generator), generator) + )[0] + except StopIteration: + next_occurrence = heapq.heappop(occurrences)[0] + yield occ_replacer.get_occurrence(next_occurrence) + + +class OccurrenceReplacer: + """ + When getting a list of occurrences, the last thing that needs to be done + before passing it forward is to make sure all of the occurrences that + have been stored in the datebase replace, in the list you are returning, + the generated ones that are equivalent. This class makes this easier. + """ + + def __init__(self, persisted_occurrences): + lookup = [ + ((occ.event.id, occ.original_start, occ.original_end), occ) + for occ in persisted_occurrences + ] + self.lookup = dict(lookup) + + def get_occurrence(self, occ): + """ + Return a persisted occurrences matching the occ and remove it from lookup since it + has already been matched + """ + return self.lookup.pop( + (occ.event.id, occ.original_start, occ.original_end), occ + ) + + def has_occurrence(self, occ): + try: + return (occ.event.id, occ.original_start, occ.original_end) in self.lookup + except TypeError: + if not self.lookup: + return False + else: + raise TypeError( + "A problem with checking if a persisted occurrence exists has occured!" + ) + + def get_additional_occurrences(self, start, end): + """ + Return persisted occurrences which are now in the period + """ + return [ + occ + for _, occ in list(self.lookup.items()) + if (occ.start < end and occ.end >= start and not occ.cancelled) + ] + + +def get_kwarg_or_param(request, kwargs, key): + value = None + try: + value = kwargs[key] + except KeyError: + if request.method == "GET": + value = request.GET.get(key) + elif request.method == "POST": + value = request.POST.get(key) + return value + + +def get_occurrence(request, **kwargs): + from ohq_schedule.models import Occurrence + + occurrence_id = get_kwarg_or_param(request, kwargs, "occurrence_id") + return ( + Occurrence.objects.filter(pk=occurrence_id).first() if occurrence_id else None + ) + + +def get_event(occurrence, request, **kwargs): + from ohq_schedule.models import Event + + if occurrence: + event = occurrence.event + else: + event_id = get_kwarg_or_param(request, kwargs, "event_id") + event = Event.objects.filter(pk=event_id).first() if event_id else None + return event + + +def get_calendar(event, request, **kwargs): + from ohq_schedule.models import Calendar + + calendar = None + if event: + calendar = event.calendar + else: + calendar_slug = get_kwarg_or_param(request, kwargs, "calendar_slug") + calendar = ( + Calendar.objects.filter(slug=calendar_slug).first() + if calendar_slug + else None + ) + return calendar + + +def get_objects(request, **kwargs): + occurrence = get_occurrence(request, **kwargs) + event = get_event(occurrence, request, **kwargs) + calendar = get_calendar(event, request, **kwargs) + return occurrence, event, calendar + + +def check_occurrence_permissions(function): + @wraps(function) + def decorator(request, *args, **kwargs): + user = request.user + if not user: + return HttpResponseRedirect(settings.LOGIN_URL) + occurrence, event, calendar = get_objects(request, **kwargs) + if calendar and event: + allowed = ( + CHECK_EVENT_PERM_FUNC(event, user) + and CHECK_CALENDAR_PERM_FUNC(calendar, user) + and CHECK_OCCURRENCE_PERM_FUNC(occurrence, user) + ) + if not allowed: + return HttpResponseRedirect(settings.LOGIN_URL) + # all checks passed + return function(request, *args, **kwargs) + return HttpResponseNotFound("

Page not found

") + + return decorator + + +def check_event_permissions(function): + @wraps(function) + def decorator(request, *args, **kwargs): + user = request.user + if not user: + return HttpResponseRedirect(settings.LOGIN_URL) + occurrence, event, calendar = get_objects(request, **kwargs) + if calendar: + allowed = CHECK_EVENT_PERM_FUNC(event, user) and CHECK_CALENDAR_PERM_FUNC( + calendar, user + ) + if not allowed: + return HttpResponseRedirect(settings.LOGIN_URL) + # all checks passed + return function(request, *args, **kwargs) + return HttpResponseNotFound("

Page not found

") + + return decorator + + +def check_calendar_permissions(function): + @wraps(function) + def decorator(request, *args, **kwargs): + if CALENDAR_VIEW_PERM: + user = request.user + if not user: + return HttpResponseRedirect(settings.LOGIN_URL) + occurrence, event, calendar = get_objects(request, **kwargs) + if calendar: + allowed = CHECK_CALENDAR_PERM_FUNC(calendar, user) + if not allowed: + return HttpResponseRedirect(settings.LOGIN_URL) + # all checks passed + return function(request, *args, **kwargs) + return HttpResponseNotFound("

Page not found

") + return function(request, *args, **kwargs) + + return decorator + + +def coerce_date_dict(date_dict): + """ + given a dictionary (presumed to be from request.GET) it returns a tuple + that represents a date. It will return from year down to seconds until one + is not found. ie if year, month, and seconds are in the dictionary, only + year and month will be returned, the rest will be returned as min. If none + of the parts are found return an empty tuple. + """ + keys = ["year", "month", "day", "hour", "minute", "second"] + ret_val = {"year": 1, "month": 1, "day": 1, "hour": 0, "minute": 0, "second": 0} + modified = False + for key in keys: + try: + ret_val[key] = int(date_dict[key]) + modified = True + except KeyError: + break + return modified and ret_val or {} diff --git a/backend/ohq_schedule/widgets.py b/backend/ohq_schedule/widgets.py new file mode 100644 index 00000000..ddff983d --- /dev/null +++ b/backend/ohq_schedule/widgets.py @@ -0,0 +1,5 @@ +from django.forms.widgets import Input + + +class ColorInput(Input): + input_type = "color" diff --git a/backend/tests/ohq/test_models.py b/backend/tests/ohq/test_models.py index 3e1141a6..9b8232eb 100644 --- a/backend/tests/ohq/test_models.py +++ b/backend/tests/ohq/test_models.py @@ -2,6 +2,8 @@ from django.core import mail from django.test import TestCase from django.utils import timezone +import pytz +from ohq_schedule.models import Calendar, Event, Occurrence, EventRelationManager from ohq.models import ( Course, diff --git a/backend/tests/ohq/test_permissions.py b/backend/tests/ohq/test_permissions.py index 8326a156..48934bc9 100644 --- a/backend/tests/ohq/test_permissions.py +++ b/backend/tests/ohq/test_permissions.py @@ -7,7 +7,7 @@ from django.urls import reverse from parameterized import parameterized from rest_framework.test import APIClient -from schedule.models import Calendar, Event, EventRelationManager +from ohq_schedule.models import Calendar, Event, Occurrence, EventRelationManager from ohq.models import ( Announcement, diff --git a/backend/tests/ohq/test_schedule_data_migration.py b/backend/tests/ohq/test_schedule_data_migration.py new file mode 100644 index 00000000..62489fef --- /dev/null +++ b/backend/tests/ohq/test_schedule_data_migration.py @@ -0,0 +1,169 @@ +from django.db import connection +from django.db.migrations.executor import MigrationExecutor +from django.test import TransactionTestCase +from django.utils import timezone + +class TestScheduleDataMigration(TransactionTestCase): + """Test the migration of schedule data from external 'schedule' app to internal 'ohq_schedule' app""" + + migrate_from = '0022_booking' + migrate_to = '0023_migrate_schedule_data' + + @property + def app(self): + return 'ohq' + + def setUp(self): + super().setUp() + self.migrate_from = [ + (self.app, self.migrate_from), + ('schedule', '0001_initial'), + ('ohq_schedule', '0015_event_bookable_event_interval_event_location_and_more') + ] + self.migrate_to = [(self.app, self.migrate_to)] + + self.executor = MigrationExecutor(connection) + self.pre_migration = self.executor.loader.project_state(self.migrate_from).apps + + # Revert to the original migration + self.executor.migrate(self.migrate_from) + self.addCleanup(self.force_migrate) + + self.setUpBeforeMigration(self.pre_migration) + + # Apply the migration + self.executor.loader.build_graph() + self.executor.migrate(self.migrate_to) + self.post_migration = self.executor.loader.project_state(self.migrate_to).apps + + def setUpBeforeMigration(self, apps): + # Get old models + Calendar = apps.get_model('schedule', 'Calendar') + Event = apps.get_model('schedule', 'Event') + Rule = apps.get_model('schedule', 'Rule') + Occurrence = apps.get_model('schedule', 'Occurrence') + CalendarRelation = apps.get_model('schedule', 'CalendarRelation') + EventRelation = apps.get_model('schedule', 'EventRelation') + + # Create test data + self.calendar = Calendar.objects.create( + name="Test Calendar", + slug="test-calendar" + ) + + self.rule = Rule.objects.create( + name="Test Rule", + description="Test Rule Description", + frequency="WEEKLY", + params="count:5" + ) + + current_time = timezone.now() + self.event = Event.objects.create( + start=current_time, + end=current_time + timezone.timedelta(hours=1), + title="Test Event", + description="Test Event Description", + calendar=self.calendar, + rule=self.rule, + end_recurring_period=current_time + timezone.timedelta(days=30), + ) + + self.occurrence = Occurrence.objects.create( + event=self.event, + title="Test Occurrence", + description="Test Occurrence Description", + start=current_time, + end=current_time + timezone.timedelta(hours=1), + original_start=current_time, + original_end=current_time + timezone.timedelta(hours=1), + cancelled=False + ) + + ContentType = apps.get_model('contenttypes', 'ContentType') + content_type = ContentType.objects.get_or_create( + app_label='ohq', + model='course' + )[0] + + self.calendar_relation = CalendarRelation.objects.create( + calendar=self.calendar, + content_type=content_type, + object_id=1, + distinction='test-distinction', + inheritable=True + ) + + self.event_relation = EventRelation.objects.create( + event=self.event, + content_type=content_type, + object_id=1, + distinction='test-distinction' + ) + + def force_migrate(self, migrate_to=None): + self.executor.loader.build_graph() + if migrate_to is None: + migrate_to = [ + key for key in self.executor.loader.graph.leaf_nodes() + if key[0] == self.app + ] + self.executor.migrate(migrate_to) + + def test_schedule_data_migrated(self): + # Get new models + NewCalendar = self.post_migration.get_model('ohq_schedule', 'Calendar') + NewEvent = self.post_migration.get_model('ohq_schedule', 'Event') + NewRule = self.post_migration.get_model('ohq_schedule', 'Rule') + NewOccurrence = self.post_migration.get_model('ohq_schedule', 'Occurrence') + NewCalendarRelation = self.post_migration.get_model('ohq_schedule', 'CalendarRelation') + NewEventRelation = self.post_migration.get_model('ohq_schedule', 'EventRelation') + + # Test Calendar migration + new_calendar = NewCalendar.objects.get(id=self.calendar.id) + self.assertEqual(new_calendar.name, self.calendar.name) + self.assertEqual(new_calendar.slug, self.calendar.slug) + + # Test Rule migration + new_rule = NewRule.objects.get(id=self.rule.id) + self.assertEqual(new_rule.name, self.rule.name) + self.assertEqual(new_rule.description, self.rule.description) + self.assertEqual(new_rule.frequency, self.rule.frequency) + self.assertEqual(new_rule.params, self.rule.params) + + # Test Event migration + new_event = NewEvent.objects.get(id=self.event.id) + self.assertEqual(new_event.title, self.event.title) + self.assertEqual(new_event.description, self.event.description) + self.assertEqual(new_event.location, '') # Default value + self.assertEqual(new_event.bookable, False) # Default value + self.assertIsNone(new_event.interval) # Default value + + # Test Occurrence migration + new_occurrence = NewOccurrence.objects.get(id=self.occurrence.id) + self.assertEqual(new_occurrence.title, self.occurrence.title) + self.assertEqual(new_occurrence.description, self.occurrence.description) + self.assertEqual(new_occurrence.cancelled, self.occurrence.cancelled) + self.assertEqual(new_occurrence.location, '') # Default value + self.assertIsNone(new_occurrence.interval) # Default value + + # Test Relations migration + new_calendar_relation = NewCalendarRelation.objects.get( + id=self.calendar_relation.id + ) + self.assertEqual( + new_calendar_relation.distinction, + self.calendar_relation.distinction + ) + self.assertEqual( + new_calendar_relation.inheritable, + self.calendar_relation.inheritable + ) + + new_event_relation = NewEventRelation.objects.get( + id=self.event_relation.id + ) + self.assertEqual( + new_event_relation.distinction, + self.event_relation.distinction + ) \ No newline at end of file diff --git a/backend/tests/ohq/test_serializers.py b/backend/tests/ohq/test_serializers.py index f70f354d..9e1e875c 100644 --- a/backend/tests/ohq/test_serializers.py +++ b/backend/tests/ohq/test_serializers.py @@ -8,7 +8,7 @@ from django.utils import timezone from rest_framework import serializers from rest_framework.test import APIClient -from schedule.models import Event +from ohq_schedule.models import Event from ohq.models import Announcement, Course, Membership, Question, Queue, Semester, Tag from ohq.serializers import ( diff --git a/backend/tests/ohq/test_views.py b/backend/tests/ohq/test_views.py index 06a4a3b5..2a22e8e4 100644 --- a/backend/tests/ohq/test_views.py +++ b/backend/tests/ohq/test_views.py @@ -7,7 +7,7 @@ from django.utils import timezone from djangorestframework_camel_case.util import camelize from rest_framework.test import APIClient -from schedule.models import Event, Occurrence +from ohq_schedule.models import Event, Occurrence from ohq.models import Course, Membership, MembershipInvite, Question, Queue, Semester from ohq.serializers import UserPrivateSerializer