Support for processing Questions answers/votes

This commit is contained in:
Thomas Sileo 2022-07-24 10:50:58 +02:00
parent f834596197
commit 3d5a86d51e
5 changed files with 184 additions and 9 deletions

View file

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

View file

@ -475,7 +475,7 @@ async def send_question(
"tag": tags, "tag": tags,
"votersCount": 0, "votersCount": 0,
"endTime": (now() + timedelta(minutes=5)).isoformat().replace("+00:00", "Z"), "endTime": (now() + timedelta(minutes=5)).isoformat().replace("+00:00", "Z"),
"anyOf": [ "oneOf": [
{ {
"type": "Note", "type": "Note",
"name": "A", "name": "A",
@ -1027,13 +1027,22 @@ async def _process_note_object(
) )
if replied_object: if replied_object:
if replied_object.is_from_outbox: if replied_object.is_from_outbox:
await db_session.execute( if replied_object.ap_type == "Question" and inbox_object.ap_object.get(
update(models.OutboxObject) "name"
.where( ):
models.OutboxObject.id == replied_object.id, 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: else:
await db_session.execute( await db_session.execute(
update(models.InboxObject) update(models.InboxObject)
@ -1049,6 +1058,7 @@ async def _process_note_object(
parent_activity.ap_type == "Create" parent_activity.ap_type == "Create"
and replied_object and replied_object
and replied_object.is_from_outbox and replied_object.is_from_outbox
and replied_object.ap_type != "Question"
and parent_activity.has_ld_signature and parent_activity.has_ld_signature
): ):
logger.info("Forwarding Create activity as it's a local reply") logger.info("Forwarding Create activity as it's a local reply")
@ -1070,6 +1080,82 @@ async def _process_note_object(
db_session.add(notif) 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( async def _process_transient_object(
db_session: AsyncSession, db_session: AsyncSession,
raw_object: ap.RawObject, raw_object: ap.RawObject,

View file

@ -11,10 +11,12 @@ from sqlalchemy import Column
from sqlalchemy import DateTime from sqlalchemy import DateTime
from sqlalchemy import Enum from sqlalchemy import Enum
from sqlalchemy import ForeignKey from sqlalchemy import ForeignKey
from sqlalchemy import Index
from sqlalchemy import Integer from sqlalchemy import Integer
from sqlalchemy import String from sqlalchemy import String
from sqlalchemy import Table from sqlalchemy import Table
from sqlalchemy import UniqueConstraint from sqlalchemy import UniqueConstraint
from sqlalchemy import text
from sqlalchemy.orm import Mapped from sqlalchemy.orm import Mapped
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@ -476,6 +478,44 @@ class Webmention(Base):
return None 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 @enum.unique
class NotificationType(str, enum.Enum): class NotificationType(str, enum.Enum):
NEW_FOLLOWER = "new_follower" NEW_FOLLOWER = "new_follower"

View file

@ -15,7 +15,7 @@
{% elif outbox_object.ap_type == "Follow" %} {% elif outbox_object.ap_type == "Follow" %}
<div class="actor-action">You followed</div> <div class="actor-action">You followed</div>
{{ utils.display_actor(outbox_object.relates_to_actor, actors_metadata) }} {{ utils.display_actor(outbox_object.relates_to_actor, actors_metadata) }}
{% elif outbox_object.ap_type in ["Article", "Note", "Video"] %} {% elif outbox_object.ap_type in ["Article", "Note", "Video", "Question"] %}
{{ utils.display_object(outbox_object) }} {{ utils.display_object(outbox_object) }}
{% else %} {% else %}
Implement {{ outbox_object.ap_type }} Implement {{ outbox_object.ap_type }}

View file

@ -24,7 +24,7 @@
<div class="h-feed"> <div class="h-feed">
<data class="p-name" value="{{ local_actor.display_name}}'s notes"></data> <data class="p-name" value="{{ local_actor.display_name}}'s notes"></data>
{% for outbox_object in objects %} {% for outbox_object in objects %}
{% if outbox_object.ap_type in ["Note", "Article", "Video"] %} {% if outbox_object.ap_type in ["Note", "Article", "Video", "Question"] %}
{{ utils.display_object(outbox_object) }} {{ utils.display_object(outbox_object) }}
{% elif outbox_object.ap_type == "Announce" %} {% elif outbox_object.ap_type == "Announce" %}
<div class="shared-header"><strong>{{ local_actor.display_name }}</strong> shared</div> <div class="shared-header"><strong>{{ local_actor.display_name }}</strong> shared</div>