Support for processing Questions answers/votes
This commit is contained in:
parent
f834596197
commit
3d5a86d51e
5 changed files with 184 additions and 9 deletions
|
@ -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 ###
|
100
app/boxes.py
100
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,
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
{% elif outbox_object.ap_type == "Follow" %}
|
||||
<div class="actor-action">You followed</div>
|
||||
{{ 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) }}
|
||||
{% else %}
|
||||
Implement {{ outbox_object.ap_type }}
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
<div class="h-feed">
|
||||
<data class="p-name" value="{{ local_actor.display_name}}'s notes"></data>
|
||||
{% 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) }}
|
||||
{% elif outbox_object.ap_type == "Announce" %}
|
||||
<div class="shared-header"><strong>{{ local_actor.display_name }}</strong> shared</div>
|
||||
|
|
Loading…
Reference in a new issue