Merge branch 'main' into list-not-loading

This commit is contained in:
Mouse Reeve
2021-12-09 11:10:26 -08:00
133 changed files with 12356 additions and 3041 deletions

View File

@ -29,6 +29,7 @@ from .preferences.block import Block, unblock
# books
from .books.books import Book, upload_cover, add_description, resolve_book
from .books.books import update_book_from_remote
from .books.edit_book import EditBook, ConfirmEditBook
from .books.editions import Editions, switch_edition
@ -54,11 +55,18 @@ from .imports.manually_review import (
)
# misc views
from .author import Author, EditAuthor
from .author import Author, EditAuthor, update_author_from_remote
from .directory import Directory
from .discover import Discover
from .feed import DirectMessage, Feed, Replies, Status
from .follow import follow, unfollow
from .follow import (
follow,
unfollow,
ostatus_follow_request,
ostatus_follow_success,
remote_follow,
remote_follow_page,
)
from .follow import accept_follow_request, delete_follow_request
from .get_started import GetStartedBooks, GetStartedProfile, GetStartedUsers
from .goal import Goal, hide_goal
@ -76,7 +84,7 @@ from .inbox import Inbox
from .interaction import Favorite, Unfavorite, Boost, Unboost
from .isbn import Isbn
from .list import Lists, SavedLists, List, Curate, UserLists
from .list import save_list, unsave_list, delete_list
from .list import save_list, unsave_list, delete_list, unsafe_embed_list
from .notifications import Notifications
from .outbox import Outbox
from .reading import create_readthrough, delete_readthrough, delete_progressupdate

View File

@ -7,7 +7,7 @@ from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.http import require_POST
from bookwyrm import forms, models
from bookwyrm import emailing, forms, models
# pylint: disable=no-self-use
@ -142,5 +142,6 @@ def make_report(request):
if not form.is_valid():
raise ValueError(form.errors)
form.save()
report = form.save()
emailing.moderation_report_email(report)
return redirect(request.headers.get("Referer", "/"))

View File

@ -48,4 +48,7 @@ def email_preview(request):
data["invite_link"] = "https://example.com/link"
data["confirmation_link"] = "https://example.com/link"
data["confirmation_code"] = "AKJHKDGKJSDFG"
data["reporter"] = "ConcernedUser"
data["reportee"] = "UserName"
data["report_link"] = "https://example.com/link"
return TemplateResponse(request, "email/preview.html", data)

View File

@ -6,9 +6,11 @@ from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.http import require_POST
from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.connectors import connector_manager
from bookwyrm.settings import PAGE_LENGTH
from bookwyrm.views.helpers import is_api_request
@ -73,3 +75,19 @@ class EditAuthor(View):
author = form.save()
return redirect(f"/author/{author.id}")
@login_required
@require_POST
@permission_required("bookwyrm.edit_book", raise_exception=True)
# pylint: disable=unused-argument
def update_author_from_remote(request, author_id, connector_identifier):
"""load the remote data for this author"""
connector = connector_manager.load_connector(
get_object_or_404(models.Connector, identifier=connector_identifier)
)
author = get_object_or_404(models.Author, id=author_id)
connector.update_author_from_remote(author)
return redirect("author", author.id)

View File

@ -178,3 +178,19 @@ def resolve_book(request):
book = connector.get_or_create_book(remote_id)
return redirect("book", book.id)
@login_required
@require_POST
@permission_required("bookwyrm.edit_book", raise_exception=True)
# pylint: disable=unused-argument
def update_book_from_remote(request, book_id, connector_identifier):
"""load the remote data for this book"""
connector = connector_manager.load_connector(
get_object_or_404(models.Connector, identifier=connector_identifier)
)
book = get_object_or_404(models.Book.objects.select_subclasses(), id=book_id)
connector.update_book_from_remote(book)
return redirect("book", book.id)

View File

