diff --git a/app/community/forms.py b/app/community/forms.py index 12c87028..2a2283a8 100644 --- a/app/community/forms.py +++ b/app/community/forms.py @@ -187,6 +187,41 @@ class CreateImageForm(FlaskForm): return True +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'))]) + finish_choices=[ + ('30m', _l('30 minutes')), + ('1h', _l('1 hour')), + ('6h', _l('6 hours')), + ('12h', _l('12 hours')), + ('1d', _l('1 day')), + ('3d', _l('3 days')), + ('7d', _l('7 days')), + ] + finish_in = SelectField(_('End voting in'), validators=[DataRequired()], choices=finish_choices) + local_only = BooleanField(_l('Accept votes from this instance only')) + choice_1 = StringField('Choice') # intentionally left out of internationalization (no _l()) as this label is not used + choice_2 = StringField('Choice') + choice_3 = StringField('Choice') + choice_4 = StringField('Choice') + choice_5 = StringField('Choice') + choice_6 = StringField('Choice') + choice_7 = StringField('Choice') + choice_8 = StringField('Choice') + choice_9 = StringField('Choice') + choice_10 = StringField('Choice') + tags = StringField(_l('Tags'), validators=[Optional(), Length(min=3, max=5000)]) + sticky = BooleanField(_l('Sticky')) + nsfw = BooleanField(_l('NSFW')) + nsfl = BooleanField(_l('Gore/gross')) + notify_author = BooleanField(_l('Notify about replies')) + language_id = SelectField(_l('Language'), validators=[DataRequired()], coerce=int, render_kw={'class': 'form-select'}) + submit = SubmitField(_l('Save')) + + class ReportCommunityForm(FlaskForm): reason_choices = [('1', _l('Breaks instance rules')), ('2', _l('Abandoned by moderators')), diff --git a/app/community/routes.py b/app/community/routes.py index c0e14049..8e212bd2 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -15,7 +15,7 @@ from app.chat.util import send_message from app.community.forms import SearchRemoteCommunity, CreateDiscussionForm, CreateImageForm, CreateLinkForm, \ ReportCommunityForm, \ DeleteCommunityForm, AddCommunityForm, EditCommunityForm, AddModeratorForm, BanUserCommunityForm, \ - EscalateReportForm, ResolveReportForm, CreateVideoForm + EscalateReportForm, ResolveReportForm, CreateVideoForm, CreatePollForm from app.community.util import search_for_community, actor_to_community, \ opengraph_parse, url_to_thumbnail_file, save_post, save_icon_file, save_banner_file, send_to_remote_instance, \ delete_post_from_community, delete_post_reply_from_community, community_in_list @@ -769,6 +769,73 @@ def add_video_post(actor): ) +@bp.route('//submit_poll', methods=['GET', 'POST']) +@login_required +@validation_required +def add_poll_post(actor): + if current_user.banned: + return show_ban_message() + community = actor_to_community(actor) + + form = CreatePollForm() + + if g.site.enable_nsfl is False: + form.nsfl.render_kw = {'disabled': True} + if community.nsfw: + form.nsfw.data = True + form.nsfw.render_kw = {'disabled': True} + if community.nsfl: + form.nsfl.data = True + form.nsfw.render_kw = {'disabled': True} + if not(community.is_moderator() or community.is_owner() or current_user.is_admin()): + form.sticky.render_kw = {'disabled': True} + + form.communities.choices = [(c.id, c.display_name()) for c in current_user.communities()] + if not community_in_list(community.id, form.communities.choices): + form.communities.choices.append((community.id, community.display_name())) + + form.language_id.choices = languages_for_form() + + if not can_create_post(current_user, community): + abort(401) + + if form.validate_on_submit(): + community = Community.query.get_or_404(form.communities.data) + if not can_create_post(current_user, community): + abort(401) + post = Post(user_id=current_user.id, community_id=form.communities.data, instance_id=1) + save_post(form, post, 'poll') + community.post_count += 1 + community.last_active = g.site.last_active = utcnow() + db.session.commit() + post.ap_id = f"https://{current_app.config['SERVER_NAME']}/post/{post.id}" + db.session.commit() + + upvote_own_post(post) + + notify_about_post(post) + + if not community.local_only: + federate_post(community, post) + federate_post_to_user_followers(post) + + return redirect(f"/post/{post.id}") + else: + form.communities.data = community.id + form.notify_author.data = True + form.language_id.data = current_user.language_id if current_user.is_authenticated and current_user.language_id else english_language_id() + form.finish_in.data = '3d' + if community.posting_warning: + flash(community.posting_warning) + + return render_template('community/add_poll_post.html', title=_('Add poll to community'), form=form, community=community, + markdown_editor=current_user.markdown_editor, low_bandwidth=False, actor=actor, + moderating_communities=moderating_communities(current_user.get_id()), + joined_communities=joined_communities(current_user.id), + inoculation=inoculation[randint(0, len(inoculation) - 1)] + ) + + def federate_post(community, post): page = { 'type': 'Page', diff --git a/app/community/util.py b/app/community/util.py index 43f57acc..ab6b06e3 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -12,9 +12,10 @@ from app import db, cache, celery from app.activitypub.signature import post_request, default_context from app.activitypub.util import find_actor_or_create, actor_json_to_model, post_json_to_model, ensure_domains_match, \ find_hashtag_or_create -from app.constants import POST_TYPE_ARTICLE, POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_VIDEO, NOTIF_POST +from app.constants import POST_TYPE_ARTICLE, POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_VIDEO, NOTIF_POST, \ + POST_TYPE_POLL from app.models import Community, File, BannedInstances, PostReply, PostVote, Post, utcnow, CommunityMember, Site, \ - Instance, Notification, User, ActivityPubLog, NotificationSubscription, Language, Tag + Instance, Notification, User, ActivityPubLog, NotificationSubscription, Language, Tag, PollChoice, Poll from app.utils import get_request, gibberish, markdown_to_html, domain_from_url, allowlist_html, \ is_image_url, ensure_directory_exists, inbox_domain, post_ranking, shorten_string, parse_page, \ remove_tracking_from_link, ap_datetime, instance_banned, blocked_phrases @@ -360,7 +361,10 @@ def save_post(form, post: Post, type: str): db.session.add(file) elif type == 'poll': - ... + post.title = form.poll_title.data + post.body = form.poll_body.data + post.body_html = markdown_to_html(post.body) + post.type = POST_TYPE_POLL else: raise Exception('invalid post type') @@ -386,10 +390,29 @@ def save_post(form, post: Post, type: str): db.session.add(post) else: - db.session.execute(text('DELETE FROM "post_tag" WHERE post_id = :post_id'), { 'post_id': post.id}) + db.session.execute(text('DELETE FROM "post_tag" WHERE post_id = :post_id'), {'post_id': post.id}) post.tags = tags_from_string(form.tags.data) db.session.commit() + # 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}) + for i in range(1, 10): + choice_data = getattr(form, f"choice_{i}").data.strip() + if choice_data != '': + db.session.add(PollChoice(post_id=post.id, choice_text=choice_data, sort_order=i)) + + poll = Poll.query.filter_by(post_id=post.id).first() + if poll is None: + poll = Poll(post_id=post.id) + db.session.add(poll) + poll.mode = form.mode.data + poll.end_poll = end_poll_date(form.finish_in.data) + poll.local_only = form.local_only.data + poll.latest_vote = None + db.session.commit() + # Notify author about replies # Remove any subscription that currently exists existing_notification = NotificationSubscription.query.filter(NotificationSubscription.entity_id == post.id, @@ -408,6 +431,23 @@ def save_post(form, post: Post, type: str): db.session.commit() +def end_poll_date(end_choice): + delta_mapping = { + '30m': timedelta(minutes=30), + '1h': timedelta(hours=1), + '6h': timedelta(hours=6), + '12h': timedelta(hours=12), + '1d': timedelta(days=1), + '3d': timedelta(days=3), + '7d': timedelta(days=7) + } + + if end_choice in delta_mapping: + return datetime.utcnow() + delta_mapping[end_choice] + else: + raise ValueError("Invalid choice") + + def tags_from_string(tags: str) -> List[Tag]: return_value = [] tags = tags.strip() diff --git a/app/models.py b/app/models.py index 29e63ba7..580ef4bd 100644 --- a/app/models.py +++ b/app/models.py @@ -1372,6 +1372,7 @@ class NotificationSubscription(db.Model): class Poll(db.Model): post_id = db.Column(db.Integer, db.ForeignKey('post.id'), primary_key=True) end_poll = db.Column(db.DateTime) + mode = db.Column(db.String(10)) # 'single' or 'multiple' determines whether people can vote for one or multiple options local_only = db.Column(db.Boolean) latest_vote = db.Column(db.DateTime) diff --git a/app/templates/community/_add_post_types.html b/app/templates/community/_add_post_types.html new file mode 100644 index 00000000..b3e27f08 --- /dev/null +++ b/app/templates/community/_add_post_types.html @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/app/templates/community/add_discussion_post.html b/app/templates/community/add_discussion_post.html index 63cc4a83..16bb53cd 100644 --- a/app/templates/community/add_discussion_post.html +++ b/app/templates/community/add_discussion_post.html @@ -15,14 +15,7 @@ - + {% include 'community/_add_post_types.html' %} {{ render_field(form.communities) }} diff --git a/app/templates/community/add_image_post.html b/app/templates/community/add_image_post.html index b794c9e1..3b7c63bb 100644 --- a/app/templates/community/add_image_post.html +++ b/app/templates/community/add_image_post.html @@ -15,14 +15,7 @@ - + {% include 'community/_add_post_types.html' %} {{ render_field(form.communities) }} {{ render_field(form.image_title) }} diff --git a/app/templates/community/add_link_post.html b/app/templates/community/add_link_post.html index 0cf9eadb..380b6855 100644 --- a/app/templates/community/add_link_post.html +++ b/app/templates/community/add_link_post.html @@ -15,14 +15,7 @@ - + {% include 'community/_add_post_types.html' %} {{ render_field(form.communities) }} diff --git a/app/templates/community/add_poll_post.html b/app/templates/community/add_poll_post.html new file mode 100644 index 00000000..f07b3efa --- /dev/null +++ b/app/templates/community/add_poll_post.html @@ -0,0 +1,94 @@ +{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %} + {% extends 'themes/' + theme() + '/base.html' %} +{% else %} + {% extends "base.html" %} +{% endif %} %} +{% from 'bootstrap/form.html' import render_field %} + +{% block app_content %} +
+
+

