diff --git a/tom_base/settings.py b/tom_base/settings.py index 4d5bc6e82..faa3c8295 100644 --- a/tom_base/settings.py +++ b/tom_base/settings.py @@ -62,6 +62,7 @@ 'tom_observations', 'tom_dataproducts', 'tom_dataservices', + 'tom_calendar', ] SITE_ID = 1 diff --git a/tom_calendar/__init__.py b/tom_calendar/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tom_calendar/admin.py b/tom_calendar/admin.py new file mode 100644 index 000000000..b1e15c98b --- /dev/null +++ b/tom_calendar/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from .models import CalendarEvent + +admin.site.register(CalendarEvent) diff --git a/tom_calendar/apps.py b/tom_calendar/apps.py new file mode 100644 index 000000000..3bd8fe8fe --- /dev/null +++ b/tom_calendar/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class TomCalendarConfig(AppConfig): + name = 'tom_calendar' + + def nav_items(self): + """ + Integration point for adding items to the navbar. + This method should return a list of partial templates to be included in the navbar. + """ + return [{'partial': 'tom_calendar/partials/navbar_item.html'}] diff --git a/tom_calendar/migrations/0001_initial.py b/tom_calendar/migrations/0001_initial.py new file mode 100644 index 000000000..076cbea55 --- /dev/null +++ b/tom_calendar/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.27 on 2026-02-27 22:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='CalendarEvent', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('description', models.TextField(blank=True, default='')), + ('start_time', models.DateTimeField()), + ('end_time', models.DateTimeField()), + ('url', models.URLField(blank=True, default='')), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ], + ), + ] diff --git a/tom_calendar/migrations/0002_calendarevent_target_list.py b/tom_calendar/migrations/0002_calendarevent_target_list.py new file mode 100644 index 000000000..2cbd2219f --- /dev/null +++ b/tom_calendar/migrations/0002_calendarevent_target_list.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.27 on 2026-03-05 17:52 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tom_targets', '0030_alter_basetarget_slope'), + ('tom_calendar', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='calendarevent', + name='target_list', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='tom_targets.targetlist'), + ), + ] diff --git a/tom_calendar/migrations/0003_calendarevent_proposal_calendarevent_telescope_and_more.py b/tom_calendar/migrations/0003_calendarevent_proposal_calendarevent_telescope_and_more.py new file mode 100644 index 000000000..91c3bc6c0 --- /dev/null +++ b/tom_calendar/migrations/0003_calendarevent_proposal_calendarevent_telescope_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.27 on 2026-03-05 22:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tom_calendar', '0002_calendarevent_target_list'), + ] + + operations = [ + migrations.AddField( + model_name='calendarevent', + name='proposal', + field=models.CharField(blank=True, default='', max_length=200), + ), + migrations.AddField( + model_name='calendarevent', + name='telescope', + field=models.CharField(blank=True, default='', max_length=200), + ), + migrations.AddField( + model_name='calendarevent', + name='user', + field=models.CharField(blank=True, default='', max_length=200), + ), + ] diff --git a/tom_calendar/migrations/0004_eventtodo.py b/tom_calendar/migrations/0004_eventtodo.py new file mode 100644 index 000000000..019d0a5ef --- /dev/null +++ b/tom_calendar/migrations/0004_eventtodo.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.27 on 2026-03-05 23:42 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tom_calendar', '0003_calendarevent_proposal_calendarevent_telescope_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='EventTodo', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.CharField(max_length=200)), + ('is_completed', models.BooleanField(default=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='todos', to='tom_calendar.calendarevent')), + ], + ), + ] diff --git a/tom_calendar/migrations/0005_calendarevent_instrument.py b/tom_calendar/migrations/0005_calendarevent_instrument.py new file mode 100644 index 000000000..e2231f386 --- /dev/null +++ b/tom_calendar/migrations/0005_calendarevent_instrument.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.27 on 2026-03-12 20:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tom_calendar', '0004_eventtodo'), + ] + + operations = [ + migrations.AddField( + model_name='calendarevent', + name='instrument', + field=models.CharField(blank=True, default='', max_length=200), + ), + ] diff --git a/tom_calendar/migrations/__init__.py b/tom_calendar/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tom_calendar/models.py b/tom_calendar/models.py new file mode 100644 index 000000000..df19f0a06 --- /dev/null +++ b/tom_calendar/models.py @@ -0,0 +1,51 @@ +from django.db import models + +from tom_targets.models import TargetList + +from .utils import BOOTSTRAP_COLORS + + +class EventTodo(models.Model): + event = models.ForeignKey('CalendarEvent', on_delete=models.CASCADE, related_name='todos') + description = models.CharField(max_length=200) + is_completed = models.BooleanField(default=False) + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + + def __str__(self): + return f'Todo for {self.event.title}: {self.description}' + + +class CalendarEvent(models.Model): + """ + Class representing an event in the calendar. + + Other applications can create calendar events by creating instances of this class. + + """ + title = models.CharField(max_length=200) + description = models.TextField(blank=True, default="") + start_time = models.DateTimeField() + end_time = models.DateTimeField() + url = models.URLField(blank=True, default="") + """The URL a user can visit for more information or associated object.""" + target_list = models.ForeignKey(TargetList, on_delete=models.SET_NULL, null=True, blank=True) + user = models.CharField(max_length=200, blank=True, default="") + proposal = models.CharField(max_length=200, blank=True, default="") + telescope = models.CharField(max_length=200, blank=True, default="") + instrument = models.CharField(max_length=200, blank=True, default="") + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + + todos: models.Manager[EventTodo] + + def __str__(self): + return self.title + + @property + def color(self) -> str: + return BOOTSTRAP_COLORS[self.pk % len(BOOTSTRAP_COLORS)] + + @property + def active_todos(self): + return self.todos.filter(is_completed=False) diff --git a/tom_calendar/templates/tom_calendar/calendar_page.html b/tom_calendar/templates/tom_calendar/calendar_page.html new file mode 100644 index 000000000..116d92887 --- /dev/null +++ b/tom_calendar/templates/tom_calendar/calendar_page.html @@ -0,0 +1,30 @@ +{% extends 'tom_common/base.html' %} +{% block title %}Calendar{% endblock %} +{% block content %} + {% include 'tom_calendar/partials/calendar.html' %} + + +{% endblock %} +{% block extra_javascript %} + +{% endblock %} diff --git a/tom_calendar/templates/tom_calendar/partials/calendar.html b/tom_calendar/templates/tom_calendar/partials/calendar.html new file mode 100644 index 000000000..ceb182275 --- /dev/null +++ b/tom_calendar/templates/tom_calendar/partials/calendar.html @@ -0,0 +1,205 @@ + + +{% load tz calendar_tags %} + +
+
+
+ + + +
+

