Skip to content

Commit 7e5714d

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 f114a9c commit 7e5714d

File tree

9 files changed

+718
-8
lines changed

9 files changed

+718
-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: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,65 @@
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 and when it was archived.
14+
15+
.. no_pii: This model does not store PII directly, only references to users via foreign keys.
16+
"""
17+
18+
course_id = CourseKeyField(
19+
max_length=255,
20+
db_index=True,
21+
help_text="The unique identifier for the course."
22+
)
23+
24+
user = models.ForeignKey(
25+
get_user_model(),
26+
on_delete=models.CASCADE,
27+
related_name="course_archive_statuses",
28+
help_text="The user who this archive status is for."
29+
)
30+
31+
is_archived = models.BooleanField(
32+
default=False,
33+
db_index=True, # Add index for performance on this frequently filtered field
34+
help_text="Whether the course is archived."
35+
)
36+
37+
archive_date = models.DateTimeField(
38+
null=True,
39+
blank=True,
40+
help_text="The date and time when the course was archived."
41+
)
42+
43+
created_at = models.DateTimeField(auto_now_add=True)
44+
updated_at = models.DateTimeField(auto_now=True)
45+
46+
def __str__(self):
47+
"""
48+
Return a string representation of the course archive status.
49+
"""
50+
return f"{self.course_id} - {self.user.username} - {'Archived' if self.is_archived else 'Not Archived'}"
51+
52+
class Meta:
53+
"""
54+
Meta options for the CourseArchiveStatus model.
55+
"""
56+
verbose_name = "Course Archive Status"
57+
verbose_name_plural = "Course Archive Statuses"
58+
ordering = ["-updated_at"]
59+
# Ensure combination of course_id and user is unique
60+
constraints = [
61+
models.UniqueConstraint(
62+
fields=['course_id', 'user'],
63+
name='unique_user_course_archive_status'
64+
)
65+
]
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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+
'created_at',
26+
'updated_at',
27+
]
28+
read_only_fields = ['id', 'created_at', 'updated_at', 'archive_date']

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: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
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')
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 if is_archived is True
151+
if data.get('is_archived', False):
152+
data['archive_date'] = timezone.now()
153+
154+
# Create the record
155+
instance = serializer.save(**data)
156+
157+
# Log at debug level for normal operation
158+
logger.debug(
159+
"CourseArchiveStatus created: course_id=%s, user=%s, is_archived=%s",
160+
instance.course_id,
161+
instance.user.username,
162+
instance.is_archived
163+
)
164+
165+
return instance
166+
167+
def perform_update(self, serializer):
168+
"""
169+
Perform update of an existing CourseArchiveStatus.
170+
171+
Updates archive_date and archived_by if is_archived changes.
172+
"""
173+
instance = serializer.instance
174+
data = serializer.validated_data.copy()
175+
176+
# Handle archive_date if is_archived changes
177+
if 'is_archived' in data:
178+
# If changing from not archived to archived
179+
if data['is_archived'] and not instance.is_archived:
180+
data['archive_date'] = timezone.now()
181+
# If changing from archived to not archived
182+
elif not data['is_archived'] and instance.is_archived:
183+
data['archive_date'] = None
184+
185+
# Update the record
186+
updated_instance = serializer.save(**data)
187+
188+
# Log at debug level
189+
logger.debug(
190+
"CourseArchiveStatus updated: course_id=%s, user=%s, is_archived=%s",
191+
updated_instance.course_id,
192+
updated_instance.user.username,
193+
updated_instance.is_archived
194+
)
195+
196+
return updated_instance
197+
198+
def perform_destroy(self, instance):
199+
"""
200+
Perform deletion of an existing CourseArchiveStatus.
201+
"""
202+
# Log at debug level before deletion
203+
logger.debug(
204+
"CourseArchiveStatus deleted: course_id=%s, user=%s, by=%s",
205+
instance.course_id,
206+
instance.user.username,
207+
self.request.user.username
208+
)
209+
210+
# Delete the instance
211+
return super().perform_destroy(instance)

backend/test_settings.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ def root(*args):
3232
'django.contrib.contenttypes',
3333
'django.contrib.messages',
3434
'django.contrib.sessions',
35+
'rest_framework',
36+
'django_filters',
3537
'sample_plugin',
3638
)
3739

@@ -44,9 +46,9 @@ def root(*args):
4446
SECRET_KEY = 'insecure-secret-key'
4547

4648
MIDDLEWARE = (
49+
'django.contrib.sessions.middleware.SessionMiddleware',
4750
'django.contrib.auth.middleware.AuthenticationMiddleware',
4851
'django.contrib.messages.middleware.MessageMiddleware',
49-
'django.contrib.sessions.middleware.SessionMiddleware',
5052
)
5153

5254
TEMPLATES = [{
@@ -59,3 +61,24 @@ def root(*args):
5961
],
6062
},
6163
}]
64+
65+
REST_FRAMEWORK = {
66+
'DEFAULT_PERMISSION_CLASSES': [
67+
'rest_framework.permissions.IsAuthenticated',
68+
],
69+
'DEFAULT_AUTHENTICATION_CLASSES': [
70+
'rest_framework.authentication.SessionAuthentication',
71+
],
72+
'DEFAULT_FILTER_BACKENDS': [
73+
'django_filters.rest_framework.DjangoFilterBackend',
74+
'rest_framework.filters.OrderingFilter',
75+
],
76+
'DEFAULT_THROTTLE_CLASSES': [
77+
'rest_framework.throttling.AnonRateThrottle',
78+
'rest_framework.throttling.UserRateThrottle',
79+
],
80+
'DEFAULT_THROTTLE_RATES': {
81+
'anon': '20/hour',
82+
'user': '100/hour',
83+
},
84+
}

backend/tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)