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 %} + +Save the event to add TODOs
+{% endif %} +No todos yet.
+{% endfor %} + + 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/