Skip to content

Commit 2f75430

Browse files
Add course deadline calendar feed
Closes #74
1 parent 8f5073c commit 2f75430

5 files changed

Lines changed: 230 additions & 4 deletions

File tree

courses/templates/courses/course.html

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ <h1 class="text-3xl font-semibold leading-tight tracking-normal md:text-4xl">
2424
</div>
2525

2626
<div class="course-actions mt-5 flex flex-wrap gap-3 md:mt-8 md:gap-4">
27+
{% if course.registration_url %}
28+
<a href="{{ course.registration_url }}" class="primer-button primer-button-secondary whitespace-nowrap">
29+
<i class="fas fa-user-plus"></i> Register
30+
</a>
31+
{% endif %}
2732
{% if course.first_homework_scored %}
2833
<a href="{% url 'leaderboard' course.slug %}" class="primer-button whitespace-nowrap">
2934
<i class="fas fa-trophy"></i> Course leaderboard
@@ -32,6 +37,9 @@ <h1 class="text-3xl font-semibold leading-tight tracking-normal md:text-4xl">
3237
<a href="{% url 'dashboard' course.slug %}" class="primer-button primer-button-secondary whitespace-nowrap">
3338
<i class="fas fa-chart-bar"></i> Course dashboard
3439
</a>
40+
<a href="{% url 'course_calendar' course.slug %}" class="primer-button primer-button-secondary whitespace-nowrap">
41+
<i class="far fa-calendar-alt"></i> Calendar feed
42+
</a>
3543
{% if is_authenticated %}
3644
<a href="{% url 'enrollment' course.slug %}" class="primer-button primer-button-secondary whitespace-nowrap">
3745
<i class="fas fa-user-edit"></i> Edit course profile
@@ -43,6 +51,9 @@ <h1 class="text-3xl font-semibold leading-tight tracking-normal md:text-4xl">
4351
</a>
4452
{% endif %}
4553
</div>
54+
<p class="mt-2 text-sm app-muted">
55+
Use the calendar feed link to subscribe to course deadlines in Google Calendar or another calendar app.
56+
</p>
4657

4758
{% if user.is_staff %}
4859
<div class="mt-6">

courses/templates/courses/course_list.html

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,11 @@ <h2 class="mt-2 text-2xl font-semibold md:text-3xl">Active courses</h2>
5454
{% if active_courses %}
5555
<section class="mt-6 grid gap-4 lg:grid-cols-2">
5656
{% for course in active_courses %}
57-
<article class="grid gap-4 rounded-md border app-border app-surface p-4 md:p-5">
57+
<article class="grid cursor-pointer gap-4 rounded-md border app-border app-surface p-4 md:p-5"
58+
role="link"
59+
tabindex="0"
60+
onclick="if (!event.target.closest('a, button')) window.location.href='{% url 'course' course.slug %}'"
61+
onkeydown="if ((event.key === 'Enter' || event.key === ' ') && !event.target.closest('a, button')) { event.preventDefault(); window.location.href='{% url 'course' course.slug %}'; }">
5862
<div>
5963
<span class="inline-flex rounded-md app-badge-success px-2 py-0.5 text-xs font-semibold uppercase">Active</span>
6064
<h3 class="mt-2 text-xl font-semibold md:text-2xl">
@@ -112,10 +116,11 @@ <h3 class="mt-2 text-xl font-semibold md:text-2xl">
112116
<p class="mt-1 font-semibold app-heading">TBA</p>
113117
{% endif %}
114118
</div>
115-
{% if course.home_registration_open %}
119+
{% if course.registration_url %}
116120
<div class="flex flex-wrap gap-3 md:justify-end">
117121
<a href="{{ course.registration_url }}"
118-
class="inline-flex items-center font-medium app-link">
122+
class="primer-button primer-button-secondary whitespace-nowrap">
123+
<i class="fas fa-user-plus"></i>
119124
Register
120125
</a>
121126
</div>

courses/tests/test_course.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,71 @@ def test_course_detail_unauthenticated_user(self):
168168
self.assertIsNone(hw.score)
169169
self.assertFalse(hasattr(hw, "submitted_at"))
170170