@ -1,4 +1,5 @@
""" the good stuff! the books! """
from re import sub
from dateutil.parser import parse as dateparse
from django.contrib.auth.decorators import login_required, permission_required
from django.contrib.postgres.search import SearchRank, SearchVector
@ -11,10 +12,16 @@ from django.utils.decorators import method_decorator
from django.views import View
from bookwyrm import book_search, forms, models
# from bookwyrm.activitypub.base_activity import ActivityObject
from bookwyrm.utils.isni import (
find_authors_by_name,
build_author_from_isni,
augment_author_metadata,
)
from bookwyrm.views.helpers import get_edition
from .books import set_cover_from_url
# pylint: disable=no-self-use
@method_decorator(login_required, name="dispatch")
@method_decorator(
@ -33,6 +40,7 @@ class EditBook(View):
data = {"book": book, "form": forms.EditionForm(instance=book)}
return TemplateResponse(request, "book/edit/edit_book.html", data)
# pylint: disable=too-many-locals
def post(self, request, book_id=None):
"""edit a book cool"""
# returns None if no match is found
@ -43,12 +51,14 @@ class EditBook(View):
if not form.is_valid():
return TemplateResponse(request, "book/edit/edit_book.html", data)
add_author = request.POST.get("add_author")
# we're adding an author through a free text field
# filter out empty author fields
add_author = [author for author in request.POST.getlist("add_author") if author]
if add_author:
data["add_author"] = add_author
data["author_matches"] = []
for author in add_author.split(","):
data["isni_matches"] = []
for author in add_author:
if not author:
continue
# check for existing authors
@ -56,15 +66,35 @@ class EditBook(View):
"aliases", weight="B"
)
author_matches = (
models.Author.objects.annotate(search=vector)
.annotate(rank=SearchRank(vector, author))
.filter(rank__gt=0.4)
.order_by("-rank")[:5]
)
isni_authors = find_authors_by_name(
author, description=True
) # find matches from ISNI API
# dedupe isni authors we already have in the DB
exists = [
i
for i in isni_authors
for a in author_matches
if sub(r"\D", "", str(i.isni)) == sub(r"\D", "", str(a.isni))
]
# pylint: disable=cell-var-from-loop
matches = list(filter(lambda x: x not in exists, isni_authors))
# combine existing and isni authors
matches.extend(author_matches)
data["author_matches"].append(
{
"name": author.strip(),
"matches": (
models.Author.objects.annotate(search=vector)
.annotate(rank=SearchRank(vector, author))
.filter(rank__gt=0.4)
.order_by("-rank")[:5]
),
"matches": matches,
"existing_isnis": exists,
}
)
@ -122,6 +152,8 @@ class EditBook(View):
class ConfirmEditBook(View):
"""confirm edits to a book"""
# pylint: disable=too-many-locals
# pylint: disable=too-many-branches
def post(self, request, book_id=None):
"""edit a book cool"""
# returns None if no match is found
@ -147,9 +179,25 @@ class ConfirmEditBook(View):
author = get_object_or_404(
models.Author, id=request.POST[f"author_match-{i}"]
)
# update author metadata if the ISNI record is more complete
isni = request.POST.get(f"isni-for-{match}", None)
if isni is not None:
augment_author_metadata(author, isni)
except ValueError:
# otherwise it's a name
author = models.Author.objects.create(name=match)
# otherwise it's a new author
isni_match = request.POST.get(f"author_match-{i}")
author_object = build_author_from_isni(isni_match)
# with author data class from isni id
if "author" in author_object:
skeleton = models.Author.objects.create(
name=author_object["author"].name
)
author = author_object["author"].to_model(
model=models.Author, overwrite=True, instance=skeleton
)
else:
# or it's just a name
author = models.Author.objects.create(name=match)
book.authors.add(author)
# create work, if needed

View File

