forked from forks/microblog.pub
Finish Question/poll support
This commit is contained in:
parent
3d5a86d51e
commit
fb0081a554
8 changed files with 151 additions and 71 deletions
38
alembic/versions/21b9e9d71ba3_add_new_is_transient_flag.py
Normal file
38
alembic/versions/21b9e9d71ba3_add_new_is_transient_flag.py
Normal file
|
@ -0,0 +1,38 @@
|
|||
"""Add new is_transient flag
|
||||
|
||||
Revision ID: 21b9e9d71ba3
|
||||
Revises: edea0406b7d0
|
||||
Create Date: 2022-07-24 12:33:15.421906
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '21b9e9d71ba3'
|
||||
down_revision = 'edea0406b7d0'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('inbox', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('is_transient', sa.Boolean(), server_default='0', nullable=False))
|
||||
|
||||
with op.batch_alter_table('outbox', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('is_transient', sa.Boolean(), server_default='0', nullable=False))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('outbox', schema=None) as batch_op:
|
||||
batch_op.drop_column('is_transient')
|
||||
|
||||
with op.batch_alter_table('inbox', schema=None) as batch_op:
|
||||
batch_op.drop_column('is_transient')
|
||||
|
||||
# ### end Alembic commands ###
|
22
app/admin.py
22
app/admin.py
|
@ -284,6 +284,7 @@ async def admin_inbox(
|
|||
["Accept", "Delete", "Create", "Update", "Undo", "Read", "Add", "Remove"]
|
||||
),
|
||||
models.InboxObject.is_deleted.is_(False),
|
||||
models.InboxObject.is_transient.is_(False),
|
||||
]
|
||||
if filter_by:
|
||||
where.append(models.InboxObject.ap_type == filter_by)
|
||||
|
@ -358,6 +359,7 @@ async def admin_outbox(
|
|||
where = [
|
||||
models.OutboxObject.ap_type.not_in(["Accept", "Delete", "Update"]),
|
||||
models.OutboxObject.is_deleted.is_(False),
|
||||
models.OutboxObject.is_transient.is_(False),
|
||||
]
|
||||
if filter_by:
|
||||
where.append(models.OutboxObject.ap_type == filter_by)
|
||||
|
@ -658,6 +660,7 @@ async def admin_actions_new(
|
|||
content_warning: str | None = Form(None),
|
||||
is_sensitive: bool = Form(False),
|
||||
visibility: str = Form(),
|
||||
poll_type: str | None = Form(None),
|
||||
csrf_check: None = Depends(verify_csrf_token),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> RedirectResponse:
|
||||
|
@ -669,14 +672,33 @@ async def admin_actions_new(
|
|||
upload = await save_upload(db_session, f)
|
||||
uploads.append((upload, f.filename, raw_form_data.get("alt_" + f.filename)))
|
||||
|
||||
ap_type = "Note"
|
||||
|
||||
poll_duration_in_minutes = None
|
||||
if poll_type:
|
||||
ap_type = "Question"
|
||||
answers = []
|
||||
for i in ["1", "2", "3", "4"]:
|
||||
if answer := raw_form_data.get(f"poll_answer_{i}"):
|
||||
answers.append(answer)
|
||||
|
||||
if not answers or len(answers) < 2:
|
||||
raise ValueError("Question must have at least 2 answers")
|
||||
|
||||
poll_duration_in_minutes = int(raw_form_data["poll_duration"])
|
||||
|
||||
public_id = await boxes.send_create(
|
||||
db_session,
|
||||
ap_type=ap_type,
|
||||
source=content,
|
||||
uploads=uploads,
|
||||
in_reply_to=in_reply_to or None,
|
||||
visibility=ap.VisibilityEnum[visibility],
|
||||
content_warning=content_warning or None,
|
||||
is_sensitive=True if content_warning else is_sensitive,
|
||||
poll_type=poll_type,
|
||||
poll_answers=answers,
|
||||
poll_duration_in_minutes=poll_duration_in_minutes,
|
||||
)
|
||||
return RedirectResponse(
|
||||
request.url_for("outbox_by_public_id", public_id=public_id),
|
||||
|
|
|
@ -134,13 +134,16 @@ class Object:
|
|||
break
|
||||
return attachments
|
||||
|
||||
@property
|
||||
@cached_property
|
||||
def url(self) -> str | None:
|
||||
obj_url = self.ap_object.get("url")
|
||||
if isinstance(obj_url, str):
|
||||
return obj_url
|
||||
elif obj_url:
|
||||
for u in ap.as_list(obj_url):
|
||||
if u.get("type") == "Link":
|
||||
return u["href"]
|
||||
|
||||
if u["mediaType"] == "text/html":
|
||||
return u["href"]
|
||||
|
||||
|
|
116
app/boxes.py
116
app/boxes.py
|
@ -56,6 +56,7 @@ async def save_outbox_object(
|
|||
relates_to_outbox_object_id: int | None = None,
|
||||
relates_to_actor_id: int | None = None,
|
||||
source: str | None = None,
|
||||
is_transient: bool = False,
|
||||
) -> models.OutboxObject:
|
||||
ra = await RemoteObject.from_raw_object(raw_object)
|
||||
|
||||
|
@ -73,6 +74,7 @@ async def save_outbox_object(
|
|||
activity_object_ap_id=ra.activity_object_ap_id,
|
||||
is_hidden_from_homepage=True if ra.in_reply_to else False,
|
||||
source=source,
|
||||
is_transient=is_transient,
|
||||
)
|
||||
db_session.add(outbox_object)
|
||||
await db_session.flush()
|
||||
|
@ -285,12 +287,16 @@ async def send_undo(db_session: AsyncSession, ap_object_id: str) -> None:
|
|||
|
||||
async def send_create(
|
||||
db_session: AsyncSession,
|
||||
ap_type: str,
|
||||
source: str,
|
||||
uploads: list[tuple[models.Upload, str, str | None]],
|
||||
in_reply_to: str | None,
|
||||
visibility: ap.VisibilityEnum,
|
||||
content_warning: str | None = None,
|
||||
is_sensitive: bool = False,
|
||||
poll_type: str | None = None,
|
||||
poll_answers: list[str] | None = None,
|
||||
poll_duration_in_minutes: int | None = None,
|
||||
) -> str:
|
||||
note_id = allocate_outbox_id()
|
||||
published = now().replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
@ -336,9 +342,35 @@ async def send_create(
|
|||
else:
|
||||
raise ValueError(f"Unhandled visibility {visibility}")
|
||||
|
||||
note = {
|
||||
extra_obj_attrs = {}
|
||||
if ap_type == "Question":
|
||||
if not poll_answers or len(poll_answers) < 2:
|
||||
raise ValueError("Question must have at least 2 possible answers")
|
||||
|
||||
if not poll_type:
|
||||
raise ValueError("Mising poll_type")
|
||||
|
||||
if not poll_duration_in_minutes:
|
||||
raise ValueError("Missing poll_duration_in_minutes")
|
||||
|
||||
extra_obj_attrs = {
|
||||
"votersCount": 0,
|
||||
"endTime": (now() + timedelta(minutes=poll_duration_in_minutes))
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z"),
|
||||
poll_type: [
|
||||
{
|
||||
"type": "Note",
|
||||
"name": answer,
|
||||
"replies": {"type": "Collection", "totalItems": 0},
|
||||
}
|
||||
for answer in poll_answers
|
||||
],
|
||||
}
|
||||
|
||||
obj = {
|
||||
"@context": ap.AS_EXTENDED_CTX,
|
||||
"type": "Note",
|
||||
"type": ap_type,
|
||||
"id": outbox_object_id(note_id),
|
||||
"attributedTo": ID,
|
||||
"content": content,
|
||||
|
@ -353,8 +385,9 @@ async def send_create(
|
|||
"inReplyTo": in_reply_to,
|
||||
"sensitive": is_sensitive,
|
||||
"attachment": attachments,
|
||||
**extra_obj_attrs, # type: ignore
|
||||
}
|
||||
outbox_object = await save_outbox_object(db_session, note_id, note, source=source)
|
||||
outbox_object = await save_outbox_object(db_session, note_id, obj, source=source)
|
||||
if not outbox_object.id:
|
||||
raise ValueError("Should never happen")
|
||||
|
||||
|
@ -375,13 +408,13 @@ async def send_create(
|
|||
)
|
||||
db_session.add(outbox_object_attachment)
|
||||
|
||||
recipients = await _compute_recipients(db_session, note)
|
||||
recipients = await _compute_recipients(db_session, obj)
|
||||
for rcp in recipients:
|
||||
await new_outgoing_activity(db_session, rcp, outbox_object.id)
|
||||
|
||||
# If the note is public, check if we need to send any webmentions
|
||||
if visibility == ap.VisibilityEnum.PUBLIC:
|
||||
possible_targets = opengraph._urls_from_note(note)
|
||||
possible_targets = opengraph._urls_from_note(obj)
|
||||
logger.info(f"webmentions possible targert {possible_targets}")
|
||||
for target in possible_targets:
|
||||
webmention_endpoint = await webmentions.discover_webmention_endpoint(target)
|
||||
|
@ -436,7 +469,9 @@ async def send_vote(
|
|||
"url": outbox_object_id(vote_id),
|
||||
"inReplyTo": in_reply_to,
|
||||
}
|
||||
outbox_object = await save_outbox_object(db_session, vote_id, note)
|
||||
outbox_object = await save_outbox_object(
|
||||
db_session, vote_id, note, is_transient=True
|
||||
)
|
||||
if not outbox_object.id:
|
||||
raise ValueError("Should never happen")
|
||||
|
||||
|
@ -448,68 +483,6 @@ async def send_vote(
|
|||
return vote_id
|
||||
|
||||
|
||||
async def send_question(
|
||||
db_session: AsyncSession,
|
||||
source: str,
|
||||
) -> str:
|
||||
note_id = allocate_outbox_id()
|
||||
published = now().replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
context = f"{ID}/contexts/" + uuid.uuid4().hex
|
||||
content, tags, mentioned_actors = await markdownify(db_session, source)
|
||||
|
||||
to = [ap.AS_PUBLIC]
|
||||
cc = [f"{BASE_URL}/followers"]
|
||||
|
||||
note = {
|
||||
"@context": ap.AS_EXTENDED_CTX,
|
||||
"type": "Question",
|
||||
"id": outbox_object_id(note_id),
|
||||
"attributedTo": ID,
|
||||
"content": content,
|
||||
"to": to,
|
||||
"cc": cc,
|
||||
"published": published,
|
||||
"context": context,
|
||||
"conversation": context,
|
||||
"url": outbox_object_id(note_id),
|
||||
"tag": tags,
|
||||
"votersCount": 0,
|
||||
"endTime": (now() + timedelta(minutes=5)).isoformat().replace("+00:00", "Z"),
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "Note",
|
||||
"name": "A",
|
||||
"replies": {"type": "Collection", "totalItems": 0},
|
||||
},
|
||||
{
|
||||
"type": "Note",
|
||||
"name": "B",
|
||||
"replies": {"type": "Collection", "totalItems": 0},
|
||||
},
|
||||
],
|
||||
"summary": None,
|
||||
"sensitive": False,
|
||||
}
|
||||
outbox_object = await save_outbox_object(db_session, note_id, note, source=source)
|
||||
if not outbox_object.id:
|
||||
raise ValueError("Should never happen")
|
||||
|
||||
for tag in tags:
|
||||
if tag["type"] == "Hashtag":
|
||||
tagged_object = models.TaggedOutboxObject(
|
||||
tag=tag["name"][1:],
|
||||
outbox_object_id=outbox_object.id,
|
||||
)
|
||||
db_session.add(tagged_object)
|
||||
|
||||
recipients = await _compute_recipients(db_session, note)
|
||||
for rcp in recipients:
|
||||
await new_outgoing_activity(db_session, rcp, outbox_object.id)
|
||||
|
||||
await db_session.commit()
|
||||
return note_id
|
||||
|
||||
|
||||
async def send_update(
|
||||
db_session: AsyncSession,
|
||||
ap_id: str,
|
||||
|
@ -989,7 +962,11 @@ async def _process_note_object(
|
|||
|
||||
is_from_following = ro.actor.ap_id in {f.ap_actor_id for f in following}
|
||||
is_reply = bool(ro.in_reply_to)
|
||||
is_local_reply = ro.in_reply_to and ro.in_reply_to.startswith(BASE_URL)
|
||||
is_local_reply = (
|
||||
ro.in_reply_to
|
||||
and ro.in_reply_to.startswith(BASE_URL)
|
||||
and ro.content # Hide votes from Question
|
||||
)
|
||||
is_mention = False
|
||||
tags = ro.ap_object.get("tag", [])
|
||||
for tag in ap.as_list(tags):
|
||||
|
@ -1099,6 +1076,7 @@ async def _handle_vote_answer(
|
|||
logger.warning(f"Invalid answer {answer_name=}")
|
||||
return
|
||||
|
||||
answer.is_transient = True
|
||||
poll_answer = models.PollAnswer(
|
||||
outbox_object_id=question.id,
|
||||
poll_type="oneOf" if question.is_one_of_poll else "anyOf",
|
||||
|
|
|
@ -152,6 +152,7 @@ async def post_micropub_endpoint(
|
|||
|
||||
public_id = await send_create(
|
||||
db_session,
|
||||
"Note",
|
||||
content,
|
||||
uploads=[],
|
||||
in_reply_to=None,
|
||||
|
|
|
@ -116,6 +116,7 @@ class InboxObject(Base, BaseObject):
|
|||
|
||||
# Used to mark deleted objects, but also activities that were undone
|
||||
is_deleted = Column(Boolean, nullable=False, default=False)
|
||||
is_transient = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||
|
||||
replies_count = Column(Integer, nullable=False, default=0)
|
||||
|
||||
|
@ -176,6 +177,7 @@ class OutboxObject(Base, BaseObject):
|
|||
|
||||
# For the featured collection
|
||||
is_pinned = Column(Boolean, nullable=False, default=False)
|
||||
is_transient = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||
|
||||
# Never actually delete from the outbox
|
||||
is_deleted = Column(Boolean, nullable=False, default=False)
|
||||
|
|
|
@ -13,6 +13,18 @@
|
|||
{% endif %}
|
||||
|
||||
<div class="box">
|
||||
<nav class="flexbox">
|
||||
<ul>
|
||||
{% for ap_type in ["Note", "Question"] %}
|
||||
<li><a href="?type={{ ap_type }}" {% if request.query_params.get("type", "Note") == ap_type %}class="active"{% endif %}>
|
||||
{{ ap_type }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
|
||||
<form class="form" action="{{ request.url_for("admin_actions_new") }}" enctype="multipart/form-data" method="POST">
|
||||
{{ utils.embed_csrf_token() }}
|
||||
{{ utils.embed_redirect_url() }}
|
||||
|
@ -31,6 +43,30 @@
|
|||
{% endfor %}
|
||||
|
||||
<textarea name="content" rows="10" cols="50" autofocus="autofocus" designMode="on" placeholder="Hey!" style="font-size:1.2em;width:95%;">{{ content }}</textarea>
|
||||
|
||||
{% if request.query_params.type == "Question" %}
|
||||
<p>
|
||||
<select name="poll_type">
|
||||
<option value="oneOf">single choice</option>
|
||||
<option value="anyOf">multiple choices</option>
|
||||
</select>
|
||||
</p>
|
||||
<p>
|
||||
<select name="poll_duration">
|
||||
<option value="5">ends in 5 minutes</option>
|
||||
<option value="30">ends in 30 minutes</option>
|
||||
<option value="60">ends in 1 hour</option>
|
||||
<option value="360">ends in 6 hours</option>
|
||||
<option value="1440">ends in 1 day</option>
|
||||
</select>
|
||||
</p>
|
||||
{% for i in ["1", "2", "3", "4"] %}
|
||||
<p>
|
||||
<input type="text" name="poll_answer_{{ i }}" style="width:95%;" placeholder="Option {{ i }}, leave empty to disable">
|
||||
</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<p>
|
||||
<input type="text" name="content_warning" placeholder="content warning (will mark the post as sensitive)" style="width:95%;">
|
||||
</p>
|
||||
|
|
|
@ -291,7 +291,7 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if object.ap_type == "Question" %}
|
||||
{% if object.ap_type == "Question" and (not object.sensitive or (object.sensitive and object.permalink_id in request.query_params.getlist("show_more"))) %}
|
||||
{% set can_vote = is_admin and object.is_from_inbox and not object.is_poll_ended and not object.voted_for_answers %}
|
||||
{% if can_vote %}
|
||||
<form action="{{ request.url_for("admin_actions_vote") }}" method="POST">
|
||||
|
|
Loading…
Reference in a new issue