Boards, Replies and Votes

This commit is contained in:
Evert Prants 2018-02-28 18:34:10 +02:00
parent 22ca7de96b
commit 8230e83528
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
11 changed files with 501 additions and 16 deletions

37
Discussions/forms.py Normal file
View File

@ -0,0 +1,37 @@
# Episodes.Community - Community-Driven TV Show Episode Link Sharing Site
# Copyright (C) 2017 Evert "Diamond" Prants <evert@lunasqu.ee>, Taizo "Tsa6" Simpson <taizo@tsa6.net>
#
# 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 <https://www.gnu.org/licenses/>.
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'
}

View File

@ -0,0 +1,113 @@
{% extends "base.html" %}
{% block title %}
{{board.title}} - {{show.name}} Discussions - Episodes.Community
{% endblock %}
{% block content %}
<div class="container mb-5 mt-5">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/show/{{show.abbr}}">{{show.name}}</a></li>
<li class="breadcrumb-item"><a href="/show/{{show.abbr}}/discuss">Discussions</a></li>
<li class="breadcrumb-item active" aria-current="page">{{board.title}}</li>
</ol>
</nav>
<div class="row">
<h1 class="col">{{board.title}}</h1>
<div class="col-2">
<div class="d-flex flex-row-reverse mt-2">
{% if board.locked %}
<p>This board is locked</p>
{% else %}
{% if user.is_authenticated %}
<a href="/show/{{show.abbr}}/discuss/board/reply/{{board.pk}}" class="btn btn-primary"><i class="fa fa-fw fa-pencil"></i>&nbsp;Reply</a>
{% else %}
<p><a href="/login">Log in</a> to reply</p>
{% endif %}
{% endif %}
</div>
</div>
</div>
<p class="timestamp text-muted font-weight-light">Created {{board.timestamp}} by {{board.user.display_name}}</p>
{% for reply in replies %}
<div class="reply border-bottom mb-4" id="reply-{{forloop.counter}}">
<div class="row">
<div class="avatar">
<img src="https://icynet.eu/api/avatar/{{reply.user.icy_id}}" class="m-auto d-block">
<p class="text-center font-weight-bold">{{reply.user.display_name}}</p>
</div>
<div class="col border-left d-flex flex-column">
<p class="timestamp text-muted font-weight-light">Submitted {{board.timestamp}}</p>
<div class="user-content mb-auto">
{{reply.body}}
</div>
<div class="actions d-flex flex-row-reverse">
<div class="vote-btns">
<form method="POST" class="d-inline" action="/show/{{show.abbr}}/discuss/vote/{{reply.id}}/1">
{% csrf_token %}
<button href="#" class="btn btn-link text-success">
<i class="fa fa-fw fa-thumbs-up"></i>&nbsp;{{reply.positives}}
</button>
</form>
<form method="POST" class="d-inline" action="/show/{{show.abbr}}/discuss/vote/{{reply.id}}/0">
{% csrf_token %}
<button href="#" class="btn btn-link text-danger">
<i class="fa fa-fw fa-thumbs-down"></i>&nbsp;{{reply.negatives}}
</button>
</form>
</div>
<!--<a href="#" class="btn btn-secondary mr-1">Quote</a>-->
</div>
</div>
</div>
</div>
{% empty %}
<h3>Nobody has replied to this board!</h3>
{% endfor %}
{% if replies.has_other_pages %}
<nav aria-label="Boards navigation">
<ul class="pagination">
{% if replies.has_previous %}
<li class="page-item">
<a href="?page={{ replies.previous_page_number }}" class="page-link">Previous</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Previous</a>
</li>
{% endif %}
{% for i in replies.paginator.page_range %}
{% if replies.number == i %}
<li class="page-item active">
<span class="page-link">{{ i }} <span class="sr-only">(current)</span></span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="?page={{ i }}">{{ i }}</a>
</li>
{% endif %}
{% endfor %}
{% if replies.has_next %}
<li class="page-item">
<a href="?page={{ replies.next_page_number }}" class="page-link">Next</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Next</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% if user.is_authenticated and not board.locked %}
<h2>Quick Reply</h2>
<div class="reply-box">
<form class="form-horizontal" role="form" action="/show/{{show.abbr}}/discuss/board/reply/{{board.pk}}" method="post">
{% include "form.html" %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Reply</button>
</div>
</form>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}
Reply - {{show.name}} Discussions - Episodes.Community
{% endblock %}
{% block content %}
<section class="container mt-5 mb-5">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/show/{{show.abbr}}">{{show.name}}</a></li>
<li class="breadcrumb-item"><a href="/show/{{show.abbr}}/discuss">Discussions</a></li>
<li class="breadcrumb-item"><a href="/show/{{show.abbr}}/discuss/board/{{board.pk}}-{{board.title|slugify}}">{{board.title}}</a></li>
<li class="breadcrumb-item active" aria-current="page">Reply</li>
</ol>
</nav>
<h1>New Reply</h1>
{% if error %}
<div class="alert alert-danger">{{error}}</div>
{% endif %}
<form class="form-horizontal" role="form" action="" method="post">
{% include "form.html" %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</form>
</section>
{% endblock %}

View File

@ -4,12 +4,19 @@
{% endblock %}
{% block content %}
<div class="container mb-5 mt-5">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/show/{{show.abbr}}">{{show.name}}</a></li>
<li class="breadcrumb-item active" aria-current="page">Discussions</li>
</ol>
</nav>
<h1>{{show.name}} Discussion Boards</h1>
<p>Discuss {{show.name}} with your fellow community members!</p>
<div class="d-flex flex-row-reverse mb-4">
{% if user.is_authenticated %}
<a href="/show/{{show.abbr}}/discuss/board/new" class="btn btn-primary"><i class="fa fa-fw fa-pencil"></i>&nbsp;Create New Board</a>
{% else %}
<a href="/login">Log in</a> to create boards
<p><a href="/login">Log in</a> to create boards</p>
{% endif %}
</div>
<div class="bg-light rounded p-2 row">
@ -29,7 +36,17 @@
</span>
</div>
<div class="col-2">
{% if board.num_replies > 0 %}
<div class="reply mt-2">
<div class="user">
<small class="text-muted font-weight-light">by </small>
{{board.latest_reply.user.display_name}}
</div>
<small class="timestamp text-muted font-weight-light">{{board.latest_reply.timestamp}}</small>
</div>
{% else %}
<span class="text-muted font-weight-light">No replies</span>
{% endif %}
</div>
</div>
</div>
@ -56,11 +73,11 @@
</li>
{% else %}
<li class="page-item">
<span class="page-link" href="?page={{ i }}">{{ i }}</span>
<a class="page-link" href="?page={{ i }}">{{ i }}</a>
</li>
{% endif %}
{% endfor %}
{% if users.has_next %}
{% if boards.has_next %}
<li class="page-item">
<a href="?page={{ boards.next_page_number }}" class="page-link">Next</a>
</li>

View File

@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}
Create a Board - {{show.name}} Discussions - Episodes.Community
{% endblock %}
{% block content %}
<section class="container mt-5 mb-5">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/show/{{show.abbr}}">{{show.name}}</a></li>
<li class="breadcrumb-item"><a href="/show/{{show.abbr}}/discuss">Discussions</a></li>
<li class="breadcrumb-item active" aria-current="page">New Board</li>
</ol>
</nav>
<h1>Create a Board</h1>
{% if error %}
<div class="alert alert-danger">{{error}}</div>
{% endif %}
<form class="form-horizontal" role="form" action="" method="post">
{% include "form.html" %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
</section>
{% endblock %}

View File

@ -20,5 +20,9 @@ from . import views
urlpatterns = [
url(r'^$', views.Boards.as_view()),
url(r'^vote/(?P<replyid>\d+)/(?P<positive>[0-1])/?$', views.BoardVoteSubmit.as_view()),
url(r'^board/new$', views.BoardForm),
url(r'^board/reply/(?P<bid>\d{1,4})/?$', views.BoardReplyForm),
url(r'^board/(?P<bid>\d{1,4})(-[\w-]+)?/?$', views.Board.as_view()),
]

View File

@ -14,20 +14,25 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
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.<br>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.<br>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('<h1>Error</h1><p>You cannot vote for your own reply.</p><p>'
'<a href="/show/%s/discuss/board/%d-%s">Return to board</a></p>'
% (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.<br>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))

View File

@ -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

View File

@ -36,7 +36,7 @@ from django.conf.urls.static import static
urlpatterns = [
url(r'^admin/', admin.site.urls),
url(r'^show/(?P<abbr>\w{1,16})/discuss', include('Discussions.urls')),
url(r'^show/(?P<abbr>\w{1,16})/discuss/', include('Discussions.urls')),
url(r'^show/(?P<abbr>\w{1,16})/', include('Show.urls')),
url(r'^', include('LandingPage.urls'))
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@ -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'

View File

@ -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;