@ -10,10 +10,11 @@ from django.utils.decorators import method_decorator
from django.views import View
from bookwyrm import activitystreams, forms, models
from bookwyrm.models.user import FeedFilterChoices
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import PAGE_LENGTH, STREAMS
from bookwyrm.suggested_users import suggested_users
from .helpers import get_user_from_username
from .helpers import filter_stream_by_status_type, get_user_from_username
from .helpers import is_api_request, is_bookwyrm_request
@ -22,7 +23,17 @@ from .helpers import is_api_request, is_bookwyrm_request
class Feed(View):
"""activity stream"""
def get(self, request, tab):
def post(self, request, tab):
"""save feed settings form, with a silent validation fail"""
settings_saved = False
form = forms.FeedStatusTypesForm(request.POST, instance=request.user)
if form.is_valid():
form.save()
settings_saved = True
return self.get(request, tab, settings_saved)
def get(self, request, tab, settings_saved=False):
"""user's homepage with activity feed"""
tab = [s for s in STREAMS if s["key"] == tab]
tab = tab[0] if tab else STREAMS[0]
@ -30,7 +41,11 @@ class Feed(View):
activities = activitystreams.streams[tab["key"]].get_activity_stream(
request.user
)
paginated = Paginator(activities, PAGE_LENGTH)
filtered_activities = filter_stream_by_status_type(
activities,
allowed_types=request.user.feed_status_types,
)
paginated = Paginator(filtered_activities, PAGE_LENGTH)
suggestions = suggested_users.get_suggestions(request.user)
@ -43,6 +58,9 @@ class Feed(View):
"tab": tab,
"streams": STREAMS,
"goal_form": forms.GoalForm(),
"feed_status_types_options": FeedFilterChoices,
"allowed_status_types": request.user.feed_status_types,
"settings_saved": settings_saved,
"path": f"/{tab['key']}",
},
}
@ -159,12 +177,19 @@ class Status(View):
params=[status.id, visible_thread, visible_thread],
)
preview = None
if hasattr(status, "book"):
preview = status.book.preview_image
elif status.mention_books.exists():
preview = status.mention_books.first().preview_image
data = {
**feed_page_data(request.user),
**{
"status": status,
"children": children,
"ancestors": ancestors,
"preview": preview,
},
}
return TemplateResponse(request, "feed/status.html", data)

View File

@ -1,11 +1,19 @@
""" views for actions you can take in the application """
import urllib.parse
import re
from django.contrib.auth.decorators import login_required
from django.db import IntegrityError
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.views.decorators.http import require_POST
from bookwyrm import models
from .helpers import get_user_from_username
from .helpers import (
get_user_from_username,
handle_remote_webfinger,
subscribe_remote_webfinger,
WebFingerError,
)
@login_required
@ -23,6 +31,9 @@ def follow(request):
except IntegrityError:
pass
if request.GET.get("next"):
return redirect(request.GET.get("next", "/"))
return redirect(to_follow.local_path)
@ -84,3 +95,91 @@ def delete_follow_request(request):
follow_request.delete()
return redirect(f"/user/{request.user.localname}")
def ostatus_follow_request(request):
"""prepare an outgoing remote follow request"""
uri = urllib.parse.unquote(request.GET.get("acct"))
username_parts = re.search(
r"(?:^http(?:s?):\/\/)([\w\-\.]*)(?:.)*(?:(?:\/)([\w]*))", uri
)
account = f"{username_parts[2]}@{username_parts[1]}"
user = handle_remote_webfinger(account)
error = None
if user is None or user == "":
error = "ostatus_subscribe"
# don't do these checks for AnonymousUser before they sign in
if request.user.is_authenticated:
# you have blocked them so you probably don't want to follow
if hasattr(request.user, "blocks") and user in request.user.blocks.all():
error = "is_blocked"
# they have blocked you
if hasattr(user, "blocks") and request.user in user.blocks.all():
error = "has_blocked"
# you're already following them
if hasattr(user, "followers") and request.user in user.followers.all():
error = "already_following"
# you're not following yet but you already asked
if (
hasattr(user, "follower_requests")
and request.user in user.follower_requests.all()
):
error = "already_requested"
data = {"account": account, "user": user, "error": error}
return TemplateResponse(request, "ostatus/subscribe.html", data)
@login_required
def ostatus_follow_success(request):
"""display success message for remote follow"""
user = get_user_from_username(request.user, request.GET.get("following"))
data = {"account": user.name, "user": user, "error": None}
return TemplateResponse(request, "ostatus/success.html", data)
def remote_follow_page(request):
"""display remote follow page"""
user = get_user_from_username(request.user, request.GET.get("user"))
data = {"user": user}
return TemplateResponse(request, "ostatus/remote_follow.html", data)
@require_POST
def remote_follow(request):
"""direct user to follow from remote account using ostatus subscribe protocol"""
remote_user = request.POST.get("remote_user")
try:
if remote_user[0] == "@":
remote_user = remote_user[1:]
remote_domain = remote_user.split("@")[1]
except (TypeError, IndexError):
remote_domain = None
wf_response = subscribe_remote_webfinger(remote_user)
user = get_object_or_404(models.User, id=request.POST.get("user"))
if wf_response is None:
data = {
"account": remote_user,
"user": user,
"error": "not_supported",
"remote_domain": remote_domain,
}
return TemplateResponse(request, "ostatus/subscribe.html", data)
if isinstance(wf_response, WebFingerError):
data = {
"account": remote_user,
"user": user,
"error": str(wf_response),
"remote_domain": remote_domain,
}
return TemplateResponse(request, "ostatus/subscribe.html", data)
url = wf_response.replace("{uri}", urllib.parse.quote(user.remote_id))
return redirect(url)