{{ month_name }}

+ +
+ +
+ {% for name in day_names %} +
{{ name }}
+ {% endfor %} + + {% for week in weeks %} + {% for day in week %} +
+ {{ day.date.day }} + {{ day.moon.emoji }} +
+ {% for event in day.all_day_events %} +
+ {% include 'tom_calendar/partials/target_list_block.html' with target_list=event.target_list %} +
+ {{ event.title|truncatechars:18 }} + {% if event.active_todos.count %} + ({{ event.active_todos.count }}) + {% endif %} +
+
+ {% endfor %} + {% for event in day.events %} +
+ {% include 'tom_calendar/partials/target_list_block.html' with target_list=event.target_list %} + + {{ event.title|truncatechars:16 }} + {% if event.active_todos.count %} + ({{ event.active_todos.count }}) + {% endif %} + + {{ event.start_time|offset_time:utc_offset|time:"H:i" }} +
+ {% endfor %} +
+
+ {% endfor %} + {% endfor %} +
+
+
+ {% for target_list in target_lists %} + {% include 'tom_calendar/partials/target_list_block.html' with target_list=target_list %} + + {{ target_list.name }} + + {% endfor %} +
+ +
+
diff --git a/tom_calendar/templates/tom_calendar/partials/event_form.html b/tom_calendar/templates/tom_calendar/partials/event_form.html new file mode 100644 index 000000000..71327c439 --- /dev/null +++ b/tom_calendar/templates/tom_calendar/partials/event_form.html @@ -0,0 +1,86 @@ +{% load bootstrap4 %} +
+ {% csrf_token %} +
+
+ {% bootstrap_field form.title %} +
+
+
+
+ {% bootstrap_field form.start_time %} +
+
+ {% bootstrap_field form.end_time %} +
+
+
+
+ {% bootstrap_field form.description %} +
+
+
+
+ + {% bootstrap_field form.url show_label=False %} +
+
+ + {% bootstrap_field form.target_list show_label=False%} +
+
+
+
+ {% bootstrap_field form.user %} +
+
+ {% bootstrap_field form.proposal %} +
+
+ {% bootstrap_field form.telescope %} +
+
+ {% bootstrap_field form.instrument %} +
+
+ {% buttons %} + + {% if action == "create" %} + + {% else %} + + {% endif %} + {% endbuttons %} +
+
Todo list
+
+
+{% if action == "update" %} + {% include 'tom_calendar/partials/todos.html' with event=event %} +{% else %} +

