Finish Question/poll support
parent
3d5a86d51e
commit
fb0081a554
|
@ -0,0 +1,38 @@
|
||||||
|
"""Add new is_transient flag
|
||||||
|
|
||||||
|
Revision ID: 21b9e9d71ba3
|
||||||
|
Revises: edea0406b7d0
|
||||||
|
Create Date: 2022-07-24 12:33:15.421906
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '21b9e9d71ba3'
|
||||||
|
down_revision = 'edea0406b7d0'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('inbox', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('is_transient', sa.Boolean(), server_default='0', nullable=False))
|
||||||
|
|
||||||
|
with op.batch_alter_table('outbox', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('is_transient', sa.Boolean(), server_default='0', nullable=False))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('outbox', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('is_transient')
|
||||||
|
|
||||||
|
with op.batch_alter_table('inbox', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('is_transient')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
22
app/admin.py
22
app/admin.py
|
@ -284,6 +284,7 @@ async def admin_inbox(
|
||||||
["Accept", "Delete", "Create", "Update", "Undo", "Read", "Add", "Remove"]
|
["Accept", "Delete", "Create", "Update", "Undo", "Read", "Add", "Remove"]
|
||||||
),
|
),
|
||||||
models.InboxObject.is_deleted.is_(False),
|
models.InboxObject.is_deleted.is_(False),
|
||||||
|
models.InboxObject.is_transient.is_(False),
|
||||||
]
|
]
|
||||||
if filter_by:
|
if filter_by:
|
||||||
where.append(models.InboxObject.ap_type == filter_by)
|
where.append(models.InboxObject.ap_type == filter_by)
|
||||||
|
@ -358,6 +359,7 @@ async def admin_outbox(
|
||||||
where = [
|
where = [
|
||||||
models.OutboxObject.ap_type.not_in(["Accept", "Delete", "Update"]),
|
models.OutboxObject.ap_type.not_in(["Accept", "Delete", "Update"]),
|
||||||
models.OutboxObject.is_deleted.is_(False),
|
models.OutboxObject.is_deleted.is_(False),
|
||||||
|
models.OutboxObject.is_transient.is_(False),
|
||||||
]
|
]
|
||||||
if filter_by:
|
if filter_by:
|
||||||
where.append(models.OutboxObject.ap_type == filter_by)
|
where.append(models.OutboxObject.ap_type == filter_by)
|
||||||
|
@ -658,6 +660,7 @@ async def admin_actions_new(
|
||||||
content_warning: str | None = Form(None),
|
content_warning: str | None = Form(None),
|
||||||
is_sensitive: bool = Form(False),
|
is_sensitive: bool = Form(False),
|
||||||
visibility: str = Form(),
|
visibility: str = Form(),
|
||||||
|
poll_type: str | None = Form(None),
|
||||||
csrf_check: None = Depends(verify_csrf_token),
|
csrf_check: None = Depends(verify_csrf_token),
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
|
@ -669,14 +672,33 @@ async def admin_actions_new(
|
||||||
upload = await save_upload(db_session, f)
|
upload = await save_upload(db_session, f)
|
||||||
uploads.append((upload, f.filename, raw_form_data.get("alt_" + f.filename)))
|
uploads.append((upload, f.filename, raw_form_data.get("alt_" + f.filename)))
|
||||||
|
|
||||||
|
ap_type = "Note"
|
||||||
|
|
||||||
|
poll_duration_in_minutes = None
|
||||||
|
if poll_type:
|
||||||
|
ap_type = "Question"
|
||||||
|
answers = []
|
||||||
|
for i in ["1", "2", "3", "4"]:
|
||||||
|
if answer := raw_form_data.get(f"poll_answer_{i}"):
|
||||||
|
answers.append(answer)
|
||||||
|
|
||||||
|
if not answers or len(answers) < 2:
|
||||||
|
raise ValueError("Question must have at least 2 answers")
|
||||||
|
|
||||||
|
poll_duration_in_minutes = int(raw_form_data["poll_duration"])
|
||||||
|
|
||||||
public_id = await boxes.send_create(
|
public_id = await boxes.send_create(
|
||||||
db_session,
|
db_session,
|
||||||
|
ap_type=ap_type,
|
||||||
source=content,
|
source=content,
|
||||||
uploads=uploads,
|
uploads=uploads,
|
||||||
in_reply_to=in_reply_to or None,
|
in_reply_to=in_reply_to or None,
|
||||||
visibility=ap.VisibilityEnum[visibility],
|
visibility=ap.VisibilityEnum[visibility],
|
||||||
content_warning=content_warning or None,
|
content_warning=content_warning or None,
|
||||||
is_sensitive=True if content_warning else is_sensitive,
|
is_sensitive=True if content_warning else is_sensitive,
|
||||||
|
poll_type=poll_type,
|
||||||
|
poll_answers=answers,
|
||||||
|
poll_duration_in_minutes=poll_duration_in_minutes,
|
||||||
)
|
)
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
request.url_for("outbox_by_public_id", public_id=public_id),
|
request.url_for("outbox_by_public_id", public_id=public_id),
|
||||||
|
|
|
@ -134,13 +134,16 @@ class Object:
|
||||||
break
|
break
|
||||||
return attachments
|
return attachments
|
||||||
|
|
||||||
@property
|
@cached_property
|
||||||
def url(self) -> str | None:
|
def url(self) -> str | None:
|
||||||
obj_url = self.ap_object.get("url")
|
obj_url = self.ap_object.get("url")
|
||||||
if isinstance(obj_url, str):
|
if isinstance(obj_url, str):
|
||||||
return obj_url
|
return obj_url
|
||||||
elif obj_url:
|
elif obj_url:
|
||||||
for u in ap.as_list(obj_url):
|
for u in ap.as_list(obj_url):
|
||||||
|
if u.get("type") == "Link":
|
||||||
|
return u["href"]
|
||||||
|
|
||||||
if u["mediaType"] == "text/html":
|
if u["mediaType"] == "text/html":
|
||||||
return u["href"]
|
return u["href"]
|
||||||
|
|
||||||
|
|
116
app/boxes.py
116
app/boxes.py
|
@ -56,6 +56,7 @@ async def save_outbox_object(
|
||||||
relates_to_outbox_object_id: int | None = None,
|
relates_to_outbox_object_id: int | None = None,
|
||||||
relates_to_actor_id: int | None = None,
|
relates_to_actor_id: int | None = None,
|
||||||
source: str | None = None,
|
source: str | None = None,
|
||||||
|
is_transient: bool = False,
|
||||||
) -> models.OutboxObject:
|
) -> models.OutboxObject:
|
||||||
ra = await RemoteObject.from_raw_object(raw_object)
|
ra = await RemoteObject.from_raw_object(raw_object)
|
||||||
|
|
||||||
|
@ -73,6 +74,7 @@ async def save_outbox_object(
|
||||||
activity_object_ap_id=ra.activity_object_ap_id,
|
activity_object_ap_id=ra.activity_object_ap_id,
|
||||||
is_hidden_from_homepage=True if ra.in_reply_to else False,
|
is_hidden_from_homepage=True if ra.in_reply_to else False,
|
||||||
source=source,
|
source=source,
|
||||||
|
is_transient=is_transient,
|
||||||
)
|
)
|
||||||
db_session.add(outbox_object)
|
db_session.add(outbox_object)
|
||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
|
@ -285,12 +287,16 @@ async def send_undo(db_session: AsyncSession, ap_object_id: str) -> None:
|
||||||
|
|
||||||
async def send_create(
|
async def send_create(
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
|
ap_type: str,
|
||||||
source: str,
|
source: str,
|
||||||
uploads: list[tuple[models.Upload, str, str | None]],
|
uploads: list[tuple[models.Upload, str, str | None]],
|
||||||
in_reply_to: str | None,
|
in_reply_to: str | None,
|
||||||
visibility: ap.VisibilityEnum,
|
visibility: ap.VisibilityEnum,
|
||||||
content_warning: str | None = None,
|
content_warning: str | None = None,
|
||||||
is_sensitive: bool = False,
|
is_sensitive: bool = False,
|
||||||
|
poll_type: str | None = None,
|
||||||
|
poll_answers: list[str] | None = None,
|
||||||
|
poll_duration_in_minutes: int | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
note_id = allocate_outbox_id()
|
note_id = allocate_outbox_id()
|
||||||
published = now().replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
published = now().replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||||
|
@ -336,9 +342,35 @@ async def send_create(
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unhandled visibility {visibility}")
|
raise ValueError(f"Unhandled visibility {visibility}")
|
||||||
|
|
||||||
note = {
|
extra_obj_attrs = {}
|
||||||
|
if ap_type == "Question":
|
||||||
|
if not poll_answers or len(poll_answers) < 2:
|
||||||
|
raise ValueError("Question must have at least 2 possible answers")
|
||||||
|
|
||||||
|
if not poll_type:
|
||||||
|
raise ValueError("Mising poll_type")
|
||||||
|
|
||||||
|
if not poll_duration_in_minutes:
|
||||||
|
raise ValueError("Missing poll_duration_in_minutes")
|
||||||
|
|
||||||
|
extra_obj_attrs = {
|
||||||
|
"votersCount": 0,
|
||||||
|
"endTime": (now() + timedelta(minutes=poll_duration_in_minutes))
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z"),
|
||||||
|
poll_type: [
|
||||||
|
{
|
||||||
|
"type": "Note",
|
||||||
|
"name": answer,
|
||||||
|
"replies": {"type": "Collection", "totalItems": 0},
|
||||||
|
}
|
||||||
|
for answer in poll_answers
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
obj = {
|
||||||
"@context": ap.AS_EXTENDED_CTX,
|
"@context": ap.AS_EXTENDED_CTX,
|
||||||
"type": "Note",
|
"type": ap_type,
|
||||||
"id": outbox_object_id(note_id),
|
"id": outbox_object_id(note_id),
|
||||||
"attributedTo": ID,
|
"attributedTo": ID,
|
||||||
"content": content,
|
"content": content,
|
||||||
|
@ -353,8 +385,9 @@ async def send_create(
|
||||||
"inReplyTo": in_reply_to,
|
"inReplyTo": in_reply_to,
|
||||||
"sensitive": is_sensitive,
|
"sensitive": is_sensitive,
|
||||||
"attachment": attachments,
|
"attachment": attachments,
|
||||||
|
**extra_obj_attrs, # type: ignore
|
||||||
}
|
}
|
||||||
outbox_object = await save_outbox_object(db_session, note_id, note, source=source)
|
outbox_object = await save_outbox_object(db_session, note_id, obj, source=source)
|
||||||
if not outbox_object.id:
|
if not outbox_object.id:
|
||||||
raise ValueError("Should never happen")
|
raise ValueError("Should never happen")
|
||||||
|
|
||||||
|
@ -375,13 +408,13 @@ async def send_create(
|
||||||
)
|
)
|
||||||
db_session.add(outbox_object_attachment)
|
db_session.add(outbox_object_attachment)
|
||||||
|
|
||||||
recipients = await _compute_recipients(db_session, note)
|
recipients = await _compute_recipients(db_session, obj)
|
||||||
for rcp in recipients:
|
for rcp in recipients:
|
||||||
await new_outgoing_activity(db_session, rcp, outbox_object.id)
|
await new_outgoing_activity(db_session, rcp, outbox_object.id)
|
||||||
|
|
||||||
# If the note is public, check if we need to send any webmentions
|
# If the note is public, check if we need to send any webmentions
|
||||||
if visibility == ap.VisibilityEnum.PUBLIC:
|
if visibility == ap.VisibilityEnum.PUBLIC:
|
||||||
possible_targets = opengraph._urls_from_note(note)
|
possible_targets = opengraph._urls_from_note(obj)
|
||||||
logger.info(f"webmentions possible targert {possible_targets}")
|
logger.info(f"webmentions possible targert {possible_targets}")
|
||||||
for target in possible_targets:
|
for target in possible_targets:
|
||||||
webmention_endpoint = await webmentions.discover_webmention_endpoint(target)
|
webmention_endpoint = await webmentions.discover_webmention_endpoint(target)
|
||||||
|
@ -436,7 +469,9 @@ async def send_vote(
|
||||||
"url": outbox_object_id(vote_id),
|
"url": outbox_object_id(vote_id),
|
||||||
"inReplyTo": in_reply_to,
|
"inReplyTo": in_reply_to,
|
||||||
}
|
}
|
||||||
outbox_object = await save_outbox_object(db_session, vote_id, note)
|
outbox_object = await save_outbox_object(
|
||||||
|
db_session, vote_id, note, is_transient=True
|
||||||
|
)
|
||||||
if not outbox_object.id:
|
if not outbox_object.id:
|
||||||
raise ValueError("Should never happen")
|
raise ValueError("Should never happen")
|
||||||
|
|
||||||
|
@ -448,68 +483,6 @@ async def send_vote(
|
||||||
return vote_id
|
return vote_id
|
||||||
|
|
||||||
|
|
||||||
async def send_question(
|
|
||||||
db_session: AsyncSession,
|
|
||||||
source: str,
|
|
||||||
) -> str:
|
|
||||||
note_id = allocate_outbox_id()
|
|
||||||
published = now().replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
||||||
context = f"{ID}/contexts/" + uuid.uuid4().hex
|
|
||||||
content, tags, mentioned_actors = await markdownify(db_session, source)
|
|
||||||
|
|
||||||
to = [ap.AS_PUBLIC]
|
|
||||||
cc = [f"{BASE_URL}/followers"]
|
|
||||||
|
|
||||||
note = {
|
|
||||||
"@context": ap.AS_EXTENDED_CTX,
|
|
||||||
"type": "Question",
|
|
||||||
"id": outbox_object_id(note_id),
|
|
||||||
"attributedTo": ID,
|
|
||||||
"content": content,
|
|
||||||
"to": to,
|
|
||||||
"cc": cc,
|
|
||||||
"published": published,
|
|
||||||
"context": context,
|
|
||||||
"conversation": context,
|
|
||||||
"url": outbox_object_id(note_id),
|
|
||||||
"tag": tags,
|
|
||||||
"votersCount": 0,
|
|
||||||
"endTime": (now() + timedelta(minutes=5)).isoformat().replace("+00:00", "Z"),
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"type": "Note",
|
|
||||||
"name": "A",
|
|
||||||
"replies": {"type": "Collection", "totalItems": 0},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Note",
|
|
||||||
"name": "B",
|
|
||||||
"replies": {"type": "Collection", "totalItems": 0},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"summary": None,
|
|
||||||
"sensitive": False,
|
|
||||||
}
|
|
||||||
outbox_object = await save_outbox_object(db_session, note_id, note, source=source)
|
|
||||||
if not outbox_object.id:
|
|
||||||
raise ValueError("Should never happen")
|
|
||||||
|
|
||||||
for tag in tags:
|
|
||||||
if tag["type"] == "Hashtag":
|
|
||||||
tagged_object = models.TaggedOutboxObject(
|
|
||||||
tag=tag["name"][1:],
|
|
||||||
outbox_object_id=outbox_object.id,
|
|
||||||
)
|
|
||||||
db_session.add(tagged_object)
|
|
||||||
|
|
||||||
recipients = await _compute_recipients(db_session, note)
|
|
||||||
for rcp in recipients:
|
|
||||||
await new_outgoing_activity(db_session, rcp, outbox_object.id)
|
|
||||||
|
|
||||||
await db_session.commit()
|
|
||||||
return note_id
|
|
||||||
|
|
||||||
|
|
||||||
async def send_update(
|
async def send_update(
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
ap_id: str,
|
ap_id: str,
|
||||||
|
@ -989,7 +962,11 @@ async def _process_note_object(
|
||||||
|
|
||||||
is_from_following = ro.actor.ap_id in {f.ap_actor_id for f in following}
|
is_from_following = ro.actor.ap_id in {f.ap_actor_id for f in following}
|
||||||
is_reply = bool(ro.in_reply_to)
|
is_reply = bool(ro.in_reply_to)
|
||||||
is_local_reply = ro.in_reply_to and ro.in_reply_to.startswith(BASE_URL)
|
is_local_reply = (
|
||||||
|
ro.in_reply_to
|
||||||
|
and ro.in_reply_to.startswith(BASE_URL)
|
||||||
|
and ro.content # Hide votes from Question
|
||||||
|
)
|
||||||
is_mention = False
|
is_mention = False
|
||||||
tags = ro.ap_object.get("tag", [])
|
tags = ro.ap_object.get("tag", [])
|
||||||
for tag in ap.as_list(tags):
|
for tag in ap.as_list(tags):
|
||||||
|
@ -1099,6 +1076,7 @@ async def _handle_vote_answer(
|
||||||
logger.warning(f"Invalid answer {answer_name=}")
|
logger.warning(f"Invalid answer {answer_name=}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
answer.is_transient = True
|
||||||
poll_answer = models.PollAnswer(
|
poll_answer = models.PollAnswer(
|
||||||
outbox_object_id=question.id,
|
outbox_object_id=question.id,
|
||||||
poll_type="oneOf" if question.is_one_of_poll else "anyOf",
|
poll_type="oneOf" if question.is_one_of_poll else "anyOf",
|
||||||
|
|
|
@ -152,6 +152,7 @@ async def post_micropub_endpoint(
|
||||||
|
|
||||||
public_id = await send_create(
|
public_id = await send_create(
|
||||||
db_session,
|
db_session,
|
||||||
|
"Note",
|
||||||
content,
|
content,
|
||||||
uploads=[],
|
uploads=[],
|
||||||
in_reply_to=None,
|
in_reply_to=None,
|
||||||
|
|
|
@ -116,6 +116,7 @@ class InboxObject(Base, BaseObject):
|
||||||
|
|
||||||
# Used to mark deleted objects, but also activities that were undone
|
# Used to mark deleted objects, but also activities that were undone
|
||||||
is_deleted = Column(Boolean, nullable=False, default=False)
|
is_deleted = Column(Boolean, nullable=False, default=False)
|
||||||
|
is_transient = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||||
|
|
||||||
replies_count = Column(Integer, nullable=False, default=0)
|
replies_count = Column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
@ -176,6 +177,7 @@ class OutboxObject(Base, BaseObject):
|
||||||
|
|
||||||
# For the featured collection
|
# For the featured collection
|
||||||
is_pinned = Column(Boolean, nullable=False, default=False)
|
is_pinned = Column(Boolean, nullable=False, default=False)
|
||||||
|
is_transient = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||||
|
|
||||||
# Never actually delete from the outbox
|
# Never actually delete from the outbox
|
||||||
is_deleted = Column(Boolean, nullable=False, default=False)
|
is_deleted = Column(Boolean, nullable=False, default=False)
|
||||||
|
|
|
@ -13,6 +13,18 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="box">
|
<div class="box">
|
||||||
|
<nav class="flexbox">
|
||||||
|
<ul>
|
||||||
|
{% for ap_type in ["Note", "Question"] %}
|
||||||
|
<li><a href="?type={{ ap_type }}" {% if request.query_params.get("type", "Note") == ap_type %}class="active"{% endif %}>
|
||||||
|
{{ ap_type }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
|
||||||
<form class="form" action="{{ request.url_for("admin_actions_new") }}" enctype="multipart/form-data" method="POST">
|
<form class="form" action="{{ request.url_for("admin_actions_new") }}" enctype="multipart/form-data" method="POST">
|
||||||
{{ utils.embed_csrf_token() }}
|
{{ utils.embed_csrf_token() }}
|
||||||
{{ utils.embed_redirect_url() }}
|
{{ utils.embed_redirect_url() }}
|
||||||
|
@ -31,6 +43,30 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<textarea name="content" rows="10" cols="50" autofocus="autofocus" designMode="on" placeholder="Hey!" style="font-size:1.2em;width:95%;">{{ content }}</textarea>
|
<textarea name="content" rows="10" cols="50" autofocus="autofocus" designMode="on" placeholder="Hey!" style="font-size:1.2em;width:95%;">{{ content }}</textarea>
|
||||||
|
|
||||||
|
{% if request.query_params.type == "Question" %}
|
||||||
|
<p>
|
||||||
|
<select name="poll_type">
|
||||||
|
<option value="oneOf">single choice</option>
|
||||||
|
<option value="anyOf">multiple choices</option>
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<select name="poll_duration">
|
||||||
|
<option value="5">ends in 5 minutes</option>
|
||||||
|
<option value="30">ends in 30 minutes</option>
|
||||||
|
<option value="60">ends in 1 hour</option>
|
||||||
|
<option value="360">ends in 6 hours</option>
|
||||||
|
<option value="1440">ends in 1 day</option>
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
{% for i in ["1", "2", "3", "4"] %}
|
||||||
|
<p>
|
||||||
|
<input type="text" name="poll_answer_{{ i }}" style="width:95%;" placeholder="Option {{ i }}, leave empty to disable">
|
||||||
|
</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<input type="text" name="content_warning" placeholder="content warning (will mark the post as sensitive)" style="width:95%;">
|
<input type="text" name="content_warning" placeholder="content warning (will mark the post as sensitive)" style="width:95%;">
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -291,7 +291,7 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if object.ap_type == "Question" %}
|
{% if object.ap_type == "Question" and (not object.sensitive or (object.sensitive and object.permalink_id in request.query_params.getlist("show_more"))) %}
|
||||||
{% set can_vote = is_admin and object.is_from_inbox and not object.is_poll_ended and not object.voted_for_answers %}
|
{% set can_vote = is_admin and object.is_from_inbox and not object.is_poll_ended and not object.voted_for_answers %}
|
||||||
{% if can_vote %}
|
{% if can_vote %}
|
||||||
<form action="{{ request.url_for("admin_actions_vote") }}" method="POST">
|
<form action="{{ request.url_for("admin_actions_vote") }}" method="POST">
|
||||||
|
|
Loading…
Reference in New Issue