poll ui, wip #181

This commit is contained in:
rimu 2024-05-16 21:53:38 +12:00
parent 9e003a5d8b
commit 526ac5260f
11 changed files with 259 additions and 40 deletions

View file

@ -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')),

View file

@ -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('/<actor>/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',

View file

@ -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')
@ -390,6 +394,25 @@ def save_post(form, post: Post, type: str):
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()

View file

@ -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)

View file

@ -0,0 +1,9 @@
<div id="type_of_post" class="btn-group flex-wrap" role="navigation">
<a href="{{ url_for('community.add_discussion_post', actor=actor) }}" class="btn {{ 'btn-primary' if request.path.endswith('submit') else 'btn-outline-secondary' }}" aria-label="{{ _('Start a discussion') }}">{{ _('Discussion') }}</a>
<a href="{{ url_for('community.add_link_post', actor=actor) }}" class="btn {{ 'btn-primary' if request.path.endswith('submit_link') else 'btn-outline-secondary' }}" aria-label="{{ _('Share a link') }}">{{ _('Link') }}</a>
<a href="{{ url_for('community.add_image_post', actor=actor) }}" class="btn {{ 'btn-primary' if request.path.endswith('submit_image') else 'btn-outline-secondary' }}" aria-label="{{ _('Share an image') }}">{{ _('Image') }}</a>
<a href="{{ url_for('community.add_video_post', actor=actor) }}" class="btn {{ 'btn-primary' if request.path.endswith('submit_video') else 'btn-outline-secondary' }}" aria-label="{{ _('Share a video') }}">{{ _('Video') }}</a>
<a href="{{ url_for('community.add_poll_post', actor=actor) }}" class="btn {{ 'btn-primary' if request.path.endswith('submit_poll') else 'btn-outline-secondary' }}" aria-label="{{ _('Create a poll') }}">{{ _('Poll') }}</a>
<!--
<a href="#" class="btn" aria-label="{{ _('Create an event') }}">{{ _('Event') }}</a> -->
</div>

View file

@ -15,14 +15,7 @@
<label class="form-control-label" for="type_of_post">
{{ _('Type of post') }}
</label>
<div id="type_of_post" class="btn-group flex-wrap" role="navigation">
<a href="{{ url_for('community.add_discussion_post', actor=actor) }}" class="btn btn-primary" aria-label="{{ _('Start a discussion') }}">{{ _('Discussion') }}</a>
<a href="{{ url_for('community.add_link_post', actor=actor) }}" class="btn btn-outline-secondary" aria-label="{{ _('Share a link') }}">{{ _('Link') }}</a>
<a href="{{ url_for('community.add_image_post', actor=actor) }}" class="btn btn-outline-secondary" aria-label="{{ _('Share an image') }}">{{ _('Image') }}</a>
<a href="{{ url_for('community.add_video_post', actor=actor) }}" class="btn btn-outline-secondary" aria-label="{{ _('Share a video') }}">{{ _('Video') }}</a>
<!-- <a href="#" class="btn" aria-label="{{ _('Create a poll') }}">{{ _('Poll') }}</a>
<a href="#" class="btn" aria-label="{{ _('Create an event') }}">{{ _('Event') }}</a> -->
</div>
{% include 'community/_add_post_types.html' %}
</div>
{{ render_field(form.communities) }}

View file

@ -15,14 +15,7 @@
<label class="form-control-label" for="type_of_post">
{{ _('Type of post') }}
</label>
<div id="type_of_post" class="btn-group flex-wrap" role="navigation">
<a href="{{ url_for('community.add_discussion_post', actor=actor) }}" class="btn btn-outline-secondary" aria-label="{{ _('Start a discussion') }}">{{ _('Discussion') }}</a>
<a href="{{ url_for('community.add_link_post', actor=actor) }}" class="btn btn-outline-secondary" aria-label="{{ _('Share a link') }}">{{ _('Link') }}</a>
<a href="{{ url_for('community.add_image_post', actor=actor) }}" class="btn btn-primary" aria-label="{{ _('Share an image') }}">{{ _('Image') }}</a>
<a href="{{ url_for('community.add_video_post', actor=actor) }}" class="btn btn-outline-secondary" aria-label="{{ _('Share a video') }}">{{ _('Video') }}</a>
<!-- <a href="#" class="btn" aria-label="{{ _('Create a poll') }}">{{ _('Poll') }}</a>
<a href="#" class="btn" aria-label="{{ _('Create an event') }}">{{ _('Event') }}</a> -->
</div>
{% include 'community/_add_post_types.html' %}
</div>
{{ render_field(form.communities) }}
{{ render_field(form.image_title) }}

View file

