From 2d632d7b21e6b1776eb7ebef53f5d5c955781bfc Mon Sep 17 00:00:00 2001 From: Liang Chenxuan Date: Wed, 8 Jul 2026 12:18:59 +0800 Subject: [PATCH 1/5] add JWT token and create post api --- forum/api.py | 52 ++++++++++++++++++++++++++++++++++++------ forum/tests.py | 39 +++++++++++++++++++++++++++++++ forum/urls.py | 2 +- lean_forum/settings.py | 12 ++++++---- lean_forum/urls.py | 6 +++++ requirements.txt | 1 + 6 files changed, 99 insertions(+), 13 deletions(-) diff --git a/forum/api.py b/forum/api.py index 53bb4f4..2f74b88 100644 --- a/forum/api.py +++ b/forum/api.py @@ -1,23 +1,36 @@ from .models import Post, Comment -from rest_framework import routers, serializers, viewsets +from rest_framework import routers, serializers, viewsets, status +from rest_framework.decorators import action +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response + -# Serializers define the API representation. class CommentSerializer(serializers.ModelSerializer): - author_name = serializers.CharField(source="author.username") + author_name = serializers.CharField(source="author.username", read_only=True) class Meta: model = Comment fields = ["id", "author", "author_name", "content", "created_at"] + read_only_fields = ["author", "author_name", "created_at"] + + +class CommentCreateSerializer(serializers.ModelSerializer): + class Meta: + model = Comment + fields = ["content"] + class PostListSerializer(serializers.ModelSerializer): - author_name = serializers.CharField(source="author.username") + author_name = serializers.CharField(source="author.username", read_only=True) class Meta: model = Post fields = ["id", "author", "author_name", "title", "created_at"] + read_only_fields = ["author", "author_name", "created_at"] + class PostDetailSerializer(serializers.ModelSerializer): - author_name = serializers.CharField(source="author.username") + author_name = serializers.CharField(source="author.username", read_only=True) comments = CommentSerializer(many=True, read_only=True) class Meta: @@ -31,10 +44,18 @@ class Meta: "created_at", "comments", ] + read_only_fields = ["author", "author_name", "created_at", "comments"] + + +class PostCreateSerializer(serializers.ModelSerializer): + class Meta: + model = Post + fields = ["title", "content"] + -# ViewSets define the view behavior. class PostViewSet(viewsets.ModelViewSet): queryset = Post.objects.all() + permission_classes = [AllowAny] def get_queryset(self): return Post.objects.select_related("author").prefetch_related("comments__author") @@ -42,10 +63,27 @@ def get_queryset(self): def get_serializer_class(self): if self.action == "list": return PostListSerializer + if self.action == "create": + return PostCreateSerializer return PostDetailSerializer + def get_permissions(self): + if self.action in {"create", "comments"}: + return [IsAuthenticated()] + return [AllowAny()] + + def perform_create(self, serializer): + serializer.save(author=self.request.user) + + @action(detail=True, methods=["post"], url_path="comments") + def comments(self, request, pk=None): + post = self.get_object() + serializer = CommentCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save(author=request.user, post=post) + return Response(CommentSerializer(serializer.instance).data, status=status.HTTP_201_CREATED) + -# Routers provide a way of automatically determining the URL conf. router = routers.DefaultRouter() router.register(r"posts", PostViewSet) diff --git a/forum/tests.py b/forum/tests.py index d87fcae..463e7af 100644 --- a/forum/tests.py +++ b/forum/tests.py @@ -72,6 +72,45 @@ def test_anonymous_cannot_create_post(self): self.assertEqual(resp.status_code, 302) self.assertFalse(Post.objects.filter(title='NoAuth').exists()) + def test_api_create_post_and_comment(self): + posts_url = reverse('post-list') + + # Unauthorized + anon_resp = self.client.post( + posts_url, + {'title': 'API Post', 'content': 'hello from api'}, + content_type='application/json', + ) + self.assertEqual(anon_resp.status_code, 401) + + # Get JWT token for the user + token_resp = self.client.post( + reverse('token_obtain_pair'), + {'username': self.username, 'password': self.password}, + content_type='application/json', + ) + self.assertEqual(token_resp.status_code, 200) + access_token = token_resp.json()['access'] + create_resp = self.client.post( + posts_url, + {'title': 'API Post', 'content': 'hello from api'}, + content_type='application/json', + HTTP_AUTHORIZATION=f'Bearer {access_token}', + ) + self.assertEqual(create_resp.status_code, 201) + self.assertTrue(Post.objects.filter(title='API Post', author=self.user).exists()) + + post = Post.objects.get(title='API Post', author=self.user) + comments_url = reverse('post-comments', kwargs={'pk': post.id}) + comment_resp = self.client.post( + comments_url, + {'content': 'nice api comment'}, + content_type='application/json', + HTTP_AUTHORIZATION=f'Bearer {access_token}', + ) + self.assertEqual(comment_resp.status_code, 201) + self.assertTrue(Comment.objects.filter(post=post, author=self.user, content='nice api comment').exists()) + def test_post_delete_only_author(self): other = User.objects.create_user('other2', password='p3') post = Post.objects.create(author=self.user, title='to_delete', content='c') diff --git a/forum/urls.py b/forum/urls.py index 0552b48..a7973f2 100644 --- a/forum/urls.py +++ b/forum/urls.py @@ -25,5 +25,5 @@ path('register/', views.RegisterView.as_view(), name='register'), path('about/', views.about_view, name='about'), path('logout/', views.logout_view, name='logout'), - path('api/', include(api.router.urls)), + path('api/forum/', include(api.router.urls)), ] diff --git a/lean_forum/settings.py b/lean_forum/settings.py index 2a97a1a..7beabec 100644 --- a/lean_forum/settings.py +++ b/lean_forum/settings.py @@ -39,7 +39,8 @@ 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.humanize', - "rest_framework", + 'rest_framework', + 'rest_framework_simplejwt', 'mdeditor', 'webpush', 'forum', @@ -145,12 +146,13 @@ } REST_FRAMEWORK = { - # Use Django's standard `django.contrib.auth` permissions, - # or allow read-only access for unauthenticated users. + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication", + "rest_framework.authentication.SessionAuthentication", + ], "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly", ], 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', - 'PAGE_SIZE': 20 - + 'PAGE_SIZE': 20, } diff --git a/lean_forum/urls.py b/lean_forum/urls.py index 94a59da..584d1ce 100644 --- a/lean_forum/urls.py +++ b/lean_forum/urls.py @@ -18,12 +18,18 @@ from django.urls import include, path, re_path from django.conf import settings from django.conf.urls.static import static +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, +) urlpatterns = [ path('admin/', admin.site.urls), re_path(r'^webpush/', include('webpush.urls')), path('mdeditor/', include('mdeditor.urls')), path('', include('forum.urls')), + path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), ] if settings.DEBUG: diff --git a/requirements.txt b/requirements.txt index a5b9a8d..4a42338 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,5 @@ sqlparse>=0.5.3 django-webpush>=0.3.6 bleach>=6.3.0 djangorestframework +djangorestframework-simplejwt openai From d8f59d6d38e9f327ef3ff93651ba4827e434c196 Mon Sep 17 00:00:00 2001 From: Liang Chenxuan Date: Wed, 8 Jul 2026 14:43:27 +0800 Subject: [PATCH 2/5] Update access control to restrict only the author to deleting posts, and add corresponding test cases. --- forum/api.py | 16 +++++++++------- forum/tests.py | 24 ++++++++++++++++++++---- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/forum/api.py b/forum/api.py index 2f74b88..d3438b0 100644 --- a/forum/api.py +++ b/forum/api.py @@ -1,7 +1,8 @@ from .models import Post, Comment from rest_framework import routers, serializers, viewsets, status from rest_framework.decorators import action -from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.exceptions import AuthenticationFailed +from rest_framework.permissions import IsAuthenticatedOrReadOnly from rest_framework.response import Response @@ -55,7 +56,7 @@ class Meta: class PostViewSet(viewsets.ModelViewSet): queryset = Post.objects.all() - permission_classes = [AllowAny] + permission_classes = [IsAuthenticatedOrReadOnly] def get_queryset(self): return Post.objects.select_related("author").prefetch_related("comments__author") @@ -67,14 +68,15 @@ def get_serializer_class(self): return PostCreateSerializer return PostDetailSerializer - def get_permissions(self): - if self.action in {"create", "comments"}: - return [IsAuthenticated()] - return [AllowAny()] - def perform_create(self, serializer): serializer.save(author=self.request.user) + def destroy(self, request, *args, **kwargs): + post = self.get_object() + if not request.user.is_authenticated or post.author != request.user: + raise AuthenticationFailed("Only the author can delete this post.") + return super().destroy(request, *args, **kwargs) + @action(detail=True, methods=["post"], url_path="comments") def comments(self, request, pk=None): post = self.get_object() diff --git a/forum/tests.py b/forum/tests.py index 463e7af..f08e30b 100644 --- a/forum/tests.py +++ b/forum/tests.py @@ -12,6 +12,7 @@ def setUp(self): self.username = 'testuser' self.password = 'pass12345' self.user = User.objects.create_user(self.username, password=self.password) + self.user2 = User.objects.create_user('other', password='p2') self.item = Item.objects.create(name='Item1', content='desc') def test_create_post(self): @@ -61,9 +62,8 @@ def test_rate_item_and_unique_constraint(self): self.assertEqual(r.score, 2) def test_average_rating_method(self): - other = User.objects.create_user('other', password='p2') Rating.objects.create(user=self.user, item=self.item, score=3) - Rating.objects.create(user=other, item=self.item, score=5) + Rating.objects.create(user=self.user2, item=self.item, score=5) self.assertAlmostEqual(self.item.average_rating(), 4.0) def test_anonymous_cannot_create_post(self): @@ -111,11 +111,27 @@ def test_api_create_post_and_comment(self): self.assertEqual(comment_resp.status_code, 201) self.assertTrue(Comment.objects.filter(post=post, author=self.user, content='nice api comment').exists()) + def test_api_others_cannot_delete_post(self): + post = Post.objects.create(author=self.user, title='to_delete', content='c') + url = reverse('post-detail', kwargs={'pk': post.id}) + + # anonymous user should not be able to delete + resp = self.client.delete(url) + + self.assertEqual(resp.status_code, 401) + self.assertTrue(Post.objects.filter(id=post.id).exists()) + + # logged in as different user + self.client.login(username='other', password='p2') + resp = self.client.delete(url) + + self.assertEqual(resp.status_code, 401) + self.assertTrue(Post.objects.filter(id=post.id).exists()) + def test_post_delete_only_author(self): - other = User.objects.create_user('other2', password='p3') post = Post.objects.create(author=self.user, title='to_delete', content='c') # login as different user - self.client.login(username='other2', password='p3') + self.client.login(username='other', password='p2') url = reverse('post_delete', kwargs={'pk': post.id}) resp = self.client.post(url) # Other user should not be allowed to delete (404 from queryset filter) From 3650fe7ba884d944ab1810836bcf75ad7be933d6 Mon Sep 17 00:00:00 2001 From: Liang Chenxuan Date: Wed, 8 Jul 2026 15:11:33 +0800 Subject: [PATCH 3/5] add an api to register user --- forum/api.py | 25 +++++++++++++++++++++++++ forum/tests.py | 14 +++++++++++--- forum/urls.py | 1 + forum/views.py | 20 +++++++++++++++++++- 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/forum/api.py b/forum/api.py index d3438b0..0d10abd 100644 --- a/forum/api.py +++ b/forum/api.py @@ -4,6 +4,7 @@ from rest_framework.exceptions import AuthenticationFailed from rest_framework.permissions import IsAuthenticatedOrReadOnly from rest_framework.response import Response +from django.contrib.auth.models import User class CommentSerializer(serializers.ModelSerializer): @@ -89,3 +90,27 @@ def comments(self, request, pk=None): router = routers.DefaultRouter() router.register(r"posts", PostViewSet) +class UserRegistrationSerializer(serializers.ModelSerializer): + password2 = serializers.CharField(style={'input_type': 'password'}, write_only=True) + email = serializers.EmailField(required=False, allow_blank=True) + + class Meta: + model = User + fields = ['username', 'email', 'password', 'password2'] + extra_kwargs = { + 'password': {'write_only': True} + } + + def validate(self, attrs): + if attrs['password'] != attrs['password2']: + raise serializers.ValidationError({"password": "Password fields didn't match."}) + return attrs + + def create(self, validated_data): + user = User.objects.create_user( + username=validated_data['username'], + password=validated_data['password'], + email=validated_data.get('email', '') + ) + return user + diff --git a/forum/tests.py b/forum/tests.py index f08e30b..42d1f43 100644 --- a/forum/tests.py +++ b/forum/tests.py @@ -142,10 +142,18 @@ def test_item_str_and_average_zero(self): # Newly created item without ratings should report 0 average self.assertAlmostEqual(self.item.average_rating(), 0) self.assertIn('(avg: 0.0)', str(self.item)) + + def test_register_api_creates_user(self): + url = reverse('register_api') + resp = self.client.post(url, {'username': 'newuser', 'password': 'pw1', 'password2': 'pw1'}, content_type='application/json') + self.assertEqual(resp.status_code, 201) + self.assertTrue(User.objects.filter(username='newuser').exists()) + new_user = User.objects.get(username='newuser') + self.assertTrue(new_user.check_password('pw1')) def test_register_view_creates_user(self): url = reverse('register') - resp = self.client.post(url, {'username': 'newuser', 'password': 'pw1', 'confirm_password': 'pw1'}, follow=True) - self.assertEqual(User.objects.filter(username='newuser').count(), 1) - new_user = User.objects.get(username='newuser') + resp = self.client.post(url, {'username': 'newuser2', 'password': 'pw1', 'confirm_password': 'pw1'}, follow=True) + self.assertEqual(User.objects.filter(username='newuser2').count(), 1) + new_user = User.objects.get(username='newuser2') self.assertTrue(new_user.check_password('pw1')) diff --git a/forum/urls.py b/forum/urls.py index a7973f2..7fbff38 100644 --- a/forum/urls.py +++ b/forum/urls.py @@ -26,4 +26,5 @@ path('about/', views.about_view, name='about'), path('logout/', views.logout_view, name='logout'), path('api/forum/', include(api.router.urls)), + path('api/register/', views.UserRegistrationView.as_view(), name='register_api'), ] diff --git a/forum/views.py b/forum/views.py index 0055ea7..45a113f 100644 --- a/forum/views.py +++ b/forum/views.py @@ -12,8 +12,14 @@ from django.contrib import messages from django.views.generic import ListView, View, DeleteView from django.contrib.auth.mixins import LoginRequiredMixin -from .utils import send_group_notification +from rest_framework import status +from rest_framework.response import Response +from rest_framework.permissions import AllowAny +from rest_framework.views import APIView + +from forum.api import UserRegistrationSerializer +from .utils import send_group_notification from forum.form import MDEditorCommentForm, MDEditorModelForm, CollectionForm from forum.models import Comment, Item, Post, Rating, Collection, CollectionPost from forum.bots_manager import manager @@ -380,3 +386,15 @@ def post_add_to_collection(request, post_id): 'post': post, 'collections': collections, }) + +class UserRegistrationView(APIView): + permission_classes = [AllowAny] + + def post(self, request): + serializer = UserRegistrationSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response({ + "message": "User registered successfully" + }, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) From f5300959ec85c4ce61ff6497392695bdd4b2635b Mon Sep 17 00:00:00 2001 From: Liang Chenxuan Date: Wed, 8 Jul 2026 15:34:16 +0800 Subject: [PATCH 4/5] remove irrelevant references --- forum/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/forum/views.py b/forum/views.py index 45a113f..dd1314b 100644 --- a/forum/views.py +++ b/forum/views.py @@ -1,5 +1,5 @@ -import json, re, random -from django.db.models import F, CharField, Subquery, Value +import re, random +from django.db.models import F from django.db import models as db_models from django.http import JsonResponse from django.urls import reverse_lazy From 74b1944867024e854811e808163e6222545c8bb8 Mon Sep 17 00:00:00 2001 From: Liang Chenxuan Date: Thu, 9 Jul 2026 16:59:55 +0800 Subject: [PATCH 5/5] add corsheaders --- lean_forum/settings.py | 6 ++++++ requirements.txt | 1 + 2 files changed, 7 insertions(+) diff --git a/lean_forum/settings.py b/lean_forum/settings.py index 7beabec..3367e5e 100644 --- a/lean_forum/settings.py +++ b/lean_forum/settings.py @@ -41,12 +41,14 @@ 'django.contrib.humanize', 'rest_framework', 'rest_framework_simplejwt', + 'corsheaders', 'mdeditor', 'webpush', 'forum', ] MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -56,6 +58,10 @@ 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] +CORS_ALLOWED_ORIGINS = [] + +CORS_ALLOW_ALL_ORIGINS = DEBUG # Allow all origins in development mode + ROOT_URLCONF = 'lean_forum.urls' TEMPLATES = [ diff --git a/requirements.txt b/requirements.txt index 4a42338..0f80b3d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,5 @@ django-webpush>=0.3.6 bleach>=6.3.0 djangorestframework djangorestframework-simplejwt +django-cors-headers openai