171+
def test_course_detail_shows_registration_url(self):
172+
self.course.registration_url = (
173+
"https://courses.datatalks.club/test-course/register"
174+
)
175+
self.course.save()
176+
177+
url = reverse(
178+
"course", kwargs={"course_slug": self.course.slug}
179+
)
180+
181+
response = self.client.get(url)
182+
183+
self.assertEqual(response.status_code, 200)
184+
self.assertContains(response, "Register")
185+
self.assertContains(
186+
response,
187+
"https://courses.datatalks.club/test-course/register",
188+
)
189+
190+
def test_course_detail_shows_calendar_feed_link(self):
191+
url = reverse(
192+
"course", kwargs={"course_slug": self.course.slug}
193+
)
194+
195+
response = self.client.get(url)
196+
197+
self.assertEqual(response.status_code, 200)
198+
self.assertContains(response, "Calendar feed")
199+
self.assertContains(
200+
response,
201+
(
202+
"Use the calendar feed link to subscribe to course "
203+
"deadlines in Google Calendar or another calendar app."
204+
),
205+
)
206+
self.assertContains(
207+
response,
208+
reverse(
209+
"course_calendar",
210+
kwargs={"course_slug": self.course.slug},
211+
),
212+
)
213+
214+
def test_course_calendar_feed(self):
215+
url = reverse(
216+
"course_calendar",
217+
kwargs={"course_slug": self.course.slug},
218+
)
219+
220+
response = self.client.get(url)
221+
content = response.content.decode()
222+
223+
self.assertEqual(response.status_code, 200)
224+
self.assertEqual(
225+
response["Content-Type"],
226+
"text/calendar; charset=utf-8",
227+
)
228+
self.assertIn("BEGIN:VCALENDAR", content)
229+
self.assertIn("VERSION:2.0", content)
230+
self.assertIn("X-WR-CALNAME:Test Course deadlines", content)
231+
self.assertIn("SUMMARY:Test Course: Submitted Homework deadline", content)
232+
self.assertIn("SUMMARY:Test Course: Open Project submission deadline", content)
233+
self.assertIn("SUMMARY:Test Course: Open Project peer review deadline", content)
234+
self.assertEqual(content.count("BEGIN:VEVENT"), 7)
235+
171236
def test_course_detail_authenticated_user(self):
172237
# Test the view for an authenticated user
173238