View File

@ -6,6 +6,7 @@ import dateutil.tz
from dateutil.parser import ParserError
from requests import HTTPError
from django.db.models import Q
from django.http import Http404
from django.utils import translation
@ -15,6 +16,13 @@ from bookwyrm.status import create_generated_note
from bookwyrm.utils import regex
# pylint: disable=unnecessary-pass
class WebFingerError(Exception):
"""empty error class for problems finding user information with webfinger"""
pass
def get_user_from_username(viewer, username):
"""helper function to resolve a localname or a username to a user"""
if viewer.is_authenticated and viewer.localname == username:
@ -56,10 +64,8 @@ def handle_remote_webfinger(query):
# usernames could be @user@domain or user@domain
if not query:
return None
if query[0] == "@":
query = query[1:]
try:
domain = query.split("@")[1]
except IndexError:
@ -85,6 +91,35 @@ def handle_remote_webfinger(query):
return user
def subscribe_remote_webfinger(query):
"""get subscribe template from other servers"""
template = None
# usernames could be @user@domain or user@domain
if not query:
return WebFingerError("invalid_username")
if query[0] == "@":
query = query[1:]
try:
domain = query.split("@")[1]
except IndexError:
return WebFingerError("invalid_username")
url = f"https://{domain}/.well-known/webfinger?resource=acct:{query}"
try:
data = get_data(url)
except (ConnectorException, HTTPError):
return WebFingerError("user_not_found")
for link in data.get("links"):
if link.get("rel") == "http://ostatus.org/schema/1.0/subscribe":
template = link["template"]
return template
def get_edition(book_id):
"""look up a book in the db and return an edition"""
book = models.Book.objects.select_subclasses().get(id=book_id)
@ -153,3 +188,29 @@ def set_language(user, response):
translation.activate(user.preferred_language)
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, user.preferred_language)
return response
def filter_stream_by_status_type(activities, allowed_types=None):
"""filter out activities based on types"""
if not allowed_types:
allowed_types = []
if "review" not in allowed_types:
activities = activities.filter(
Q(review__isnull=True), Q(boost__boosted_status__review__isnull=True)
)
if "comment" not in allowed_types:
activities = activities.filter(
Q(comment__isnull=True), Q(boost__boosted_status__comment__isnull=True)
)
if "quotation" not in allowed_types:
activities = activities.filter(
Q(quotation__isnull=True), Q(boost__boosted_status__quotation__isnull=True)
)
if "everything" not in allowed_types:
activities = activities.filter(
Q(generatednote__isnull=True),
Q(boost__boosted_status__generatednote__isnull=True),
)
return activities

View File

