diff --git a/alembic/versions/c8cbfccf885d_keep_track_of_poll_answers.py b/alembic/versions/c8cbfccf885d_keep_track_of_poll_answers.py new file mode 100644 index 0000000..79ec900 --- /dev/null +++ b/alembic/versions/c8cbfccf885d_keep_track_of_poll_answers.py @@ -0,0 +1,32 @@ +"""Keep track of poll answers + +Revision ID: c8cbfccf885d +Revises: c9f204f5611d +Create Date: 2022-07-23 19:01:16.289953 + +""" +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = 'c8cbfccf885d' +down_revision = 'c9f204f5611d' +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('voted_for_answers', sa.JSON(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('inbox', schema=None) as batch_op: + batch_op.drop_column('voted_for_answers') + + # ### end Alembic commands ### diff --git a/app/activitypub.py b/app/activitypub.py index 5c9f264..fcd48e7 100644 --- a/app/activitypub.py +++ b/app/activitypub.py @@ -35,6 +35,7 @@ AS_EXTENDED_CTX = [ "featured": {"@id": "toot:featured", "@type": "@id"}, "Emoji": "toot:Emoji", "blurhash": "toot:blurhash", + "votersCount": "toot:votersCount", # schema "schema": "http://schema.org#", "PropertyValue": "schema:PropertyValue", @@ -281,7 +282,7 @@ def wrap_object(activity: RawObject) -> RawObject: def wrap_object_if_needed(raw_object: RawObject) -> RawObject: - if raw_object["type"] in ["Note"]: + if raw_object["type"] in ["Note", "Article", "Question"]: return wrap_object(raw_object) return raw_object diff --git a/app/admin.py b/app/admin.py index 3ef1671..706ac5b 100644 --- a/app/admin.py +++ b/app/admin.py @@ -6,6 +6,7 @@ from fastapi import Request from fastapi import UploadFile from fastapi.exceptions import HTTPException from fastapi.responses import RedirectResponse +from loguru import logger from sqlalchemy import func from sqlalchemy import select from sqlalchemy.orm import joinedload @@ -683,6 +684,26 @@ async def admin_actions_new( ) +@router.post("/actions/vote") +async def admin_actions_vote( + request: Request, + redirect_url: str = Form(), + in_reply_to: str = Form(), + csrf_check: None = Depends(verify_csrf_token), + db_session: AsyncSession = Depends(get_db_session), +) -> RedirectResponse: + form_data = await request.form() + names = form_data.getlist("name") + logger.info(f"{names=}") + for name in names: + await boxes.send_vote( + db_session, + in_reply_to=in_reply_to, + name=name, + ) + return RedirectResponse(redirect_url, status_code=302) + + @unauthenticated_router.get("/login") async def login( request: Request, diff --git a/app/boxes.py b/app/boxes.py index 7b25641..b9ee147 100644 --- a/app/boxes.py +++ b/app/boxes.py @@ -398,6 +398,112 @@ async def send_create( return note_id +async def send_vote( + db_session: AsyncSession, + in_reply_to: str, + name: str, +) -> str: + logger.info(f"Send vote {name}") + vote_id = allocate_outbox_id() + published = now().replace(microsecond=0).isoformat().replace("+00:00", "Z") + + in_reply_to_object = await get_anybox_object_by_ap_id(db_session, in_reply_to) + if not in_reply_to_object: + raise ValueError(f"Invalid in reply to {in_reply_to=}") + if not in_reply_to_object.ap_context: + raise ValueError("Object has no context") + context = in_reply_to_object.ap_context + + to = [in_reply_to_object.actor.ap_id] + + note = { + "@context": ap.AS_EXTENDED_CTX, + "type": "Note", + "id": outbox_object_id(vote_id), + "attributedTo": ID, + "name": name, + "to": to, + "cc": [], + "published": published, + "context": context, + "conversation": context, + "url": outbox_object_id(vote_id), + "inReplyTo": in_reply_to, + } + outbox_object = await save_outbox_object(db_session, vote_id, note) + if not outbox_object.id: + raise ValueError("Should never happen") + + 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 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"), + "anyOf": [ + { + "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, diff --git a/app/main.py b/app/main.py index a2d7dc1..fdc7d3f 100644 --- a/app/main.py +++ b/app/main.py @@ -76,7 +76,7 @@ _RESIZED_CACHE: MutableMapping[tuple[str, int], tuple[bytes, str, Any]] = LFUCac # TODO(ts): # # Next: -# - show pending follow request (and prevent double follow?) +# - prevent double accept/double follow # - UI support for updating posts # - Article support # - Fix tests diff --git a/app/models.py b/app/models.py index 631857e..d3553e4 100644 --- a/app/models.py +++ b/app/models.py @@ -108,6 +108,7 @@ class InboxObject(Base, BaseObject): # Link the oubox AP ID to allow undo without any extra query liked_via_outbox_object_ap_id = Column(String, nullable=True) announced_via_outbox_object_ap_id = Column(String, nullable=True) + voted_for_answers: Mapped[list[str] | None] = Column(JSON, nullable=True) is_bookmarked = Column(Boolean, nullable=False, default=False) diff --git a/app/templates/admin_inbox.html b/app/templates/admin_inbox.html index d2f8714..2feae63 100644 --- a/app/templates/admin_inbox.html +++ b/app/templates/admin_inbox.html @@ -24,7 +24,7 @@ {% if inbox_object.ap_type == "Announce" %} {{ actor_action(inbox_object, "shared") }} {{ utils.display_object(inbox_object.relates_to_anybox_object) }} -{% elif inbox_object.ap_type in ["Article", "Note", "Video"] %} +{% elif inbox_object.ap_type in ["Article", "Note", "Video", "Page", "Question"] %} {{ utils.display_object(inbox_object) }} {% elif inbox_object.ap_type == "Follow" %} {{ actor_action(inbox_object, "followed you") }} diff --git a/app/templates/utils.html b/app/templates/utils.html index 8dd34df..a58636b 100644 --- a/app/templates/utils.html +++ b/app/templates/utils.html @@ -292,6 +292,12 @@ {% endif %} {% if object.ap_type == "Question" %} + {% if object.is_from_inbox %} +
+ {{ embed_csrf_token() }} + {{ embed_redirect_url(object.permalink_id) }} + + {% endif %} {% if object.ap_object.oneOf %} {% endif %} + {% if object.is_from_inbox %} +

+ +

+
+ {% endif %} + + {% endif %} {{ display_og_meta(object) }} @@ -325,7 +344,8 @@ {% if object.ap_type == "Question" %} -
  • ends {{ object.ap_object.endTime | parse_datetime | timeago }}
  • + {% set endAt = object.ap_object.endTime | parse_datetime %} +
  • ends
  • {{ object.ap_object.votersCount }} voters
  • {% endif %} {% if is_admin %}