Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 72 additions & 7 deletions forum/api.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,38 @@
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.exceptions import AuthenticationFailed
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from rest_framework.response import Response
from django.contrib.auth.models import User


# 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:
Expand All @@ -31,21 +46,71 @@ 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 = [IsAuthenticatedOrReadOnly]

def get_queryset(self):
return Post.objects.select_related("author").prefetch_related("comments__author")

def get_serializer_class(self):
if self.action == "list":
return PostListSerializer
if self.action == "create":
return PostCreateSerializer
return PostDetailSerializer

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()
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)

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

77 changes: 70 additions & 7 deletions forum/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -72,11 +72,66 @@ 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_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)
Expand All @@ -87,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'))
3 changes: 2 additions & 1 deletion forum/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,6 @@
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)),
path('api/register/', views.UserRegistrationView.as_view(), name='register_api'),
]
24 changes: 21 additions & 3 deletions forum/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
18 changes: 13 additions & 5 deletions lean_forum/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,16 @@
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.humanize',
"rest_framework",
'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',
Expand All @@ -55,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 = [
Expand Down Expand Up @@ -145,12 +152,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,
}
6 changes: 6 additions & 0 deletions lean_forum/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ sqlparse>=0.5.3
django-webpush>=0.3.6
bleach>=6.3.0
djangorestframework
djangorestframework-simplejwt
django-cors-headers
openai
Loading