@ -7,13 +7,14 @@ from django.core.paginator import Paginator
from django.db import IntegrityError, transaction
from django.db.models import Avg, DecimalField, Q, Max
from django.db.models.functions import Coalesce
from django.http import HttpResponseBadRequest, HttpResponse
from django.http import HttpResponseBadRequest, HttpResponse, Http404
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.http import require_POST
from django.views.decorators.clickjacking import xframe_options_exempt
from bookwyrm import book_search, forms, models
from bookwyrm.activitypub import ActivitypubResponse
@ -157,6 +158,14 @@ class List(View):
][: 5 - len(suggestions)]
page = paginated.get_page(request.GET.get("page"))
embed_key = str(book_list.embed_key.hex)
embed_url = reverse("embed-list", args=[book_list.id, embed_key])
embed_url = request.build_absolute_uri(embed_url)
if request.GET:
embed_url = f"{embed_url}?{request.GET.urlencode()}"
data = {
"list": book_list,
"items": page,
@ -170,6 +179,7 @@ class List(View):
"sort_form": forms.SortListForm(
{"direction": direction, "sort_by": sort_by}
),
"embed_url": embed_url,
}
return TemplateResponse(request, "lists/list.html", data)
@ -190,6 +200,60 @@ class List(View):
return redirect(book_list.local_path)
class EmbedList(View):
"""embeded book list page"""
def get(self, request, list_id, list_key):
"""display a book list"""
book_list = get_object_or_404(models.List, id=list_id)
embed_key = str(book_list.embed_key.hex)
if list_key != embed_key:
raise Http404()
# sort_by shall be "order" unless a valid alternative is given
sort_by = request.GET.get("sort_by", "order")
if sort_by not in ("order", "title", "rating"):
sort_by = "order"
# direction shall be "ascending" unless a valid alternative is given
direction = request.GET.get("direction", "ascending")
if direction not in ("ascending", "descending"):
direction = "ascending"
directional_sort_by = {
"order": "order",
"title": "book__title",
"rating": "average_rating",
}[sort_by]
if direction == "descending":
directional_sort_by = "-" + directional_sort_by
items = book_list.listitem_set.prefetch_related("user", "book", "book__authors")
if sort_by == "rating":
items = items.annotate(
average_rating=Avg(
Coalesce("book__review__rating", 0.0),
output_field=DecimalField(),
)
)
items = items.filter(approved=True).order_by(directional_sort_by)
paginated = Paginator(items, PAGE_LENGTH)
page = paginated.get_page(request.GET.get("page"))
data = {
"list": book_list,
"items": page,
"page_range": paginated.get_elided_page_range(
page.number, on_each_side=2, on_ends=1
),
}
return TemplateResponse(request, "lists/embed-list.html", data)
class Curate(View):
"""approve or discard list suggestsions"""
@ -437,3 +501,11 @@ def normalize_book_list_ordering(book_list_id, start=0, add_offset=0):
if item.order != effective_order:
item.order = effective_order
item.save()
@xframe_options_exempt
def unsafe_embed_list(request, *args, **kwargs):
"""allows the EmbedList view to be loaded through unsafe iframe origins"""
embed_list_view = EmbedList.as_view()
return embed_list_view(request, *args, **kwargs)

View File

@ -9,6 +9,7 @@ from django.views import View
from django.views.decorators.http import require_POST
from bookwyrm import models
from bookwyrm.views.shelf.shelf_actions import unshelve
from .status import CreateStatus
from .helpers import get_edition, handle_reading_status, is_api_request
from .helpers import load_date_in_user_tz_as_utc
@ -16,6 +17,7 @@ from .helpers import load_date_in_user_tz_as_utc
@method_decorator(login_required, name="dispatch")
# pylint: disable=no-self-use
# pylint: disable=too-many-return-statements
class ReadingStatus(View):
"""consider reading a book"""
@ -89,8 +91,21 @@ class ReadingStatus(View):
privacy = request.POST.get("privacy")
handle_reading_status(request.user, desired_shelf, book, privacy)
# if the request includes a "shelf" value we are using the 'move' button
if bool(request.POST.get("shelf")):
# unshelve the existing shelf
this_shelf = request.POST.get("shelf")
if (
bool(current_status_shelfbook)
and int(this_shelf) != int(current_status_shelfbook.shelf.id)
and current_status_shelfbook.shelf.identifier
!= desired_shelf.identifier
):
return unshelve(request, book_id=book_id)
if is_api_request(request):
return HttpResponse()
return redirect(referer)

