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'})
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})
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=[
('30m', _l('30 minutes')),
('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'})
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):
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
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" 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 = :post_id'), {'post_id': post.id})
for i in range(1, 10):
choice_data = getattr(form, f"choice_{i}").data.strip()
if choice_data != '':

View file

@ -17,6 +17,7 @@ from sqlalchemy_searchable import SearchQueryMixin
from app import db, login, cache, celery
import jwt
import os
import math
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
@ -874,6 +875,7 @@ class User(UserMixin, db.Model):
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(Notification).filter(Notification.user_id == self.id).delete()
db.session.query(PollChoiceVote).filter(PollChoiceVote.user_id == self.id).delete()
def purge_content(self):
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()
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.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})
@ -1376,6 +1381,25 @@ class Poll(db.Model):
local_only = db.Column(db.Boolean)
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):
id = db.Column(db.Integer, primary_key=True)
@ -1384,6 +1408,9 @@ class PollChoice(db.Model):
sort_order = db.Column(db.Integer)
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):
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_babel import _
from sqlalchemy import or_, desc
from wtforms import SelectField, RadioField
from app import db, constants, cache
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.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR, POST_TYPE_LINK, \
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, \
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.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \
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, \
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, \
languages_for_form, english_language_id
languages_for_form, english_language_id, MultiCheckboxField
def show_post(post_id: int):
@ -279,12 +280,32 @@ def show_post(post_id: int):
recently_upvoted_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,
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,
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_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,
recently_upvoted=recently_upvoted, recently_downvoted=recently_downvoted,
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)
@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>')
def continue_discussion(post_id, comment_id):
post = Post.query.get_or_404(post_id)

View file

@ -11,6 +11,7 @@ document.addEventListener("DOMContentLoaded", function () {
setupTopicChooser();
setupConversationChooser();
setupMarkdownEditorEnabler();
setupAddPollChoice();
});
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) {
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 */

View file

@ -1070,4 +1070,28 @@ h1 .warning_badge {
max-width: 150px;
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>
{% 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.finish_in) }}
{{ render_field(form.local_only) }}

View file

@ -136,6 +136,41 @@
<div class="post_body">
{{ post.body_html|community_links|safe if post.body_html else '' }}
</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>
{% endif %}