mirror of
https://codeberg.org/rimu/pyfedi
synced 2025-01-23 19:36:56 -08:00
poll ui - create and vote #181
This commit is contained in:
parent
f90d07f470
commit
e912c8d84f
9 changed files with 216 additions and 7 deletions
|
@ -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')),
|
||||
|
|
|
@ -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 != '':
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(';');
|
||||
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -1071,3 +1071,27 @@ h1 .warning_badge {
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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) }}
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue