Question/poll support

This commit is contained in:
Thomas Sileo 2019-04-14 19:17:54 +02:00
parent 4c0e81224f
commit be7648c9ed
7 changed files with 361 additions and 41 deletions

View file

@ -1,3 +1,4 @@
import hashlib
import logging
import os
import json
@ -73,6 +74,12 @@ def ensure_it_is_me(f):
return wrapper
def _answer_key(choice: str) -> str:
h = hashlib.new("sha1")
h.update(choice.encode())
return h.hexdigest()
class Box(Enum):
INBOX = "inbox"
OUTBOX = "outbox"
@ -106,13 +113,17 @@ class MicroblogPubBackend(Backend):
def save(self, box: Box, activity: ap.BaseActivity) -> None:
"""Custom helper for saving an activity to the DB."""
is_public = True
if activity.has_type(ap.ActivityType.CREATE) and not activity.is_public():
is_public = False
DB.activities.insert_one(
{
"box": box.value,
"activity": activity.to_dict(),
"type": _to_list(activity.type),
"remote_id": activity.id,
"meta": {"undo": False, "deleted": False},
"meta": {"undo": False, "deleted": False, "public": is_public},
}
)
@ -453,6 +464,46 @@ class MicroblogPubBackend(Backend):
{"$inc": {"meta.count_reply": -1, "meta.count_direct_reply": -1}},
)
def _process_question_reply(self, create: ap.Create, question: ap.Question) -> None:
choice = create.get_object().name
# Ensure it's a valid choice
if choice not in [
c["name"] for c in question._data.get("oneOf", question.anyOf)
]:
logger.info("invalid choice")
return
# Check for duplicate votes
if DB.activities.find_one(
{
"activity.object.actor": create.get_actor().id,
"meta.answer_to": question.id,
}
):
logger.info("duplicate response")
return
# Update the DB
answer_key = _answer_key(choice)
DB.activities.update_one(
{"activity.object.id": question.id},
{
"$inc": {
"meta.question_replies": 1,
f"meta.question_answers.{answer_key}": 1,
}
},
)
DB.activities.update_one(
{"remote_id": create.id},
{"$set": {"meta.answer_to": question.id, "meta.stream": False}},
)
return None
@ensure_it_is_me
def _handle_replies(self, as_actor: ap.Person, create: ap.Create) -> None:
"""Go up to the root reply, store unknown replies in the `threads` DB and set the "meta.thread_root_parent"
@ -465,6 +516,26 @@ class MicroblogPubBackend(Backend):
root_reply = in_reply_to
reply = ap.fetch_remote_activity(root_reply)
# Ensure the this is a local reply, of a question, with a direct "to" addressing
if (
reply.id.startswith(BASE_URL)
and reply.has_type(ap.ActivityType.QUESTION.value)
and _to_list(create.get_object().to)[0].startswith(BASE_URL)
and not create.is_public()
):
return self._process_question_reply(create, reply)
elif (
create.id.startswith(BASE_URL)
and reply.has_type(ap.ActivityType.QUESTION.value)
and not create.is_public()
):
# Keep track of our own votes
DB.activities.update_one(
{"activity.object.id": reply.id, "box": "inbox"},
{"$set": {"meta.voted_for": create.get_object().name}},
)
return None
creply = DB.activities.find_one_and_update(
{"activity.object.id": in_reply_to},
{"$inc": {"meta.count_reply": 1, "meta.count_direct_reply": 1}},

161
app.py
View file

@ -42,6 +42,7 @@ from little_boxes import activitypub as ap
from little_boxes.activitypub import ActivityType
from little_boxes.activitypub import _to_list
from little_boxes.activitypub import clean_activity
from little_boxes.activitypub import format_datetime
from little_boxes.activitypub import get_backend
from little_boxes.content_helper import parse_markdown
from little_boxes.linked_data_sig import generate_signature
@ -65,6 +66,7 @@ import config
from activitypub import Box
from activitypub import embed_collection
from activitypub import _answer_key
from config import USER_AGENT
from config import ADMIN_API_KEY
from config import BASE_URL
@ -129,16 +131,21 @@ def verify_pass(pwd):
def inject_config():
q = {
"type": "Create",
"activity.object.type": "Note",
"activity.object.inReplyTo": None,
"meta.deleted": False,
"meta.public": True,
}
notes_count = DB.activities.find(
{"box": Box.OUTBOX.value, "$or": [q, {"type": "Announce", "meta.undo": False}]}
).count()
q = {"type": "Create", "activity.object.type": "Note", "meta.deleted": False}
with_replies_count = DB.activities.find(
{"box": Box.OUTBOX.value, "$or": [q, {"type": "Announce", "meta.undo": False}]}
{
"box": Box.OUTBOX.value,
"type": {"$in": [ActivityType.CREATE.value, ActivityType.ANNOUNCE.value]},
"meta.undo": False,
"meta.deleted": False,
"meta.public": True,
}
).count()
liked_count = DB.activities.count(
{
@ -169,6 +176,7 @@ def inject_config():
liked_count=liked_count,
with_replies_count=with_replies_count,
me=ME,
base_url=config.BASE_URL,
)
@ -233,6 +241,16 @@ def _get_file_url(url, size, kind):
return url
@app.template_filter()
def gtone(n):
return n > 1
@app.template_filter()
def gtnow(dtstr):
return format_datetime(datetime.now().astimezone()) > dtstr
@app.template_filter()
def remove_mongo_id(dat):
if isinstance(dat, list):
@ -805,6 +823,9 @@ def index():
DB.activities, q, limit=25 - len(pinned)
)
# FIXME(tsileo): add it on permakink too
[_add_answers_to_questions(item) for item in outbox_data]
resp = render_template(
"index.html",
outbox_data=outbox_data,
@ -823,6 +844,7 @@ def with_replies():
"box": Box.OUTBOX.value,
"type": {"$in": [ActivityType.CREATE.value, ActivityType.ANNOUNCE.value]},
"meta.deleted": False,
"meta.public": True,
"meta.undo": False,
}
outbox_data, older_than, newer_than = paginated_query(DB.activities, q)
@ -914,6 +936,10 @@ def note_by_id(note_id):
abort(404)
if data["meta"].get("deleted", False):
abort(410)
# If it's a Question, add the answers from meta
_add_answers_to_questions(data)
thread = _build_thread(data)
app.logger.info(f"thread={thread!r}")
@ -1084,9 +1110,31 @@ def remove_context(activity: Dict[str, Any]) -> Dict[str, Any]:
return activity
def _add_answers_to_questions(raw_doc: Dict[str, Any]) -> None:
activity = raw_doc["activity"]
if (
"object" in activity
and _to_list(activity["object"]["type"])[0] == ActivityType.QUESTION.value
):
for choice in activity["object"].get("oneOf", activity["object"].get("anyOf")):
choice["replies"] = {
"type": ActivityType.COLLECTION.value,
"totalItems": raw_doc["meta"]
.get("question_answers", {})
.get(_answer_key(choice["name"]), 0),
}
now = datetime.now().astimezone()
if format_datetime(now) > activity["object"]["endTime"]:
activity["object"]["closed"] = activity["object"]["endTime"]
def activity_from_doc(raw_doc: Dict[str, Any], embed: bool = False) -> Dict[str, Any]:
raw_doc = add_extra_collection(raw_doc)
activity = clean_activity(raw_doc["activity"])
# Handle Questions
# TODO(tsileo): what about object embedded by ID/URL?
_add_answers_to_questions(raw_doc)
if embed:
return remove_context(activity)
return activity
@ -1101,6 +1149,8 @@ def outbox():
q = {
"box": Box.OUTBOX.value,
"meta.deleted": False,
"meta.undo": False,
"meta.public": True,
"type": {"$in": [ActivityType.CREATE.value, ActivityType.ANNOUNCE.value]},
}
return jsonify(
@ -1150,6 +1200,7 @@ def outbox_activity(item_id):
)
if not data:
abort(404)
obj = activity_from_doc(data)
if data["meta"].get("deleted", False):
obj = ap.parse_activity(data["activity"])
@ -1487,19 +1538,7 @@ def _user_api_arg(key: str, **kwargs):
def _user_api_get_note(from_outbox: bool = False):
oid = _user_api_arg("id")
app.logger.info(f"fetching {oid}")
try:
note = ap.parse_activity(
get_backend().fetch_iri(oid), expected=ActivityType.NOTE
)
except Exception:
try:
note = ap.parse_activity(
get_backend().fetch_iri(oid), expected=ActivityType.VIDEO
)
except Exception:
raise ActivityNotFoundError(
"Expected Note or Video ActivityType, but got something else"
)
note = ap.parse_activity(get_backend().fetch_iri(oid))
if from_outbox and not note.id.startswith(ID):
raise NotFromOutboxError(
f"cannot load {note.id}, id must be owned by the server"
@ -1542,6 +1581,30 @@ def api_boost():
return _user_api_response(activity=announce_id)
@app.route("/api/vote", methods=["POST"])
@api_required
def api_vote():
oid = _user_api_arg("id")
app.logger.info(f"fetching {oid}")
note = ap.parse_activity(get_backend().fetch_iri(oid))
choice = _user_api_arg("choice")
raw_note = dict(
attributedTo=MY_PERSON.id,
cc=[],
to=note.get_actor().id,
name=choice,
tag=[],
inReplyTo=note.id,
)
note = ap.Note(**raw_note)
create = note.build_create()
create_id = post_to_outbox(create)
return _user_api_response(activity=create_id)
@app.route("/api/like", methods=["POST"])
@api_required
def api_like():
@ -1780,6 +1843,57 @@ def api_new_note():
return _user_api_response(activity=create_id)
@app.route("/api/new_question", methods=["POST"])
@api_required
def api_new_question():
source = _user_api_arg("content")
if not source:
raise ValueError("missing content")
content, tags = parse_markdown(source)
cc = [ID + "/followers"]
for tag in tags:
if tag["type"] == "Mention":
cc.append(tag["href"])
answers = []
for i in range(4):
a = _user_api_arg(f"answer{i}", default=None)
if not a:
break
answers.append({"type": ActivityType.NOTE.value, "name": a})
choices = {
"endTime": ap.format_datetime(
datetime.now().astimezone()
+ timedelta(minutes=int(_user_api_arg("open_for")))
)
}
of = _user_api_arg("of")
if of == "anyOf":
choices["anyOf"] = answers
else:
choices["oneOf"] = answers
raw_question = dict(
attributedTo=MY_PERSON.id,
cc=list(set(cc)),
to=[ap.AS_PUBLIC],
content=content,
tag=tags,
source={"mediaType": "text/markdown", "content": source},
inReplyTo=None,
**choices,
)
question = ap.Question(**raw_question)
create = question.build_create()
create_id = post_to_outbox(create)
return _user_api_response(activity=create_id)
@app.route("/api/stream")
@api_required
def api_stream():
@ -2583,6 +2697,7 @@ def task_process_new_activity():
if not note.inReplyTo or note.inReplyTo.startswith(ID):
tag_stream = True
# FIXME(tsileo): check for direct addressing in the to, cc, bcc... fields
if (note.inReplyTo and note.inReplyTo.startswith(ID)) or note.has_mention(
ID
):
@ -2734,8 +2849,6 @@ def task_cleanup():
task = p.parse(request)
app.logger.info(f"task={task!r}")
p.push({}, "/task/cleanup_part_1")
p.push({}, "/task/cleanup_part_2")
p.push({}, "/task/cleanup_part_3")
return ""
@ -2847,6 +2960,17 @@ def task_cleanup_part_1():
},
{"$set": {"meta.keep": False}},
)
DB.activities.update_many(
{
"box": Box.OUTBOX.value,
"type": ActivityType.CREATE.value,
"meta.public": {"$exists": False},
},
{"$set": {"meta.public": True}},
)
p.push({}, "/task/cleanup_part_2")
return "OK"
@ -2870,6 +2994,7 @@ def task_cleanup_part_2():
MEDIA_CACHE.fs.delete(grid_item._id)
DB.activities.delete_one({"_id": data["_id"]})
p.push({}, "/task/cleanup_part_3")
return "OK"

View file

@ -144,6 +144,19 @@ def create_indexes():
]
)
DB.activities.create_index([("box", pymongo.ASCENDING)])
# Outbox query
DB.activities.create_index(
[
("box", pymongo.ASCENDING),
("type", pymongo.ASCENDING),
("meta.undo", pymongo.ASCENDING),
("meta.deleted", pymongo.ASCENDING),
("meta.public", pymongo.ASCENDING),
]
)
DB.activities.create_index(
[
("type", pymongo.ASCENDING),

View file

@ -329,3 +329,25 @@ input[type=submit] {
.note-video {
margin: 30px 0 10px 0;
}
li.answer {
height:30px;
margin-bottom:10px;
position:relative;
}
.answer .answer-bar {
position:absolute;
height:30px;
border-radius:2px;
}
.answer .answer-text {
position:relative;
top:6px;
padding-left:10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.answer .answer-text > span {
width:70px;
display:inline-block;
}

View file

@ -12,9 +12,13 @@
<h3 style="padding-bottom: 30px">Replying to {{ content }}</h3>
{{ utils.display_thread(thread) }}
{% else %}
<h3 style="padding-bottom:20px;">New note</h3>
{% if request.args.get("question") == "1" %}
<h3 style="padding-bottom:20px;">New question <small><a href="/admin/new">make it a note?</a></small></h3>
{% else %}
<h3 style="padding-bottom:20px;">New note <small><a href="/admin/new?question=1">make it a question?</a></small></h3>
{% endif %}
<form action="/api/new_note" method="POST" enctype="multipart/form-data">
{% endif %}
<form action="/api/new_{% if request.args.get("question") == "1" %}question{%else%}note{%endif%}" method="POST" enctype="multipart/form-data">
<input type="hidden" name="redirect" value="/">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if reply %}<input type="hidden" name="reply" value="{{reply}}">{% endif %}
@ -27,7 +31,32 @@
<textarea name="content" rows="10" cols="50" autofocus="autofocus" designMode="on">{{ content }}</textarea>
<input type="file" name="file">
{% if request.args.get("question") == "1" %}
<div style="margin-top:20px;">
<p>Open for: <select name="open_for">
<option value="30">30 minutes</option>
<option value="60">1 hour</option>
<option value="360">6 hour</option>
<option value="1440" selected>1 day</option>
<option value="4320">3 days</option>
<option value="10080">7 days</option>
</select></p>
<input type="hidden" name="of" value="oneOf" />
<!--
<p><select name="of">
<option value="oneOf">Single choice</option>
<option value="anyOf">Multiple choices</option>
</select></p>-->
{% for i in range(4) %}
<p><input type="text" name="answer{{i}}" placeholder="Answer #{{i+1}}"></p>
{% endfor %}
</div>
{% endif %}
<input type="submit" value="post">
</div>
</form>

View file

@ -15,8 +15,6 @@
{% endif %}
{%- endmacro %}
{% macro display_note(obj, perma=False, ui=False, likes=[], shares=[], meta={}, no_color=False) -%}
{% if meta.actor %}
{% set actor = meta.actor %}
@ -24,6 +22,20 @@
{% set actor = obj.attributedTo | get_actor %}
{% endif %}
{% if session.logged_in %}
{% set perma_id = obj.id | permalink_id %}
{% if request.args.get('older_than') %}
{% set redir = request.path + "?older_than=" + request.args.get('older_than') + "#activity-" + perma_id %}
{% elif request.args.get('newer_than') %}
{% set redir = request.path + "?newer_than=" + request.args.get('newer_than') + "#activity-" + perma_id %}
{% else %}
{% set redir = request.path + "#activity-" + perma_id %}
{% endif %}
<div class="note-box">
<div class="note h-entry" id="activity-{{ obj.id | permalink_id }}">
@ -58,11 +70,67 @@
{{ obj.name }} <a href="{{ obj | url_or_id | get_url }}">{{ obj | url_or_id | get_url }}</a>
{% elif obj | has_type('Question') %}
{{ obj.content | clean | safe }}
<ul>
{% if obj.id | is_from_outbox %}
<ul style="list-style:none;padding:0;">
{% set total_votes = [0] %}
{% for oneOf in obj.oneOf %}
<li>{{ oneOf.name }} ({{ oneOf.replies.totalItems }})</li>
{% if oneOf.replies %}
{% if total_votes.append(total_votes.pop() + oneOf.replies.totalItems) %}{% endif %}
{% endif %}
{% endfor %}
{% for oneOf in obj.oneOf %}
{% set pct = 0 %}
{% if total_votes[0] > 0 and oneOf.replies %}
{% set pct = oneOf.replies.totalItems * 100.0 / total_votes[0] %}
{% endif %}
<li class="answer">
<span class="answer-bar color-menu-background" style="width:{{pct}}%;"></span>
<span class="answer-text">
<span>{{ '%0.0f'| format(pct) }}%</span>
{{ oneOf.name }}
</span>
</li>
{% endfor %}
</ul>
<p><small>
{% if obj.closed %}
Ended {{ obj.endTime | format_timeago }} with <strong>{{ total_votes[0] }}</strong> vote{% if total_votes[0] | gtone %}s{% endif %}.
{% else %}
Ends {{ obj.endTime | format_timeago }} (<strong>{{ total_votes[0] }}</strong> vote{% if total_votes[0] | gtone %}s{% endif %} as of now).
{% endif %}
</small></p>
{% else %}
<ul style="list-style:none;padding:0;">
{% for oneOf in obj.oneOf %}
<li class="answer">
<span class="answer-text">
{% if not meta.voted_for and not obj.endTime | gtnow %}
<span><form action="/api/vote" class="action-form" method="POST">
<input type="hidden" name="redirect" value="{{ redir }}">
<input type="hidden" name="id" value="{{ obj.id }}">
<input type="hidden" name="choice" value="{{ oneOf.name }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="bar-item">vote</button>
</form></span>
{% else %}
<span>???</span>
{% endif %}
{{ oneOf.name }} {% if oneOf.name == meta.voted_for %}(your vote){% endif %}
</span>
</li>
{% endfor %}
<p><small>{% if obj.endTime | gtnow %}This question ended {{ obj.endTime | format_timeago }}.</small></p>
{% else %}This question ends {{ obj.endTime | format_timeago }}{% endif %}
</small></p>
</ul>
{% endif %}
{% else %}
{{ obj.content | clean | safe }}
{% endif %}
@ -120,17 +188,6 @@
<a class ="bar-item" href="{{ obj | url_or_id | get_url }}">permalink</a>
{% endif %}
{% if session.logged_in %}
{% set perma_id = obj.id | permalink_id %}
{% if request.args.get('older_than') %}
{% set redir = request.path + "?older_than=" + request.args.get('older_than') + "#activity-" + perma_id %}
{% elif request.args.get('newer_than') %}
{% set redir = request.path + "?newer_than=" + request.args.get('newer_than') + "#activity-" + perma_id %}
{% else %}
{% set redir = request.path + "#activity-" + perma_id %}
{% endif %}
{% set aid = obj.id | quote_plus %}
{% endif %}

View file

@ -20,11 +20,14 @@ def links_from_note(note):
tags_href.add(h)
links = set()
soup = BeautifulSoup(note["content"])
for link in soup.find_all("a"):
h = link.get("href")
if h.startswith("http") and h not in tags_href and is_url_valid(h):
links.add(h)
if "content" in note:
soup = BeautifulSoup(note["content"])
for link in soup.find_all("a"):
h = link.get("href")
if h.startswith("http") and h not in tags_href and is_url_valid(h):
links.add(h)
# FIXME(tsileo): support summary and name fields
return links