{{ _('Create post') }}

+
+ {{ form.csrf_token() }} +
+ + {% include 'community/_add_post_types.html' %} +
+ + {{ render_field(form.communities) }} + {{ render_field(form.poll_title) }} + {{ render_field(form.poll_body) }} + {% if not low_bandwidth %} + {% if markdown_editor %} + + {% else %} + + {% endif %} + {% endif %} + {{ render_field(form.mode) }} + {{ render_field(form.finish_in) }} + {{ render_field(form.local_only) }} + {{ render_field(form.tags) }} + {{ _('Separate each tag with a comma.') }} + +
+
+ {{ render_field(form.notify_author) }} +
+
+ {{ render_field(form.sticky) }} +
+
+ {{ render_field(form.nsfw) }} +
+
+ {{ render_field(form.nsfl) }} +
+
+ {{ render_field(form.language_id) }} +
+
+ + {{ render_field(form.submit) }} +
+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/community/add_video_post.html b/app/templates/community/add_video_post.html index 68a55185..edd342c1 100644 --- a/app/templates/community/add_video_post.html +++ b/app/templates/community/add_video_post.html @@ -15,14 +15,7 @@ - + {% include 'community/_add_post_types.html' %} {{ render_field(form.communities) }} diff --git a/migrations/versions/92bdb39f6c72_polls.py b/migrations/versions/05e3a7023a1e_polls.py similarity index 94% rename from migrations/versions/92bdb39f6c72_polls.py rename to migrations/versions/05e3a7023a1e_polls.py index 7cfd764c..9e493697 100644 --- a/migrations/versions/92bdb39f6c72_polls.py +++ b/migrations/versions/05e3a7023a1e_polls.py @@ -1,8 +1,8 @@ """polls -Revision ID: 92bdb39f6c72 +Revision ID: 05e3a7023a1e Revises: 9752fb47d7a6 -Create Date: 2024-05-16 20:42:05.491951 +Create Date: 2024-05-16 20:47:35.566151 """ from alembic import op @@ -10,7 +10,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = '92bdb39f6c72' +revision = '05e3a7023a1e' down_revision = '9752fb47d7a6' branch_labels = None depends_on = None @@ -21,6 +21,7 @@ def upgrade(): op.create_table('poll', sa.Column('post_id', sa.Integer(), nullable=False), sa.Column('end_poll', sa.DateTime(), nullable=True), + sa.Column('mode', sa.String(length=10), nullable=True), sa.Column('local_only', sa.Boolean(), nullable=True), sa.Column('latest_vote', sa.DateTime(), nullable=True), sa.ForeignKeyConstraint(['post_id'], ['post.id'], ),