diff --git a/forum/__init__.py b/forum/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/forum/admin.py b/forum/admin.py new file mode 100644 index 000000000..63ad5a5c6 --- /dev/null +++ b/forum/admin.py @@ -0,0 +1,7 @@ +from django.contrib import admin + +from .models import Post, Comment + +# Register your models here. +admin.site.register(Post) +admin.site.register(Comment) \ No newline at end of file diff --git a/forum/api/__init__.py b/forum/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/forum/api/permissions.py b/forum/api/permissions.py new file mode 100644 index 000000000..799cd5aa9 --- /dev/null +++ b/forum/api/permissions.py @@ -0,0 +1,14 @@ +from rest_framework import permissions + +from yaksh.models import Course + + +class IsAuthorOrReadOnly(permissions.BasePermission): + + def has_object_permission(self, request, view, obj): + # Read-only permissions are allowed for any request + if request.method in permissions.SAFE_METHODS: + return True + + # write permissions are only allowed to the author of a post + return obj.creator == request.user diff --git a/forum/api/relations.py b/forum/api/relations.py new file mode 100644 index 000000000..0a759a77a --- /dev/null +++ b/forum/api/relations.py @@ -0,0 +1,13 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + +from yaksh.models import Course, Lesson + + +class PostObjectRelatedField(serializers.RelatedField): + def to_representation(self, value): + if isinstance(value, Course): + return 'Course: ' + value.name + elif isinstance(value, Lesson): + return 'Lesson: ' + value.name + raise Exception('Unexpected type of tagged object') diff --git a/forum/api/serializers.py b/forum/api/serializers.py new file mode 100644 index 000000000..95a651689 --- /dev/null +++ b/forum/api/serializers.py @@ -0,0 +1,35 @@ +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType + +from rest_framework import serializers + +from forum.models import Post, Comment +from yaksh.models import Course, Lesson +from .relations import PostObjectRelatedField + + +class CommentSerializer(serializers.ModelSerializer): + + class Meta: + model = Comment + fields = ['id', 'description', 'image', 'active', 'anonymous', 'creator', + 'post_field'] + + +class PostSerializer(serializers.ModelSerializer): + comments = CommentSerializer(read_only=True, many=True) + target = PostObjectRelatedField(read_only=True) + + class Meta: + model = Post + fields = ['id', 'title', 'creator', 'description', 'created_at', + 'modified_at', 'image', 'active', 'anonymous', + 'target_id', 'target', 'comments'] + + def create(self, validated_data): + target_id = validated_data.get('target_id') + post = Post(**validated_data) + object = Course.objects.get(id=target_id) + post.target = object + post.save() + return post \ No newline at end of file diff --git a/forum/api/tests.py b/forum/api/tests.py new file mode 100644 index 000000000..fe85fe35a --- /dev/null +++ b/forum/api/tests.py @@ -0,0 +1,253 @@ +from django.contrib.auth.models import Group, User +from django.contrib.contenttypes.models import ContentType + +from rest_framework.test import APITestCase +from rest_framework.reverse import reverse +from rest_framework import status + + +from yaksh.models import Course +from forum.models import Post, Comment + + +class TestPost(APITestCase): + def setUp(self): + self.mod_group = Group.objects.create(name='moderator') + self.student_pass = 'student' + self.student = User.objects.create_user( + username='student', + password=self.student_pass, + first_name='Tony', + last_name='Stark', + email='tony@starkenterprises.com' + ) + + self.mod_user_pass = 'demo' + self.mod_user = User.objects.create_user( + username='mod_user', + password=self.mod_user_pass, + first_name='first_name', + last_name='last_name', + email='mod_user@test.com' + ) + + self.course = Course.objects.create( + name='Python Course', + enrollment='Enroll Request', + creator=self.mod_user + ) + + course_ct = ContentType.objects.get_for_model(self.course) + + self.post = Post.objects.create( + title="Post1", + description="Post 1 description", + target_ct=course_ct, target_id=self.course.id, + creator=self.student + ) + + def test_view_course_forum_anonymously(self): + url = reverse('forum_api:course_post_list', kwargs={ + 'course_id': self.course.id + }) + response = self.client.get(url) + self.assertEqual(status.HTTP_401_UNAUTHORIZED, response.status_code) + + def test_view_course_forum(self): + self.client.login(username=self.student.username, password=self.student_pass) + url = reverse('forum_api:course_post_list', kwargs={ + 'course_id': self.course.id + }) + response = self.client.get(url) + self.assertEqual(status.HTTP_200_OK, response.status_code) + + def test_new_post_valid_post_data(self): + self.client.login(username=self.student.username, password=self.student_pass) + self.course.students.add(self.student) + url = reverse('forum_api:course_post_list', kwargs={ + 'course_id': self.course.id + }) + data = { + "title": "Post 1", + "description": "Post 1 description", + "creator": self.student.id, + "target_id": self.course.id + } + response = self.client.post(url, data) + self.assertEqual(status.HTTP_201_CREATED, response.status_code) + + def test_new_post_invalid_post_data(self): + self.client.login(username=self.student.username, password=self.student_pass) + self.course.students.add(self.student) + url = reverse('forum_api:course_post_list', kwargs={ + 'course_id': self.course.id + }) + data = { + "title": "Post 1", + "description": "Post 1 description", + "target_id": self.course.id + } + response = self.client.post(url, data) + self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) + + def test_new_post_invalid_post_data_empty_fields(self): + self.client.login(username=self.student.username, password=self.student_pass) + self.course.students.add(self.student) + url = reverse('forum_api:course_post_list', kwargs={ + 'course_id': self.course.id + }) + data = { + "title": "", + "description": "", + "creator": "", + "target_id": "" + } + response = self.client.post(url, data) + self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) + + def test_hide_post(self): + self.client.login(username=self.student.username, password=self.student_pass) + self.course.students.add(self.student) + url = reverse('forum_api:course_post_detail', kwargs={ + 'course_id': self.course.id, + 'post_id': self.post.id + }) + data = { + "title": "Post1", + "description": "Post 1 description", + "target_id": self.course.id, + "creator": self.student.id, + "active": False + } + response = self.client.put(url, data) + self.assertEqual(status.HTTP_200_OK, response.status_code) + + + def tearDown(self): + self.mod_user.delete() + self.student.delete() + self.course.delete() + self.mod_group.delete() + self.post.delete() + + +class TestPostComment(APITestCase): + def setUp(self): + self.mod_group = Group.objects.create(name='moderator') + self.student_pass = 'student' + self.student = User.objects.create_user( + username='student', + password=self.student_pass, + first_name='Tony', + last_name='Stark', + email='tony@starkenterprises.com' + ) + + self.mod_user_pass = 'demo' + self.mod_user = User.objects.create_user( + username='mod_user', + password=self.mod_user_pass, + first_name='first_name', + last_name='last_name', + email='mod_user@test.com' + ) + + self.course = Course.objects.create( + name='Python Course', + enrollment='Enroll Request', + creator=self.mod_user + ) + + course_ct = ContentType.objects.get_for_model(self.course) + + self.post = Post.objects.create( + title="Post1", + description="Post 1 description", + target_ct=course_ct, target_id=self.course.id, + creator=self.student + ) + + self.comment = Comment.objects.create( + post_field=self.post, + description='post 1 comment', + creator=self.student + ) + + def test_post_comments_view_success_status_code(self): + self.client.login(username=self.student.username, password=self.student_pass) + self.course.students.add(self.student) + url = reverse('forum_api:course_post_comments', kwargs={ + 'course_id': self.course.id, + 'post_id': self.post.id + }) + response = self.client.get(url) + self.assertEqual(status.HTTP_200_OK, response.status_code) + + def test_post_comment_valid_post_data(self): + self.client.login(username=self.student.username, password=self.student_pass) + self.course.students.add(self.student) + url = reverse('forum_api:course_post_comments', kwargs={ + 'course_id': self.course.id, + 'post_id': self.post.id + }) + data = { + 'post_field': self.post.id, + 'description': 'post 1 comment', + 'creator': self.student.id + } + response = self.client.post(url, data) + self.assertEqual(status.HTTP_201_CREATED, response.status_code) + + def test_post_comment_invalid_post_data(self): + self.client.login(username=self.student.username, password=self.student_pass) + self.course.students.add(self.student) + url = reverse('forum_api:course_post_comments', kwargs={ + 'course_id': self.course.id, + 'post_id': self.post.id + }) + data = { + 'post_field': self.post.id, + 'description': 'post 1 comment', + 'creator': "" + } + response = self.client.post(url, data) + self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) + + def test_post_comments_post_data_empty_fields(self): + self.client.login(username=self.student.username, password=self.student_pass) + self.course.students.add(self.student) + url = reverse('forum_api:course_post_comments', kwargs={ + 'course_id': self.course.id, + 'post_id': self.post.id + }) + data = { + 'post_field': "", + 'description': "", + 'creator': "" + } + response = self.client.post(url, data) + self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) + + def test_hide_post_comment(self): + self.client.login(username=self.student.username, password=self.student_pass) + self.course.students.add(self.student) + url = reverse('forum_api:course_post_comment_detail', kwargs={ + 'course_id': self.course.id, + 'post_id': self.post.id, + 'comment_id': self.comment.id + }) + data = { + 'post_field': self.post.id, + 'description': 'post 1 comment', + 'creator': self.student.id, + 'active': False + } + response = self.client.put(url, data) + self.assertEqual(status.HTTP_200_OK, response.status_code) + + def tearDown(self): + self.mod_user.delete() + self.student.delete() + self.course.delete() + self.mod_group.delete() + self.post.delete() diff --git a/forum/api/urls.py b/forum/api/urls.py new file mode 100644 index 000000000..d74bee77c --- /dev/null +++ b/forum/api/urls.py @@ -0,0 +1,42 @@ +from django.urls import path, include + +from .views import CoursePostList, CoursePostDetail, CoursePostComments, \ + CoursePostCommentDetail, LessonPostDetail, \ + LessonPostComments, LessonPostCommentDetail + +app_name = 'forum_api' + + +urlpatterns = [ + path('course_forum//', CoursePostList.as_view(), name='course_post_list'), + path( + 'course_forum///', + CoursePostDetail.as_view(), + name='course_post_detail' + ), + path( + 'course_forum///comments/', + CoursePostComments.as_view(), + name='course_post_comments' + ), + path( + 'course_forum///comments//', + CoursePostCommentDetail.as_view(), + name='course_post_comment_detail' + ), + path( + 'lesson_forum///', + LessonPostDetail.as_view(), + name='lesson_post_detail' + ), + path( + 'lesson_forum////comments/', + LessonPostComments.as_view(), + name='lesson_post_comments' + ), + path( + 'lesson_forum////comments//', + LessonPostCommentDetail.as_view(), + name='lesson_post_comment_detail' + ), +] diff --git a/forum/api/views.py b/forum/api/views.py new file mode 100644 index 000000000..5d86e0641 --- /dev/null +++ b/forum/api/views.py @@ -0,0 +1,162 @@ +from django.contrib.contenttypes.models import ContentType + +from rest_framework.decorators import api_view +from rest_framework.response import Response +from rest_framework import status, generics +from rest_framework.permissions import IsAuthenticated + +from forum.models import Post, Comment +from yaksh.models import Course, Lesson +from .serializers import PostSerializer, CommentSerializer +from .permissions import IsAuthorOrReadOnly + + +class CoursePostList(generics.ListCreateAPIView): + + serializer_class = PostSerializer + permission_classes = [IsAuthenticated] + + def get_course(self, course_id): + try: + course = Course.objects.get(id=course_id) + except Course.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + return course + + def get_queryset(self): + course_id = self.kwargs['course_id'] + course = self.get_course(course_id) + course_ct = ContentType.objects.get_for_model(course) + posts = Post.objects.filter(target_ct=course_ct, + target_id=course.id, + active=True) + return posts + + +class CoursePostDetail(generics.RetrieveUpdateDestroyAPIView): + + serializer_class = PostSerializer + + def get_object(self): + try: + post = Post.objects.get(id=self.kwargs['post_id']) + except Post.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + return post + + def get_permissions(self): + permission_classes = [] + if self.request.method == 'GET': + permission_classes = [IsAuthenticated] + elif self.request.method == 'PUT' \ + or self.request.method == 'DELETE' \ + or self.request.method == 'PATCH': + permission_classes = [IsAuthenticated, IsAuthorOrReadOnly] + + return [permission() for permission in permission_classes] + + +class CoursePostComments(generics.ListCreateAPIView): + + serializer_class = CommentSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + try: + post = Post.objects.get(id=self.kwargs['post_id']) + except Post.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + comments = post.comments.filter(active=True) + return comments + + +class CoursePostCommentDetail(generics.RetrieveUpdateDestroyAPIView): + + serializer_class = CommentSerializer + + def get_object(self): + try: + comment = Comment.objects.get(id=self.kwargs['comment_id']) + except Comment.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + return comment + + def get_permissions(self): + permission_classes = [] + if self.request.method == 'GET': + permission_classes = [IsAuthenticated] + elif self.request.method == 'PUT' \ + or self.request.method == 'DELETE' \ + or self.request.method == 'PATCH': + permission_classes = [IsAuthenticated, IsAuthorOrReadOnly] + + return [permission() for permission in permission_classes] + + +class LessonPostDetail(generics.RetrieveUpdateDestroyAPIView): + serializer_class = PostSerializer + + def get_object(self): + lesson_id = self.kwargs['lesson_id'] + lesson = Lesson.objects.get(id=lesson_id) + lesson_ct = ContentType.objects.get_for_model(lesson) + try: + post = Post.objects.get( + target_ct=lesson_ct, target_id=lesson_id, + active=True, title=lesson.name + ) + except Post.DoesNotExist: + post = Post.objects.create( + target_ct=lesson_ct, target_id=lesson_id, + active=True, title=lesson.name, creator=self.request.user, + description=f'Discussion on {lesson.name} lesson', + ) + + return post + + def get_permissions(self): + permission_classes = [] + if self.request.method == 'GET': + permission_classes = [IsAuthenticated] + elif self.request.method == 'PUT' \ + or self.request.method == 'DELETE' \ + or self.request.method == 'PATCH': + permission_classes = [IsAuthenticated, IsAuthorOrReadOnly] + + return [permission() for permission in permission_classes] + + +class LessonPostComments(generics.ListCreateAPIView): + serializer_class = CommentSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + try: + post = Post.objects.get(id=self.kwargs['post_id']) + except Post.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + comments = post.comments.filter(active=True) + return comments + + +class LessonPostCommentDetail(generics.RetrieveUpdateDestroyAPIView): + + serializer_class = CommentSerializer + + def get_object(self): + try: + comment = Comment.objects.get(id=self.kwargs['comment_id']) + except Comment.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + return comment + + def get_permissions(self): + permission_classes = [] + if self.request.method == 'GET': + permission_classes = [IsAuthenticated] + elif self.request.method == 'PUT' \ + or self.request.method == 'DELETE' \ + or self.request.method == 'PATCH': + permission_classes = [IsAuthenticated, IsAuthorOrReadOnly] + + return [permission() for permission in permission_classes] diff --git a/forum/apps.py b/forum/apps.py new file mode 100644 index 000000000..99ee7e7e9 --- /dev/null +++ b/forum/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ForumConfig(AppConfig): + name = 'forum' diff --git a/forum/forms.py b/forum/forms.py new file mode 100644 index 000000000..305c54f17 --- /dev/null +++ b/forum/forms.py @@ -0,0 +1,43 @@ +from django import forms + +from .models import Post, Comment + +class PostForm(forms.ModelForm): + class Meta: + model = Post + fields = ["title", "description", "image", "anonymous"] + widgets = { + 'title': forms.TextInput( + attrs={ + 'class': 'form-control' + } + ), + 'description': forms.Textarea( + attrs={ + 'class': 'form-control' + } + ), + 'image': forms.FileInput( + attrs={ + 'class': 'form-control-file' + } + ) + } + + +class CommentForm(forms.ModelForm): + class Meta: + model = Comment + fields = ["description", "image", "anonymous"] + widgets = { + 'description': forms.Textarea( + attrs={ + 'class': 'form-control' + } + ), + 'image': forms.FileInput( + attrs={ + 'class': 'form-control-file' + } + ) + } diff --git a/forum/models.py b/forum/models.py new file mode 100644 index 000000000..0bbdf08d3 --- /dev/null +++ b/forum/models.py @@ -0,0 +1,64 @@ +import uuid + +from django.db import models +from django.contrib.contenttypes.models import ContentType +from django.contrib.auth.models import User +from django.contrib.contenttypes.fields import ( + GenericForeignKey, GenericRelation +) + + +def validate_image(image): + file_size = image.file.size + limit_mb = 30 + if file_size > limit_mb * 1024 * 1024: + raise ValidationError("Max size of file is {0} MB".format(limit_mb)) + + +def get_image_dir(instance, filename): + return os.sep.join(( + 'post_%s' % (instance.uid), filename + )) + + +class ForumBase(models.Model): + uid = models.UUIDField(unique=True, default=uuid.uuid4, editable=False) + creator = models.ForeignKey(User, on_delete=models.CASCADE) + description = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + modified_at = models.DateTimeField(auto_now=True) + image = models.ImageField(upload_to=get_image_dir, blank=True, + null=True, validators=[validate_image]) + active = models.BooleanField(default=True) + anonymous = models.BooleanField(default=False) + + +class Post(ForumBase): + title = models.CharField(max_length=200) + target_ct = models.ForeignKey(ContentType, + blank=True, + null=True, + related_name='target_obj', + on_delete=models.CASCADE) + target_id = models.PositiveIntegerField(null=True, + blank=True, + db_index=True) + target = GenericForeignKey('target_ct', 'target_id') + + def __str__(self): + return self.title + + def get_last_comment(self): + return self.comment.last() + + def get_comments_count(self): + return self.comment.filter(active=True).count() + + +class Comment(ForumBase): + post_field = models.ForeignKey(Post, on_delete=models.CASCADE, + related_name='comments') + + def __str__(self): + return 'Comment by {0}: {1}'.format(self.creator.username, + self.post_field.title) \ No newline at end of file diff --git a/yaksh/templates/yaksh/course_forum.html b/forum/templates/forum/course_forum.html similarity index 97% rename from yaksh/templates/yaksh/course_forum.html rename to forum/templates/forum/course_forum.html index 86195ddde..6a5a36177 100644 --- a/yaksh/templates/yaksh/course_forum.html +++ b/forum/templates/forum/course_forum.html @@ -86,7 +86,7 @@ @@ -114,7 +114,7 @@ {% for post in posts %} - {{post.title}} + {{post.title}} {{ post.description|truncatewords:10 }} Last updated: {{post.modified_at}} @@ -137,7 +137,7 @@ {% if user == course.creator or user in course.get_teachers %} - + {% endif %} @@ -178,4 +178,4 @@ }); }); -{% endblock script %} \ No newline at end of file +{% endblock script %} diff --git a/yaksh/templates/yaksh/lessons_forum.html b/forum/templates/forum/lessons_forum.html similarity index 97% rename from yaksh/templates/yaksh/lessons_forum.html rename to forum/templates/forum/lessons_forum.html index 070738fa3..50e4dc0a6 100644 --- a/yaksh/templates/yaksh/lessons_forum.html +++ b/forum/templates/forum/lessons_forum.html @@ -61,7 +61,7 @@

