Merge branch 'main' into suggestions-redis

This commit is contained in:
Mouse Reeve
2021-06-18 16:48:04 -07:00
222 changed files with 5284 additions and 3547 deletions

View File

@ -6,6 +6,7 @@ from .block import Block, unblock
from .books import Book, EditBook, ConfirmEditBook, Editions
from .books import upload_cover, add_description, switch_edition, resolve_book
from .directory import Directory
from .edit_user import EditUser, DeleteUser
from .federation import Federation, FederatedServer
from .federation import AddFederatedServer, ImportServerBlocklist
from .federation import block_server, unblock_server
@ -24,8 +25,9 @@ from .landing import About, Home, Discover
from .list import Lists, List, Curate, UserLists
from .notifications import Notifications
from .outbox import Outbox
from .reading import edit_readthrough, create_readthrough, delete_readthrough
from .reading import start_reading, finish_reading, delete_progressupdate
from .reading import edit_readthrough, create_readthrough
from .reading import delete_readthrough, delete_progressupdate
from .reading import ReadingStatus
from .reports import Report, Reports, make_report, resolve_report, suspend_user
from .rss_feed import RssFeed
from .password import PasswordResetRequest, PasswordReset, ChangePassword
@ -36,6 +38,6 @@ from .shelf import shelve, unshelve
from .site import Site
from .status import CreateStatus, DeleteStatus, DeleteAndRedraft
from .updates import get_notification_count, get_unread_status_count
from .user import User, EditUser, Followers, Following
from .user import User, Followers, Following
from .user_admin import UserAdmin, UserAdminList
from .wellknown import *

View File

@ -81,6 +81,7 @@ class Announcement(View):
form = forms.AnnouncementForm(request.POST, instance=announcement)
if form.is_valid():
announcement = form.save()
form = forms.AnnouncementForm(instance=announcement)
data = {
"announcement": announcement,
"form": form,

View File

@ -62,13 +62,16 @@ class Book(View):
queryset = queryset.filter(user=request.user)
else:
queryset = reviews.exclude(Q(content__isnull=True) | Q(content=""))
queryset = queryset.select_related("user")
paginated = Paginator(queryset, PAGE_LENGTH)
data = {
"book": book,
"statuses": paginated.get_page(request.GET.get("page")),
"review_count": reviews.count(),
"ratings": reviews.filter(Q(content__isnull=True) | Q(content=""))
"ratings": reviews.filter(
Q(content__isnull=True) | Q(content="")
).select_related("user")
if not user_statuses
else None,
"rating": reviews.aggregate(Avg("rating"))["rating__avg"],
@ -89,15 +92,15 @@ class Book(View):
)
data["readthroughs"] = readthroughs
data["user_shelves"] = models.ShelfBook.objects.filter(
data["user_shelfbooks"] = models.ShelfBook.objects.filter(
user=request.user, book=book
)
).select_related("shelf")
data["other_edition_shelves"] = models.ShelfBook.objects.filter(
~Q(book=book),
user=request.user,
book__parent_work=book.parent_work,
)
).select_related("shelf", "book")
data["user_statuses"] = {
"review_count": book.review_set.filter(user=request.user).count(),

113
bookwyrm/views/edit_user.py Normal file
View File

@ -0,0 +1,113 @@
""" edit or delete ones own account"""
from io import BytesIO
from uuid import uuid4
from PIL import Image
from django.contrib.auth import logout
from django.contrib.auth.decorators import login_required
from django.core.files.base import ContentFile
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views import View
from bookwyrm import forms, models
# pylint: disable=no-self-use
@method_decorator(login_required, name="dispatch")
class EditUser(View):
"""edit user view"""
def get(self, request):
"""edit profile page for a user"""
data = {
"form": forms.EditUserForm(instance=request.user),
"user": request.user,
}
return TemplateResponse(request, "preferences/edit_user.html", data)
def post(self, request):
"""les get fancy with images"""
form = forms.EditUserForm(request.POST, request.FILES, instance=request.user)
if not form.is_valid():
data = {"form": form, "user": request.user}
return TemplateResponse(request, "preferences/edit_user.html", data)
user = save_user_form(form)
return redirect(user.local_path)
# pylint: disable=no-self-use
@method_decorator(login_required, name="dispatch")
class DeleteUser(View):
"""delete user view"""
def get(self, request):
"""delete page for a user"""
data = {
"form": forms.DeleteUserForm(),
"user": request.user,
}
return TemplateResponse(request, "preferences/delete_user.html", data)
def post(self, request):
"""les get fancy with images"""
form = forms.DeleteUserForm(request.POST, instance=request.user)
form.is_valid()
# idk why but I couldn't get check_password to work on request.user
user = models.User.objects.get(id=request.user.id)
if form.is_valid() and user.check_password(form.cleaned_data["password"]):
user.deactivation_reason = "self_deletion"
user.delete()
logout(request)
return redirect("/")
form.errors["password"] = ["Invalid password"]
data = {"form": form, "user": request.user}
return TemplateResponse(request, "preferences/delete_user.html", data)
def save_user_form(form):
"""special handling for the user form"""
user = form.save(commit=False)
if "avatar" in form.files:
# crop and resize avatar upload
image = Image.open(form.files["avatar"])
image = crop_avatar(image)
# set the name to a hash
extension = form.files["avatar"].name.split(".")[-1]
filename = "%s.%s" % (uuid4(), extension)
user.avatar.save(filename, image, save=False)
user.save()
return user
def crop_avatar(image):
"""reduce the size and make an avatar square"""
target_size = 120
width, height = image.size
thumbnail_scale = (
height / (width / target_size)
if height > width
else width / (height / target_size)
)
image.thumbnail([thumbnail_scale, thumbnail_scale])
width, height = image.size
width_diff = width - target_size
height_diff = height - target_size
cropped = image.crop(
(
int(width_diff / 2),
int(height_diff / 2),
int(width - (width_diff / 2)),
int(height - (height_diff / 2)),
)
)
output = BytesIO()
cropped.save(output, format=image.format)
return ContentFile(output.getvalue())

View File

@ -163,14 +163,15 @@ def get_suggested_books(user, max_books=5):
else max_books - book_count
)
shelf = user.shelf_set.get(identifier=preset)
shelf_books = shelf.shelfbook_set.order_by("-updated_date")[:limit]
if not shelf_books:
if not shelf.books.exists():
continue
shelf_preview = {
"name": shelf.name,
"identifier": shelf.identifier,
"books": [s.book for s in shelf_books],
"books": shelf.books.order_by("shelfbook").prefetch_related("authors")[
:limit
],
}
suggested_books.append(shelf_preview)
book_count += len(shelf_preview["books"])