Save the event to add TODOs

+{% endif %} +
diff --git a/tom_calendar/templates/tom_calendar/partials/navbar_item.html b/tom_calendar/templates/tom_calendar/partials/navbar_item.html new file mode 100644 index 000000000..07ad7d6e9 --- /dev/null +++ b/tom_calendar/templates/tom_calendar/partials/navbar_item.html @@ -0,0 +1,3 @@ + diff --git a/tom_calendar/templates/tom_calendar/partials/target_list_block.html b/tom_calendar/templates/tom_calendar/partials/target_list_block.html new file mode 100644 index 000000000..f4f5d488d --- /dev/null +++ b/tom_calendar/templates/tom_calendar/partials/target_list_block.html @@ -0,0 +1,7 @@ +{% load calendar_tags %} +{% if target_list %} + {% target_list_color target_list as tl_color %} + + + +{% endif %} diff --git a/tom_calendar/templates/tom_calendar/partials/todos.html b/tom_calendar/templates/tom_calendar/partials/todos.html new file mode 100644 index 000000000..27565b8d8 --- /dev/null +++ b/tom_calendar/templates/tom_calendar/partials/todos.html @@ -0,0 +1,33 @@ +{% for todo in event.todos.all %} +
+ {% csrf_token %} + +
+ + +
+
+{% empty %} +

No todos yet.