{{course.name}}

{% for post in posts %} - {{post.title}} + {{post.title}} {{ post.description|safe|truncatewords:10 }} Last updated: {{post.modified_at}} @@ -84,7 +84,7 @@

{{course.name}}

{% if user == course.creator or user in course.get_teachers %} - + {% endif %} @@ -101,4 +101,3 @@

{{course.name}}

{% endblock content %} - diff --git a/yaksh/templates/yaksh/post_comments.html b/forum/templates/forum/post_comments.html similarity index 95% rename from yaksh/templates/yaksh/post_comments.html rename to forum/templates/forum/post_comments.html index 17576b8f1..cc7711996 100644 --- a/yaksh/templates/yaksh/post_comments.html +++ b/forum/templates/forum/post_comments.html @@ -6,7 +6,7 @@ {% block content %}
- +  Back to Posts
@@ -38,7 +38,7 @@ {% endif %} {{post.created_at}} - {% if user == course.creator or user in course.get_teachers %}{% endif %} + {% if user == course.creator or user in course.get_teachers %}{% endif %}
@@ -77,7 +77,7 @@
- {{comment.created_at}} {% if user == course.creator or user in course.get_teachers %} {% endif %} + {{comment.created_at}} {% if user == course.creator or user in course.get_teachers %} {% endif %}

