mirror of
				https://git.sr.ht/~tsileo/microblog.pub
				synced 2025-06-05 21:59:23 +02:00 
			
		
		
		
	Support for processing Questions answers/votes
This commit is contained in:
		| @@ -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 ### | ||||
							
								
								
									
										88
									
								
								app/boxes.py
									
									
									
									
									
								
							
							
						
						
									
										88
									
								
								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,6 +1027,15 @@ async def _process_note_object( | ||||
|         ) | ||||
|         if replied_object: | ||||
|             if replied_object.is_from_outbox: | ||||
|                 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( | ||||
| @@ -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> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user