diff --git a/alembic/versions/edea0406b7d0_poll_questions_answers_handling.py b/alembic/versions/edea0406b7d0_poll_questions_answers_handling.py new file mode 100644 index 0000000..38b7180 --- /dev/null +++ b/alembic/versions/edea0406b7d0_poll_questions_answers_handling.py @@ -0,0 +1,49 @@ +"""Poll/Questions answers handling + +Revision ID: edea0406b7d0 +Revises: c8cbfccf885d +Create Date: 2022-07-24 09:49:53.669481 + +""" +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = 'edea0406b7d0' +down_revision = 'c8cbfccf885d' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('poll_answer', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('outbox_object_id', sa.Integer(), nullable=False), + sa.Column('poll_type', sa.String(), nullable=False), + sa.Column('inbox_object_id', sa.Integer(), nullable=False), + sa.Column('actor_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['actor_id'], ['actor.id'], ), + sa.ForeignKeyConstraint(['inbox_object_id'], ['inbox.id'], ), + sa.ForeignKeyConstraint(['outbox_object_id'], ['outbox.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('outbox_object_id', 'name', 'actor_id', name='uix_outbox_object_id_name_actor_id') + ) + with op.batch_alter_table('poll_answer', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_poll_answer_id'), ['id'], unique=False) + batch_op.create_index('uix_one_of_outbox_object_id_actor_id', ['outbox_object_id', 'actor_id'], unique=True, sqlite_where=sa.text('poll_type = "oneOf"')) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('poll_answer', schema=None) as batch_op: + batch_op.drop_index('uix_one_of_outbox_object_id_actor_id', sqlite_where=sa.text('poll_type = "oneOf"')) + batch_op.drop_index(batch_op.f('ix_poll_answer_id')) + + op.drop_table('poll_answer') + # ### end Alembic commands ### diff --git a/app/boxes.py b/app/boxes.py index 1168a65..3c683bc 100644 --- a/app/boxes.py +++ b/app/boxes.py @@ -475,7 +475,7 @@ async def send_question( "tag": tags, "votersCount": 0, "endTime": (now() + timedelta(minutes=5)).isoformat().replace("+00:00", "Z"), - "anyOf": [ + "oneOf": [ { "type": "Note", "name": "A", @@ -1027,13 +1027,22 @@ async def _process_note_object( ) if replied_object: if replied_object.is_from_outbox: - await db_session.execute( - update(models.OutboxObject) - .where( - models.OutboxObject.id == replied_object.id, + if replied_object.ap_type == "Question" and inbox_object.ap_object.get( + "name" + ): + await _handle_vote_answer( + db_session, + inbox_object, + replied_object, # type: ignore # outbox check below + ) + else: + await db_session.execute( + update(models.OutboxObject) + .where( + models.OutboxObject.id == replied_object.id, + ) + .values(replies_count=models.OutboxObject.replies_count + 1) ) - .values(replies_count=models.OutboxObject.replies_count + 1) - ) else: await db_session.execute( update(models.InboxObject) @@ -1049,6 +1058,7 @@ async def _process_note_object( parent_activity.ap_type == "Create" and replied_object and replied_object.is_from_outbox + and replied_object.ap_type != "Question" and parent_activity.has_ld_signature ): logger.info("Forwarding Create activity as it's a local reply") @@ -1070,6 +1080,82 @@ async def _process_note_object( db_session.add(notif) +async def _handle_vote_answer( + db_session: AsyncSession, + answer: models.InboxObject, + question: models.OutboxObject, +) -> None: + logger.info(f"Processing poll answer for {question.ap_id}: {answer.ap_id}") + + if question.is_poll_ended: + logger.warning("Poll is ended, discarding answer") + return + + if not question.poll_items: + raise ValueError("Should never happen") + + answer_name = answer.ap_object["name"] + if answer_name not in {pi["name"] for pi in question.poll_items}: + logger.warning(f"Invalid answer {answer_name=}") + return + + poll_answer = models.PollAnswer( + outbox_object_id=question.id, + poll_type="oneOf" if question.is_one_of_poll else "anyOf", + inbox_object_id=answer.id, + actor_id=answer.actor.id, + name=answer_name, + ) + db_session.add(poll_answer) + await db_session.flush() + + voters_count = await db_session.scalar( + select(func.count(func.distinct(models.PollAnswer.actor_id))).where( + models.PollAnswer.outbox_object_id == question.id + ) + ) + + all_answers = await db_session.execute( + select( + func.count(models.PollAnswer.name).label("answer_count"), + models.PollAnswer.name, + ) + .where(models.PollAnswer.outbox_object_id == question.id) + .group_by(models.PollAnswer.name) + ) + all_answers_count = {a["name"]: a["answer_count"] for a in all_answers} + + logger.info(f"{voters_count=}") + logger.info(f"{all_answers_count=}") + + question_ap_object = dict(question.ap_object) + question_ap_object["votersCount"] = voters_count + items_key = "oneOf" if question.is_one_of_poll else "anyOf" + question_ap_object[items_key] = [ + { + "type": "Note", + "name": item["name"], + "replies": { + "type": "Collection", + "totalItems": all_answers_count.get(item["name"], 0), + }, + } + for item in question.poll_items + ] + updated = now().replace(microsecond=0).isoformat().replace("+00:00", "Z") + question_ap_object["updated"] = updated + question.ap_object = question_ap_object + + logger.info(f"Updated question: {question.ap_object}") + + await db_session.flush() + + # Finally send an update + recipients = await _compute_recipients(db_session, question.ap_object) + for rcp in recipients: + await new_outgoing_activity(db_session, rcp, question.id) + + async def _process_transient_object( db_session: AsyncSession, raw_object: ap.RawObject, diff --git a/app/models.py b/app/models.py index d3553e4..9ecbc82 100644 --- a/app/models.py +++ b/app/models.py @@ -11,10 +11,12 @@ from sqlalchemy import Column from sqlalchemy import DateTime from sqlalchemy import Enum from sqlalchemy import ForeignKey +from sqlalchemy import Index from sqlalchemy import Integer from sqlalchemy import String from sqlalchemy import Table from sqlalchemy import UniqueConstraint +from sqlalchemy import text from sqlalchemy.orm import Mapped from sqlalchemy.orm import relationship @@ -476,6 +478,44 @@ class Webmention(Base): return None +class PollAnswer(Base): + __tablename__ = "poll_answer" + __table_args__ = ( + # Enforce a single answer for poll/actor/answer + UniqueConstraint( + "outbox_object_id", + "name", + "actor_id", + name="uix_outbox_object_id_name_actor_id", + ), + # Enforce an actor can only vote once on a "oneOf" Question + Index( + "uix_one_of_outbox_object_id_actor_id", + "outbox_object_id", + "actor_id", + unique=True, + sqlite_where=text('poll_type = "oneOf"'), + ), + ) + + id = Column(Integer, primary_key=True, index=True) + created_at = Column(DateTime(timezone=True), nullable=False, default=now) + + outbox_object_id = Column(Integer, ForeignKey("outbox.id"), nullable=False) + outbox_object = relationship(OutboxObject, uselist=False) + + # oneOf|anyOf + poll_type = Column(String, nullable=False) + + inbox_object_id = Column(Integer, ForeignKey("inbox.id"), nullable=False) + inbox_object = relationship(InboxObject, uselist=False) + + actor_id = Column(Integer, ForeignKey("actor.id"), nullable=False) + actor = relationship(Actor, uselist=False) + + name = Column(String, nullable=False) + + @enum.unique class NotificationType(str, enum.Enum): NEW_FOLLOWER = "new_follower" diff --git a/app/templates/admin_outbox.html b/app/templates/admin_outbox.html index de6ed23..4a26f2a 100644 --- a/app/templates/admin_outbox.html +++ b/app/templates/admin_outbox.html @@ -15,7 +15,7 @@ {% elif outbox_object.ap_type == "Follow" %}