+{% endfor %} + +
+ {% csrf_token %} +
+ + +
+ +
diff --git a/tom_calendar/templatetags/__init__.py b/tom_calendar/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tom_calendar/templatetags/calendar_tags.py b/tom_calendar/templatetags/calendar_tags.py new file mode 100644 index 000000000..ed632cc4a --- /dev/null +++ b/tom_calendar/templatetags/calendar_tags.py @@ -0,0 +1,17 @@ +from datetime import timedelta + +from django import template + +from tom_calendar.utils import target_list_color as _target_list_color + +register = template.Library() + + +@register.simple_tag +def target_list_color(target_list): + return _target_list_color(target_list) + + +@register.filter +def offset_time(dt, utc_offset): + return dt + timedelta(hours=int(utc_offset)) diff --git a/tom_calendar/tests.py b/tom_calendar/tests.py new file mode 100644 index 000000000..f315ac925 --- /dev/null +++ b/tom_calendar/tests.py @@ -0,0 +1,182 @@ +from datetime import datetime, timedelta, timezone + +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse + +from tom_targets.models import TargetList + +from .models import CalendarEvent +from .utils import BOOTSTRAP_COLORS, target_list_color + +UTC = timezone.utc + + +def make_event(title='Test Event', start=None, end=None, **kwargs): + if start is None: + start = datetime(2025, 6, 15, 10, 0, tzinfo=UTC) + if end is None: + end = start + timedelta(hours=1) + return CalendarEvent.objects.create(title=title, start_time=start, end_time=end, **kwargs) + + +class TargetListColorTest(TestCase): + def test_color_cycles(self): + tl = TargetList.objects.create(name='TestList') + color = target_list_color(tl) + self.assertIn(color, BOOTSTRAP_COLORS) + self.assertEqual(color, BOOTSTRAP_COLORS[tl.pk % len(BOOTSTRAP_COLORS)]) + + def test_color_is_deterministic(self): + tl = TargetList.objects.create(name='TestList') + self.assertEqual(target_list_color(tl), target_list_color(tl)) + + +class RenderCalendarViewTest(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='tester', password='pass') + self.client.force_login(self.user) + self.url = reverse('calendar:calendar') + + def test_event_in_month_appears(self): + event = make_event(title='June Event', start=datetime(2025, 6, 10, 9, 0, tzinfo=UTC)) + response = self.client.get(self.url, {'month': 6, 'year': 2025}) + self.assertContains(response, event.title) + + def test_event_outside_month_absent(self): + make_event(title='August Event', start=datetime(2025, 8, 15, 9, 0, tzinfo=UTC)) + response = self.client.get(self.url, {'month': 6, 'year': 2025}) + self.assertNotContains(response, 'August Event') + + def test_month_clamped_to_valid_range(self): + response = self.client.get(self.url, {'month': 99, 'year': 2025}) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'December') + + response = self.client.get(self.url, {'month': -5, 'year': 2025}) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'January') + + def test_multi_day_event_appears_on_each_spanned_day(self): + make_event( + title='Multi Day', + start=datetime(2025, 6, 28, 0, 0, tzinfo=UTC), + end=datetime(2025, 7, 2, 23, 59, tzinfo=UTC), + ) + response = self.client.get(self.url, {'month': 6, 'year': 2025}) + self.assertContains(response, 'Multi Day') + + +class CreateEventViewTest(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='tester', password='pass') + self.client.force_login(self.user) + self.url = reverse('calendar:create-event') + + def test_get_with_date_prefills_start_and_end(self): + response = self.client.get(self.url, {'date': '2025-06-15'}) + self.assertEqual(response.status_code, 200) + self.assertContains(response, '2025-06-15') + + def test_post_valid_creates_event(self): + self.assertEqual(CalendarEvent.objects.count(), 0) + response = self.client.post( + self.url, + { + 'title': 'New Event', + 'start_time': '2025-06-15T10:00', + 'end_time': '2025-06-15T11:00', + }, + HTTP_HX_REQUEST='true', + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(CalendarEvent.objects.count(), 1) + + def test_post_missing_title_returns_form_with_error(self): + response = self.client.post( + self.url, + { + 'start_time': '2025-06-15T10:00', + 'end_time': '2025-06-15T11:00', + }, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(CalendarEvent.objects.count(), 0) + self.assertContains(response, 'This field is required') + + def test_post_end_before_start_returns_form(self): + response = self.client.post( + self.url, + { + 'title': 'Bad Times', + 'start_time': '2025-06-15T12:00', + 'end_time': '2025-06-15T10:00', + }, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(CalendarEvent.objects.count(), 0) + + +class UpdateEventViewTest(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='tester', password='pass') + self.client.force_login(self.user) + self.event = make_event(title='Original') + + def _url(self): + return reverse('calendar:update-event', args=[self.event.pk]) + + def test_get_returns_populated_form(self): + response = self.client.get(self._url()) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Original') + + def test_post_valid_updates_event(self): + response = self.client.post( + self._url(), + { + 'title': 'Updated', + 'start_time': '2025-06-15T10:00', + 'end_time': '2025-06-15T11:00', + }, + HTTP_HX_REQUEST='true', + ) + self.assertEqual(response.status_code, 200) + self.event.refresh_from_db() + self.assertEqual(self.event.title, 'Updated') + + def test_post_invalid_returns_form_with_error(self): + response = self.client.post( + self._url(), + { + 'title': '', + 'start_time': '2025-06-15T10:00', + 'end_time': '2025-06-15T11:00', + }, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'This field is required') + self.event.refresh_from_db() + self.assertEqual(self.event.title, 'Original') + + def test_get_nonexistent_event_returns_404(self): + url = reverse('calendar:update-event', args=[99999]) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + +class DeleteEventViewTest(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='tester', password='pass') + self.client.force_login(self.user) + self.event = make_event() + + def test_delete_removes_event(self): + url = reverse('calendar:delete-event', args=[self.event.pk]) + self.client.post(url, HTTP_HX_REQUEST='true') + self.assertFalse(CalendarEvent.objects.filter(pk=self.event.pk).exists()) + + def test_delete_nonexistent_returns_404(self): + url = reverse('calendar:delete-event', args=[99999]) + response = self.client.post(url) + self.assertEqual(response.status_code, 404) diff --git a/tom_calendar/urls.py b/tom_calendar/urls.py new file mode 100644 index 000000000..d2ad65b6c --- /dev/null +++ b/tom_calendar/urls.py @@ -0,0 +1,14 @@ +from django.urls import path + +from .views import render_calendar, create_event, update_event, delete_event, create_todo, update_todo + +app_name = 'tom_calendar' + +urlpatterns = [ + path("", render_calendar, name="calendar"), + path("create/", create_event, name="create-event"), + path("update//", update_event, name="update-event"), + path("delete//", delete_event, name="delete-event"), + path("todo/create//", create_todo, name="create-todo"), + path("todo/update//", update_todo, name="update-todo"), +] diff --git a/tom_calendar/utils.py b/tom_calendar/utils.py new file mode 100644 index 000000000..d9c958795 --- /dev/null +++ b/tom_calendar/utils.py @@ -0,0 +1,17 @@ +from tom_targets.models import TargetList + +BOOTSTRAP_COLORS = [ + 'var(--red)', + 'var(--teal)', + 'var(--orange)', + 'var(--indigo)', + 'var(--pink)', + 'var(--green)', + 'var(--cyan)', + 'var(--purple)', + 'var(--blue)', +] + + +def target_list_color(target_list: TargetList) -> str: + return BOOTSTRAP_COLORS[target_list.pk % len(BOOTSTRAP_COLORS)] diff --git a/tom_calendar/views.py b/tom_calendar/views.py new file mode 100644 index 000000000..c9e783d27 --- /dev/null +++ b/tom_calendar/views.py @@ -0,0 +1,239 @@ +from tom_targets.models import TargetList +import calendar as cal_module +import math +from dataclasses import dataclass +from datetime import date, datetime, time, timedelta + +from astropy.coordinates import get_body, get_sun +from astropy.time import Time +from django import forms +from django.shortcuts import get_object_or_404, render +from django.utils import timezone +from django_htmx.http import trigger_client_event + +from .models import CalendarEvent, EventTodo + +DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] +MOON_EMOJIS = ["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"] +DATETIME_INPUT = forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%dT%H:%M') + + +@dataclass +class MoonPhase: + illumination: float + emoji: str + + @classmethod + def from_date(cls, date: date) -> "MoonPhase": + d = datetime.combine(date, time(12, 0)) + t = Time(d) + moon = get_body("moon", t) + sun = get_sun(t) + moon_lon = moon.geocentricmeanecliptic.lon + sun_lon = sun.geocentricmeanecliptic.lon + phase_angle = (moon_lon - sun_lon).wrap_at('360d').deg + + # Illumination fraction from elongation + # 0 (new) -> 0.0, 90 (quarter) -> 0.5, 180 (full) -> 1.0 + illumination = (1 - math.cos(math.radians(phase_angle))) / 2 + + # 8 45deg slices map to the phase emoji + emoji = MOON_EMOJIS[int(phase_angle / 45) % 8] + + return cls(illumination, emoji) + + +def render_calendar(request, month: int | None = None): + utc_offset = int(request.GET.get("utc_offset", 0)) + offset = timedelta(hours=utc_offset) + now = timezone.now() + now_offset = now + offset + today = now_offset.date() + if month is None: + month = int(request.GET.get("month", now_offset.month)) + month = max(1, min(12, month)) + year = int(request.GET.get("year", now_offset.year)) + + # Sunday is 6 in python calendar for some reason + calendar = cal_module.Calendar(firstweekday=6) + + if month == 1: + prev_month, prev_year = 12, year - 1 + else: + prev_month, prev_year = month - 1, year + + if month == 12: + next_month, next_year = 1, year + 1 + else: + next_month, next_year = month + 1, year + + month_name = date(year, month, 1).strftime("%B %Y") + weeks = calendar.monthdatescalendar(year, month) + + # Fetch all events for this month instead of querying for each day + events = CalendarEvent.objects.filter( + start_time__date__lte=weeks[-1][-1], + end_time__date__gte=weeks[0][0], + ) + + def offset_date(dt): + return (dt + offset).date() + + events = list(events) + weeks_with_events = [ + [ + { + "date": d, + "moon": MoonPhase.from_date(d), + "all_day_events": [ + e for e in events + if offset_date(e.start_time) <= d <= offset_date(e.end_time) + and offset_date(e.start_time) != offset_date(e.end_time) + ], + "events": [ + e for e in events + if offset_date(e.start_time) == offset_date(e.end_time) == d + ], + } + for d in week + ] + for week in weeks + ] + + context = { + "month": month, + "year": year, + "month_name": month_name, + "weeks": weeks_with_events, + "day_names": DAY_NAMES, + "today": today, + "prev_month": prev_month, + "prev_year": prev_year, + "next_month": next_month, + "next_year": next_year, + "target_lists": TargetList.objects.filter(calendarevent__in=events).distinct(), + "utc_offset": utc_offset, + "utc_offset_choices": range(-12, 13), + } + + if request.htmx: + template = "tom_calendar/partials/calendar.html" + else: + template = "tom_calendar/calendar_page.html" + + return render(request, template, context) + + +class EventForm(forms.ModelForm): + class Meta: + model = CalendarEvent + fields = [ + 'title', 'start_time', 'end_time', 'description', 'url', + 'target_list', 'user', 'proposal', 'telescope', 'instrument' + ] + widgets = { + 'start_time': DATETIME_INPUT, + 'end_time': DATETIME_INPUT, + 'description': forms.Textarea(attrs={'rows': 5}), + } + + def clean(self): + cleaned_data = super().clean() or {} + start = cleaned_data.get('start_time') + end = cleaned_data.get('end_time') + if start and end and end < start: + raise forms.ValidationError('End time must be after start time.') + return cleaned_data + + +def create_event(request): + if request.method == "POST": + form = EventForm(request.POST) + if form.is_valid(): + event = form.save() + if "save_and_edit" in request.POST: + form = EventForm(instance=event) + response = render( + request, + "tom_calendar/partials/event_form.html", + {"form": form, "event": event, "action": "update"} + ) + response["HX-Retarget"] = "#cal-modal-body" + response["HX-Reswap"] = "innerHTML" + return trigger_client_event(response, "calRefresh") + response = render_calendar(request, month=event.start_time.month) + return trigger_client_event(response, "calClose") + else: + response = render(request, "tom_calendar/partials/event_form.html", {"form": form, "action": "create"}) + response["HX-Retarget"] = "#cal-modal-body" + response["HX-Reswap"] = "innerHTML" + return response + + else: + try: + date_str = request.GET["date"] + date_obj = datetime.strptime(date_str, "%Y-%m-%d").date() + initial_data = { + "start_time": datetime.combine(date_obj, datetime.min.time()), + "end_time": datetime.combine(date_obj, datetime.max.time()), + } + form = EventForm(initial=initial_data) + except KeyError: + form = EventForm() + + return render(request, "tom_calendar/partials/event_form.html", {"form": form, "action": "create"}) + + +def update_event(request, event_id): + event = get_object_or_404(CalendarEvent, pk=event_id) + + if request.method == "POST": + form = EventForm(request.POST, instance=event) + if form.is_valid(): + event = form.save() + response = render_calendar(request, month=event.start_time.month) + return trigger_client_event(response, "calClose") + else: + response = render( + request, + "tom_calendar/partials/event_form.html", + {"form": form, "event": event, "action": "update"} + ) + response["HX-Retarget"] = "#cal-modal-body" + response["HX-Reswap"] = "innerHTML" + return response + + else: + form = EventForm(instance=event) + return render( + request, + "tom_calendar/partials/event_form.html", + {"form": form, "event": event, "action": "update"} + ) + + +def delete_event(request, event_id): + event = get_object_or_404(CalendarEvent, pk=event_id) + event.delete() + response = render_calendar(request) + return trigger_client_event(response, "calClose") + + +def create_todo(request, event_id): + event = get_object_or_404(CalendarEvent, pk=event_id) + description = request.POST.get("description", "").strip() + if description: + event.todos.create(description=description) + response = render(request, "tom_calendar/partials/todos.html", {"event": event}) + return trigger_client_event(response, "calRefresh") + + +def update_todo(request, todo_id): + todo = get_object_or_404(EventTodo, pk=todo_id) + is_completed = request.POST.get("is_completed") == "true" + description = request.POST.get("description", "").strip() + todo.is_completed = is_completed + todo.description = description + todo.save() + response = render(request, "tom_calendar/partials/todos.html", {"event": todo.event}) + return trigger_client_event(response, "calRefresh") diff --git a/tom_common/urls.py b/tom_common/urls.py index b53b728f2..24f8d9cc4 100644 --- a/tom_common/urls.py +++ b/tom_common/urls.py @@ -49,6 +49,7 @@ path('robots.txt', robots_txt, name='robots_txt'), path('targets/', include('tom_targets.urls', namespace='targets')), path('alerts/', include('tom_alerts.urls', namespace='alerts')), + path('calendar/', include('tom_calendar.urls', namespace='calendar')), path('comments/', include('django_comments.urls')), path('catalogs/', include('tom_catalogs.urls')), path('observations/', include('tom_observations.urls', namespace='observations')),