View File

@ -14,7 +14,7 @@ from django.views import View
from bookwyrm import forms, models
from bookwyrm.connectors import connector_manager
from bookwyrm.suggested_users import suggested_users
from .user import save_user_form
from .edit_user import save_user_form
# pylint: disable= no-self-use

View File

@ -13,6 +13,10 @@ from bookwyrm.utils import regex
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:
# that's yourself, fool
return viewer
# raises 404 if the user isn't found
try:
return models.User.viewer_aware_objects(viewer).get(localname=username)
@ -34,7 +38,7 @@ def is_api_request(request):
def is_bookwyrm_request(request):
"""check if the request is coming from another bookwyrm instance"""
user_agent = request.headers.get("User-Agent")
if user_agent is None or re.search(regex.bookwyrm_user_agent, user_agent) is None:
if user_agent is None or re.search(regex.BOOKWYRM_USER_AGENT, user_agent) is None:
return False
return True

View File

@ -78,10 +78,15 @@ class ImportStatus(View):
def get(self, request, job_id):
"""status of an import job"""
job = models.ImportJob.objects.get(id=job_id)
job = get_object_or_404(models.ImportJob, id=job_id)
if job.user != request.user:
raise PermissionDenied
task = app.AsyncResult(job.task_id)
try:
task = app.AsyncResult(job.task_id)
except ValueError:
task = None
items = job.items.order_by("index").all()
failed_items = [i for i in items if i.fail_reason]
items = [i for i in items if not i.fail_reason]

View File

@ -21,6 +21,7 @@ from bookwyrm.utils import regex
class Inbox(View):
"""requests sent by outside servers"""
# pylint: disable=too-many-return-statements
def post(self, request, username=None):
"""only works as POST request"""
# first check if this server is on our shitlist
@ -70,7 +71,7 @@ def is_blocked_user_agent(request):
user_agent = request.headers.get("User-Agent")
if not user_agent:
return False
url = re.search(r"https?://{:s}/?".format(regex.domain), user_agent)
url = re.search(r"https?://{:s}/?".format(regex.DOMAIN), user_agent)
if not url:
return False
url = url.group()

View File