@ -15,14 +15,7 @@
<label class="form-control-label" for="type_of_post">
{{ _('Type of post') }}
</label>
<div id="type_of_post" class="btn-group flex-wrap" role="navigation">
<a href="{{ url_for('community.add_discussion_post', actor=actor) }}" class="btn btn-outline-secondary" aria-label="{{ _('Start a discussion') }}">{{ _('Discussion') }}</a>
<a href="{{ url_for('community.add_link_post', actor=actor) }}" class="btn btn-primary" aria-label="{{ _('Share a link') }}">{{ _('Link') }}</a>
<a href="{{ url_for('community.add_image_post', actor=actor) }}" class="btn btn-outline-secondary" aria-label="{{ _('Share an image') }}">{{ _('Image') }}</a>
<a href="{{ url_for('community.add_video_post', actor=actor) }}" class="btn btn-outline-secondary" aria-label="{{ _('Share a video') }}">{{ _('Video') }}</a>
<!-- <a href="#" class="btn" aria-label="{{ _('Create a poll') }}">{{ _('Poll') }}</a>
<a href="#" class="btn" aria-label="{{ _('Create an event') }}">{{ _('Event') }}</a> -->
</div>
{% include 'community/_add_post_types.html' %}
</div>
{{ render_field(form.communities) }}

View file

@ -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 %}
<div class="row">
<div class="col-12 col-md-8 position-relative main_pane">
<h1>{{ _('Create post') }}</h1>
<form method="post" enctype="multipart/form-data" role="form">
{{ form.csrf_token() }}
<div class="form-group">
<label class="form-control-label" for="type_of_post">
{{ _('Type of post') }}
</label>
{% include 'community/_add_post_types.html' %}
</div>
{{ render_field(form.communities) }}
{{ render_field(form.poll_title) }}
{{ render_field(form.poll_body) }}
{% if not low_bandwidth %}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#poll_body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
});
setupAutoResize('discussion_body');
});
</script>
{% else %}
<a href="#" aria-hidden="true" class="markdown_editor_enabler create_post_markdown_editor_enabler" data-id="discussion_body">{{ _('Enable markdown editor') }}</a>
{% endif %}
{% endif %}
{{ render_field(form.mode) }}
{{ render_field(form.finish_in) }}
{{ render_field(form.local_only) }}
{{ render_field(form.tags) }}
<small class="field_hint">{{ _('Separate each tag with a comma.') }}</small>
<div class="row mt-4">
<div class="col-md-3">
{{ render_field(form.notify_author) }}
</div>
<div class="col-md-1">
{{ render_field(form.sticky) }}
</div>
<div class="col-md-1">
{{ render_field(form.nsfw) }}
</div>
<div class="col-md-1">
{{ render_field(form.nsfl) }}
</div>
<div class="col post_language_chooser">
{{ render_field(form.language_id) }}
</div>
</div>
{{ render_field(form.submit) }}
</form>
</div>
<aside id="side_pane" class="col-12 col-md-4 side_pane" role="complementary">
<div class="card mb-3">
<div class="card-header">
<h2>{{ community.title }}</h2>
</div>
<div class="card-body">
<p>{{ community.description_html|safe if community.description_html else '' }}</p>
<p>{{ community.rules_html|safe if community.rules_html else '' }}</p>
{% if len(mods) > 0 and not community.private_mods %}
<h3>Moderators</h3>
<ul class="moderator_list">
{% for mod in mods %}
<li>{{ render_username(mod) }}</li>
{% endfor %}
</ul>
{% endif %}
{% if rss_feed %}
<p class="mt-4">
<a class="no-underline" href="{{ rss_feed }}" rel="nofollow"><span class="fe fe-rss"></span> RSS feed</a>
</p>
{% endif %}
</div>
</div>
{% include "_inoculation_links.html" %}
</aside>
</div>
{% endblock %}

View file

@ -15,14 +15,7 @@
<label class="form-control-label" for="type_of_post">
{{ _('Type of post') }}
</label>
<div id="type_of_post" class="btn-group flex-wrap" role="navigation">
<a href="{{ url_for('community.add_discussion_post', actor=actor) }}" class="btn btn-outline-secondary" aria-label="{{ _('Start a discussion') }}">{{ _('Discussion') }}</a>
<a href="{{ url_for('community.add_link_post', actor=actor) }}" class="btn btn-outline-secondary" aria-label="{{ _('Share a link') }}">{{ _('Link') }}</a>
<a href="{{ url_for('community.add_image_post', actor=actor) }}" class="btn btn-outline-secondary" aria-label="{{ _('Share an image') }}">{{ _('Image') }}</a>
<a href="{{ url_for('community.add_video_post', actor=actor) }}" class="btn btn-primary" aria-label="{{ _('Share a video') }}">{{ _('Video') }}</a>
<!-- <a href="#" class="btn" aria-label="{{ _('Create a poll') }}">{{ _('Poll') }}</a>
<a href="#" class="btn" aria-label="{{ _('Create an event') }}">{{ _('Event') }}</a> -->
</div>
{% include 'community/_add_post_types.html' %}
</div>
{{ render_field(form.communities) }}

View file

@ -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'], ),