diff --git a/Discussions/forms.py b/Discussions/forms.py new file mode 100644 index 0000000..b66a9dd --- /dev/null +++ b/Discussions/forms.py @@ -0,0 +1,37 @@ +# Episodes.Community - Community-Driven TV Show Episode Link Sharing Site +# Copyright (C) 2017 Evert "Diamond" Prants , Taizo "Tsa6" Simpson +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django import forms +from LandingPage.models import DiscussionBoard, DiscussionReply + +class BoardForm(forms.ModelForm): + body = forms.CharField(widget=forms.Textarea) + + class Meta(): + model = DiscussionBoard + fields = ('title','body',) + help_texts = { + 'title': 'Name of the board', + 'body': 'Enter your message here' + } + +class ReplyForm(forms.ModelForm): + class Meta(): + model = DiscussionReply + fields = ('body',) + help_texts = { + 'body': 'Enter your message here' + } diff --git a/Discussions/templates/board.html b/Discussions/templates/board.html new file mode 100644 index 0000000..c05e533 --- /dev/null +++ b/Discussions/templates/board.html @@ -0,0 +1,113 @@ +{% extends "base.html" %} +{% block title %} + {{board.title}} - {{show.name}} Discussions - Episodes.Community +{% endblock %} +{% block content %} +
+ +
+

{{board.title}}

+
+
+ {% if board.locked %} +

This board is locked

+ {% else %} + {% if user.is_authenticated %} +  Reply + {% else %} +

Log in to reply

+ {% endif %} + {% endif %} +
+
+
+

Created {{board.timestamp}} by {{board.user.display_name}}

+ {% for reply in replies %} +
+
+
+ +

{{reply.user.display_name}}

+
+
+

Submitted {{board.timestamp}}

+
+ {{reply.body}} +
+
+
+
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+
+ +
+
+
+
+ {% empty %} +

Nobody has replied to this board!

+ {% endfor %} + {% if replies.has_other_pages %} + + {% endif %} + {% if user.is_authenticated and not board.locked %} +

Quick Reply

+
+
+ {% include "form.html" %} +
+ +
+
+
+ {% endif %} +
+{% endblock %} diff --git a/Discussions/templates/board_reply.html b/Discussions/templates/board_reply.html new file mode 100644 index 0000000..2429352 --- /dev/null +++ b/Discussions/templates/board_reply.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %} + Reply - {{show.name}} Discussions - Episodes.Community +{% endblock %} +{% block content %} +
+ +

New Reply

+ {% if error %} +
{{error}}
+ {% endif %} +
+ {% include "form.html" %} +
+ +
+
+
+{% endblock %} diff --git a/Discussions/templates/boards.html b/Discussions/templates/boards.html index ff51386..4ad937b 100644 --- a/Discussions/templates/boards.html +++ b/Discussions/templates/boards.html @@ -4,12 +4,19 @@ {% endblock %} {% block content %}
+

{{show.name}} Discussion Boards

+

Discuss {{show.name}} with your fellow community members!

{% if user.is_authenticated %}  Create New Board {% else %} - Log in to create boards +

Log in to create boards

