poll ui - create and vote #181

This commit is contained in:
rimu 2024-05-18 19:41:20 +12:00
parent f90d07f470
commit e912c8d84f
9 changed files with 216 additions and 7 deletions

View file

@ -191,7 +191,7 @@ class CreatePollForm(FlaskForm):
communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int, render_kw={'class': 'form-select'}) communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int, render_kw={'class': 'form-select'})
poll_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)]) poll_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)])
poll_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'rows': 5}) poll_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'rows': 5})
mode = SelectField(_('Mode'), validators=[DataRequired()], choices=[('single', _l('Single choice')), ('multiple', _l('Multiple choices'))], render_kw={'class': 'form-select'}) mode = SelectField(_('Mode'), validators=[DataRequired()], choices=[('single', _l('People choose one option')), ('multiple', _l('People choose many options'))], render_kw={'class': 'form-select'})
finish_choices=[ finish_choices=[
('30m', _l('30 minutes')), ('30m', _l('30 minutes')),
('1h', _l('1 hour')), ('1h', _l('1 hour')),
@ -221,6 +221,21 @@ class CreatePollForm(FlaskForm):
language_id = SelectField(_l('Language'), validators=[DataRequired()], coerce=int, render_kw={'class': 'form-select'}) language_id = SelectField(_l('Language'), validators=[DataRequired()], coerce=int, render_kw={'class': 'form-select'})
submit = SubmitField(_l('Save')) submit = SubmitField(_l('Save'))
def validate(self, extra_validators=None) -> bool:
choices_made = 0
for i in range(1, 10):
choice_data = getattr(self, f"choice_{i}").data.strip()
if choice_data != '':
choices_made += 1
if choices_made == 0:
self.choice_1.errors.append(_l('Polls need options for people to choose from'))
return False
elif choices_made <= 1:
self.choice_2.errors.append(_l('Provide at least two choices'))
return False
return True
class ReportCommunityForm(FlaskForm): class ReportCommunityForm(FlaskForm):
reason_choices = [('1', _l('Breaks instance rules')), reason_choices = [('1', _l('Breaks instance rules')),

View file

@ -396,8 +396,8 @@ def save_post(form, post: Post, type: str):
# Save poll choices. NB this will delete all votes whenever a poll is edited. Partially because it's easier to code but also to stop malicious alterations to polls after people have already voted # Save poll choices. NB this will delete all votes whenever a poll is edited. Partially because it's easier to code but also to stop malicious alterations to polls after people have already voted
if type == 'poll': if type == 'poll':
db.session.execute(text('DELETE FROM "poll_choice_vote" WHERE post_id = :poll_id'), {'post_id': post.id}) db.session.execute(text('DELETE FROM "poll_choice_vote" WHERE post_id = :post_id'), {'post_id': post.id})
db.session.execute(text('DELETE FROM "poll_choice" WHERE post_id = :poll_id'), {'post_id': post.id}) db.session.execute(text('DELETE FROM "poll_choice" WHERE post_id = :post_id'), {'post_id': post.id})
for i in range(1, 10): for i in range(1, 10):
choice_data = getattr(form, f"choice_{i}").data.strip() choice_data = getattr(form, f"choice_{i}").data.strip()
if choice_data != '': if choice_data != '':

View file

@ -17,6 +17,7 @@ from sqlalchemy_searchable import SearchQueryMixin
from app import db, login, cache, celery from app import db, login, cache, celery
import jwt import jwt
import os import os
import math
from app.constants import SUBSCRIPTION_NONMEMBER, SUBSCRIPTION_MEMBER, SUBSCRIPTION_MODERATOR, SUBSCRIPTION_OWNER, \ from app.constants import SUBSCRIPTION_NONMEMBER, SUBSCRIPTION_MEMBER, SUBSCRIPTION_MODERATOR, SUBSCRIPTION_OWNER, \
SUBSCRIPTION_BANNED, SUBSCRIPTION_PENDING, NOTIF_USER, NOTIF_COMMUNITY, NOTIF_TOPIC, NOTIF_POST, NOTIF_REPLY SUBSCRIPTION_BANNED, SUBSCRIPTION_PENDING, NOTIF_USER, NOTIF_COMMUNITY, NOTIF_TOPIC, NOTIF_POST, NOTIF_REPLY
@ -874,6 +875,7 @@ class User(UserMixin, db.Model):
db.session.query(UserRegistration).filter(UserRegistration.user_id == self.id).delete() db.session.query(UserRegistration).filter(UserRegistration.user_id == self.id).delete()
db.session.query(NotificationSubscription).filter(NotificationSubscription.user_id == self.id).delete() db.session.query(NotificationSubscription).filter(NotificationSubscription.user_id == self.id).delete()
db.session.query(Notification).filter(Notification.user_id == self.id).delete() db.session.query(Notification).filter(Notification.user_id == self.id).delete()
db.session.query(PollChoiceVote).filter(PollChoiceVote.user_id == self.id).delete()
def purge_content(self): def purge_content(self):
files = File.query.join(Post).filter(Post.user_id == self.id).all() files = File.query.join(Post).filter(Post.user_id == self.id).all()
@ -977,6 +979,9 @@ class Post(db.Model):
return cls.query.filter_by(ap_id=ap_id).first() return cls.query.filter_by(ap_id=ap_id).first()
def delete_dependencies(self): def delete_dependencies(self):
db.session.query(PollChoiceVote).filter(PollChoiceVote.post_id == self.id).delete()
db.session.query(PollChoice).filter(PollChoice.post_id == self.id).delete()
db.session.query(Poll).filter(Poll.post_id == self.id).delete()
db.session.query(Report).filter(Report.suspect_post_id == self.id).delete() db.session.query(Report).filter(Report.suspect_post_id == self.id).delete()
db.session.execute(text('DELETE FROM post_reply_vote WHERE post_reply_id IN (SELECT id FROM post_reply WHERE post_id = :post_id)'), db.session.execute(text('DELETE FROM post_reply_vote WHERE post_reply_id IN (SELECT id FROM post_reply WHERE post_id = :post_id)'),
{'post_id': self.id}) {'post_id': self.id})
@ -1376,6 +1381,25 @@ class Poll(db.Model):
local_only = db.Column(db.Boolean) local_only = db.Column(db.Boolean)
latest_vote = db.Column(db.DateTime) latest_vote = db.Column(db.DateTime)
def has_voted(self, user_id):
existing_vote = PollChoiceVote.query.filter(PollChoiceVote.user_id == user_id, PollChoiceVote.post_id == self.post_id).first()
return existing_vote is not None
def vote_for_choice(self, choice_id, user_id):
existing_vote = PollChoiceVote.query.filter(PollChoiceVote.user_id == user_id,
PollChoiceVote.choice_id == choice_id).first()
if not existing_vote:
new_vote = PollChoiceVote(choice_id=choice_id, user_id=user_id, post_id=self.post_id)
db.session.add(new_vote)
choice = PollChoice.query.get(choice_id)
choice.num_votes += 1
self.latest_vote = datetime.utcnow()
db.session.commit()
def total_votes(self):
return db.session.execute(text('SELECT SUM(num_votes) as s FROM "poll_choice" WHERE post_id = :post_id'),
{'post_id': self.post_id}).scalar()
class PollChoice(db.Model): class PollChoice(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@ -1384,6 +1408,9 @@ class PollChoice(db.Model):
sort_order = db.Column(db.Integer) sort_order = db.Column(db.Integer)
num_votes = db.Column(db.Integer, default=0) num_votes = db.Column(db.Integer, default=0)
def percentage(self, poll_total_votes):
return math.ceil(self.num_votes / poll_total_votes * 100)
class PollChoiceVote(db.Model): class PollChoiceVote(db.Model):
choice_id = db.Column(db.Integer, db.ForeignKey('poll_choice.id'), primary_key=True) choice_id = db.Column(db.Integer, db.ForeignKey('poll_choice.id'), primary_key=True)

View file

@ -6,6 +6,7 @@ from flask import redirect, url_for, flash, current_app, abort, request, g, make
from flask_login import login_user, logout_user, current_user, login_required from flask_login import login_user, logout_user, current_user, login_required
from flask_babel import _ from flask_babel import _
from sqlalchemy import or_, desc from sqlalchemy import or_, desc
from wtforms import SelectField, RadioField
from app import db, constants, cache from app import db, constants, cache
from app.activitypub.signature import HttpSignature, post_request, default_context, post_request_in_background from app.activitypub.signature import HttpSignature, post_request, default_context, post_request_in_background
@ -17,10 +18,10 @@ from app.community.forms import CreateLinkForm, CreateImageForm, CreateDiscussio
from app.post.util import post_replies, get_comment_branch, post_reply_count, tags_to_string from app.post.util import post_replies, get_comment_branch, post_reply_count, tags_to_string
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR, POST_TYPE_LINK, \ from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR, POST_TYPE_LINK, \
POST_TYPE_IMAGE, \ POST_TYPE_IMAGE, \
POST_TYPE_ARTICLE, POST_TYPE_VIDEO, NOTIF_REPLY, NOTIF_POST POST_TYPE_ARTICLE, POST_TYPE_VIDEO, NOTIF_REPLY, NOTIF_POST, POST_TYPE_POLL
from app.models import Post, PostReply, \ from app.models import Post, PostReply, \
PostReplyVote, PostVote, Notification, utcnow, UserBlock, DomainBlock, InstanceBlock, Report, Site, Community, \ PostReplyVote, PostVote, Notification, utcnow, UserBlock, DomainBlock, InstanceBlock, Report, Site, Community, \
Topic, User, Instance, NotificationSubscription, UserFollower Topic, User, Instance, NotificationSubscription, UserFollower, Poll, PollChoice
from app.post import bp from app.post import bp
from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \ from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \
shorten_string, markdown_to_text, gibberish, ap_datetime, return_304, \ shorten_string, markdown_to_text, gibberish, ap_datetime, return_304, \
@ -28,7 +29,7 @@ from app.utils import get_setting, render_template, allowlist_html, markdown_to_
reply_already_exists, reply_is_just_link_to_gif_reaction, confidence, moderating_communities, joined_communities, \ reply_already_exists, reply_is_just_link_to_gif_reaction, confidence, moderating_communities, joined_communities, \
blocked_instances, blocked_domains, community_moderators, blocked_phrases, show_ban_message, recently_upvoted_posts, \ blocked_instances, blocked_domains, community_moderators, blocked_phrases, show_ban_message, recently_upvoted_posts, \
recently_downvoted_posts, recently_upvoted_post_replies, recently_downvoted_post_replies, reply_is_stupid, \ recently_downvoted_posts, recently_upvoted_post_replies, recently_downvoted_post_replies, reply_is_stupid, \
languages_for_form, english_language_id languages_for_form, english_language_id, MultiCheckboxField
def show_post(post_id: int): def show_post(post_id: int):
@ -279,12 +280,32 @@ def show_post(post_id: int):
recently_upvoted_replies = [] recently_upvoted_replies = []
recently_downvoted_replies = [] recently_downvoted_replies = []
# Polls
poll_form = False
poll_results = False
poll_choices = []
poll_data = None
poll_total_votes = 0
if post.type == POST_TYPE_POLL:
poll_data = Poll.query.get(post.id)
poll_choices = PollChoice.query.filter_by(post_id=post.id).order_by(PollChoice.sort_order).all()
poll_total_votes = poll_data.total_votes()
# Show poll results to everyone after the poll finishes, to the poll creator and to those who have voted
if (current_user.is_authenticated and (poll_data.has_voted(current_user.id))) \
or poll_data.end_poll < datetime.utcnow():
poll_results = True
else:
poll_form = True
response = render_template('post/post.html', title=post.title, post=post, is_moderator=is_moderator, community=post.community, response = render_template('post/post.html', title=post.title, post=post, is_moderator=is_moderator, community=post.community,
breadcrumbs=breadcrumbs, related_communities=related_communities, mods=mod_list, breadcrumbs=breadcrumbs, related_communities=related_communities, mods=mod_list,
poll_form=poll_form, poll_results=poll_results, poll_data=poll_data, poll_choices=poll_choices, poll_total_votes=poll_total_votes,
canonical=post.ap_id, form=form, replies=replies, THREAD_CUTOFF_DEPTH=constants.THREAD_CUTOFF_DEPTH, canonical=post.ap_id, form=form, replies=replies, THREAD_CUTOFF_DEPTH=constants.THREAD_CUTOFF_DEPTH,
description=description, og_image=og_image, POST_TYPE_IMAGE=constants.POST_TYPE_IMAGE, description=description, og_image=og_image, POST_TYPE_IMAGE=constants.POST_TYPE_IMAGE,
POST_TYPE_LINK=constants.POST_TYPE_LINK, POST_TYPE_ARTICLE=constants.POST_TYPE_ARTICLE, POST_TYPE_LINK=constants.POST_TYPE_LINK, POST_TYPE_ARTICLE=constants.POST_TYPE_ARTICLE,
POST_TYPE_VIDEO=constants.POST_TYPE_VIDEO, autoplay=request.args.get('autoplay', False), POST_TYPE_VIDEO=constants.POST_TYPE_VIDEO, POST_TYPE_POLL=constants.POST_TYPE_POLL,
autoplay=request.args.get('autoplay', False),
noindex=not post.author.indexable, preconnect=post.url if post.url else None, noindex=not post.author.indexable, preconnect=post.url if post.url else None,
recently_upvoted=recently_upvoted, recently_downvoted=recently_downvoted, recently_upvoted=recently_upvoted, recently_downvoted=recently_downvoted,
recently_upvoted_replies=recently_upvoted_replies, recently_downvoted_replies=recently_downvoted_replies, recently_upvoted_replies=recently_upvoted_replies, recently_downvoted_replies=recently_downvoted_replies,
@ -542,6 +563,21 @@ def comment_vote(comment_id, vote_direction):
community=comment.community) community=comment.community)
@bp.route('/poll/<int:post_id>/vote', methods=['POST'])
@login_required
@validation_required
def poll_vote(post_id):
poll_data = Poll.query.get_or_404(post_id)
if poll_data.mode == 'single':
choice_id = int(request.form.get('poll_choice'))
poll_data.vote_for_choice(choice_id, current_user.id)
else:
for choice_id in request.form.getlist('poll_choice[]'):
poll_data.vote_for_choice(int(choice_id), current_user.id)
flash(_('Vote has been cast.'))
return redirect(url_for('activitypub.post_ap', post_id=post_id))
@bp.route('/post/<int:post_id>/comment/<int:comment_id>') @bp.route('/post/<int:post_id>/comment/<int:comment_id>')
def continue_discussion(post_id, comment_id): def continue_discussion(post_id, comment_id):
post = Post.query.get_or_404(post_id) post = Post.query.get_or_404(post_id)

View file

@ -11,6 +11,7 @@ document.addEventListener("DOMContentLoaded", function () {
setupTopicChooser(); setupTopicChooser();
setupConversationChooser(); setupConversationChooser();
setupMarkdownEditorEnabler(); setupMarkdownEditorEnabler();
setupAddPollChoice();
}); });
function renderMasonry(masonry, htmlSnippets) { function renderMasonry(masonry, htmlSnippets) {
@ -632,6 +633,24 @@ function setupMarkdownEditorEnabler() {
}); });
} }
function setupAddPollChoice() {
const addChoiceButton = document.getElementById('addPollChoice');
const pollChoicesFieldset = document.getElementById('pollChoicesFieldset');
const formGroups = pollChoicesFieldset.getElementsByClassName('form-group');
if(addChoiceButton) {
addChoiceButton.addEventListener('click', function(event) {
// Loop through the form groups and show the first hidden one
for (let i = 0; i < formGroups.length; i++) {
if (formGroups[i].style.display === 'none') {
formGroups[i].style.display = 'block';
break; // Stop once we've shown the next hidden form group
}
}
});
}
}
function getCookie(name) { function getCookie(name) {
var cookies = document.cookie.split(';'); var cookies = document.cookie.split(';');

View file

@ -1394,4 +1394,23 @@ h1 .warning_badge {
} }
} }
#pollChoicesFieldset {
margin-bottom: 20px;
}
.post_poll ul {
list-style: none;
padding-left: 0;
}
.post_poll ul li {
margin-bottom: 5px;
}
.post_poll ul li .vote_bar .vote_score {
background-color: #bbb;
color: white;
text-align: right;
padding-right: 5px;
font-weight: bold;
}
/*# sourceMappingURL=structure.css.map */ /*# sourceMappingURL=structure.css.map */

