diff --git a/backend/apps/planner/admin.py b/backend/apps/planner/admin.py index a779abff..8d99aad0 100644 --- a/backend/apps/planner/admin.py +++ b/backend/apps/planner/admin.py @@ -1,7 +1,12 @@ from django.contrib import admin -from .models import CalendarEvent +from .models import CalendarEvent, UnscheduledTask @admin.register(CalendarEvent) class CalendarEventAdmin(admin.ModelAdmin): list_display = ("title", "date", "start_time", "end_time", "event_type", "related_task") - \ No newline at end of file + +@admin.register(UnscheduledTask) +class UnscheduledTaskAdmin(admin.ModelAdmin): + list_display = ('title', 'due_date', 'assigned_assignment', 'created_at') + list_filter = ('assigned_assignment',) + search_fields = ('title', 'description') diff --git a/backend/apps/planner/migrations/0003_unscheduledtask.py b/backend/apps/planner/migrations/0003_unscheduledtask.py new file mode 100644 index 00000000..a86fdd0f --- /dev/null +++ b/backend/apps/planner/migrations/0003_unscheduledtask.py @@ -0,0 +1,32 @@ +# Generated by Django 5.1.6 on 2025-04-29 17:23 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lessons', '0009_merge_20250419_2347'), + ('planner', '0002_calendarevent_location_calendarevent_virtual_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='UnscheduledTask', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('description', models.TextField(blank=True)), + ('due_date', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('assigned_assignment', models.ForeignKey(blank=True, help_text='Optionally link this task to an existing assignment.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='lessons.assignment')), + ], + options={ + 'verbose_name': 'Unscheduled Task', + 'verbose_name_plural': 'Unscheduled Tasks', + 'ordering': ['due_date', 'created_at'], + }, + ), + ] diff --git a/backend/apps/planner/models.py b/backend/apps/planner/models.py index 3133333f..c2bdff95 100644 --- a/backend/apps/planner/models.py +++ b/backend/apps/planner/models.py @@ -2,6 +2,8 @@ from django.utils import timezone from django.core.exceptions import ValidationError from apps.planner.managers import CalendarEventManager +from apps.lessons.models import Assignment + class CalendarEvent(models.Model): # Constants for event type choices @@ -66,4 +68,28 @@ def clean(self): if self.date < timezone.now().date(): raise ValidationError("The event date cannot be in the past.") elif self.date == timezone.now().date() and self.start_time < timezone.now().time(): - raise ValidationError("The event start time cannot be in the past.") \ No newline at end of file + raise ValidationError("The event start time cannot be in the past.") + + +class UnscheduledTask(models.Model): + # Fields + title = models.CharField(max_length=255) + description = models.TextField(blank=True) + due_date = models.DateTimeField(null=True, blank=True) + assigned_assignment = models.ForeignKey( + Assignment, + on_delete=models.SET_NULL, + null=True, + blank=True, + help_text="Optionally link this task to an existing assignment." + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['due_date', 'created_at'] + verbose_name = "Unscheduled Task" + verbose_name_plural = "Unscheduled Tasks" + + def __str__(self): + return f"{self.title} (due {self.due_date:%Y-%m-%d %H:%M})" if self.due_date else self.title diff --git a/backend/apps/planner/serializers.py b/backend/apps/planner/serializers.py index 85dbf9ca..810b84e7 100644 --- a/backend/apps/planner/serializers.py +++ b/backend/apps/planner/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from django.core.exceptions import ValidationError as DjangoValidationError from rest_framework.exceptions import ValidationError as DRFValidationError -from .models import CalendarEvent +from .models import CalendarEvent, UnscheduledTask class CalendarEventSerializer(serializers.ModelSerializer): class Meta: @@ -14,4 +14,19 @@ def validate(self, data): instance.clean() except DjangoValidationError as e: raise DRFValidationError(e.messages) - return data \ No newline at end of file + return data + + +class UnscheduledTaskSerializer(serializers.ModelSerializer): + class Meta: + model = UnscheduledTask + fields = [ + 'id', + 'title', + 'description', + 'due_date', + 'assigned_assignment', + 'created_at', + 'updated_at', + ] + read_only_fields = ['created_at', 'updated_at'] diff --git a/backend/apps/planner/tests.py b/backend/apps/planner/tests.py index 0d3a5f3b..e9f02ca0 100644 --- a/backend/apps/planner/tests.py +++ b/backend/apps/planner/tests.py @@ -1,14 +1,14 @@ from django.test import TestCase from django.utils import timezone from datetime import timedelta, time, date -from django.contrib.auth.models import User -from apps.users.models import Profile from django.core.exceptions import ValidationError from decimal import InvalidOperation from rest_framework import status from rest_framework.test import APIClient, APITestCase from django.urls import reverse -from apps.planner.models import CalendarEvent +from django.contrib.auth.models import User +from apps.users.models import Profile +from apps.planner.models import CalendarEvent, UnscheduledTask from apps.lessons.models import Assignment # Model Tests @@ -279,4 +279,101 @@ def test_get_single_event(self): url = reverse("calendarevent-detail", args=[event.id]) response = self.client.get(url) self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["title"], "Single Event") \ No newline at end of file + self.assertEqual(response.data["title"], "Single Event") + + +class UnscheduledTaskAPITestCase(APITestCase): + def setUp(self): + # create and authenticate a test user + self.user = User.objects.create_user(username="student1", password="password") + Profile.objects.create(user=self.user, role=3) + + # (optional) an assignment to link tasks to + self.assignment = Assignment.objects.create( + title="Task Assignment", + description="Assignment for task tests", + assignment_type="QZ", + deadline="2025-12-01T12:00:00Z", + ) + + # obtain JWT token and set credentials + self.login_url = reverse("token_obtain_pair") + resp = self.client.post( + self.login_url, + {"username": "student1", "password": "password"}, + format="json" + ) + self.token = resp.data["access"] + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {self.token}") + + # base list/create URL + self.list_url = reverse("unscheduledtask-list") + + # GET /api/unscheduled-tasks/: List all unscheduled tasks (should start/begin empty) + def test_list_unscheduled_tasks_initially_empty(self): + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, []) + + # POST /api/unscheduled-tasks/: Create a new unscheduled task + def test_create_unscheduled_task(self): + due = (timezone.now() + timedelta(days=3)).isoformat() + payload = { + "title": "New Task", + "description": "Do something important", + "due_date": due, + "assigned_assignment": self.assignment.id + } + response = self.client.post(self.list_url, payload, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + # one task in DB, with matching title + self.assertEqual(UnscheduledTask.objects.count(), 1) + self.assertEqual(response.data["title"], "New Task") + + # GET /api/unscheduled-tasks/: List all unscheduled tasks (after creating some directly) + def test_get_list_after_creating_tasks(self): + # create two tasks directly + UnscheduledTask.objects.create(title="Task#1.0", due_date=timezone.now()) + UnscheduledTask.objects.create(title="Task#2.0") + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 2) + + # PATCH /api/unscheduled-tasks/{id}/: Update an existing unscheduled task + def test_update_unscheduled_task(self): + task = UnscheduledTask.objects.create( + title="Old Title", + description="Desc", + due_date=timezone.now(), + assigned_assignment=self.assignment + ) + url = reverse("unscheduledtask-detail", args=[task.id]) + response = self.client.patch(url, {"title": "Updated Title"}, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + task.refresh_from_db() + self.assertEqual(task.title, "Updated Title") + + # DELETE /api/unscheduled-tasks/{id}/: Delete an unscheduled task + def test_delete_unscheduled_task(self): + task = UnscheduledTask.objects.create(title="To Delete") + url = reverse("unscheduledtask-detail", args=[task.id]) + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(UnscheduledTask.objects.filter(id=task.id).exists()) + + # GET edge case test: /api/unscheduled-tasks/ without authentication, thus 401 UNAUTHORIZED + def test_unauthorized_list_access(self): + self.client.credentials() # clear auth + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + # POST edge case test: /api/unscheduled-tasks/ missing required 'title', thus 400 BAD REQUEST + def test_create_missing_title(self): + payload = { + "description": "Missing the title field", + # due_date and assigned_assignment are optional + } + response = self.client.post(self.list_url, payload, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("title", response.data) + \ No newline at end of file diff --git a/backend/apps/planner/urls.py b/backend/apps/planner/urls.py index cac76adc..dd079b5a 100644 --- a/backend/apps/planner/urls.py +++ b/backend/apps/planner/urls.py @@ -1,11 +1,14 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from .views import CalendarEventViewSet +from .views import CalendarEventViewSet, UnscheduledTaskViewSet # Create a router and register viewsets router = DefaultRouter() router.register(r'calendar', CalendarEventViewSet) +router.register(r'unscheduled-tasks', UnscheduledTaskViewSet, basename='unscheduledtask') + urlpatterns = [ path('', include(router.urls)), + path('api/', include(router.urls)) ] diff --git a/backend/apps/planner/views.py b/backend/apps/planner/views.py index 569bcf8b..0df081c6 100644 --- a/backend/apps/planner/views.py +++ b/backend/apps/planner/views.py @@ -1,8 +1,24 @@ from django.shortcuts import render -from rest_framework import viewsets -from .models import CalendarEvent -from .serializers import CalendarEventSerializer +from rest_framework import viewsets, permissions +from .models import CalendarEvent, UnscheduledTask +from .serializers import CalendarEventSerializer, UnscheduledTaskSerializer + class CalendarEventViewSet(viewsets.ModelViewSet): queryset = CalendarEvent.objects.all() - serializer_class = CalendarEventSerializer \ No newline at end of file + serializer_class = CalendarEventSerializer + + +# ------------------------------ +class UnscheduledTaskViewSet(viewsets.ModelViewSet): + """ + retrieve: Return a single unscheduled task. + list: Return all unscheduled tasks. + create: Create a new unscheduled task. + update: Update an existing unscheduled task. + partial_update: Partially update fields on an unscheduled task. + delete: Delete an unscheduled task. + """ + queryset = UnscheduledTask.objects.all() + serializer_class = UnscheduledTaskSerializer + permission_classes = [permissions.IsAuthenticated]