Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions backend/apps/planner/admin.py
Original file line number Diff line number Diff line change
@@ -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")


@admin.register(UnscheduledTask)
class UnscheduledTaskAdmin(admin.ModelAdmin):
list_display = ('title', 'due_date', 'assigned_assignment', 'created_at')
list_filter = ('assigned_assignment',)
search_fields = ('title', 'description')
32 changes: 32 additions & 0 deletions backend/apps/planner/migrations/0003_unscheduledtask.py
Original file line number Diff line number Diff line change
@@ -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'],
},
),
]
28 changes: 27 additions & 1 deletion backend/apps/planner/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.")
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
19 changes: 17 additions & 2 deletions backend/apps/planner/serializers.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -14,4 +14,19 @@ def validate(self, data):
instance.clean()
except DjangoValidationError as e:
raise DRFValidationError(e.messages)
return data
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']
105 changes: 101 additions & 4 deletions backend/apps/planner/tests.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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")
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)

5 changes: 4 additions & 1 deletion backend/apps/planner/urls.py
Original file line number Diff line number Diff line change
@@ -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))
]
24 changes: 20 additions & 4 deletions backend/apps/planner/views.py
Original file line number Diff line number Diff line change
@@ -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
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]