Finish Question/poll support

This commit is contained in:
Thomas Sileo 2022-07-24 12:36:59 +02:00
parent 3d5a86d51e
commit fb0081a554
8 changed files with 151 additions and 71 deletions

View file

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

View file

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

View file

@ -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"]

View file

@ -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 = {}
"@context": ap.AS_EXTENDED_CTX, 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", "type": "Note",
"name": answer,
"replies": {"type": "Collection", "totalItems": 0},
}
for answer in poll_answers
],
}
obj = {
"@context": ap.AS_EXTENDED_CTX,
"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",

View file

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

View file

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

View file

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

View file

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