{% endif %}
@@ -29,7 +36,17 @@
+ {% if board.num_replies > 0 %} +
+
+ by + {{board.latest_reply.user.display_name}} +
+ {{board.latest_reply.timestamp}} +
+ {% else %} No replies + {% endif %}
@@ -56,11 +73,11 @@ {% else %}
  • - {{ i }} + {{ i }}
  • {% endif %} {% endfor %} - {% if users.has_next %} + {% if boards.has_next %}
  • Next
  • diff --git a/Discussions/templates/boards_new.html b/Discussions/templates/boards_new.html new file mode 100644 index 0000000..c4a3736 --- /dev/null +++ b/Discussions/templates/boards_new.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %} + Create a Board - {{show.name}} Discussions - Episodes.Community +{% endblock %} +{% block content %} +
    + +

    Create a Board

    + {% if error %} +
    {{error}}
    + {% endif %} +
    + {% include "form.html" %} +
    + +
    +
    +
    +{% endblock %} diff --git a/Discussions/urls.py b/Discussions/urls.py index 69472ff..c138c66 100644 --- a/Discussions/urls.py +++ b/Discussions/urls.py @@ -20,5 +20,9 @@ from . import views urlpatterns = [ url(r'^$', views.Boards.as_view()), + url(r'^vote/(?P\d+)/(?P[0-1])/?$', views.BoardVoteSubmit.as_view()), + url(r'^board/new$', views.BoardForm), + url(r'^board/reply/(?P\d{1,4})/?$', views.BoardReplyForm), + url(r'^board/(?P\d{1,4})(-[\w-]+)?/?$', views.Board.as_view()), ] diff --git a/Discussions/views.py b/Discussions/views.py index cc4b16c..0802631 100644 --- a/Discussions/views.py +++ b/Discussions/views.py @@ -14,20 +14,25 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from django.template import RequestContext -from django.shortcuts import render, get_list_or_404, get_object_or_404 +from django.shortcuts import render, redirect, get_list_or_404, get_object_or_404 from django.views import View from django.views.generic.base import TemplateView from django.contrib.auth.decorators import login_required from django.conf import settings from django.http import Http404, HttpResponseForbidden, HttpResponse, HttpResponseRedirect -from django.db.models import Case, When, Value, IntegerField, Count, F, Q +from django.db.models import Case, When, Value, IntegerField, Count, F, Q, Max from django.contrib.auth.mixins import LoginRequiredMixin from django.shortcuts import render -from django.core.paginator import Paginator +from django.core.paginator import Paginator, PageNotAnInteger, EmptyPage +from django.utils.text import slugify from guardian.decorators import permission_required_or_403 -from LandingPage.models import Show, DiscussionBoard, DiscussionReply, DiscussionVote +from LandingPage.models import Show, DiscussionBoard, DiscussionReply, DiscussionVote, Ban +from . import forms + +import datetime +import re class Boards(TemplateView): @@ -39,8 +44,21 @@ class Boards(TemplateView): page = self.request.GET.get('page', 1) - boards_list = DiscussionBoard.objects.filter(show=show) + boards_list = DiscussionBoard.objects.filter(show=show).annotate( + num_replies=Count('replies'), + recency=Case( + When( + num_replies=0, + then=Max('timestamp') + ), + When( + num_replies__gt=0, + then=Max('replies__timestamp') + ) + ), + ).order_by('-recency') paginator = Paginator(boards_list, getattr(settings, "DISCUSSIONS_PER_PAGE", 26)) + try: boards = paginator.page(page) except PageNotAnInteger: @@ -52,3 +70,214 @@ class Boards(TemplateView): ctx['show'] = show return ctx + +class Board(TemplateView): + + template_name = "board.html" + + def get_context_data(self, abbr, bid, **kwargs): + ctx = super().get_context_data() + show = get_object_or_404(Show, abbr=abbr) + board = get_object_or_404(DiscussionBoard, pk=bid) + + page = self.request.GET.get('page', 1) + find = self.request.GET.get('findReply', None) + + reply_list = DiscussionReply.objects.filter(board=board).order_by('timestamp').annotate( + positives=Count( + Case( + When( + votes__positive=True, + then=Value(1) + ) + ) + ), + negatives=Count('votes') - F('positives'), + score=F('positives') - F('negatives') + ) + perpage = getattr(settings, "DISCUSSIONS_REPLIES_PER_PAGE", 10) + paginator = Paginator(reply_list, perpage) + + if find and find.isnumeric(): + item = get_object_or_404(DiscussionReply, pk=find) + if item.board == board: + found = DiscussionReply.objects.filter(timestamp__lt=item.timestamp,board=board).count() + page = int(found / perpage) + 1 + index = int(found % perpage) + 1 + ctx['url'] = '/show/%s/discuss/board/%d-%s?page=%d#reply-%d'%(abbr, board.pk, slugify(board.title), page, index) + + return ctx + + try: + replies = paginator.page(page) + except PageNotAnInteger: + replies = paginator.page(1) + except EmptyPage: + replies = paginator.page(paginator.num_pages) + + ctx['board'] = board + ctx['replies'] = replies + ctx['show'] = show + ctx['form'] = forms.ReplyForm() + + return ctx + + def render_to_response(self, context): + if 'url' in context: + return redirect(context['url']) + + return super(Board, self).render_to_response(context) + +# Board form GET and POST +@login_required +def BoardForm(req, abbr): + show = get_object_or_404(Show, abbr=abbr) + user = req.user + + form = forms.BoardForm() + + # Request context + ctx = { + 'form': form, + 'show': show + } + + # Get bans for this user regarding this show + bans = Ban.objects.filter(Q(scope=show) | Q(site_wide=True), Q(expiration__gte=datetime.datetime.now()) | Q(permanent=True), user=user) + + if bans.count() > 0: + return HttpResponseForbidden('You are banned from discussing this show.
    Reason: %s'%(bans.first().reason)) + + # Handle POST + if req.method == 'POST': + form = forms.BoardForm(req.POST) + ctx['form'] = form + + if form.is_valid(): + form_data = form.cleaned_data + + # Check if the Title has already been posted + if DiscussionBoard.objects.filter(show=show,title=form_data['title']).count() > 0: + ctx['error'] = 'A board with this title already exists!' + return render(req, "boards_new.html", ctx) + + if not user.has_perm('LandingPage.can_moderate_board', show): + # Check if there has been a board by this user for this show within the last 24 hours + if DiscussionBoard.objects.filter(user=user,show=show,timestamp__gte=datetime.datetime.now() - datetime.timedelta(hours=24)).count() > 8: + ctx['error'] = 'You can only create 8 boards for a show in 24 hours!' + return render(req, "boards_new.html", ctx) + + new_board = form.save(commit=False) + new_board.user = user + new_board.show = show + new_board.save() + + new_post = DiscussionReply(user=user,board=new_board,body=form_data['body']) + new_post.save() + + return HttpResponseRedirect('/show/%s/discuss/board/%d-%s'%(abbr, new_board.pk, slugify(form_data['title']))) + else: + ctx['error'] = 'Invalid fields!' + + return render(req, "boards_new.html", ctx) + +# Reply form GET and POST +@login_required +def BoardReplyForm(req, abbr, bid): + show = get_object_or_404(Show, abbr=abbr) + board = get_object_or_404(DiscussionBoard, pk=bid) + user = req.user + + form = forms.ReplyForm() + + # Request context + ctx = { + 'form': form, + 'board': board, + 'show': show + } + + # Get bans for this user regarding this show + bans = Ban.objects.filter(Q(scope=show) | Q(site_wide=True), Q(expiration__gte=datetime.datetime.now()) | Q(permanent=True), user=user) + + if bans.count() > 0: + return HttpResponseForbidden('You are banned from discussing this show.
    Reason: %s'%(bans.first().reason)) + + # Handle POST + if req.method == 'POST': + form = forms.ReplyForm(req.POST) + ctx['form'] = form + + if form.is_valid(): + form_data = form.cleaned_data + + # Body Content Filter + real_content = re.sub(r'[\s\W]+', '', form_data['body']) + err_res = False + if len(real_content) < 10: + ctx['error'] = 'The content is too small! Please write more meaningful replies.' + err_res = True + elif len(real_content) > 4000: + ctx['error'] = 'The content body is too large! Please write less in a single reply.' + err_res = True + + # TODO: Apply word filtering here + # TODO: Apply markdown + + if err_res: + return render(req, "board_reply.html", ctx) + + new_reply = form.save(commit=False) + new_reply.user = user + new_reply.board = board + new_reply.save() + + return HttpResponseRedirect('/show/%s/discuss/board/%d-%s?findReply=%d'%(abbr, board.pk, slugify(board.title), new_reply.pk)) + else: + ctx['error'] = 'Invalid fields!' + + return render(req, "board_reply.html", ctx) + +# Vote request +# /show/{{abbr}}/vote/{{submission id}}/{{positive == 1}} +class BoardVoteSubmit(LoginRequiredMixin, View): + def post (self, req, abbr, replyid, positive): + # Convert positive parameter into a boolean + pos_bool = int(positive) == 1 + + user = req.user + + # Get the reply from the database + reply = get_object_or_404(DiscussionReply, id=replyid) + + # Prevent voting for own reply + if reply.user == user: + return HttpResponse('

    Error

    You cannot vote for your own reply.

    ' + 'Return to board

    ' + % (abbr, reply.board.pk, slugify(reply.board.title)), status=400) + + show = reply.board.show + + # Get bans for this user regarding this show + bans = Ban.objects.filter(Q(scope=show) | Q(site_wide=True), Q(expiration__gte=datetime.datetime.now()) | Q(permanent=True), user=user) + + if bans.count() > 0: + return HttpResponseForbidden('You are banned from voting on this show\'s discussion boards.
    Reason: %s'%(bans.first().reason)) + + # Allow changing a vote from positive to negative or vice-versa. Delete vote if its a re-vote + vote = reply.votes.filter(user=user,reply=reply).first() + if vote: + if not vote.positive == pos_bool: + vote.positive = pos_bool + vote.save() + else: + vote.delete() + else: + new_vote = DiscussionVote( + user=user, + reply=reply, + positive=pos_bool + ) + new_vote.save() + + return HttpResponseRedirect('/show/%s/discuss/board/%d-%s?findReply=%d'%(abbr, reply.board.pk, slugify(reply.board.title), reply.pk)) diff --git a/EpisodesCommunity/settings.py b/EpisodesCommunity/settings.py index e426da0..d1fed2c 100644 --- a/EpisodesCommunity/settings.py +++ b/EpisodesCommunity/settings.py @@ -173,3 +173,4 @@ AUTH_B64 = base64.b64encode(bytearray('%s:%s'%(AUTH_CLIENT_ID,oauth_options.get( AUTH_REDIRECT_URL = oauth_options.get('redirect_url') DISCUSSIONS_PER_PAGE = 26 +DISCUSSIONS_REPLIES_PER_PAGE = 10 diff --git a/EpisodesCommunity/urls.py b/EpisodesCommunity/urls.py index 1a6d679..4dd5832 100644 --- a/EpisodesCommunity/urls.py +++ b/EpisodesCommunity/urls.py @@ -36,7 +36,7 @@ from django.conf.urls.static import static urlpatterns = [ url(r'^admin/', admin.site.urls), - url(r'^show/(?P\w{1,16})/discuss', include('Discussions.urls')), + url(r'^show/(?P\w{1,16})/discuss/', include('Discussions.urls')), url(r'^show/(?P\w{1,16})/', include('Show.urls')), url(r'^', include('LandingPage.urls')) ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/LandingPage/models.py b/LandingPage/models.py index 90cc515..8d06e9c 100644 --- a/LandingPage/models.py +++ b/LandingPage/models.py @@ -92,6 +92,7 @@ class Show(TimestampedModel): permissions = ( ('can_create_show_ban', 'Can ban an user from submitting to this show'), ('can_moderate_show', 'Can add episodes, seasons and unrestricted submissions'), + ('can_moderate_board', 'Can delete and edit boards and replies of this show'), ) def __str__(self): @@ -387,10 +388,6 @@ class DiscussionBoard(TimestampedModel): max_length=100, help_text='The title of the discussion' ) - body = models.TextField( - help_text='The body of the post', - verbose_name='Body' - ) views = models.IntegerField( help_text='The amount of times this board has been viewed', default=0 @@ -399,6 +396,14 @@ class DiscussionBoard(TimestampedModel): help_text='Whether or not this board is pinned', default=False ) + locked = models.BooleanField( + help_text='Whether or not this board is locked for further replies', + default=False + ) + + def latest_reply(self): + return self.replies.latest('timestamp') + def __str__(self): return '[%s] "%s" by %s'%(self.show.abbr, self.title, self.user) @@ -420,6 +425,10 @@ class DiscussionReply(TimestampedModel): help_text='The body of the response', verbose_name='Body' ) + deleted = models.BooleanField( + help_text='Whether or not the content has been deleted by a moderator', + default=False + ) def __str__(self): return '[%s] %s\'s response to "%s"'%(self.board.show.abbr,self.user, self.board.title) @@ -430,11 +439,11 @@ class DiscussionVote(TimestampedModel): related_name='discussion_votes', help_text='The user which cast this vote' ) - board = models.ForeignKey( - DiscussionBoard, + reply = models.ForeignKey( + DiscussionReply, on_delete=models.CASCADE, related_name='votes', - help_text='The board this vote was cast on' + help_text='The reply this vote was cast on' ) positive = models.BooleanField( help_text='If true, the vote is an upvote. Otherwise, it is a downvote. Neutral votes are not recorded' diff --git a/LandingPage/static/css/style.css b/LandingPage/static/css/style.css index 5294b35..40b2d15 100644 --- a/LandingPage/static/css/style.css +++ b/LandingPage/static/css/style.css @@ -187,6 +187,30 @@ footer .logo .part1 { text-shadow: 2px 2px 1px #0059a0; margin-right: 5px; } +.avatar { + -webkit-box-flex: 0; + -ms-flex: 0 0 180px; + flex: 0 0 180px; + max-width: 180px; +} +.avatar img { + max-width: 150px; + max-height: 150px; +} +.mini_avatar { + -webkit-box-flex: 0; + -ms-flex: 0 0 50px; + flex: 0 0 45px; + max-width: 45px; +} +.mini_avatar img { + max-width: 45px; + max-height: 45px; +} +.user-content { + word-wrap: break-word; + word-break: break-all; +} @media all and (max-width: 800px) { .logo { font-size: 5vw !important;