@ -37,8 +37,12 @@ class ManageInvites(View):
PAGE_LENGTH,
)
page = paginated.get_page(request.GET.get("page"))
data = {
"invites": paginated.get_page(request.GET.get("page")),
"invites": page,
"page_range": paginated.get_elided_page_range(
page.number, on_each_side=2, on_ends=1
),
"form": forms.CreateInviteForm(),
}
return TemplateResponse(request, "settings/manage_invites.html", data)
@ -118,15 +122,16 @@ class ManageInviteRequests(View):
reduce(operator.or_, (Q(**f) for f in filters))
).distinct()
paginated = Paginator(
requests,
PAGE_LENGTH,
)
paginated = Paginator(requests, PAGE_LENGTH)
page = paginated.get_page(request.GET.get("page"))
data = {
"ignored": ignored,
"count": paginated.count,
"requests": paginated.get_page(request.GET.get("page")),
"requests": page,
"page_range": paginated.get_elided_page_range(
page.number, on_each_side=2, on_ends=1
),
"sort": sort,
}
return TemplateResponse(request, "settings/manage_invite_requests.html", data)

View File

@ -1,13 +1,8 @@
""" isbn search view """
from django.http import HttpResponseNotFound
from django.http import JsonResponse
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.connectors import connector_manager
from .helpers import is_api_request
@ -23,7 +18,6 @@ class Isbn(View):
return JsonResponse([r.json() for r in book_results], safe=False)
data = {
"title": "ISBN Search Results",
"results": book_results,
"query": isbn,
}

View File

@ -314,8 +314,7 @@ def set_book_position(request, list_item_id):
Max("order")
)["order__max"]
if int_position > order_max:
int_position = order_max
int_position = min(int_position, order_max)
if request.user not in (book_list.user, list_item.user):
return HttpResponseNotFound()

View File