View File

@ -91,13 +91,13 @@ def shelve(request):
@login_required
@require_POST
def unshelve(request):
def unshelve(request, book_id=False):
"""remove a book from a user's shelf"""
book = get_object_or_404(models.Edition, id=request.POST.get("book"))
identity = book_id if book_id else request.POST.get("book")
book = get_object_or_404(models.Edition, id=identity)
shelf_book = get_object_or_404(
models.ShelfBook, book=book, shelf__id=request.POST["shelf"]
)
shelf_book.raise_not_deletable(request.user)
shelf_book.delete()
return redirect(request.headers.get("Referer", "/"))

View File

@ -54,6 +54,7 @@ class CreateStatus(View):
data = {"book": book}
return TemplateResponse(request, "compose.html", data)
# pylint: disable=too-many-branches
def post(self, request, status_type, existing_status_id=None):
"""create status of whatever type"""
created = not existing_status_id
@ -117,11 +118,12 @@ class CreateStatus(View):
status.save(created=created)
# update a readthorugh, if needed
try:
edit_readthrough(request)
except Http404:
pass
# update a readthrough, if needed
if bool(request.POST.get("id")):
try:
edit_readthrough(request)
except Http404:
pass
if is_api_request(request):
return HttpResponse()

View File

@ -22,4 +22,9 @@ def get_unread_status_count(request, stream="home"):
stream = activitystreams.streams.get(stream)
if not stream:
return JsonResponse({})
return JsonResponse({"count": stream.get_unread_count(request.user)})
return JsonResponse(
{
"count": stream.get_unread_count(request.user),
"count_by_type": stream.get_unread_count_by_status_type(request.user),
}
)

View File

@ -9,7 +9,7 @@ from django.utils import timezone
from django.views.decorators.http import require_GET
from bookwyrm import models
from bookwyrm.settings import DOMAIN, VERSION, MEDIA_FULL_URL, STATIC_FULL_URL
from bookwyrm.settings import DOMAIN, VERSION
@require_GET
@ -30,7 +30,11 @@ def webfinger(request):
"rel": "self",
"type": "application/activity+json",
"href": user.remote_id,
}
},
{
"rel": "http://ostatus.org/schema/1.0/subscribe",
"template": f"https://{DOMAIN}/ostatus_subscribe?acct={{uri}}",
},
],
}
)
@ -93,7 +97,7 @@ def instance_info(_):
status_count = models.Status.objects.filter(user__local=True, deleted=False).count()
site = models.SiteSettings.get()
logo = get_image_url(site.logo, "logo.png")
logo = site.logo_url
return JsonResponse(
{
"uri": DOMAIN,
@ -108,7 +112,8 @@ def instance_info(_):
"thumbnail": logo,
"languages": ["en"],
"registrations": site.allow_registration,
"approval_required": site.allow_registration and site.allow_invite_requests,
"approval_required": not site.allow_registration
and site.allow_invite_requests,
"email": site.admin_email,
}
)
@ -133,14 +138,7 @@ def host_meta(request):
def opensearch(request):
"""Open Search xml spec"""
site = models.SiteSettings.get()
image = get_image_url(site.favicon, "favicon.png")
image = site.favicon_url
return TemplateResponse(
request, "opensearch.xml", {"image": image, "DOMAIN": DOMAIN}
)
def get_image_url(obj, fallback):
"""helper for loading the full path to an image"""
if obj:
return f"{MEDIA_FULL_URL}{obj}"
return f"{STATIC_FULL_URL}images/{fallback}"