Skip to content

Commit d20336f

Browse files
committed
feat: Add a sample model and rest API.
We add a sample CourseArchiveStatus model and a rest API to access it. The plan is for this model to be used by the frontend plugin eventually to modify the course dashboard view.
1 parent 6b65245 commit d20336f

File tree

8 files changed

+737
-8
lines changed

8 files changed

+737
-8
lines changed

CLAUDE.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Build/Lint/Test Commands
6+
- Backend testing: `cd backend && pytest` or `cd backend && make test`
7+
- Run a single test: `cd backend && pytest tests/test_models.py::test_placeholder`
8+
- Quality checks: `cd backend && make quality`
9+
- Install requirements: `cd backend && make requirements`
10+
- Compile requirements: `cd backend && make compile-requirements`
11+
12+
## Code Style Guidelines
13+
- Python: Follow PEP 8 with max line length of 120
14+
- Use isort for import sorting
15+
- Document classes and functions with docstrings
16+
- Type hints are encouraged but not required
17+
- Error handling should use appropriate exceptions with descriptive messages
18+
- Use pytest for testing, with descriptive test function names
19+
- Frontend uses React and follows standard JSX conventions
20+
21+
Always run `make quality` before creating a PR to ensure consistent code style.

backend/sample_plugin/models.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,73 @@
11
"""
22
Database models for sample_plugin.
33
"""
4+
from django.contrib.auth import get_user_model
5+
from django.db import models
6+
from opaque_keys.edx.django.models import CourseKeyField
7+
8+
9+
class CourseArchiveStatus(models.Model):
10+
"""
11+
Model to track the archive status of a course.
12+
13+
Stores information about whether a course has been archived, when it was archived,
14+
and who archived it.
15+
"""
16+
17+
course_id = CourseKeyField(
18+
max_length=255,
19+
db_index=True,
20+
help_text="The unique identifier for the course."
21+
)
22+
23+
user = models.ForeignKey(
24+
get_user_model(),
25+
on_delete=models.CASCADE,
26+
related_name="course_archive_statuses",
27+
help_text="The user who this archive status is for."
28+
)
29+
30+
is_archived = models.BooleanField(
31+
default=False,
32+
db_index=True, # Add index for performance on this frequently filtered field
33+
help_text="Whether the course is archived."
34+
)
35+
36+
archive_date = models.DateTimeField(
37+
null=True,
38+
blank=True,
39+
help_text="The date and time when the course was archived."
40+
)
41+
42+
archived_by = models.ForeignKey(
43+
get_user_model(),
44+
null=True,
45+
blank=True,
46+
on_delete=models.SET_NULL,
47+
related_name="archived_courses",
48+
help_text="The user who archived the course."
49+
)
50+
51+
created_at = models.DateTimeField(auto_now_add=True)
52+
updated_at = models.DateTimeField(auto_now=True)
53+
54+
def __str__(self):
55+
"""
56+
Return a string representation of the course archive status.
57+
"""
58+
return f"{self.course_id} - {self.user.username} - {'Archived' if self.is_archived else 'Not Archived'}"
59+
60+
class Meta:
61+
"""
62+
Meta options for the CourseArchiveStatus model.
63+
"""
64+
verbose_name = "Course Archive Status"
65+
verbose_name_plural = "Course Archive Statuses"
66+
ordering = ["-updated_at"]
67+
# Ensure combination of course_id and user is unique
68+
constraints = [
69+
models.UniqueConstraint(
70+
fields=['course_id', 'user'],
71+
name='unique_user_course_archive_status'
72+
)
73+
]
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""
2+
Serializers for the sample_plugin app.
3+
"""
4+
from rest_framework import serializers
5+
6+
from sample_plugin.models import CourseArchiveStatus
7+
8+
9+
class CourseArchiveStatusSerializer(serializers.ModelSerializer):
10+
"""
11+
Serializer for the CourseArchiveStatus model.
12+
"""
13+
14+
class Meta:
15+
"""
16+
Meta class for CourseArchiveStatusSerializer.
17+
"""
18+
model = CourseArchiveStatus
19+
fields = [
20+
'id',
21+
'course_id',
22+
'user',
23+
'is_archived',
24+
'archive_date',
25+
'archived_by',
26+
'created_at',
27+
'updated_at',
28+
]
29+
read_only_fields = ['id', 'created_at', 'updated_at', 'archive_date', 'archived_by']

backend/sample_plugin/urls.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
"""
22
URLs for sample_plugin.
33
"""
4-
from django.urls import re_path # pylint: disable=unused-import
5-
from django.views.generic import TemplateView # pylint: disable=unused-import
4+
from django.urls import include, path
5+
from rest_framework.routers import DefaultRouter
66

7+
from sample_plugin.views import CourseArchiveStatusViewSet
8+
9+
# Create a router and register our viewsets with it
10+
router = DefaultRouter()
11+
router.register(r'course-archive-status', CourseArchiveStatusViewSet, basename='course-archive-status')
12+
13+
# The API URLs are now determined automatically by the router
714
urlpatterns = [
8-
# TODO: Fill in URL patterns and views here.
9-
# re_path(r'', TemplateView.as_view(template_name="sample_plugin/base.html")),
15+
path('api/v1/', include(router.urls)),
1016
]

backend/sample_plugin/views.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
"""
2+
Views for the sample_plugin app.
3+
"""
4+
import logging
5+
from django.utils import timezone
6+
from django_filters.rest_framework import DjangoFilterBackend
7+
from rest_framework import filters, permissions, viewsets
8+
from rest_framework.exceptions import PermissionDenied, ValidationError
9+
from rest_framework.pagination import PageNumberPagination
10+
from rest_framework.throttling import UserRateThrottle, AnonRateThrottle
11+
12+
from sample_plugin.models import CourseArchiveStatus
13+
from sample_plugin.serializers import CourseArchiveStatusSerializer
14+
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
class IsOwnerOrStaffSuperuser(permissions.BasePermission):
20+
"""
21+
Custom permission to only allow owners of an object or staff/superusers to view or edit it.
22+
"""
23+
24+
def has_permission(self, request, view):
25+
"""
26+
Return True if permission is granted to the view.
27+
"""
28+
# Allow authenticated users to list and create
29+
return request.user and request.user.is_authenticated
30+
31+
def has_object_permission(self, request, view, obj):
32+
"""
33+
Return True if permission is granted to the object.
34+
"""
35+
# Allow if the object belongs to the requesting user
36+
if obj.user == request.user:
37+
return True
38+
39+
# Allow staff users and superusers
40+
if request.user.is_staff or request.user.is_superuser:
41+
return True
42+
43+
return False
44+
45+
46+
class CourseArchiveStatusPagination(PageNumberPagination):
47+
"""
48+
Pagination class for CourseArchiveStatus.
49+
"""
50+
page_size = 20
51+
page_size_query_param = 'page_size'
52+
max_page_size = 100
53+
54+
55+
class CourseArchiveStatusThrottle(UserRateThrottle):
56+
"""
57+
Throttle for the CourseArchiveStatus API.
58+
"""
59+
rate = '60/minute'
60+
61+
62+
class CourseArchiveStatusViewSet(viewsets.ModelViewSet):
63+
"""
64+
API viewset for CourseArchiveStatus.
65+
66+
Allows users to view their own course archive statuses and staff/superusers to view all.
67+
Pagination is applied with a default page size of 20 (max 100).
68+
Filtering is available on course_id, user, and is_archived fields.
69+
Ordering is available on all fields.
70+
"""
71+
serializer_class = CourseArchiveStatusSerializer
72+
permission_classes = [IsOwnerOrStaffSuperuser]
73+
pagination_class = CourseArchiveStatusPagination
74+
throttle_classes = [CourseArchiveStatusThrottle, AnonRateThrottle]
75+
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
76+
filterset_fields = ['course_id', 'user', 'is_archived']
77+
ordering_fields = ['course_id', 'user', 'is_archived', 'archive_date', 'created_at', 'updated_at']
78+
ordering = ['-updated_at']
79+
80+
def get_queryset(self):
81+
"""
82+
Return the queryset for this viewset.
83+
84+
Regular users can only see their own records.
85+
Staff and superusers can see all records but with optimized queries.
86+
"""
87+
user = self.request.user
88+
89+
# Validate query parameters to prevent injection
90+
self._validate_query_params()
91+
92+
# Always use select_related to avoid N+1 queries
93+
base_queryset = CourseArchiveStatus.objects.select_related('user', 'archived_by')
94+
95+
if user.is_staff or user.is_superuser:
96+
return base_queryset
97+
98+
# Regular users only see their own records
99+
return base_queryset.filter(user=user)
100+
101+
def _validate_query_params(self):
102+
"""
103+
Validate query parameters to prevent injection.
104+
"""
105+
# Example validation for course_id format
106+
course_id = self.request.query_params.get('course_id')
107+
if course_id and not self._is_valid_course_id(course_id):
108+
logger.warning(
109+
"Invalid course_id in request: %s, user: %s",
110+
course_id,
111+
self.request.user.username
112+
)
113+
raise ValidationError({"course_id": "Invalid course ID format."})
114+
115+
def _is_valid_course_id(self, course_id):
116+
"""
117+
Check if the course_id is in a valid format.
118+
119+
This is a basic implementation - in production, you might use a more
120+
sophisticated validator from the edx-platform.
121+
"""
122+
try:
123+
from opaque_keys.edx.keys import CourseKey
124+
CourseKey.from_string(course_id)
125+
return True
126+
except:
127+
return False
128+
129+
def perform_create(self, serializer):
130+
"""
131+
Perform creation of a new CourseArchiveStatus.
132+
133+
Sets the user to the requesting user if not specified.
134+
Sets archive_date and archived_by if is_archived is True.
135+
"""
136+
data = serializer.validated_data.copy()
137+
138+
# Set user to requesting user if not specified
139+
if 'user' not in data:
140+
data['user'] = self.request.user
141+
# Only allow staff/superusers to create records for other users
142+
elif data['user'] != self.request.user and not (self.request.user.is_staff or self.request.user.is_superuser):
143+
logger.warning(
144+
"Permission denied: User %s tried to create a record for user %s",
145+
self.request.user.username,
146+
data['user'].username
147+
)
148+
raise PermissionDenied("You do not have permission to create records for other users.")
149+
150+
# Set archive_date and archived_by if is_archived is True
151+
if data.get('is_archived', False):
152+
data['archive_date'] = timezone.now()
153+
data['archived_by'] = self.request.user
154+
155+
# Create the record
156+
instance = serializer.save(**data)
157+
158+
# Log at debug level for normal operation
159+
logger.debug(
160+
"CourseArchiveStatus created: course_id=%s, user=%s, is_archived=%s",
161+
instance.course_id,
162+
instance.user.username,
163+
instance.is_archived
164+
)
165+
166+
return instance
167+
168+
def perform_update(self, serializer):
169+
"""
170+
Perform update of an existing CourseArchiveStatus.
171+
172+
Updates archive_date and archived_by if is_archived changes.
173+
"""
174+
instance = serializer.instance
175+
data = serializer.validated_data.copy()
176+
177+
# Handle archive_date and archived_by if is_archived changes
178+
if 'is_archived' in data:
179+
# If changing from not archived to archived
180+
if data['is_archived'] and not instance.is_archived:
181+
data['archive_date'] = timezone.now()
182+
data['archived_by'] = self.request.user
183+
# If changing from archived to not archived
184+
elif not data['is_archived'] and instance.is_archived:
185+
data['archive_date'] = None
186+
data['archived_by'] = None
187+
188+
# Update the record
189+
updated_instance = serializer.save(**data)
190+
191+
# Log at debug level
192+
logger.debug(
193+
"CourseArchiveStatus updated: course_id=%s, user=%s, is_archived=%s",
194+
updated_instance.course_id,
195+
updated_instance.user.username,
196+
updated_instance.is_archived
197+
)
198+
199+
return updated_instance
200+
201+
def perform_destroy(self, instance):
202+
"""
203+
Perform deletion of an existing CourseArchiveStatus.
204+
"""
205+
# Log at debug level before deletion
206+
logger.debug(
207+
"CourseArchiveStatus deleted: course_id=%s, user=%s, by=%s",
208+
instance.course_id,
209+
instance.user.username,
210+
self.request.user.username
211+
)
212+
213+
# Delete the instance
214+
return super().perform_destroy(instance)

0 commit comments

Comments
 (0)