diff --git a/app/community/forms.py b/app/community/forms.py index 048afd10..491fefee 100644 --- a/app/community/forms.py +++ b/app/community/forms.py @@ -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')), diff --git a/app/community/util.py b/app/community/util.py index ab6b06e3..5f9af78f 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -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 != '': diff --git a/app/models.py b/app/models.py index 580ef4bd..fa79283b 100644 --- a/app/models.py +++ b/app/models.py @@ -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) diff --git a/app/post/routes.py b/app/post/routes.py index d1fcd499..3fe20b93 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -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//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//comment/') def continue_discussion(post_id, comment_id): post = Post.query.get_or_404(post_id) diff --git a/app/static/js/scripts.js b/app/static/js/scripts.js index 286daebe..23a6ff1b 100644 --- a/app/static/js/scripts.js +++ b/app/static/js/scripts.js @@ -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(';'); diff --git a/app/static/structure.css b/app/static/structure.css index 812e5ee2..83b02d79 100644 --- a/app/static/structure.css +++ b/app/static/structure.css @@ -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 */ diff --git a/app/static/structure.scss b/app/static/structure.scss index e3d5e443..4b1ee8a6 100644 --- a/app/static/structure.scss +++ b/app/static/structure.scss @@ -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; + } + } + } + } } \ No newline at end of file diff --git a/app/templates/community/add_poll_post.html b/app/templates/community/add_poll_post.html index f07b3efa..109224a5 100644 --- a/app/templates/community/add_poll_post.html +++ b/app/templates/community/add_poll_post.html @@ -37,6 +37,40 @@ {% endif %} {% endif %} +
+ {{ _('Poll choices') }} +
+ {{ form.choice_1(class_="form-control", **{"placeholder": "First choice"}) }} +
+
+ {{ form.choice_2(class_="form-control", **{"placeholder": "Second choice"}) }} +
+ + + + + + + + + +
{{ render_field(form.mode) }} {{ render_field(form.finish_in) }} {{ render_field(form.local_only) }} diff --git a/app/templates/post/_post_full.html b/app/templates/post/_post_full.html index 43602c50..1f0768cc 100644 --- a/app/templates/post/_post_full.html +++ b/app/templates/post/_post_full.html @@ -136,6 +136,41 @@
{{ post.body_html|community_links|safe if post.body_html else '' }}
+ {% if post.type == POST_TYPE_POLL %} +
+ {% if poll_results %} +
    + {% for choice in poll_choices %} +
  • +
    +

    {{ choice.choice_text }}

    +
    {{ choice.percentage(poll_total_votes) }}%
    +
    +
  • + {% endfor %} +
+

{{ _('Total votes: %(total_votes)d.', total_votes=poll_total_votes) }}

+

{{ _('Poll closes') }} {{ moment(poll_data.end_poll).fromNow(refresh=True) }}.

+ {% elif poll_form %} +
+
    + {% for choice in poll_choices %} + {% if poll_data.mode == 'single' %} +
  • + {% else %} +
  • + {% endif %} + {% endfor %} +
+ +
+ {% endif %} +
+ {% endif %} {% endif %}