Support for processing Questions answers/votes
parent
f834596197
commit
3d5a86d51e
|
@ -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,
|
"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,
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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 }}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in New Issue