View file

@ -1071,3 +1071,27 @@ h1 .warning_badge {
float: right; float: right;
} }
} }
#pollChoicesFieldset {
margin-bottom: 20px;
}
.post_poll {
ul {
list-style: none;
padding-left: 0;
li {
margin-bottom: 5px;
.vote_bar {
.vote_score {
background-color: $grey;
color: white;
text-align: right;
padding-right: 5px;
font-weight: bold;
}
}
}
}
}

View file

@ -37,6 +37,40 @@
<a href="#" aria-hidden="true" class="markdown_editor_enabler create_post_markdown_editor_enabler" data-id="discussion_body">{{ _('Enable markdown editor') }}</a> <a href="#" aria-hidden="true" class="markdown_editor_enabler create_post_markdown_editor_enabler" data-id="discussion_body">{{ _('Enable markdown editor') }}</a>
{% endif %} {% endif %}
{% endif %} {% endif %}
<fieldset id="pollChoicesFieldset">
<legend>{{ _('Poll choices') }}</legend>
<div class="form-group">
{{ form.choice_1(class_="form-control", **{"placeholder": "First choice"}) }}
</div>
<div class="form-group">
{{ form.choice_2(class_="form-control", **{"placeholder": "Second choice"}) }}
</div>
<div class="form-group" style="display: none;">
{{ form.choice_3(class_="form-control") }}
</div>
<div class="form-group" style="display: none;">
{{ form.choice_4(class_="form-control") }}
</div>
<div class="form-group" style="display: none;">
{{ form.choice_5(class_="form-control") }}
</div>
<div class="form-group" style="display: none;">
{{ form.choice_6(class_="form-control") }}
</div>
<div class="form-group" style="display: none;">
{{ form.choice_7(class_="form-control") }}
</div>
<div class="form-group" style="display: none;">
{{ form.choice_8(class_="form-control") }}
</div>
<div class="form-group" style="display: none;">
{{ form.choice_9(class_="form-control") }}
</div>
<div class="form-group" style="display: none;">
{{ form.choice_10(class_="form-control") }}
</div>
<button id="addPollChoice" type="button" class="btn btn-primary">{{ _('Add choice') }}</button>
</fieldset>
{{ render_field(form.mode) }} {{ render_field(form.mode) }}
{{ render_field(form.finish_in) }} {{ render_field(form.finish_in) }}
{{ render_field(form.local_only) }} {{ render_field(form.local_only) }}