{{comment.description|safe}}

@@ -116,4 +116,4 @@ }); }); -{% endblock script %} \ No newline at end of file +{% endblock script %} diff --git a/forum/tests/__init__.py b/forum/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/forum/tests/test_models.py b/forum/tests/test_models.py new file mode 100644 index 000000000..4f587bc9e --- /dev/null +++ b/forum/tests/test_models.py @@ -0,0 +1,85 @@ +from django.test import TestCase +from django.contrib.auth.models import Group +from django.contrib.contenttypes.models import ContentType +from yaksh.models import User, Profile, Course +from forum.models import Post, Comment + + +class PostModelTestCases(TestCase): + def setUp(self): + self.user1 = User.objects.create( + username='bart', + password='bart', + email='bart@test.com' + ) + Profile.objects.create( + user=self.user1, + roll_number=1, + institute='IIT', + department='Chemical', + position='Student' + ) + + self.user2 = User.objects.create( + username='dart', + password='dart', + email='dart@test.com' + ) + Profile.objects.create( + user=self.user2, + roll_number=2, + institute='IIT', + department='Chemical', + position='Student' + ) + + self.user3 = User.objects.create( + username='user3', + password='user3', + email='user3@test.com' + ) + Profile.objects.create( + user=self.user3, + roll_number=3, + is_moderator=True, + department='Chemical', + position='Teacher' + ) + + self.course = Course.objects.create( + name='Python Course', + enrollment='Enroll Request', + creator=self.user3 + ) + course_ct = ContentType.objects.get_for_model(self.course) + self.post1 = Post.objects.create( + title='Post 1', + target_ct=course_ct, target_id=self.course.id, + creator=self.user1, + description='Post 1 description' + ) + self.comment1 = Comment.objects.create( + post_field=self.post1, + creator=self.user2, + description='Post 1 comment 1' + ) + self.comment2 = Comment.objects.create( + post_field=self.post1, + creator=self.user3, + description='Post 1 user3 comment 2' + ) + + def test_get_last_comment(self): + last_comment = self.post1.get_last_comment() + self.assertEquals(last_comment.description, 'Post 1 user3 comment 2') + + def test_get_comments_count(self): + count = self.post1.get_comments_count() + self.assertEquals(count, 2) + + def tearDown(self): + self.user1.delete() + self.user2.delete() + self.user3.delete() + self.course.delete() + self.post1.delete() diff --git a/forum/tests/test_views.py b/forum/tests/test_views.py new file mode 100644 index 000000000..93f73f057 --- /dev/null +++ b/forum/tests/test_views.py @@ -0,0 +1,476 @@ +from django.test import TestCase +from django.test import Client +from django.http import Http404 +from django.utils import timezone +from django.urls import reverse, resolve +from django.contrib.auth.models import Group, User +from django.contrib.contenttypes.models import ContentType + +from yaksh.models import Profile, Course +from forum.models import Post, Comment +from forum.forms import CommentForm +from forum.views import course_forum, post_comments + + +class TestPost(TestCase): + def setUp(self): + self.client = Client() + self.mod_group = Group.objects.create(name='moderator') + + self.student_plaintext_pass = 'student' + self.student = User.objects.create_user( + username='student', + password=self.student_plaintext_pass, + first_name='first_name', + last_name='last_name', + email='student@test.com' + ) + + Profile.objects.create( + user=self.student, + roll_number=10, + institute='IIT', + department='Chemical', + position='student', + timezone='UTC' + ) + + # moderator + self.user_plaintext_pass = 'demo' + self.user = User.objects.create_user( + username='demo_user', + password=self.user_plaintext_pass, + first_name='first_name', + last_name='last_name', + email='demo@test.com' + ) + + Profile.objects.create( + user=self.user, + roll_number=10, + institute='IIT', + department='Chemical', + position='Moderator', + timezone='UTC' + ) + + self.course = Course.objects.create( + name="Python Course", + enrollment="Enroll Request", creator=self.user + ) + + def test_csrf(self): + self.client.login( + username=self.student.username, + password=self.student_plaintext_pass + ) + self.course.students.add(self.student) + url = reverse('forum:course_forum', kwargs={ + 'course_id': self.course.id + }) + response = self.client.get(url) + self.assertContains(response, 'csrfmiddlewaretoken') + + def test_view_course_forum_denies_anonymous_user(self): + url = reverse('forum:course_forum', kwargs={ + 'course_id': self.course.id + }) + response = self.client.get(url, follow=True) + self.assertEqual(response.status_code, 200) + redirection_url = '/exam/login/?next=/forum/course_forum/{0}/'.format( + str(self.course.id) + ) + self.assertRedirects(response, redirection_url) + + def test_view_course_forum(self): + self.client.login( + username=self.student.username, + password=self.student_plaintext_pass + ) + self.course.students.add(self.student) + url = reverse('forum:course_forum', kwargs={ + 'course_id': self.course.id + }) + response = self.client.get(url, follow=True) + self.assertEquals(response.status_code, 200) + self.assertTemplateUsed(response, 'forum/course_forum.html') + + def test_view_course_forum_not_found_status_code(self): + self.client.login( + username=self.student.username, + password=self.student_plaintext_pass + ) + self.course.students.add(self.student) + url = reverse('forum:course_forum', kwargs={ + 'course_id': 99 + }) + response = self.client.get(url) + self.assertEquals(response.status_code, 404) + + def test_course_forum_url_resolves_course_forum_view(self): + view = resolve('/forum/course_forum/1/') + self.assertEqual(view.func, course_forum) + + def test_course_forum_contains_link_to_post_comments_page(self): + # create a post in setup + self.client.login( + username=self.student.username, + password=self.student_plaintext_pass + ) + self.course.students.add(self.student) + url = reverse('forum:course_forum', kwargs={ + 'course_id': self.course.id + }) + course_ct = ContentType.objects.get_for_model(self.course) + post = Post.objects.create( + title='post 1', + description='post 1 description', + target_ct=course_ct, target_id=self.course.id, + creator=self.student + ) + response = self.client.get(url) + post_comments_url = reverse('forum:post_comments', kwargs={ + 'course_id': self.course.id, + 'uuid': post.uid + }) + self.assertContains(response, 'href="{0}'.format(post_comments_url)) + + def test_new_post_valid_post_data(self): + self.client.login( + username=self.student.username, + password=self.student_plaintext_pass + ) + self.course.students.add(self.student) + url = reverse('forum:course_forum', kwargs={ + 'course_id': self.course.id + }) + data = { + "title": 'Post 1', + "description": 'Post 1 description', + } + response = self.client.post(url, data) + # This shouldn't be 302. Check where does it redirects. + course_ct = ContentType.objects.get_for_model(self.course) + result = Post.objects.filter(title='Post 1', + creator=self.student, + target_ct=course_ct, + target_id=self.course.id) + self.assertTrue(result.exists()) + + def test_new_post_invalid_post_data(self): + self.client.login( + username=self.student.username, + password=self.student_plaintext_pass + ) + self.course.students.add(self.student) + url = reverse('forum:course_forum', kwargs={ + 'course_id': self.course.id + }) + data = {} + response = self.client.post(url, data) + self.assertEquals(response.status_code, 200) + + def test_new_post_invalid_post_data_empty_fields(self): + self.client.login( + username=self.student.username, + password=self.student_plaintext_pass + ) + self.course.students.add(self.student) + url = reverse('forum:course_forum', kwargs={ + 'course_id': self.course.id + }) + data = { + "title": '', + "description": '', + } + response = self.client.post(url, data) + self.assertEquals(response.status_code, 200) + self.assertFalse(Post.objects.exists()) + + def test_open_created_post_denies_anonymous_user(self): + course_ct = ContentType.objects.get_for_model(self.course) + post = Post.objects.create( + title='post 1', + description='post 1 description', + target_ct=course_ct, target_id=self.course.id, + creator=self.student + ) + url = reverse('forum:post_comments', kwargs={ + 'course_id': self.course.id, + 'uuid': post.uid + }) + response = self.client.get(url, follow=True) + self.assertEqual(response.status_code, 200) + redirection_url = '/exam/login/?next=/forum/{0}/post/{1}/'.format( + str(self.course.id), str(post.uid) + ) + self.assertRedirects(response, redirection_url) + + def test_new_post_invalid_post_data(self): + """ + Invalid post data should not redirect + The expected behavior is to show form again with validation errors + """ + self.client.login( + username=self.student.username, + password=self.student_plaintext_pass + ) + self.course.students.add(self.student) + url = reverse('forum:course_forum', kwargs={ + 'course_id': self.course.id + }) + data = {} + response = self.client.post(url, data) + form = response.context.get('form') + self.assertEquals(response.status_code, 200) + self.assertTrue(form.errors) + + def test_hide_post(self): + self.client.login( + username=self.user.username, + password=self.user_plaintext_pass + ) + self.course.students.add(self.user) + course_ct = ContentType.objects.get_for_model(self.course) + post = Post.objects.create( + title='post 1', + description='post 1 description', + target_ct=course_ct, target_id=self.course.id, + creator=self.student + ) + url = reverse('forum:hide_post', kwargs={ + 'course_id': self.course.id, + 'uuid': post.uid + }) + response = self.client.get(url, follow=True) + self.assertEqual(response.status_code, 200) + + def tearDown(self): + self.client.logout() + self.user.delete() + self.course.delete() + self.mod_group.delete() + + +class TestPostComment(TestCase): + def setUp(self): + self.client = Client() + self.mod_group = Group.objects.create(name='moderator') + + self.student_plaintext_pass = 'student' + self.student = User.objects.create_user( + username='student', + password=self.student_plaintext_pass, + first_name='first_name', + last_name='last_name', + email='student@test.com' + ) + + Profile.objects.create( + user=self.student, + roll_number=10, + institute='IIT', + department='Chemical', + position='student', + timezone='UTC' + ) + + # moderator + self.user_plaintext_pass = 'demo' + self.user = User.objects.create_user( + username='demo_user', + password=self.user_plaintext_pass, + first_name='first_name', + last_name='last_name', + email='demo@test.com' + ) + + Profile.objects.create( + user=self.user, + roll_number=10, + institute='IIT', + department='Chemical', + position='Moderator', + timezone='UTC' + ) + + self.course = Course.objects.create( + name="Python Course", + enrollment="Enroll Request", creator=self.user + ) + + course_ct = ContentType.objects.get_for_model(self.course) + self.post = Post.objects.create( + title='post 1', + description='post 1 description', + target_ct=course_ct, target_id=self.course.id, + creator=self.student + ) + + def test_csrf(self): + self.client.login( + username=self.student.username, + password=self.student_plaintext_pass + ) + self.course.students.add(self.student) + url = reverse('forum:post_comments', kwargs={ + 'course_id': self.course.id, + 'uuid': self.post.uid + }) + response = self.client.get(url) + self.assertContains(response, 'csrfmiddlewaretoken') + + def test_post_comments_view_success_status_code(self): + self.client.login( + username=self.student.username, + password=self.student_plaintext_pass + ) + self.course.students.add(self.student) + url = reverse('forum:post_comments', kwargs={ + 'course_id': self.course.id, + 'uuid': self.post.uid + }) + response = self.client.get(url) + self.assertEquals(response.status_code, 200) + + def test_post_comments_view_not_found_status_code(self): + self.client.login( + username=self.student.username, + password=self.student_plaintext_pass + ) + self.course.students.add(self.student) + url = reverse('forum:post_comments', kwargs={ + 'course_id': 99, + 'uuid': '90da38ad-06fa-451b-9e82-5035e839da90' + }) + response = self.client.get(url) + self.assertEquals(response.status_code, 404) + + def test_post_comments_url_resolves_post_comments_view(self): + view = resolve( + '/forum/1/post/90da38ad-06fa-451b-9e82-5035e839da89/' + ) + self.assertEquals(view.func, post_comments) + + def test_post_comments_view_contains_link_back_to_course_forum_view(self): + self.client.login( + username=self.student.username, + password=self.student_plaintext_pass + ) + self.course.students.add(self.student) + comment_url = reverse('forum:post_comments', kwargs={ + 'course_id': self.course.id, + 'uuid': self.post.uid + }) + course_forum_url = reverse('forum:course_forum', kwargs={ + 'course_id': self.course.id + }) + response = self.client.get(comment_url) + self.assertContains(response, 'href="{0}"'.format(course_forum_url)) + + def test_post_comments_valid_post_data(self): + self.client.login( + username=self.student.username, + password=self.student_plaintext_pass + ) + self.course.students.add(self.student) + url = reverse('forum:post_comments', kwargs={ + 'course_id': self.course.id, + 'uuid': self.post.uid + }) + data = { + 'post_field': self.post, + 'description': 'post 1 comment', + 'creator': self.user, + } + response = self.client.post(url, data) + self.assertEquals(response.status_code, 302) + result = Comment.objects.filter(post_field__uid=self.post.uid) + self.assertTrue(result.exists()) + + def test_post_comments_invalid_post_data(self): + self.client.login( + username=self.student.username, + password=self.student_plaintext_pass + ) + self.course.students.add(self.student) + url = reverse('forum:post_comments', kwargs={ + 'course_id': self.course.id, + 'uuid': self.post.uid + }) + data = {} + response = self.client.post(url, data) + self.assertEquals(response.status_code, 200) + + def test_post_comments_post_data_empty_fields(self): + self.client.login( + username=self.student.username, + password=self.student_plaintext_pass + ) + self.course.students.add(self.student) + url = reverse('forum:post_comments', kwargs={ + 'course_id': self.course.id, + 'uuid': self.post.uid + }) + data = { + 'post_field': '', + 'description': '', + 'creator': '', + } + response = self.client.post(url, data) + self.assertEquals(response.status_code, 200) + self.assertFalse(Comment.objects.exists()) + + def test_contains_form(self): + self.client.login( + username=self.student.username, + password=self.student_plaintext_pass + ) + self.course.students.add(self.student) + url = reverse('forum:post_comments', kwargs={ + 'course_id': self.course.id, + 'uuid': self.post.uid + }) + response = self.client.get(url) + form = response.context.get('form') + self.assertIsInstance(form, CommentForm) + + def post_comment_invalid_post_data(self): + self.client.login( + username=self.student.username, + password=self.student_plaintext_pass + ) + self.course.students.add(self.student) + url = reverse('forum:post_comments', kwargs={ + 'course_id': self.course.id, + 'uuid': self.post.uid + }) + data = {} + response = self.client.post(url, data) + form = response.context.get('form') + self.assertEquals(response.status_code, 200) + self.assertTrue(form.errors) + + def test_hide_post_comment(self): + self.client.login( + username=self.user.username, + password=self.user_plaintext_pass + ) + self.course.students.add(self.user) + comment = Comment.objects.create( + post_field=self.post, + description='post 1 comment', + creator=self.user + ) + url = reverse('forum:hide_comment', kwargs={ + 'course_id': self.course.id, + 'uuid': comment.uid + }) + response = self.client.get(url) + self.assertEquals(response.status_code, 302) + + def tearDown(self): + self.client.logout() + self.user.delete() + self.course.delete() + self.mod_group.delete() diff --git a/forum/urls.py b/forum/urls.py new file mode 100644 index 000000000..8c360f96a --- /dev/null +++ b/forum/urls.py @@ -0,0 +1,33 @@ +from django.conf.urls import url +from django.urls import path +from forum import views + +app_name = 'forum' + +urlpatterns = [ + url( + r'^course_forum/(?P\d+)/$', + views.course_forum, + name='course_forum' + ), + url( + r'^lessons_forum/(?P\d+)/$', + views.lessons_forum, + name='lessons_forum' + ), + url( + r'^(?P\d+)/post/(?P[0-9a-f-]+)/$', + views.post_comments, + name='post_comments' + ), + url( + r'^(?P\d+)/post/(?P[0-9a-f-]+)/delete/', + views.hide_post, + name='hide_post' + ), + url( + r'^(?P\d+)/comment/(?P[0-9a-f-]+)/delete/', + views.hide_comment, + name='hide_comment' + ), +] \ No newline at end of file diff --git a/forum/views.py b/forum/views.py new file mode 100644 index 000000000..fadaa266b --- /dev/null +++ b/forum/views.py @@ -0,0 +1,155 @@ +from django.shortcuts import render, redirect +from django.contrib.contenttypes.models import ContentType +from django.contrib.auth.decorators import login_required +from django.shortcuts import render, get_object_or_404 +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from django.contrib import messages + +from yaksh.models import Course +from yaksh.views import is_moderator +from yaksh.decorators import email_verified, has_profile + +from .models import Post, Comment +from .forms import PostForm, CommentForm + + +@login_required +@email_verified +def course_forum(request, course_id): + user = request.user + base_template = 'user.html' + moderator = False + if is_moderator(user): + base_template = 'manage.html' + moderator = True + course = get_object_or_404(Course, id=course_id) + course_ct = ContentType.objects.get_for_model(course) + if (not course.is_creator(user) and not course.is_teacher(user) + and not course.is_student(user)): + raise Http404('You are not enrolled in {0} course'.format(course.name)) + search_term = request.GET.get('search_post') + if search_term: + posts = Post.objects.filter( + Q(title__icontains=search_term) | + Q(description__icontains=search_term), + target_ct=course_ct, target_id=course.id, active=True + ) + else: + posts = Post.objects.filter( + target_ct=course_ct, target_id=course.id, active=True + ).order_by('-modified_at') + paginator = Paginator(posts, 10) + page = request.GET.get('page') + posts = paginator.get_page(page) + if request.method == "POST": + form = PostForm(request.POST, request.FILES) + if form.is_valid(): + new_post = form.save(commit=False) + new_post.creator = user + new_post.target = course + new_post.anonymous = request.POST.get('anonymous', '') == 'on' + new_post.save() + messages.success(request, "Added post successfully") + return redirect('forum:post_comments', + course_id=course.id, uuid=new_post.uid) + else: + form = PostForm() + return render(request, 'forum/course_forum.html', { + 'user': user, + 'course': course, + 'base_template': base_template, + 'moderator': moderator, + 'objects': posts, + 'form': form, + 'user': user + }) + + +@login_required +@email_verified +def lessons_forum(request, course_id): + user = request.user + base_template = 'user.html' + moderator = False + if is_moderator(user): + base_template = 'manage.html' + moderator = True + course = get_object_or_404(Course, id=course_id) + course_ct = ContentType.objects.get_for_model(course) + lesson_posts = course.get_lesson_posts() + return render(request, 'forum/lessons_forum.html', { + 'user': user, + 'base_template': base_template, + 'moderator': moderator, + 'course': course, + 'posts': lesson_posts, + }) + + +@login_required +@email_verified +def post_comments(request, course_id, uuid): + user = request.user + base_template = 'user.html' + if is_moderator(user): + base_template = 'manage.html' + post = get_object_or_404(Post, uid=uuid) + comments = post.comments.filter(active=True) + course = get_object_or_404(Course, id=course_id) + if (not course.is_creator(user) and not course.is_teacher(user) + and not course.is_student(user)): + raise Http404('You are not enrolled in {0} course'.format(course.name)) + form = CommentForm() + if request.method == "POST": + form = CommentForm(request.POST, request.FILES) + if form.is_valid(): + new_comment = form.save(commit=False) + new_comment.creator = request.user + new_comment.post_field = post + new_comment.anonymous = request.POST.get('anonymous', '') == 'on' + new_comment.save() + messages.success(request, "Added comment successfully") + return redirect(request.path_info) + return render(request, 'forum/post_comments.html', { + 'post': post, + 'comments': comments, + 'base_template': base_template, + 'form': form, + 'user': user, + 'course': course + }) + + +@login_required +@email_verified +def hide_post(request, course_id, uuid): + user = request.user + course = get_object_or_404(Course, id=course_id) + if (not course.is_creator(user) and not course.is_teacher(user)): + raise Http404( + 'Only a course creator or a teacher can delete the post.' + ) + post = get_object_or_404(Post, uid=uuid) + post.comment.active = False + post.active = False + post.save() + messages.success(request, "Post deleted successfully") + return redirect('forum:course_forum', course_id) + + +@login_required +@email_verified +def hide_comment(request, course_id, uuid): + user = request.user + if course_id: + course = get_object_or_404(Course, id=course_id) + if (not course.is_creator(user) and not course.is_teacher(user)): + raise Http404( + 'Only a course creator or a teacher can delete the comments' + ) + comment = get_object_or_404(Comment, uid=uuid) + post_uid = comment.post_field.uid + comment.active = False + comment.save() + messages.success(request, "Post comment deleted successfully") + return redirect('forum:post_comments', course_id, post_uid) diff --git a/online_test/urls.py b/online_test/urls.py index 96b8bf14c..644690430 100644 --- a/online_test/urls.py +++ b/online_test/urls.py @@ -1,8 +1,10 @@ from django.conf.urls import include, url +from django.urls import path from django.conf import settings from django.conf.urls.static import static from django.contrib import admin from online_test import views + admin.autodiscover() urlpatterns = [ @@ -16,9 +18,9 @@ url(r'^exam/reset/', include('django.contrib.auth.urls')), url(r'^', include('social_django.urls', namespace='social')), url(r'^grades/', include(('grades.urls', 'grades'))), + url(r'^forum/', include(('forum.urls', 'forum'))), url(r'^api/', include('api.urls', namespace='api')), url(r'^stats/', include('stats.urls', namespace='stats')), url(r'^flatfiles/', include(('upload.urls', 'upload'))), - ] urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/yaksh/admin.py b/yaksh/admin.py index 101053691..14599bc7a 100644 --- a/yaksh/admin.py +++ b/yaksh/admin.py @@ -1,7 +1,7 @@ from yaksh.models import Question, Quiz, QuestionPaper, Profile from yaksh.models import (TestCase, StandardTestCase, StdIOBasedTestCase, Course, AnswerPaper, CourseStatus, LearningModule, - Lesson, Post, Comment, Topic, TableOfContents, + Lesson, Topic, TableOfContents, LessonQuizAnswer, Answer, AssignmentUpload ) from django.contrib import admin @@ -49,8 +49,6 @@ class QuizAdmin(admin.ModelAdmin): admin.site.register(Profile, ProfileAdmin) admin.site.register(Question) admin.site.register(TestCase) -admin.site.register(Post) -admin.site.register(Comment) admin.site.register(StandardTestCase) admin.site.register(StdIOBasedTestCase) admin.site.register(Course, CourseAdmin) diff --git a/yaksh/forms.py b/yaksh/forms.py index 01e691d50..b5b54cdf0 100644 --- a/yaksh/forms.py +++ b/yaksh/forms.py @@ -1,8 +1,7 @@ from django import forms from yaksh.models import ( get_model_class, Profile, Quiz, Question, Course, QuestionPaper, Lesson, - LearningModule, TestCase, languages, question_types, Post, Comment, - Topic + LearningModule, TestCase, languages, question_types, Topic ) from grades.models import GradingSystem from django.contrib.auth import authenticate @@ -627,47 +626,6 @@ class Meta: fields = ["type"] -class PostForm(forms.ModelForm): - class Meta: - model = Post - fields = ["title", "description", "image", "anonymous"] - widgets = { - 'title': forms.TextInput( - attrs={ - 'class': 'form-control' - } - ), - 'description': forms.Textarea( - attrs={ - 'class': 'form-control' - } - ), - 'image': forms.FileInput( - attrs={ - 'class': 'form-control-file' - } - ) - } - - -class CommentForm(forms.ModelForm): - class Meta: - model = Comment - fields = ["description", "image", "anonymous"] - widgets = { - 'description': forms.Textarea( - attrs={ - 'class': 'form-control' - } - ), - 'image': forms.FileInput( - attrs={ - 'class': 'form-control-file' - } - ) - } - - class TopicForm(forms.ModelForm): timer = forms.CharField() diff --git a/yaksh/models.py b/yaksh/models.py index 8e5562b3d..f890d2d72 100644 --- a/yaksh/models.py +++ b/yaksh/models.py @@ -55,7 +55,7 @@ from yaksh.settings import SERVER_POOL_PORT, SERVER_HOST_NAME from .file_utils import extract_files, delete_files from grades.models import GradingSystem - +from forum.models import Post languages = ( ("python", "Python"), @@ -251,20 +251,7 @@ def render_template(template_path, data=None): context = Context(data) render = template.render(context) return render - - -def validate_image(image): - file_size = image.file.size - limit_mb = 30 - if file_size > limit_mb * 1024 * 1024: - raise ValidationError("Max size of file is {0} MB".format(limit_mb)) - - -def get_image_dir(instance, filename): - return os.sep.join(( - 'post_%s' % (instance.uid), filename - )) - + def is_valid_time_format(time): try: @@ -2851,49 +2838,6 @@ class TestCaseOrder(models.Model): ############################################################################## -class ForumBase(models.Model): - uid = models.UUIDField(unique=True, default=uuid.uuid4, editable=False) - creator = models.ForeignKey(User, on_delete=models.CASCADE) - description = models.TextField() - created_at = models.DateTimeField(auto_now_add=True) - modified_at = models.DateTimeField(auto_now=True) - image = models.ImageField(upload_to=get_image_dir, blank=True, - null=True, validators=[validate_image]) - active = models.BooleanField(default=True) - anonymous = models.BooleanField(default=False) - - -class Post(ForumBase): - title = models.CharField(max_length=200) - target_ct = models.ForeignKey(ContentType, - blank=True, - null=True, - related_name='target_obj', - on_delete=models.CASCADE) - target_id = models.PositiveIntegerField(null=True, - blank=True, - db_index=True) - target = GenericForeignKey('target_ct', 'target_id') - - def __str__(self): - return self.title - - def get_last_comment(self): - return self.comment.last() - - def get_comments_count(self): - return self.comment.filter(active=True).count() - - -class Comment(ForumBase): - post_field = models.ForeignKey(Post, on_delete=models.CASCADE, - related_name='comment') - - def __str__(self): - return 'Comment by {0}: {1}'.format(self.creator.username, - self.post_field.title) - - class TOCManager(models.Manager): def get_data(self, course_id, lesson_id): diff --git a/yaksh/templates/yaksh/course_detail_options.html b/yaksh/templates/yaksh/course_detail_options.html index 0910c01fa..ad2c57430 100644 --- a/yaksh/templates/yaksh/course_detail_options.html +++ b/yaksh/templates/yaksh/course_detail_options.html @@ -63,7 +63,7 @@