From fb0081a554da4c8b08379121269fa185c5cedf20 Mon Sep 17 00:00:00 2001 From: Thomas Sileo Date: Sun, 24 Jul 2022 12:36:59 +0200 Subject: [PATCH] Finish Question/poll support --- .../21b9e9d71ba3_add_new_is_transient_flag.py | 38 ++++++ app/admin.py | 22 ++++ app/ap_object.py | 5 +- app/boxes.py | 116 +++++++----------- app/micropub.py | 1 + app/models.py | 2 + app/templates/admin_new.html | 36 ++++++ app/templates/utils.html | 2 +- 8 files changed, 151 insertions(+), 71 deletions(-) create mode 100644 alembic/versions/21b9e9d71ba3_add_new_is_transient_flag.py diff --git a/alembic/versions/21b9e9d71ba3_add_new_is_transient_flag.py b/alembic/versions/21b9e9d71ba3_add_new_is_transient_flag.py new file mode 100644 index 0000000..c747a12 --- /dev/null +++ b/alembic/versions/21b9e9d71ba3_add_new_is_transient_flag.py @@ -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 ### diff --git a/app/admin.py b/app/admin.py index 91e5069..0938ed7 100644 --- a/app/admin.py +++ b/app/admin.py @@ -284,6 +284,7 @@ async def admin_inbox( ["Accept", "Delete", "Create", "Update", "Undo", "Read", "Add", "Remove"] ), models.InboxObject.is_deleted.is_(False), + models.InboxObject.is_transient.is_(False), ] if filter_by: where.append(models.InboxObject.ap_type == filter_by) @@ -358,6 +359,7 @@ async def admin_outbox( where = [ models.OutboxObject.ap_type.not_in(["Accept", "Delete", "Update"]), models.OutboxObject.is_deleted.is_(False), + models.OutboxObject.is_transient.is_(False), ] if 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), is_sensitive: bool = Form(False), visibility: str = Form(), + poll_type: str | None = Form(None), csrf_check: None = Depends(verify_csrf_token), db_session: AsyncSession = Depends(get_db_session), ) -> RedirectResponse: @@ -669,14 +672,33 @@ async def admin_actions_new( upload = await save_upload(db_session, f) 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( db_session, + ap_type=ap_type, source=content, uploads=uploads, in_reply_to=in_reply_to or None, visibility=ap.VisibilityEnum[visibility], content_warning=content_warning or None, 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( request.url_for("outbox_by_public_id", public_id=public_id), diff --git a/app/ap_object.py b/app/ap_object.py index 32584bd..ce9a41d 100644 --- a/app/ap_object.py +++ b/app/ap_object.py @@ -134,13 +134,16 @@ class Object: break return attachments - @property + @cached_property def url(self) -> str | None: obj_url = self.ap_object.get("url") if isinstance(obj_url, str): return obj_url elif obj_url: for u in ap.as_list(obj_url): + if u.get("type") == "Link": + return u["href"] + if u["mediaType"] == "text/html": return u["href"] diff --git a/app/boxes.py b/app/boxes.py index 3c683bc..8e5849c 100644 --- a/app/boxes.py +++ b/app/boxes.py @@ -56,6 +56,7 @@ async def save_outbox_object( relates_to_outbox_object_id: int | None = None, relates_to_actor_id: int | None = None, source: str | None = None, + is_transient: bool = False, ) -> models.OutboxObject: 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, is_hidden_from_homepage=True if ra.in_reply_to else False, source=source, + is_transient=is_transient, ) db_session.add(outbox_object) await db_session.flush() @@ -285,12 +287,16 @@ async def send_undo(db_session: AsyncSession, ap_object_id: str) -> None: async def send_create( db_session: AsyncSession, + ap_type: str, source: str, uploads: list[tuple[models.Upload, str, str | None]], in_reply_to: str | None, visibility: ap.VisibilityEnum, content_warning: str | None = None, is_sensitive: bool = False, + poll_type: str | None = None, + poll_answers: list[str] | None = None, + poll_duration_in_minutes: int | None = None, ) -> str: note_id = allocate_outbox_id() published = now().replace(microsecond=0).isoformat().replace("+00:00", "Z") @@ -336,9 +342,35 @@ async def send_create( else: 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, - "type": "Note", + "type": ap_type, "id": outbox_object_id(note_id), "attributedTo": ID, "content": content, @@ -353,8 +385,9 @@ async def send_create( "inReplyTo": in_reply_to, "sensitive": is_sensitive, "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: raise ValueError("Should never happen") @@ -375,13 +408,13 @@ async def send_create( ) db_session.add(outbox_object_attachment) - recipients = await _compute_recipients(db_session, note) + recipients = await _compute_recipients(db_session, obj) for rcp in recipients: await new_outgoing_activity(db_session, rcp, outbox_object.id) # If the note is public, check if we need to send any webmentions 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}") for target in possible_targets: webmention_endpoint = await webmentions.discover_webmention_endpoint(target) @@ -436,7 +469,9 @@ async def send_vote( "url": outbox_object_id(vote_id), "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: raise ValueError("Should never happen") @@ -448,68 +483,6 @@ async def send_vote( 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( db_session: AsyncSession, 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_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 tags = ro.ap_object.get("tag", []) for tag in ap.as_list(tags): @@ -1099,6 +1076,7 @@ async def _handle_vote_answer( logger.warning(f"Invalid answer {answer_name=}") return + answer.is_transient = True poll_answer = models.PollAnswer( outbox_object_id=question.id, poll_type="oneOf" if question.is_one_of_poll else "anyOf", diff --git a/app/micropub.py b/app/micropub.py index e377e02..24c63f5 100644 --- a/app/micropub.py +++ b/app/micropub.py @@ -152,6 +152,7 @@ async def post_micropub_endpoint( public_id = await send_create( db_session, + "Note", content, uploads=[], in_reply_to=None, diff --git a/app/models.py b/app/models.py index 9ecbc82..22c956c 100644 --- a/app/models.py +++ b/app/models.py @@ -116,6 +116,7 @@ class InboxObject(Base, BaseObject): # Used to mark deleted objects, but also activities that were undone 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) @@ -176,6 +177,7 @@ class OutboxObject(Base, BaseObject): # For the featured collection 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 is_deleted = Column(Boolean, nullable=False, default=False) diff --git a/app/templates/admin_new.html b/app/templates/admin_new.html index 4528c05..2eb2cca 100644 --- a/app/templates/admin_new.html +++ b/app/templates/admin_new.html @@ -13,6 +13,18 @@ {% endif %}
+ + +
{{ utils.embed_csrf_token() }} {{ utils.embed_redirect_url() }} @@ -31,6 +43,30 @@ {% endfor %} + + {% if request.query_params.type == "Question" %} +

+ +

+

+ +

+ {% for i in ["1", "2", "3", "4"] %} +

+ +

+ {% endfor %} + {% endif %} +

diff --git a/app/templates/utils.html b/app/templates/utils.html index 5311ed0..8e4f1eb 100644 --- a/app/templates/utils.html +++ b/app/templates/utils.html @@ -291,7 +291,7 @@
{% 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 %} {% if can_vote %}