@ -7,95 +7,79 @@ from dateutil.parser import ParserError
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseBadRequest, HttpResponseNotFound
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 models
from .helpers import get_edition, handle_reading_status
from .shelf import handle_unshelve
# pylint: disable= no-self-use
@login_required
@require_POST
def start_reading(request, book_id):
"""begin reading a book"""
book = get_edition(book_id)
reading_shelf = models.Shelf.objects.filter(
identifier=models.Shelf.READING, user=request.user
).first()
@method_decorator(login_required, name="dispatch")
# pylint: disable=no-self-use
class ReadingStatus(View):
"""consider reading a book"""
# create a readthrough
readthrough = update_readthrough(request, book=book)
if readthrough:
readthrough.save()
def get(self, request, status, book_id):
"""modal page"""
book = get_edition(book_id)
template = {
"want": "want.html",
"start": "start.html",
"finish": "finish.html",
}.get(status)
if not template:
return HttpResponseNotFound()
return TemplateResponse(request, f"reading_progress/{template}", {"book": book})
# create a progress update if we have a page
readthrough.create_update()
def post(self, request, status, book_id):
"""desire a book"""
identifier = {
"want": models.Shelf.TO_READ,
"start": models.Shelf.READING,
"finish": models.Shelf.READ_FINISHED,
}.get(status)
if not identifier:
return HttpResponseBadRequest()
current_status_shelfbook = (
models.ShelfBook.objects.select_related("shelf")
.filter(
shelf__identifier__in=models.Shelf.READ_STATUS_IDENTIFIERS,
user=request.user,
book=book,
desired_shelf = models.Shelf.objects.filter(
identifier=identifier, user=request.user
).first()
book = get_edition(book_id)
current_status_shelfbook = (
models.ShelfBook.objects.select_related("shelf")
.filter(
shelf__identifier__in=models.Shelf.READ_STATUS_IDENTIFIERS,
user=request.user,
book=book,
)
.first()
)
.first()
)
if current_status_shelfbook is not None:
if current_status_shelfbook.shelf.identifier != models.Shelf.READING:
handle_unshelve(book, current_status_shelfbook.shelf)
else: # It already was on the shelf
return redirect(request.headers.get("Referer", "/"))
if current_status_shelfbook is not None:
if current_status_shelfbook.shelf.identifier != desired_shelf.identifier:
current_status_shelfbook.delete()
else: # It already was on the shelf
return redirect(request.headers.get("Referer", "/"))
models.ShelfBook.objects.create(book=book, shelf=reading_shelf, user=request.user)
# post about it (if you want)
if request.POST.get("post-status"):
privacy = request.POST.get("privacy")
handle_reading_status(request.user, reading_shelf, book, privacy)
return redirect(request.headers.get("Referer", "/"))
@login_required
@require_POST
def finish_reading(request, book_id):
"""a user completed a book, yay"""
book = get_edition(book_id)
finished_read_shelf = models.Shelf.objects.filter(
identifier=models.Shelf.READ_FINISHED, user=request.user
).first()
# update or create a readthrough
readthrough = update_readthrough(request, book=book)
if readthrough:
readthrough.save()
current_status_shelfbook = (
models.ShelfBook.objects.select_related("shelf")
.filter(
shelf__identifier__in=models.Shelf.READ_STATUS_IDENTIFIERS,
user=request.user,
book=book,
models.ShelfBook.objects.create(
book=book, shelf=desired_shelf, user=request.user
)
.first()
)
if current_status_shelfbook is not None:
if current_status_shelfbook.shelf.identifier != models.Shelf.READ_FINISHED:
handle_unshelve(book, current_status_shelfbook.shelf)
else: # It already was on the shelf
return redirect(request.headers.get("Referer", "/"))
models.ShelfBook.objects.create(
book=book, shelf=finished_read_shelf, user=request.user
)
if desired_shelf.identifier != models.Shelf.TO_READ:
# update or create a readthrough
readthrough = update_readthrough(request, book=book)
if readthrough:
readthrough.save()
# post about it (if you want)
if request.POST.get("post-status"):
privacy = request.POST.get("privacy")
handle_reading_status(request.user, finished_read_shelf, book, privacy)
# post about it (if you want)
if request.POST.get("post-status"):
privacy = request.POST.get("privacy")
handle_reading_status(request.user, desired_shelf, book, privacy)
return redirect(request.headers.get("Referer", "/"))
return redirect(request.headers.get("Referer", "/"))
@login_required

View File

@ -10,7 +10,7 @@ class RssFeed(Feed):
description_template = "rss/content.html"
title_template = "rss/title.html"
def get_object(self, request, username):
def get_object(self, request, username): # pylint: disable=arguments-differ
"""the user who's posts get serialized"""
return get_user_from_username(request.user, username)

View File

@ -83,7 +83,7 @@ def user_search(query, viewer, *_):
# use webfinger for mastodon style account@domain.com username to load the user if
# they don't exist locally (handle_remote_webfinger will check the db)
if re.match(regex.full_username, query):
if re.match(regex.FULL_USERNAME, query):
handle_remote_webfinger(query)
return (

View File

@ -2,7 +2,7 @@
from collections import namedtuple
from django.db import IntegrityError
from django.db.models import Count, OuterRef, Subquery, F, Q
from django.db.models import OuterRef, Subquery
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import HttpResponseBadRequest, HttpResponseNotFound
@ -17,10 +17,10 @@ from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import PAGE_LENGTH
from .helpers import is_api_request, get_edition, get_user_from_username
from .helpers import handle_reading_status, privacy_filter
from .helpers import privacy_filter
# pylint: disable= no-self-use
# pylint: disable=no-self-use
class Shelf(View):
"""shelf page"""
@ -28,7 +28,12 @@ class Shelf(View):
"""display a shelf"""
user = get_user_from_username(request.user, username)
shelves = privacy_filter(request.user, user.shelf_set)
is_self = user == request.user
if is_self:
shelves = user.shelf_set
else:
shelves = privacy_filter(request.user, user.shelf_set)
# get the shelf and make sure the logged in user should be able to see it
if shelf_identifier:
@ -53,26 +58,29 @@ class Shelf(View):
if is_api_request(request):
return ActivitypubResponse(shelf.to_activity(**request.GET))
reviews = privacy_filter(
request.user,
models.Review.objects.filter(
user=user,
rating__isnull=False,
book__id=OuterRef("id"),
),
reviews = models.Review.objects.filter(
user=user,
rating__isnull=False,
book__id=OuterRef("id"),
deleted=False,
).order_by("-published_date")
books = books.annotate(rating=Subquery(reviews.values("rating")[:1]))
if not is_self:
reviews = privacy_filter(request.user, reviews)
books = books.annotate(
rating=Subquery(reviews.values("rating")[:1])
).prefetch_related("authors")
paginated = Paginator(
books.order_by("-updated_date"),
books.order_by("-shelfbook__updated_date"),
PAGE_LENGTH,
)
page = paginated.get_page(request.GET.get("page"))
data = {
"user": user,
"is_self": request.user == user,
"is_self": is_self,
"shelves": shelves.all(),
"shelf": shelf,
"books": page,
@ -170,11 +178,6 @@ def shelve(request):
models.ShelfBook.objects.create(
book=book, shelf=desired_shelf, user=request.user
)
if desired_shelf.identifier == models.Shelf.TO_READ and request.POST.get(
"post-status"
):
privacy = request.POST.get("privacy") or desired_shelf.privacy
handle_reading_status(request.user, desired_shelf, book, privacy=privacy)
else:
try:
models.ShelfBook.objects.create(
@ -198,7 +201,6 @@ def unshelve(request):
return redirect(request.headers.get("Referer", "/"))
# pylint: disable=unused-argument
def handle_unshelve(book, shelf):
"""unshelve a book"""
row = models.ShelfBook.objects.get(book=book, shelf=shelf)

View File

@ -133,7 +133,7 @@ def find_mentions(content):
"""detect @mentions in raw status content"""
if not content:
return
for match in re.finditer(regex.strict_username, content):
for match in re.finditer(regex.STRICT_USERNAME, content):
username = match.group().strip().split("@")[1:]
if len(username) == 1:
# this looks like a local user (@user), fill in the domain
@ -150,7 +150,7 @@ def find_mentions(content):
def format_links(content):
"""detect and format links"""
return re.sub(
r'([^(href=")]|^|\()(https?:\/\/(%s([\w\.\-_\/+&\?=:;,])*))' % regex.domain,
r'([^(href=")]|^|\()(https?:\/\/(%s([\w\.\-_\/+&\?=:;,])*))' % regex.DOMAIN,
r'\g<1><a href="\g<2>">\g<3></a>',
content,
)

View File

@ -1,25 +1,17 @@
""" non-interactive pages """
from io import BytesIO
from uuid import uuid4
from PIL import Image
from django.contrib.auth.decorators import login_required
from django.core.files.base import ContentFile
from django.core.paginator import Paginator
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from bookwyrm import forms, models
from bookwyrm import models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import PAGE_LENGTH
from .helpers import get_user_from_username, is_api_request
from .helpers import privacy_filter
# pylint: disable= no-self-use
# pylint: disable=no-self-use
class User(View):
"""user profile page"""
@ -59,10 +51,15 @@ class User(View):
break
# user's posts
activities = privacy_filter(
request.user,
user.status_set.select_subclasses(),
activities = (
privacy_filter(
request.user,
user.status_set.select_subclasses(),
)
.select_related("reply_parent")
.prefetch_related("mention_books", "mention_users")
)
paginated = Paginator(activities, PAGE_LENGTH)
goal = models.AnnualGoal.objects.filter(
user=user, year=timezone.now().year
@ -117,72 +114,3 @@ class Following(View):
"follow_list": paginated.get_page(request.GET.get("page")),
}
return TemplateResponse(request, "user/relationships/following.html", data)
@method_decorator(login_required, name="dispatch")
class EditUser(View):
"""edit user view"""
def get(self, request):
"""edit profile page for a user"""
data = {
"form": forms.EditUserForm(instance=request.user),
"user": request.user,
}
return TemplateResponse(request, "preferences/edit_user.html", data)
def post(self, request):
"""les get fancy with images"""
form = forms.EditUserForm(request.POST, request.FILES, instance=request.user)
if not form.is_valid():
data = {"form": form, "user": request.user}
return TemplateResponse(request, "preferences/edit_user.html", data)
user = save_user_form(form)
return redirect(user.local_path)
def save_user_form(form):
"""special handling for the user form"""
user = form.save(commit=False)
if "avatar" in form.files:
# crop and resize avatar upload
image = Image.open(form.files["avatar"])
image = crop_avatar(image)
# set the name to a hash
extension = form.files["avatar"].name.split(".")[-1]
filename = "%s.%s" % (uuid4(), extension)
user.avatar.save(filename, image, save=False)
user.save()
return user
def crop_avatar(image):
"""reduce the size and make an avatar square"""
target_size = 120
width, height = image.size
thumbnail_scale = (
height / (width / target_size)
if height > width
else width / (height / target_size)
)
image.thumbnail([thumbnail_scale, thumbnail_scale])
width, height = image.size
width_diff = width - target_size
height_diff = height - target_size
cropped = image.crop(
(
int(width_diff / 2),
int(height_diff / 2),
int(width - (width_diff / 2)),
int(height - (height_diff / 2)),
)
)
output = BytesIO()
cropped.save(output, format=image.format)
return ContentFile(output.getvalue())