Skip to content

Commit a96f42e

Browse files
Add REST API for course content management (#160)
Replace /data/ content endpoints with proper REST API under /api/ with full CRUD for courses, homeworks, projects, and questions. Closes #160
1 parent a2ee6c1 commit a96f42e

20 files changed

Lines changed: 1175 additions & 1547 deletions

api/__init__.py

Whitespace-only changes.

api/apps.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.apps import AppConfig
2+
3+
4+
class ApiConfig(AppConfig):
5+
default_auto_field = "django.db.models.BigAutoField"
6+
name = "api"

api/tests/__init__.py

Whitespace-only changes.

api/tests/test_courses.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import json
2+
3+
from django.test import TestCase, Client
4+
from django.utils import timezone
5+
6+
from accounts.models import CustomUser, Token
7+
from courses.models import Course, Homework, Project
8+
from courses.models.homework import HomeworkState
9+
from courses.models.project import ProjectState
10+
11+
12+
class CoursesAPITestCase(TestCase):
13+
14+
def setUp(self):
15+
self.user = CustomUser.objects.create(
16+
username="testuser",
17+
email="test@example.com",
18+
password="password",
19+
)
20+
self.token = Token.objects.create(user=self.user)
21+
self.client = Client()
22+
self.client.defaults["HTTP_AUTHORIZATION"] = f"Token {self.token.key}"
23+
24+
self.course = Course.objects.create(
25+
title="ML Zoomcamp",
26+
slug="ml-zoomcamp",
27+
description="Machine Learning course",
28+
)
29+
30+
def test_list_courses(self):
31+
response = self.client.get("/api/courses/")
32+
self.assertEqual(response.status_code, 200)
33+
data = response.json()
34+
self.assertEqual(len(data["courses"]), 1)
35+
self.assertEqual(data["courses"][0]["slug"], "ml-zoomcamp")
36+
37+
def test_list_courses_requires_auth(self):
38+
client = Client()
39+
response = client.get("/api/courses/")
40+
self.assertEqual(response.status_code, 401)
41+
42+
def test_course_detail(self):
43+
hw = Homework.objects.create(
44+
course=self.course,
45+
title="HW1",
46+
slug="hw1",
47+
description="",
48+
due_date=timezone.now(),
49+
state=HomeworkState.OPEN.value,
50+
)
51+
proj = Project.objects.create(
52+
course=self.course,
53+
title="Project 1",
54+
slug="project-1",
55+
description="",
56+
submission_due_date=timezone.now(),
57+
peer_review_due_date=timezone.now(),
58+
state=ProjectState.CLOSED.value,
59+
)
60+
61+
response = self.client.get("/api/courses/ml-zoomcamp/")
62+
self.assertEqual(response.status_code, 200)
63+
data = response.json()
64+
self.assertEqual(data["slug"], "ml-zoomcamp")
65+
self.assertEqual(len(data["homeworks"]), 1)
66+
self.assertEqual(len(data["projects"]), 1)
67+
68+
def test_course_detail_not_found(self):
69+
response = self.client.get("/api/courses/nonexistent/")
70+
self.assertEqual(response.status_code, 404)

api/tests/test_homeworks.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import json
2+
3+
from django.test import TestCase, Client
4+
from django.utils import timezone
5+
6+
from accounts.models import CustomUser, Token
7+
from courses.models import Course, Homework, Question
8+
from courses.models.homework import HomeworkState
9+
10+
11+
class HomeworksAPITestCase(TestCase):
12+
13+
def setUp(self):
14+
self.user = CustomUser.objects.create(
15+
username="testuser",
16+
email="test@example.com",
17+
password="password",
18+
)
19+
self.token = Token.objects.create(user=self.user)
20+
self.client = Client()
21+
self.client.defaults["HTTP_AUTHORIZATION"] = f"Token {self.token.key}"
22+
23+
self.course = Course.objects.create(
24+
title="Test Course",
25+
slug="test-course",
26+
description="Test",
27+
)
28+
29+
def _create_homework(self, slug="hw1", state=HomeworkState.CLOSED.value):
30+
return Homework.objects.create(
31+
course=self.course,
32+
title="Homework 1",
33+
slug=slug,
34+
description="Description",
35+
due_date=timezone.now(),
36+
state=state,
37+
)
38+
39+
def test_list_homeworks(self):
40+
self._create_homework()
41+
response = self.client.get(f"/api/courses/{self.course.slug}/homeworks/")
42+
self.assertEqual(response.status_code, 200)
43+
data = response.json()
44+
self.assertEqual(len(data["homeworks"]), 1)
45+
self.assertEqual(data["homeworks"][0]["slug"], "hw1")
46+
47+
def test_create_homework(self):
48+
payload = {
49+
"name": "Homework 2",
50+
"due_date": "2026-04-01T23:59:59Z",
51+
"description": "New homework",
52+
}
53+
response = self.client.post(
54+
f"/api/courses/{self.course.slug}/homeworks/",
55+
json.dumps(payload),
56+
content_type="application/json",
57+
)
58+
self.assertEqual(response.status_code, 201)
59+
data = response.json()
60+
self.assertEqual(len(data["created"]), 1)
61+
self.assertEqual(data["created"][0]["title"], "Homework 2")
62+
self.assertEqual(data["created"][0]["state"], "CL")
63+
64+
def test_create_homework_bulk(self):
65+
payload = [
66+
{"name": "HW A", "due_date": "2026-04-01T23:59:59Z"},
67+
{"name": "HW B", "due_date": "2026-04-02T23:59:59Z"},
68+
]
69+
response = self.client.post(
70+
f"/api/courses/{self.course.slug}/homeworks/",
71+
json.dumps(payload),
72+
content_type="application/json",
73+
)
74+
self.assertEqual(response.status_code, 201)
75+
data = response.json()
76+
self.assertEqual(len(data["created"]), 2)
77+
78+
def test_create_homework_with_questions(self):
79+
payload = {
80+
"name": "HW with Q",
81+
"due_date": "2026-04-01T23:59:59Z",
82+
"questions": [
83+
{
84+
"text": "What is 2+2?",
85+
"question_type": "MC",
86+
"possible_answers": ["3", "4", "5"],
87+
"correct_answer": "2",
88+
}
89+
],
90+
}
91+
response = self.client.post(
92+
f"/api/courses/{self.course.slug}/homeworks/",
93+
json.dumps(payload),
94+
content_type="application/json",
95+
)
96+
self.assertEqual(response.status_code, 201)
97+
data = response.json()
98+
self.assertEqual(data["created"][0]["questions_count"], 1)
99+
100+
def test_create_homework_missing_fields(self):
101+
payload = {"name": "No date"}
102+
response = self.client.post(
103+
f"/api/courses/{self.course.slug}/homeworks/",
104+
json.dumps(payload),
105+
content_type="application/json",
106+
)
107+
self.assertEqual(response.status_code, 400)
108+
109+
def test_create_homework_duplicate_slug(self):
110+
self._create_homework(slug="hw1")
111+
payload = {"name": "HW 1", "slug": "hw1", "due_date": "2026-04-01T23:59:59Z"}
112+
response = self.client.post(
113+
f"/api/courses/{self.course.slug}/homeworks/",
114+
json.dumps(payload),
115+
content_type="application/json",
116+
)
117+
self.assertEqual(response.status_code, 400)
118+
119+
def test_patch_homework_state(self):
120+
hw = self._create_homework()
121+
response = self.client.patch(
122+
f"/api/courses/{self.course.slug}/homeworks/{hw.id}/",
123+
json.dumps({"state": "OP"}),
124+
content_type="application/json",
125+
)
126+
self.assertEqual(response.status_code, 200)
127+
data = response.json()
128+
self.assertEqual(data["state"], "OP")
129+
hw.refresh_from_db()
130+
self.assertEqual(hw.state, "OP")
131+
132+
def test_patch_homework_description(self):
133+
hw = self._create_homework()
134+
response = self.client.patch(
135+
f"/api/courses/{self.course.slug}/homeworks/{hw.id}/",
136+
json.dumps({"description": "Updated"}),
137+
content_type="application/json",
138+
)
139+
self.assertEqual(response.status_code, 200)
140+
hw.refresh_from_db()
141+
self.assertEqual(hw.description, "Updated")
142+
143+
def test_patch_homework_invalid_state(self):
144+
hw = self._create_homework()
145+
response = self.client.patch(
146+
f"/api/courses/{self.course.slug}/homeworks/{hw.id}/",
147+
json.dumps({"state": "XX"}),
148+
content_type="application/json",
149+
)
150+
self.assertEqual(response.status_code, 400)
151+
152+
def test_patch_homework_invalid_field(self):
153+
hw = self._create_homework()
154+
response = self.client.patch(
155+
f"/api/courses/{self.course.slug}/homeworks/{hw.id}/",
156+
json.dumps({"id": 999}),
157+
content_type="application/json",
158+
)
159+
self.assertEqual(response.status_code, 400)
160+
161+
def test_delete_homework_closed(self):
162+
hw = self._create_homework(state=HomeworkState.CLOSED.value)
163+
response = self.client.delete(
164+
f"/api/courses/{self.course.slug}/homeworks/{hw.id}/"
165+
)
166+
self.assertEqual(response.status_code, 200)
167+
self.assertFalse(Homework.objects.filter(id=hw.id).exists())
168+
169+
def test_delete_homework_not_closed(self):
170+
hw = self._create_homework(state=HomeworkState.OPEN.value)
171+
response = self.client.delete(
172+
f"/api/courses/{self.course.slug}/homeworks/{hw.id}/"
173+
)
174+
self.assertEqual(response.status_code, 400)
175+
self.assertTrue(Homework.objects.filter(id=hw.id).exists())

api/tests/test_projects.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import json
2+
3+
from django.test import TestCase, Client
4+
from django.utils import timezone
5+
6+
from accounts.models import CustomUser, Token
7+
from courses.models import Course, Project
8+
from courses.models.project import ProjectState
9+
10+
11+
class ProjectsAPITestCase(TestCase):
12+
13+
def setUp(self):
14+
self.user = CustomUser.objects.create(
15+
username="testuser",
16+
email="test@example.com",
17+
password="password",
18+
)
19+
self.token = Token.objects.create(user=self.user)
20+
self.client = Client()
21+
self.client.defaults["HTTP_AUTHORIZATION"] = f"Token {self.token.key}"
22+
23+
self.course = Course.objects.create(
24+
title="Test Course",
25+
slug="test-course",
26+
description="Test",
27+
)
28+
29+
def _create_project(self, slug="proj1", state=ProjectState.CLOSED.value):
30+
return Project.objects.create(
31+
course=self.course,
32+
title="Project 1",
33+
slug=slug,
34+
description="Description",
35+
submission_due_date=timezone.now(),
36+
peer_review_due_date=timezone.now(),
37+
state=state,
38+
)
39+
40+
def test_list_projects(self):
41+
self._create_project()
42+
response = self.client.get(f"/api/courses/{self.course.slug}/projects/")
43+
self.assertEqual(response.status_code, 200)
44+
data = response.json()
45+
self.assertEqual(len(data["projects"]), 1)
46+
47+
def test_create_project(self):
48+
payload = {
49+
"name": "Project 2",
50+
"submission_due_date": "2026-04-01T23:59:59Z",
51+
"peer_review_due_date": "2026-04-08T23:59:59Z",
52+
}
53+
response = self.client.post(
54+
f"/api/courses/{self.course.slug}/projects/",
55+
json.dumps(payload),
56+
content_type="application/json",
57+
)
58+
self.assertEqual(response.status_code, 201)
59+
data = response.json()
60+
self.assertEqual(len(data["created"]), 1)
61+
self.assertEqual(data["created"][0]["state"], "CL")
62+
63+
def test_create_project_bulk(self):
64+
payload = [
65+
{
66+
"name": "P1",
67+
"submission_due_date": "2026-04-01T23:59:59Z",
68+
"peer_review_due_date": "2026-04-08T23:59:59Z",
69+
},
70+
{
71+
"name": "P2",
72+
"submission_due_date": "2026-05-01T23:59:59Z",
73+
"peer_review_due_date": "2026-05-08T23:59:59Z",
74+
},
75+
]
76+
response = self.client.post(
77+
f"/api/courses/{self.course.slug}/projects/",
78+
json.dumps(payload),
79+
content_type="application/json",
80+
)
81+
self.assertEqual(response.status_code, 201)
82+
self.assertEqual(len(response.json()["created"]), 2)
83+
84+
def test_create_project_missing_fields(self):
85+
payload = {"name": "No dates"}
86+
response = self.client.post(
87+
f"/api/courses/{self.course.slug}/projects/",
88+
json.dumps(payload),
89+
content_type="application/json",
90+
)
91+
self.assertEqual(response.status_code, 400)
92+
93+
def test_patch_project_state(self):
94+
proj = self._create_project()
95+
response = self.client.patch(
96+
f"/api/courses/{self.course.slug}/projects/{proj.id}/",
97+
json.dumps({"state": "CS"}),
98+
content_type="application/json",
99+
)
100+
self.assertEqual(response.status_code, 200)
101+
proj.refresh_from_db()
102+
self.assertEqual(proj.state, "CS")
103+
104+
def test_patch_project_invalid_state(self):
105+
proj = self._create_project()
106+
response = self.client.patch(
107+
f"/api/courses/{self.course.slug}/projects/{proj.id}/",
108+
json.dumps({"state": "XX"}),
109+
content_type="application/json",
110+
)
111+
self.assertEqual(response.status_code, 400)
112+
113+
def test_delete_project_closed(self):
114+
proj = self._create_project(state=ProjectState.CLOSED.value)
115+
response = self.client.delete(
116+
f"/api/courses/{self.course.slug}/projects/{proj.id}/"
117+
)
118+
self.assertEqual(response.status_code, 200)
119+
self.assertFalse(Project.objects.filter(id=proj.id).exists())
120+
121+
def test_delete_project_not_closed(self):
122+
proj = self._create_project(state=ProjectState.COLLECTING_SUBMISSIONS.value)
123+
response = self.client.delete(
124+
f"/api/courses/{self.course.slug}/projects/{proj.id}/"
125+
)
126+
self.assertEqual(response.status_code, 400)
127+
self.assertTrue(Project.objects.filter(id=proj.id).exists())

0 commit comments

Comments
 (0)