@@ -952,7 +1017,7 @@ def test_course_list_shows_active_course_metadata(self):
9521017
self.assertContains(response, "Apr 15, 2026")
9531018
self.assertContains(response, "13 weeks")
9541019
self.assertContains(response, "Submitted Homework")
955-
self.assertNotContains(
1020+
self.assertContains(
9561021
response,
9571022
"https://courses.datatalks.club/test-course/register",
9581023
)

courses/urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
path("", course.course_list, name="course_list"),
1111
path("wrapped/<int:year>/", wrapped.wrapped_view, name="wrapped"),
1212
path("wrapped/<int:year>/<int:student_id>/", wrapped.user_wrapped_view, name="user_wrapped"),
13+
path(
14+
"<slug:course_slug>/calendar.ics",
15+
course.course_calendar_view,
16+
name="course_calendar",
17+
),
1318
path(
1419
"<slug:course_slug>/",
1520
course.course_view,

courses/views/course.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import statistics
33

44
from collections import defaultdict
5+
from datetime import timedelta, timezone as datetime_timezone
56
from typing import List
67

78
from django.http import HttpRequest, HttpResponse
@@ -10,6 +11,7 @@
1011
from django.utils import timezone
1112
from django.contrib import messages
1213
from django.shortcuts import render, get_object_or_404, redirect
14+
from django.urls import reverse
1315

1416
from django.db.models import Prefetch, Value, Count
1517
from django.db.models import Case, When, IntegerField
@@ -393,6 +395,144 @@ def course_view(request: HttpRequest, course_slug: str) -> HttpResponse:
393395
return render(request, "courses/course.html", context)
394396

395397

398+
def escape_ics_text(value: str) -> str:
399+
return (
400+
value.replace("\\", "\\\\")
401+
.replace("\n", "\\n")
402+
.replace(";", "\\;")
403+
.replace(",", "\\,")
404+
)
405+
406+
407+
def format_ics_datetime(value) -> str:
408+
if timezone.is_naive(value):
409+
value = timezone.make_aware(
410+
value, timezone.get_current_timezone()
411+
)
412+
413+
value = value.astimezone(datetime_timezone.utc)
414+
return value.strftime("%Y%m%dT%H%M%SZ")
415+
416+
417+
def calendar_event(
418+
*,
419+
uid: str,
420+
summary: str,
421+
start,
422+
url: str,
423+
description: str,
424+
dtstamp,
425+
) -> list[str]:
426+
end = start + timedelta(minutes=30)
427+
428+
return [
429+
"BEGIN:VEVENT",
430+
f"UID:{escape_ics_text(uid)}",
431+
f"DTSTAMP:{format_ics_datetime(dtstamp)}",
432+
f"DTSTART:{format_ics_datetime(start)}",
433+
f"DTEND:{format_ics_datetime(end)}",
434+
f"SUMMARY:{escape_ics_text(summary)}",
435+
f"DESCRIPTION:{escape_ics_text(description)}",
436+
f"URL:{escape_ics_text(url)}",
437+
"END:VEVENT",
438+
]
439+
440+
441+
def course_calendar_view(
442+
request: HttpRequest,
443+
course_slug: str,
444+
) -> HttpResponse:
445+
course = get_object_or_404(Course, slug=course_slug, visible=True)
446+
dtstamp = timezone.now()
447+
events = []
448+
449+
homeworks = Homework.objects.filter(course=course).order_by("due_date")
450+
for homework in homeworks:
451+
url = request.build_absolute_uri(
452+
reverse(
453+
"homework",
454+
kwargs={
455+
"course_slug": course.slug,
456+
"homework_slug": homework.slug,
457+
},
458+
)
459+
)
460+
events.extend(
461+
calendar_event(
462+
uid=f"homework-{homework.id}@courses.datatalks.club",
463+
summary=f"{course.title}: {homework.title} deadline",
464+
start=homework.due_date,
465+
url=url,
466+
description=(
467+
f"Homework deadline for {homework.title}. "
468+
f"Open the assignment: {url}"
469+
),
470+
dtstamp=dtstamp,
471+
)
472+
)
473+
474+
projects = Project.objects.filter(course=course).order_by(
475+
"submission_due_date",
476+
"peer_review_due_date",
477+
)
478+
for project in projects:
479+
project_url = request.build_absolute_uri(
480+
reverse(
481+
"project",
482+
kwargs={
483+
"course_slug": course.slug,
484+
"project_slug": project.slug,
485+
},
486+
)
487+
)
488+
events.extend(
489+
calendar_event(
490+
uid=f"project-{project.id}-submission@courses.datatalks.club",
491+
summary=f"{course.title}: {project.title} submission deadline",
492+
start=project.submission_due_date,
493+
url=project_url,
494+
description=(
495+
f"Project submission deadline for {project.title}. "
496+
f"Open the project: {project_url}"
497+
),
498+
dtstamp=dtstamp,
499+
)
500+
)
501+
events.extend(
502+
calendar_event(
503+
uid=f"project-{project.id}-peer-review@courses.datatalks.club",
504+
summary=f"{course.title}: {project.title} peer review deadline",
505+
start=project.peer_review_due_date,
506+
url=project_url,
507+
description=(
508+
f"Project peer review deadline for {project.title}. "
509+
f"Open the project: {project_url}"
510+
),
511+
dtstamp=dtstamp,
512+
)
513+
)
514+
515+
calendar_lines = [
516+
"BEGIN:VCALENDAR",
517+
"VERSION:2.0",
518+
"PRODID:-//DataTalks.Club//Course Management Platform//EN",
519+
"CALSCALE:GREGORIAN",
520+
"METHOD:PUBLISH",
521+
f"X-WR-CALNAME:{escape_ics_text(course.title)} deadlines",
522+
*events,
523+
"END:VCALENDAR",
524+
]
525+
526+
response = HttpResponse(
527+
"\r\n".join(calendar_lines) + "\r\n",
528+
content_type="text/calendar; charset=utf-8",
529+
)
530+
response["Content-Disposition"] = (
531+
f'inline; filename="{course.slug}-deadlines.ics"'
532+
)
533+
return response
534+
535+
396536
def get_homeworks_for_course(course: Course, user) -> List[Homework]:
397537
if user.is_authenticated:
398538
queryset = Submission.objects.filter(student=user)

0 commit comments

Comments
 (0)