View file

@ -136,6 +136,41 @@
<div class="post_body"> <div class="post_body">
{{ post.body_html|community_links|safe if post.body_html else '' }} {{ post.body_html|community_links|safe if post.body_html else '' }}
</div> </div>
{% if post.type == POST_TYPE_POLL %}
<div class="post_poll">
{% if poll_results %}
<ul>
{% for choice in poll_choices %}
<li>
<div class="vote_bar">
<p class="mb-0 mt-3">{{ choice.choice_text }}</p>
<div class="vote_score" style="width: {{ choice.percentage(poll_total_votes) }}%">{{ choice.percentage(poll_total_votes) }}%</div>
</div>
</li>
{% endfor %}
</ul>
<p>{{ _('Total votes: %(total_votes)d.', total_votes=poll_total_votes) }}</p>
<p>{{ _('Poll closes') }} {{ moment(poll_data.end_poll).fromNow(refresh=True) }}.</p>
{% elif poll_form %}
<form action='/poll/{{ post.id }}/vote' method="post">
<ul>
{% for choice in poll_choices %}
{% if poll_data.mode == 'single' %}
<li><label for="choice_{{ choice.id }}">
<input type="radio" name="poll_choice" id="choice_{{ choice.id }}" required value="{{ choice.id }}"> {{ choice.choice_text }}
</label></li>
{% else %}
<li><label for="choice_{{ choice.id }}">
<input type="checkbox" name="poll_choice[]" id="choice_{{ choice.id }}" value="{{ choice.id }}"> {{ choice.choice_text }}
</label></li>
{% endif %}
{% endfor %}
</ul>
<input type="submit" class="btn btn-primary" value="Vote">
</form>
{% endif %}
</div>
{% endif %}
</div> </div>
{% endif %} {% endif %}