forked from forks/microblog.pub
Add support for voting on Question
This commit is contained in:
parent
4046fa0506
commit
d67a44bb59
8 changed files with 187 additions and 6 deletions
32
alembic/versions/c8cbfccf885d_keep_track_of_poll_answers.py
Normal file
32
alembic/versions/c8cbfccf885d_keep_track_of_poll_answers.py
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
"""Keep track of poll answers
|
||||||
|
|
||||||
|
Revision ID: c8cbfccf885d
|
||||||
|
Revises: c9f204f5611d
|
||||||
|
Create Date: 2022-07-23 19:01:16.289953
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'c8cbfccf885d'
|
||||||
|
down_revision = 'c9f204f5611d'
|
||||||
|
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('voted_for_answers', sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('inbox', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('voted_for_answers')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
|
@ -35,6 +35,7 @@ AS_EXTENDED_CTX = [
|
||||||
"featured": {"@id": "toot:featured", "@type": "@id"},
|
"featured": {"@id": "toot:featured", "@type": "@id"},
|
||||||
"Emoji": "toot:Emoji",
|
"Emoji": "toot:Emoji",
|
||||||
"blurhash": "toot:blurhash",
|
"blurhash": "toot:blurhash",
|
||||||
|
"votersCount": "toot:votersCount",
|
||||||
# schema
|
# schema
|
||||||
"schema": "http://schema.org#",
|
"schema": "http://schema.org#",
|
||||||
"PropertyValue": "schema:PropertyValue",
|
"PropertyValue": "schema:PropertyValue",
|
||||||
|
@ -281,7 +282,7 @@ def wrap_object(activity: RawObject) -> RawObject:
|
||||||
|
|
||||||
|
|
||||||
def wrap_object_if_needed(raw_object: RawObject) -> RawObject:
|
def wrap_object_if_needed(raw_object: RawObject) -> RawObject:
|
||||||
if raw_object["type"] in ["Note"]:
|
if raw_object["type"] in ["Note", "Article", "Question"]:
|
||||||
return wrap_object(raw_object)
|
return wrap_object(raw_object)
|
||||||
|
|
||||||
return raw_object
|
return raw_object
|
||||||
|
|
21
app/admin.py
21
app/admin.py
|
@ -6,6 +6,7 @@ from fastapi import Request
|
||||||
from fastapi import UploadFile
|
from fastapi import UploadFile
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
from loguru import logger
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
@ -683,6 +684,26 @@ async def admin_actions_new(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/actions/vote")
|
||||||
|
async def admin_actions_vote(
|
||||||
|
request: Request,
|
||||||
|
redirect_url: str = Form(),
|
||||||
|
in_reply_to: str = Form(),
|
||||||
|
csrf_check: None = Depends(verify_csrf_token),
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
form_data = await request.form()
|
||||||
|
names = form_data.getlist("name")
|
||||||
|
logger.info(f"{names=}")
|
||||||
|
for name in names:
|
||||||
|
await boxes.send_vote(
|
||||||
|
db_session,
|
||||||
|
in_reply_to=in_reply_to,
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
return RedirectResponse(redirect_url, status_code=302)
|
||||||
|
|
||||||
|
|
||||||
@unauthenticated_router.get("/login")
|
@unauthenticated_router.get("/login")
|
||||||
async def login(
|
async def login(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
106
app/boxes.py
106
app/boxes.py
|
@ -398,6 +398,112 @@ async def send_create(
|
||||||
return note_id
|
return note_id
|
||||||
|
|
||||||
|
|
||||||
|
async def send_vote(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
in_reply_to: str,
|
||||||
|
name: str,
|
||||||
|
) -> str:
|
||||||
|
logger.info(f"Send vote {name}")
|
||||||
|
vote_id = allocate_outbox_id()
|
||||||
|
published = now().replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
in_reply_to_object = await get_anybox_object_by_ap_id(db_session, in_reply_to)
|
||||||
|
if not in_reply_to_object:
|
||||||
|
raise ValueError(f"Invalid in reply to {in_reply_to=}")
|
||||||
|
if not in_reply_to_object.ap_context:
|
||||||
|
raise ValueError("Object has no context")
|
||||||
|
context = in_reply_to_object.ap_context
|
||||||
|
|
||||||
|
to = [in_reply_to_object.actor.ap_id]
|
||||||
|
|
||||||
|
note = {
|
||||||
|
"@context": ap.AS_EXTENDED_CTX,
|
||||||
|
"type": "Note",
|
||||||
|
"id": outbox_object_id(vote_id),
|
||||||
|
"attributedTo": ID,
|
||||||
|
"name": name,
|
||||||
|
"to": to,
|
||||||
|
"cc": [],
|
||||||
|
"published": published,
|
||||||
|
"context": context,
|
||||||
|
"conversation": context,
|
||||||
|
"url": outbox_object_id(vote_id),
|
||||||
|
"inReplyTo": in_reply_to,
|
||||||
|
}
|
||||||
|
outbox_object = await save_outbox_object(db_session, vote_id, note)
|
||||||
|
if not outbox_object.id:
|
||||||
|
raise ValueError("Should never happen")
|
||||||
|
|
||||||
|
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 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"),
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"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(
|
async def send_update(
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
ap_id: str,
|
ap_id: str,
|
||||||
|
|
|
@ -76,7 +76,7 @@ _RESIZED_CACHE: MutableMapping[tuple[str, int], tuple[bytes, str, Any]] = LFUCac
|
||||||
# TODO(ts):
|
# TODO(ts):
|
||||||
#
|
#
|
||||||
# Next:
|
# Next:
|
||||||
# - show pending follow request (and prevent double follow?)
|
# - prevent double accept/double follow
|
||||||
# - UI support for updating posts
|
# - UI support for updating posts
|
||||||
# - Article support
|
# - Article support
|
||||||
# - Fix tests
|
# - Fix tests
|
||||||
|
|
|
@ -108,6 +108,7 @@ class InboxObject(Base, BaseObject):
|
||||||
# Link the oubox AP ID to allow undo without any extra query
|
# Link the oubox AP ID to allow undo without any extra query
|
||||||
liked_via_outbox_object_ap_id = Column(String, nullable=True)
|
liked_via_outbox_object_ap_id = Column(String, nullable=True)
|
||||||
announced_via_outbox_object_ap_id = Column(String, nullable=True)
|
announced_via_outbox_object_ap_id = Column(String, nullable=True)
|
||||||
|
voted_for_answers: Mapped[list[str] | None] = Column(JSON, nullable=True)
|
||||||
|
|
||||||
is_bookmarked = Column(Boolean, nullable=False, default=False)
|
is_bookmarked = Column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,7 @@
|
||||||
{% if inbox_object.ap_type == "Announce" %}
|
{% if inbox_object.ap_type == "Announce" %}
|
||||||
{{ actor_action(inbox_object, "shared") }}
|
{{ actor_action(inbox_object, "shared") }}
|
||||||
{{ utils.display_object(inbox_object.relates_to_anybox_object) }}
|
{{ utils.display_object(inbox_object.relates_to_anybox_object) }}
|
||||||
{% elif inbox_object.ap_type in ["Article", "Note", "Video"] %}
|
{% elif inbox_object.ap_type in ["Article", "Note", "Video", "Page", "Question"] %}
|
||||||
{{ utils.display_object(inbox_object) }}
|
{{ utils.display_object(inbox_object) }}
|
||||||
{% elif inbox_object.ap_type == "Follow" %}
|
{% elif inbox_object.ap_type == "Follow" %}
|
||||||
{{ actor_action(inbox_object, "followed you") }}
|
{{ actor_action(inbox_object, "followed you") }}
|
||||||
|
|
|
@ -292,6 +292,12 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if object.ap_type == "Question" %}
|
{% if object.ap_type == "Question" %}
|
||||||
|
{% if object.is_from_inbox %}
|
||||||
|
<form action="{{ request.url_for("admin_actions_vote") }}" method="POST">
|
||||||
|
{{ embed_csrf_token() }}
|
||||||
|
{{ embed_redirect_url(object.permalink_id) }}
|
||||||
|
<input type="hidden" name="in_reply_to" value="{{ object.ap_id }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if object.ap_object.oneOf %}
|
{% if object.ap_object.oneOf %}
|
||||||
<ul style="list-style-type: none;padding:0;">
|
<ul style="list-style-type: none;padding:0;">
|
||||||
|
@ -299,15 +305,28 @@
|
||||||
{% for item in object.ap_object.oneOf %}
|
{% for item in object.ap_object.oneOf %}
|
||||||
<li style="display:block;">
|
<li style="display:block;">
|
||||||
{% set pct = item | poll_item_pct(object.ap_object.votersCount) %}
|
{% set pct = item | poll_item_pct(object.ap_object.votersCount) %}
|
||||||
<p style="margin:20px 0 10px 0;">{{ item.name | clean_html(object) | safe }} <span style="float:right;">{{ pct }}% <span class="muted">({{ item.replies.totalItems }} votes)</span></span></p>
|
<p style="margin:20px 0 10px 0;">
|
||||||
|
{% if object.is_from_inbox %}
|
||||||
|
<input type="radio" name="name" value="{{ item.name }}">
|
||||||
|
{% endif %}
|
||||||
|
{{ item.name | clean_html(object) | safe }} <span style="float:right;">{{ pct }}% <span class="muted">({{ item.replies.totalItems }} votes)</span></span>
|
||||||
|
</p>
|
||||||
<svg class="poll-bar">
|
<svg class="poll-bar">
|
||||||
<line x1="0" y1="10px" x2="{{ pct }}%" y2="10px" style="stroke-width: 20px;"></line>
|
<line x1="0" y1="10px" x2="{{ pct or 1 }}%" y2="10px" style="stroke-width: 20px;"></line>
|
||||||
</svg>
|
</svg>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if object.is_from_inbox %}
|
||||||
|
<p class="form">
|
||||||
|
<input type="submit" value="vote">
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{{ display_og_meta(object) }}
|
{{ display_og_meta(object) }}
|
||||||
|
@ -325,7 +344,8 @@
|
||||||
<time class="dt-published" datetime="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}" title="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}">{{ object.ap_published_at | timeago }}</time>
|
<time class="dt-published" datetime="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}" title="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}">{{ object.ap_published_at | timeago }}</time>
|
||||||
</li>
|
</li>
|
||||||
{% if object.ap_type == "Question" %}
|
{% if object.ap_type == "Question" %}
|
||||||
<li>ends {{ object.ap_object.endTime | parse_datetime | timeago }}</li>
|
{% set endAt = object.ap_object.endTime | parse_datetime %}
|
||||||
|
<li>ends <time title="{{ endAt.replace(microsecond=0).isoformat() }}">{{ endAt | timeago }}</time></li>
|
||||||
<li>{{ object.ap_object.votersCount }} voters</li>
|
<li>{{ object.ap_object.votersCount }} voters</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if is_admin %}
|
{% if is_admin %}
|
||||||
|
|
Loading…
Reference in a new issue