1
0
Fork 0

Merge branch 'main' into groups-merge-test

Big merge of a couple of weeks' work from the main project back into this branch. :ohno:
This commit is contained in:
Hugh Rundle 2021-10-03 10:49:38 +11:00
commit 602664b8d7
231 changed files with 8983 additions and 7361 deletions

View file

@ -1,4 +1,5 @@
""" make sure all our nice views are available """
# site admin
from .admin.announcements import Announcements, Announcement, delete_announcement
from .admin.dashboard import Dashboard
from .admin.federation import Federation, FederatedServer
@ -19,14 +20,22 @@ from .admin.reports import (
)
from .admin.site import Site
from .admin.user_admin import UserAdmin, UserAdminList
# user preferences
from .preferences.change_password import ChangePassword
from .preferences.edit_user import EditUser
from .preferences.delete_user import DeleteUser
from .preferences.block import Block, unblock
# books
from .books.books import Book, upload_cover, add_description, resolve_book
from .books.edit_book import EditBook, ConfirmEditBook
from .books.editions import Editions, switch_edition
# misc views
from .author import Author, EditAuthor
from .block import Block, unblock
from .books import Book, EditBook, ConfirmEditBook
from .books import upload_cover, add_description, resolve_book
from .directory import Directory
from .discover import Discover
from .edit_user import EditUser, DeleteUser
from .editions import Editions, switch_edition
from .feed import DirectMessage, Feed, Replies, Status
from .follow import follow, unfollow
from .follow import accept_follow_request, delete_follow_request
@ -43,17 +52,17 @@ from .list import save_list, unsave_list, delete_list
from .login import Login, Logout
from .notifications import Notifications
from .outbox import Outbox
from .reading import edit_readthrough, create_readthrough
from .reading import delete_readthrough, delete_progressupdate
from .reading import create_readthrough, delete_readthrough, delete_progressupdate
from .reading import ReadingStatus
from .register import Register, ConfirmEmail, ConfirmEmailCode, resend_link
from .rss_feed import RssFeed
from .password import PasswordResetRequest, PasswordReset, ChangePassword
from .password import PasswordResetRequest, PasswordReset
from .search import Search
from .shelf import Shelf
from .shelf import create_shelf, delete_shelf
from .shelf import shelve, unshelve
from .status import CreateStatus, DeleteStatus, DeleteAndRedraft
from .status import CreateStatus, DeleteStatus, DeleteAndRedraft, update_progress
from .status import edit_readthrough
from .updates import get_notification_count, get_unread_status_count
from .user import User, Followers, Following, hide_suggestions
from .wellknown import *

View file

View file

@ -31,6 +31,7 @@ class Announcements(View):
"end_date",
"active",
]
# pylint: disable=consider-using-f-string
if sort in sort_fields + ["-{:s}".format(f) for f in sort_fields]:
announcements = announcements.order_by(sort)
data = {
@ -40,7 +41,9 @@ class Announcements(View):
"form": forms.AnnouncementForm(),
"sort": sort,
}
return TemplateResponse(request, "settings/announcements.html", data)
return TemplateResponse(
request, "settings/announcements/announcements.html", data
)
def post(self, request):
"""edit the site settings"""
@ -55,7 +58,9 @@ class Announcements(View):
).get_page(request.GET.get("page")),
"form": form,
}
return TemplateResponse(request, "settings/announcements.html", data)
return TemplateResponse(
request, "settings/announcements/announcements.html", data
)
@method_decorator(login_required, name="dispatch")
@ -73,7 +78,9 @@ class Announcement(View):
"announcement": announcement,
"form": forms.AnnouncementForm(instance=announcement),
}
return TemplateResponse(request, "settings/announcement.html", data)
return TemplateResponse(
request, "settings/announcements/announcement.html", data
)
def post(self, request, announcement_id):
"""edit announcement"""
@ -86,7 +93,9 @@ class Announcement(View):
"announcement": announcement,
"form": form,
}
return TemplateResponse(request, "settings/announcement.html", data)
return TemplateResponse(
request, "settings/announcements/announcement.html", data
)
@login_required

View file

@ -85,4 +85,4 @@ class Dashboard(View):
"user_stats": user_stats,
"status_stats": status_stats,
}
return TemplateResponse(request, "settings/dashboard.html", data)
return TemplateResponse(request, "settings/dashboard/dashboard.html", data)

View file

@ -22,7 +22,9 @@ class EmailBlocklist(View):
"domains": models.EmailBlocklist.objects.order_by("-created_date").all(),
"form": forms.EmailBlocklistForm(),
}
return TemplateResponse(request, "settings/email_blocklist.html", data)
return TemplateResponse(
request, "settings/email_blocklist/email_blocklist.html", data
)
def post(self, request, domain_id=None):
"""create a new domain block"""
@ -35,11 +37,15 @@ class EmailBlocklist(View):
"form": form,
}
if not form.is_valid():
return TemplateResponse(request, "settings/email_blocklist.html", data)
return TemplateResponse(
request, "settings/email_blocklist/email_blocklist.html", data
)
form.save()
data["form"] = forms.EmailBlocklistForm()
return TemplateResponse(request, "settings/email_blocklist.html", data)
return TemplateResponse(
request, "settings/email_blocklist/email_blocklist.html", data
)
# pylint: disable=unused-argument
def delete(self, request, domain_id):

View file

@ -28,6 +28,7 @@ class Federation(View):
sort = request.GET.get("sort")
sort_fields = ["created_date", "application_type", "server_name"]
# pylint: disable=consider-using-f-string
if not sort in sort_fields + ["-{:s}".format(f) for f in sort_fields]:
sort = "-created_date"
servers = servers.order_by(sort)
@ -43,7 +44,7 @@ class Federation(View):
"sort": sort,
"form": forms.ServerForm(),
}
return TemplateResponse(request, "settings/federation.html", data)
return TemplateResponse(request, "settings/federation/instance_list.html", data)
class AddFederatedServer(View):
@ -52,14 +53,16 @@ class AddFederatedServer(View):
def get(self, request):
"""add server form"""
data = {"form": forms.ServerForm()}
return TemplateResponse(request, "settings/edit_server.html", data)
return TemplateResponse(request, "settings/federation/edit_instance.html", data)
def post(self, request):
"""add a server from the admin panel"""
form = forms.ServerForm(request.POST)
if not form.is_valid():
data = {"form": form}
return TemplateResponse(request, "settings/edit_server.html", data)
return TemplateResponse(
request, "settings/federation/edit_instance.html", data
)
server = form.save()
return redirect("settings-federated-server", server.id)
@ -74,7 +77,7 @@ class ImportServerBlocklist(View):
def get(self, request):
"""add server form"""
return TemplateResponse(request, "settings/server_blocklist.html")
return TemplateResponse(request, "settings/federation/instance_blocklist.html")
def post(self, request):
"""add a server from the admin panel"""
@ -97,7 +100,9 @@ class ImportServerBlocklist(View):
server.block()
success_count += 1
data = {"failed": failed, "succeeded": success_count}
return TemplateResponse(request, "settings/server_blocklist.html", data)
return TemplateResponse(
request, "settings/federation/instance_blocklist.html", data
)
@method_decorator(login_required, name="dispatch")
@ -122,7 +127,7 @@ class FederatedServer(View):
user_subject__in=users.all()
),
}
return TemplateResponse(request, "settings/federated_server.html", data)
return TemplateResponse(request, "settings/federation/instance.html", data)
def post(self, request, server): # pylint: disable=unused-argument
"""update note"""

View file

@ -45,13 +45,13 @@ class ManageInvites(View):
),
"form": forms.CreateInviteForm(),
}
return TemplateResponse(request, "settings/manage_invites.html", data)
return TemplateResponse(request, "settings/invites/manage_invites.html", data)
def post(self, request):
"""creates an invite database entry"""
form = forms.CreateInviteForm(request.POST)
if not form.is_valid():
return HttpResponseBadRequest("ERRORS : %s" % (form.errors,))
return HttpResponseBadRequest(f"ERRORS: {form.errors}")
invite = form.save(commit=False)
invite.user = request.user
@ -64,7 +64,7 @@ class ManageInvites(View):
PAGE_LENGTH,
)
data = {"invites": paginated.page(1), "form": form}
return TemplateResponse(request, "settings/manage_invites.html", data)
return TemplateResponse(request, "settings/invites/manage_invites.html", data)
class Invite(View):
@ -98,6 +98,7 @@ class ManageInviteRequests(View):
"invite__times_used",
"invite__invitees__created_date",
]
# pylint: disable=consider-using-f-string
if not sort in sort_fields + ["-{:s}".format(f) for f in sort_fields]:
sort = "-created_date"
@ -134,7 +135,9 @@ class ManageInviteRequests(View):
),
"sort": sort,
}
return TemplateResponse(request, "settings/manage_invite_requests.html", data)
return TemplateResponse(
request, "settings/invites/manage_invite_requests.html", data
)
def post(self, request):
"""send out an invite"""
@ -149,6 +152,7 @@ class ManageInviteRequests(View):
)
invite_request.save()
emailing.invite_email(invite_request)
# pylint: disable=consider-using-f-string
return redirect(
"{:s}?{:s}".format(
reverse("settings-invite-requests"), urlencode(request.GET.dict())

View file

@ -22,7 +22,9 @@ class IPBlocklist(View):
"addresses": models.IPBlocklist.objects.all(),
"form": forms.IPBlocklistForm(),
}
return TemplateResponse(request, "settings/ip_blocklist.html", data)
return TemplateResponse(
request, "settings/ip_blocklist/ip_blocklist.html", data
)
def post(self, request, block_id=None):
"""create a new ip address block"""
@ -35,11 +37,15 @@ class IPBlocklist(View):
"form": form,
}
if not form.is_valid():
return TemplateResponse(request, "settings/ip_blocklist.html", data)
return TemplateResponse(
request, "settings/ip_blocklist/ip_blocklist.html", data
)
form.save()
data["form"] = forms.IPBlocklistForm()
return TemplateResponse(request, "settings/ip_blocklist.html", data)
return TemplateResponse(
request, "settings/ip_blocklist/ip_blocklist.html", data
)
# pylint: disable=unused-argument
def delete(self, request, domain_id):

View file

@ -40,7 +40,7 @@ class Reports(View):
"server": server,
"reports": models.Report.objects.filter(**filters),
}
return TemplateResponse(request, "moderation/reports.html", data)
return TemplateResponse(request, "settings/reports/reports.html", data)
@method_decorator(login_required, name="dispatch")
@ -60,7 +60,7 @@ class Report(View):
data = {
"report": get_object_or_404(models.Report, id=report_id),
}
return TemplateResponse(request, "moderation/report.html", data)
return TemplateResponse(request, "settings/reports/report.html", data)
def post(self, request, report_id):
"""comment on a report"""
@ -105,7 +105,7 @@ def moderator_delete_user(request, user_id):
# we can't delete users on other instances
if not user.local:
raise PermissionDenied
raise PermissionDenied()
form = forms.DeleteUserForm(request.POST, instance=user)

View file

@ -41,9 +41,9 @@ def email_preview(request):
"""for development, renders and example email template"""
template = request.GET.get("email")
data = emailing.email_data()
data["subject_path"] = "email/{}/subject.html".format(template)
data["html_content_path"] = "email/{}/html_content.html".format(template)
data["text_content_path"] = "email/{}/text_content.html".format(template)
data["subject_path"] = f"email/{template}/subject.html"
data["html_content_path"] = f"email/{template}/html_content.html"
data["text_content_path"] = f"email/{template}/text_content.html"
data["reset_link"] = "https://example.com/link"
data["invite_link"] = "https://example.com/link"
data["confirmation_link"] = "https://example.com/link"

View file

@ -47,6 +47,7 @@ class UserAdminList(View):
"federated_server__server_name",
"is_active",
]
# pylint: disable=consider-using-f-string
if sort in sort_fields + ["-{:s}".format(f) for f in sort_fields]:
users = users.order_by(sort)
@ -56,7 +57,7 @@ class UserAdminList(View):
"sort": sort,
"server": server,
}
return TemplateResponse(request, "user_admin/user_admin.html", data)
return TemplateResponse(request, "settings/users/user_admin.html", data)
@method_decorator(login_required, name="dispatch")
@ -71,7 +72,7 @@ class UserAdmin(View):
"""user view"""
user = get_object_or_404(models.User, id=user)
data = {"user": user, "group_form": forms.UserGroupForm()}
return TemplateResponse(request, "user_admin/user.html", data)
return TemplateResponse(request, "settings/users/user.html", data)
def post(self, request, user):
"""update user group"""
@ -80,4 +81,4 @@ class UserAdmin(View):
if form.is_valid():
form.save()
data = {"user": user, "group_form": form}
return TemplateResponse(request, "user_admin/user.html", data)
return TemplateResponse(request, "settings/users/user.html", data)

View file

View file

@ -0,0 +1,182 @@
""" the good stuff! the books! """
from uuid import uuid4
from django.contrib.auth.decorators import login_required, permission_required
from django.core.files.base import ContentFile
from django.core.paginator import Paginator
from django.db.models import Avg, Q
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
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.connectors.abstract_connector import get_image
from bookwyrm.settings import PAGE_LENGTH
from bookwyrm.views.helpers import is_api_request, privacy_filter
# pylint: disable=no-self-use
class Book(View):
"""a book! this is the stuff"""
def get(self, request, book_id, user_statuses=False):
"""info about a book"""
if is_api_request(request):
book = get_object_or_404(
models.Book.objects.select_subclasses(), id=book_id
)
return ActivitypubResponse(book.to_activity())
user_statuses = user_statuses if request.user.is_authenticated else False
# it's safe to use this OR because edition and work and subclasses of the same
# table, so they never have clashing IDs
book = (
models.Edition.viewer_aware_objects(request.user)
.filter(Q(id=book_id) | Q(parent_work__id=book_id))
.order_by("-edition_rank")
.select_related("parent_work")
.prefetch_related("authors")
.first()
)
if not book or not book.parent_work:
raise Http404()
# all reviews for all editions of the book
reviews = privacy_filter(
request.user, models.Review.objects.filter(book__parent_work__editions=book)
)
# the reviews to show
if user_statuses:
if user_statuses == "review":
queryset = book.review_set.select_subclasses()
elif user_statuses == "comment":
queryset = book.comment_set
else:
queryset = book.quotation_set
queryset = queryset.filter(user=request.user, deleted=False)
else:
queryset = reviews.exclude(Q(content__isnull=True) | Q(content=""))
queryset = queryset.select_related("user").order_by("-published_date")
paginated = Paginator(queryset, PAGE_LENGTH)
lists = privacy_filter(
request.user,
models.List.objects.filter(
listitem__approved=True,
listitem__book__in=book.parent_work.editions.all(),
),
)
data = {
"book": book,
"statuses": paginated.get_page(request.GET.get("page")),
"review_count": reviews.count(),
"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"],
"lists": lists,
}
if request.user.is_authenticated:
readthroughs = models.ReadThrough.objects.filter(
user=request.user,
book=book,
).order_by("start_date")
for readthrough in readthroughs:
readthrough.progress_updates = (
readthrough.progressupdate_set.all().order_by("-updated_date")
)
data["readthroughs"] = readthroughs
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")
filters = {"user": request.user, "deleted": False}
data["user_statuses"] = {
"review_count": book.review_set.filter(**filters).count(),
"comment_count": book.comment_set.filter(**filters).count(),
"quotation_count": book.quotation_set.filter(**filters).count(),
}
return TemplateResponse(request, "book/book.html", data)
@login_required
@require_POST
def upload_cover(request, book_id):
"""upload a new cover"""
book = get_object_or_404(models.Edition, id=book_id)
book.last_edited_by = request.user
url = request.POST.get("cover-url")
if url:
image = set_cover_from_url(url)
if image:
book.cover.save(*image)
return redirect(f"{book.local_path}?cover_error=True")
form = forms.CoverForm(request.POST, request.FILES, instance=book)
if not form.is_valid() or not form.files.get("cover"):
return redirect(book.local_path)
book.cover = form.files["cover"]
book.save()
return redirect(book.local_path)
def set_cover_from_url(url):
"""load it from a url"""
try:
image_file = get_image(url)
except: # pylint: disable=bare-except
return None
if not image_file:
return None
image_name = str(uuid4()) + "." + url.split(".")[-1]
image_content = ContentFile(image_file.content)
return [image_name, image_content]
@login_required
@require_POST
@permission_required("bookwyrm.edit_book", raise_exception=True)
def add_description(request, book_id):
"""upload a new cover"""
book = get_object_or_404(models.Edition, id=book_id)
description = request.POST.get("description")
book.description = description
book.last_edited_by = request.user
book.save(update_fields=["description", "last_edited_by"])
return redirect("book", book.id)
@require_POST
def resolve_book(request):
"""figure out the local path to a book from a remote_id"""
remote_id = request.POST.get("remote_id")
connector = connector_manager.get_or_create_connector(remote_id)
book = connector.get_or_create_book(remote_id)
return redirect("book", book.id)

View file

@ -1,122 +1,22 @@
""" the good stuff! the books! """
from uuid import uuid4
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
from django.core.files.base import ContentFile
from django.core.paginator import Paginator
from django.db import transaction
from django.db.models import Avg, Q
from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.http import HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.utils.datastructures import MultiValueDictKeyError
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.connectors.abstract_connector import get_image
from bookwyrm.settings import PAGE_LENGTH
from .helpers import is_api_request, get_edition, privacy_filter
from bookwyrm.views.helpers import get_edition
from .books import set_cover_from_url
# pylint: disable=no-self-use
class Book(View):
"""a book! this is the stuff"""
def get(self, request, book_id, user_statuses=False):
"""info about a book"""
user_statuses = user_statuses if request.user.is_authenticated else False
try:
book = models.Book.objects.select_subclasses().get(id=book_id)
except models.Book.DoesNotExist:
return HttpResponseNotFound()
if is_api_request(request):
return ActivitypubResponse(book.to_activity())
if isinstance(book, models.Work):
book = book.default_edition
if not book or not book.parent_work:
return HttpResponseNotFound()
work = book.parent_work
# all reviews for the book
reviews = privacy_filter(
request.user, models.Review.objects.filter(book__in=work.editions.all())
)
# the reviews to show
if user_statuses:
if user_statuses == "review":
queryset = book.review_set.select_subclasses()
elif user_statuses == "comment":
queryset = book.comment_set
else:
queryset = book.quotation_set
queryset = queryset.filter(user=request.user, deleted=False)
else:
queryset = reviews.exclude(Q(content__isnull=True) | Q(content=""))
queryset = queryset.select_related("user").order_by("-published_date")
paginated = Paginator(queryset, PAGE_LENGTH)
lists = privacy_filter(
request.user,
models.List.objects.filter(
listitem__approved=True,
listitem__book__in=book.parent_work.editions.all(),
),
)
data = {
"book": book,
"statuses": paginated.get_page(request.GET.get("page")),
"review_count": reviews.count(),
"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"],
"lists": lists,
}
if request.user.is_authenticated:
readthroughs = models.ReadThrough.objects.filter(
user=request.user,
book=book,
).order_by("start_date")
for readthrough in readthroughs:
readthrough.progress_updates = (
readthrough.progressupdate_set.all().order_by("-updated_date")
)
data["readthroughs"] = readthroughs
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")
filters = {"user": request.user, "deleted": False}
data["user_statuses"] = {
"review_count": book.review_set.filter(**filters).count(),
"comment_count": book.comment_set.filter(**filters).count(),
"quotation_count": book.quotation_set.filter(**filters).count(),
}
return TemplateResponse(request, "book/book.html", data)
@method_decorator(login_required, name="dispatch")
@method_decorator(
permission_required("bookwyrm.edit_book", raise_exception=True), name="dispatch"
@ -132,7 +32,7 @@ class EditBook(View):
if not book.description:
book.description = book.parent_work.description
data = {"book": book, "form": forms.EditionForm(instance=book)}
return TemplateResponse(request, "book/edit_book.html", data)
return TemplateResponse(request, "book/edit/edit_book.html", data)
def post(self, request, book_id=None):
"""edit a book cool"""
@ -142,7 +42,7 @@ class EditBook(View):
data = {"book": book, "form": form}
if not form.is_valid():
return TemplateResponse(request, "book/edit_book.html", data)
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
@ -185,6 +85,8 @@ class EditBook(View):
data["confirm_mode"] = True
# this isn't preserved because it isn't part of the form obj
data["remove_authors"] = request.POST.getlist("remove_authors")
data["cover_url"] = request.POST.get("cover-url")
# make sure the dates are passed in as datetime, they're currently a string
# QueryDicts are immutable, we need to copy
formcopy = data["form"].data.copy()
@ -199,7 +101,7 @@ class EditBook(View):
except (MultiValueDictKeyError, ValueError):
pass
data["form"].data = formcopy
return TemplateResponse(request, "book/edit_book.html", data)
return TemplateResponse(request, "book/edit/edit_book.html", data)
remove_authors = request.POST.getlist("remove_authors")
for author_id in remove_authors:
@ -230,7 +132,7 @@ class ConfirmEditBook(View):
data = {"book": book, "form": form}
if not form.is_valid():
return TemplateResponse(request, "book/edit_book.html", data)
return TemplateResponse(request, "book/edit/edit_book.html", data)
with transaction.atomic():
# save book
@ -261,74 +163,18 @@ class ConfirmEditBook(View):
work = models.Work.objects.create(title=form.cleaned_data["title"])
work.authors.set(book.authors.all())
book.parent_work = work
# we don't tell the world when creating a book
book.save(broadcast=False)
for author_id in request.POST.getlist("remove_authors"):
book.authors.remove(author_id)
# import cover, if requested
url = request.POST.get("cover-url")
if url:
image = set_cover_from_url(url)
if image:
book.cover.save(*image, save=False)
# we don't tell the world when creating a book
book.save(broadcast=False)
return redirect(f"/book/{book.id}")
@login_required
@require_POST
def upload_cover(request, book_id):
"""upload a new cover"""
book = get_object_or_404(models.Edition, id=book_id)
book.last_edited_by = request.user
url = request.POST.get("cover-url")
if url:
image = set_cover_from_url(url)
if image:
book.cover.save(*image)
return redirect(f"{book.local_path}?cover_error=True")
form = forms.CoverForm(request.POST, request.FILES, instance=book)
if not form.is_valid() or not form.files.get("cover"):
return redirect(book.local_path)
book.cover = form.files["cover"]
book.save()
return redirect(book.local_path)
def set_cover_from_url(url):
"""load it from a url"""
try:
image_file = get_image(url)
except: # pylint: disable=bare-except
return None
if not image_file:
return None
image_name = str(uuid4()) + "." + url.split(".")[-1]
image_content = ContentFile(image_file.content)
return [image_name, image_content]
@login_required
@require_POST
@permission_required("bookwyrm.edit_book", raise_exception=True)
def add_description(request, book_id):
"""upload a new cover"""
book = get_object_or_404(models.Edition, id=book_id)
description = request.POST.get("description")
book.description = description
book.last_edited_by = request.user
book.save(update_fields=["description", "last_edited_by"])
return redirect("book", book.id)
@require_POST
def resolve_book(request):
"""figure out the local path to a book from a remote_id"""
remote_id = request.POST.get("remote_id")
connector = connector_manager.get_or_create_connector(remote_id)
book = connector.get_or_create_book(remote_id)
return redirect("book", book.id)

View file

@ -14,7 +14,7 @@ from django.views.decorators.http import require_POST
from bookwyrm import models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import PAGE_LENGTH
from .helpers import is_api_request
from bookwyrm.views.helpers import is_api_request
# pylint: disable=no-self-use
@ -66,7 +66,7 @@ class Editions(View):
e.physical_format.lower() for e in editions if e.physical_format
),
}
return TemplateResponse(request, "book/editions.html", data)
return TemplateResponse(request, "book/editions/editions.html", data)
@login_required

View file

@ -25,10 +25,10 @@ class Directory(View):
users = suggested_users.get_annotated_users(request.user, **filters)
sort = request.GET.get("sort")
if sort == "recent":
users = users.order_by("-last_active_date")
else:
if sort == "suggested":
users = users.order_by("-mutuals", "-last_active_date")
else:
users = users.order_by("-last_active_date")
paginated = Paginator(users, 12)

View file

@ -3,6 +3,7 @@ from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Q
from django.http import HttpResponseNotFound, Http404
from django.shortcuts import get_object_or_404
from django.template.response import TemplateResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
@ -93,17 +94,15 @@ class Status(View):
def get(self, request, username, status_id):
"""display a particular status (and replies, etc)"""
try:
user = get_user_from_username(request.user, username)
status = models.Status.objects.select_subclasses().get(
user=user, id=status_id, deleted=False
)
except (ValueError, models.Status.DoesNotExist):
return HttpResponseNotFound()
user = get_user_from_username(request.user, username)
status = get_object_or_404(
models.Status.objects.select_subclasses(),
user=user,
id=status_id,
deleted=False,
)
# make sure the user is authorized to see the status
if not status.visible_to_user(request.user):
return HttpResponseNotFound()
status.raise_visible_to_user(request.user)
if is_api_request(request):
return ActivitypubResponse(
@ -133,6 +132,7 @@ class Replies(View):
status = models.Status.objects.get(id=status_id)
if status.user.localname != username:
return HttpResponseNotFound()
status.raise_visible_to_user(request.user)
return ActivitypubResponse(status.to_replies(**request.GET))
@ -168,9 +168,11 @@ def get_suggested_books(user, max_books=5):
shelf_preview = {
"name": shelf.name,
"identifier": shelf.identifier,
"books": shelf.books.order_by("shelfbook").prefetch_related("authors")[
:limit
],
"books": models.Edition.viewer_aware_objects(user)
.filter(
shelfbook__shelf=shelf,
)
.prefetch_related("authors")[:limit],
}
suggested_books.append(shelf_preview)
book_count += len(shelf_preview["books"])

View file

@ -1,8 +1,7 @@
""" views for actions you can take in the application """
from django.contrib.auth.decorators import login_required
from django.db import IntegrityError
from django.http import HttpResponseBadRequest
from django.shortcuts import redirect
from django.shortcuts import get_object_or_404, redirect
from django.views.decorators.http import require_POST
from bookwyrm import models
@ -78,12 +77,10 @@ def delete_follow_request(request):
username = request.POST["user"]
requester = get_user_from_username(request.user, username)
try:
follow_request = models.UserFollowRequest.objects.get(
user_subject=requester, user_object=request.user
)
except models.UserFollowRequest.DoesNotExist:
return HttpResponseBadRequest()
follow_request = get_object_or_404(
models.UserFollowRequest, user_subject=requester, user_object=request.user
)
follow_request.raise_not_deletable(request.user)
follow_request.delete()
return redirect(f"/user/{request.user.localname}")

View file

@ -5,16 +5,14 @@ from django.contrib.auth.decorators import login_required
from django.contrib.postgres.search import TrigramSimilarity
from django.db.models.functions import Greatest
from django.db.models import Count, Q
from django.http import 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 bookwyrm import forms, models
from bookwyrm.connectors import connector_manager
from bookwyrm import book_search, forms, models
from bookwyrm.suggested_users import suggested_users
from .edit_user import save_user_form
from .preferences.edit_user import save_user_form
# pylint: disable= no-self-use
@ -55,7 +53,7 @@ class GetStartedBooks(View):
query = request.GET.get("query")
book_results = popular_books = []
if query:
book_results = connector_manager.local_search(query, raw=True)[:5]
book_results = book_search.search(query)[:5]
if len(book_results) < 5:
popular_books = (
models.Edition.objects.exclude(
@ -91,9 +89,8 @@ class GetStartedBooks(View):
for (book_id, shelf_id) in shelve_actions:
book = get_object_or_404(models.Edition, id=book_id)
shelf = get_object_or_404(models.Shelf, id=shelf_id)
if shelf.user != request.user:
# hmmmmm
return HttpResponseNotFound()
shelf.raise_not_editable(request.user)
models.ShelfBook.objects.create(book=book, shelf=shelf, user=request.user)
return redirect(self.next_view)

View file

@ -31,8 +31,8 @@ class Goal(View):
if not goal and year != timezone.now().year:
return redirect("user-goal", username, current_year)
if goal and not goal.visible_to_user(request.user):
return HttpResponseNotFound()
if goal:
goal.raise_visible_to_user(request.user)
data = {
"goal_form": forms.GoalForm(instance=goal),
@ -41,16 +41,16 @@ class Goal(View):
"year": year,
"is_self": request.user == user,
}
return TemplateResponse(request, "goal.html", data)
return TemplateResponse(request, "user/goal.html", data)
def post(self, request, username, year):
"""update or create an annual goal"""
user = get_user_from_username(request.user, username)
if user != request.user:
return HttpResponseNotFound()
year = int(year)
goal = models.AnnualGoal.objects.filter(year=year, user=request.user).first()
user = get_user_from_username(request.user, username)
goal = models.AnnualGoal.objects.filter(year=year, user=user).first()
if goal:
goal.raise_not_editable(request.user)
form = forms.GoalForm(request.POST, instance=goal)
if not form.is_valid():
data = {
@ -58,15 +58,15 @@ class Goal(View):
"goal": goal,
"year": year,
}
return TemplateResponse(request, "goal.html", data)
return TemplateResponse(request, "user/goal.html", data)
goal = form.save()
if request.POST.get("post-status"):
# create status, if appropraite
# create status, if appropriate
template = get_template("snippets/generated_status/goal.html")
create_generated_note(
request.user,
template.render({"goal": goal, "user": request.user}).strip(),
template.render({"goal": goal, "user": user}).strip(),
privacy=goal.privacy,
)
@ -78,5 +78,5 @@ class Goal(View):
def hide_goal(request):
"""don't keep bugging people to set a goal"""
request.user.show_goal = False
request.user.save(broadcast=False)
request.user.save(broadcast=False, update_fields=["show_goal"])
return redirect(request.headers.get("Referer", "/"))

View file

@ -1,5 +1,10 @@
""" helper functions used in various views """
import re
from datetime import datetime
import dateutil.parser
import dateutil.tz
from dateutil.parser import ParserError
from requests import HTTPError
from django.core.exceptions import FieldError
from django.db.models import Q
@ -32,7 +37,9 @@ def get_user_from_username(viewer, username):
def is_api_request(request):
"""check whether a request is asking for html or data"""
return "json" in request.headers.get("Accept", "") or request.path[-5:] == ".json"
return "json" in request.headers.get("Accept", "") or re.match(
r".*\.json/?$", request.path
)
def is_bookwyrm_request(request):
@ -178,3 +185,15 @@ def get_landing_books():
.order_by("-review__published_date")[:6]
)
)
def load_date_in_user_tz_as_utc(date_str: str, user: models.User) -> datetime:
"""ensures that data is stored consistently in the UTC timezone"""
if not date_str:
return None
user_tz = dateutil.tz.gettz(user.preferred_timezone)
date = dateutil.parser.parse(date_str, ignoretz=True)
try:
return date.replace(tzinfo=user_tz).astimezone(dateutil.tz.UTC)
except ParserError:
return None

View file

@ -80,7 +80,7 @@ class ImportStatus(View):
"""status of an import job"""
job = get_object_or_404(models.ImportJob, id=job_id)
if job.user != request.user:
raise PermissionDenied
raise PermissionDenied()
try:
task = app.AsyncResult(job.task_id)

View file

@ -3,8 +3,9 @@ import json
import re
from urllib.parse import urldefrag
from django.http import HttpResponse, HttpResponseNotFound
from django.http import HttpResponseBadRequest, HttpResponseForbidden
from django.http import HttpResponse, Http404
from django.core.exceptions import BadRequest, PermissionDenied
from django.shortcuts import get_object_or_404
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
@ -21,36 +22,30 @@ 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
if is_blocked_user_agent(request):
return HttpResponseForbidden()
raise_is_blocked_user_agent(request)
# make sure the user's inbox even exists
if username:
try:
models.User.objects.get(localname=username)
except models.User.DoesNotExist:
return HttpResponseNotFound()
get_object_or_404(models.User, localname=username, is_active=True)
# is it valid json? does it at least vaguely resemble an activity?
try:
activity_json = json.loads(request.body)
except json.decoder.JSONDecodeError:
return HttpResponseBadRequest()
raise BadRequest()
# let's be extra sure we didn't block this domain
if is_blocked_activity(activity_json):
return HttpResponseForbidden()
raise_is_blocked_activity(activity_json)
if (
not "object" in activity_json
or not "type" in activity_json
or not activity_json["type"] in activitypub.activity_objects
):
return HttpResponseNotFound()
raise Http404()
# verify the signature
if not has_valid_signature(request, activity_json):
@ -65,32 +60,35 @@ class Inbox(View):
return HttpResponse()
def is_blocked_user_agent(request):
def raise_is_blocked_user_agent(request):
"""check if a request is from a blocked server based on user agent"""
# check user agent
user_agent = request.headers.get("User-Agent")
if not user_agent:
return False
return
url = re.search(rf"https?://{regex.DOMAIN}/?", user_agent)
if not url:
return False
return
url = url.group()
return models.FederatedServer.is_blocked(url)
if models.FederatedServer.is_blocked(url):
raise PermissionDenied()
def is_blocked_activity(activity_json):
def raise_is_blocked_activity(activity_json):
"""get the sender out of activity json and check if it's blocked"""
actor = activity_json.get("actor")
# check if the user is banned/deleted
existing = models.User.find_existing_by_remote_id(actor)
if existing and existing.deleted:
return True
raise PermissionDenied()
if not actor:
# well I guess it's not even a valid activity so who knows
return False
return models.FederatedServer.is_blocked(actor)
return
if models.FederatedServer.is_blocked(actor):
raise PermissionDenied()
@app.task(queue="medium_priority")

View file

@ -4,7 +4,7 @@ from django.http import JsonResponse
from django.template.response import TemplateResponse
from django.views import View
from bookwyrm.connectors import connector_manager
from bookwyrm import book_search
from bookwyrm.settings import PAGE_LENGTH
from .helpers import is_api_request
@ -14,10 +14,12 @@ class Isbn(View):
def get(self, request, isbn):
"""info about a book"""
book_results = connector_manager.isbn_local_search(isbn)
book_results = book_search.isbn_search(isbn)
if is_api_request(request):
return JsonResponse([r.json() for r in book_results], safe=False)
return JsonResponse(
[book_search.format_search_result(r) for r in book_results], safe=False
)
paginated = Paginator(book_results, PAGE_LENGTH).get_page(
request.GET.get("page")

View file

@ -3,12 +3,11 @@ from typing import Optional
from urllib.parse import urlencode
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.core.paginator import Paginator
from django.db import IntegrityError, transaction
from django.db.models import Avg, Count, DecimalField, Q, Max
from django.db.models.functions import Coalesce
from django.http import HttpResponseNotFound, HttpResponseBadRequest, HttpResponse
from django.http import HttpResponseBadRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.urls import reverse
@ -16,9 +15,8 @@ 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 book_search, forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.connectors import connector_manager
from bookwyrm.settings import PAGE_LENGTH
from .helpers import is_api_request, privacy_filter
from .helpers import get_user_from_username
@ -114,8 +112,7 @@ class List(View):
def get(self, request, list_id):
"""display a book list"""
book_list = get_object_or_404(models.List, id=list_id)
if not book_list.visible_to_user(request.user):
return HttpResponseNotFound()
book_list.raise_visible_to_user(request.user)
if is_api_request(request):
return ActivitypubResponse(book_list.to_activity(**request.GET))
@ -155,9 +152,8 @@ class List(View):
if query and request.user.is_authenticated:
# search for books
suggestions = connector_manager.local_search(
suggestions = book_search.search(
query,
raw=True,
filters=[~Q(parent_work__editions__in=book_list.books.all())],
)
elif request.user.is_authenticated:
@ -196,6 +192,8 @@ class List(View):
def post(self, request, list_id):
"""edit a list"""
book_list = get_object_or_404(models.List, id=list_id)
book_list.raise_not_editable(request.user)
form = forms.ListForm(request.POST, instance=book_list)
if not form.is_valid():
return redirect("list", book_list.id)
@ -213,9 +211,7 @@ class Curate(View):
def get(self, request, list_id):
"""display a pending list"""
book_list = get_object_or_404(models.List, id=list_id)
if not book_list.user == request.user:
# only the creater can curate the list
return HttpResponseNotFound()
book_list.raise_not_editable(request.user)
data = {
"list": book_list,
@ -229,6 +225,8 @@ class Curate(View):
def post(self, request, list_id):
"""edit a book_list"""
book_list = get_object_or_404(models.List, id=list_id)
book_list.raise_not_editable(request.user)
suggestion = get_object_or_404(models.ListItem, id=request.POST.get("item"))
approved = request.POST.get("approved") == "true"
if approved:
@ -276,8 +274,7 @@ def delete_list(request, list_id):
book_list = get_object_or_404(models.List, id=list_id)
# only the owner or a moderator can delete a list
if book_list.user != request.user and not request.user.has_perm("moderate_post"):
raise PermissionDenied
book_list.raise_not_deletable(request.user)
book_list.delete()
return redirect("lists")
@ -291,8 +288,8 @@ def add_book(request):
is_group_member = False
if book_list.curation == "group":
is_group_member = models.GroupMember.objects.filter(group=book_list.group, user=request.user).exists()
if not book_list.visible_to_user(request.user):
return HttpResponseNotFound()
book_list.raise_visible_to_user(request.user)
book = get_object_or_404(models.Edition, id=request.POST.get("book"))
# do you have permission to add to the list?
@ -340,16 +337,22 @@ def add_book(request):
@login_required
def remove_book(request, list_id):
"""remove a book from a list"""
with transaction.atomic():
book_list = get_object_or_404(models.List, id=list_id)
item = get_object_or_404(models.ListItem, id=request.POST.get("item"))
is_group_member = models.GroupMember.objects.filter(group=book_list.group, user=request.user).exists()
if not book_list.user == request.user and not item.user == request.user and not is_group_member:
return HttpResponseNotFound()
book_list = get_object_or_404(models.List, id=list_id)
item = get_object_or_404(models.ListItem, id=request.POST.get("item"))
# TODO: put this logiv into raise_not_deletable
# is_group_member = models.GroupMember.objects.filter(group=book_list.group, user=request.user).exists()
# if not book_list.user == request.user and not item.user == request.user and not is_group_member:
# return HttpResponseNotFound()
item.raise_not_deletable(request.user)
with transaction.atomic():
deleted_order = item.order
item.delete()
normalize_book_list_ordering(book_list.id, start=deleted_order)
normalize_book_list_ordering(book_list.id, start=deleted_order)
return redirect("list", list_id)
@ -360,34 +363,32 @@ def set_book_position(request, list_item_id):
Action for when the list user manually specifies a list position, takes
special care with the unique ordering per list.
"""
list_item = get_object_or_404(models.ListItem, id=list_item_id)
list_item.book_list.raise_not_editable(request.user)
try:
int_position = int(request.POST.get("position"))
except ValueError:
return HttpResponseBadRequest("bad value for position. should be an integer")
if int_position < 1:
return HttpResponseBadRequest("position cannot be less than 1")
book_list = list_item.book_list
# the max position to which a book may be set is the highest order for
# books which are approved
order_max = book_list.listitem_set.filter(approved=True).aggregate(Max("order"))[
"order__max"
]
int_position = min(int_position, order_max)
original_order = list_item.order
if original_order == int_position:
# no change
return HttpResponse(status=204)
with transaction.atomic():
list_item = get_object_or_404(models.ListItem, id=list_item_id)
try:
int_position = int(request.POST.get("position"))
except ValueError:
return HttpResponseBadRequest(
"bad value for position. should be an integer"
)
if int_position < 1:
return HttpResponseBadRequest("position cannot be less than 1")
book_list = list_item.book_list
# the max position to which a book may be set is the highest order for
# books which are approved
order_max = book_list.listitem_set.filter(approved=True).aggregate(
Max("order")
)["order__max"]
int_position = min(int_position, order_max)
if request.user not in (book_list.user, list_item.user):
return HttpResponseNotFound()
original_order = list_item.order
if original_order == int_position:
return HttpResponse(status=204)
if original_order > int_position:
list_item.order = -1
list_item.save()

View file

@ -13,7 +13,20 @@ class Notifications(View):
def get(self, request, notification_type=None):
"""people are interacting with you, get hyped"""
notifications = request.user.notification_set.all().order_by("-created_date")
notifications = (
request.user.notification_set.all()
.order_by("-created_date")
.select_related(
"related_status",
"related_status__reply_parent",
"related_import",
"related_report",
"related_user",
"related_book",
"related_list_item",
"related_list_item__book",
)
)
if notification_type == "mentions":
notifications = notifications.filter(
notification_type__in=["REPLY", "MENTION", "TAG"]
@ -24,9 +37,9 @@ class Notifications(View):
"unread": unread,
}
notifications.update(read=True)
return TemplateResponse(request, "notifications.html", data)
return TemplateResponse(request, "notifications/notifications_page.html", data)
def post(self, request):
"""permanently delete notification for user"""
request.user.notification_set.filter(read=True).delete()
return redirect("/notifications")
return redirect("notifications")

View file

@ -1,10 +1,8 @@
""" class views for password management """
from django.contrib.auth import login
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
from django.views import View
@ -27,7 +25,9 @@ class PasswordResetRequest(View):
"""create a password reset token"""
email = request.POST.get("email")
try:
user = models.User.objects.get(email=email, email__isnull=False)
user = models.User.viewer_aware_objects(request.user).get(
email=email, email__isnull=False
)
except models.User.DoesNotExist:
data = {"error": _("No user with that email address was found.")}
return TemplateResponse(request, "password_reset_request.html", data)
@ -52,9 +52,9 @@ class PasswordReset(View):
try:
reset_code = models.PasswordReset.objects.get(code=code)
if not reset_code.valid():
raise PermissionDenied
raise PermissionDenied()
except models.PasswordReset.DoesNotExist:
raise PermissionDenied
raise PermissionDenied()
return TemplateResponse(request, "password_reset.html", {"code": code})
@ -80,26 +80,3 @@ class PasswordReset(View):
login(request, user)
reset_code.delete()
return redirect("/")
@method_decorator(login_required, name="dispatch")
class ChangePassword(View):
"""change password as logged in user"""
def get(self, request):
"""change password page"""
data = {"user": request.user}
return TemplateResponse(request, "preferences/change_password.html", data)
def post(self, request):
"""allow a user to change their password"""
new_password = request.POST.get("password")
confirm_password = request.POST.get("confirm-password")
if new_password != confirm_password:
return redirect("preferences/password")
request.user.set_password(new_password)
request.user.save(broadcast=False, update_fields=["password"])
login(request, request.user)
return redirect(request.user.local_path)

View file

View file

@ -1,6 +1,5 @@
""" views for actions you can take in the application """
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseNotFound
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
@ -24,7 +23,7 @@ class Block(View):
models.UserBlocks.objects.create(
user_subject=request.user, user_object=to_block
)
return redirect("/preferences/block")
return redirect("prefs-block")
@require_POST
@ -32,12 +31,10 @@ class Block(View):
def unblock(request, user_id):
"""undo a block"""
to_unblock = get_object_or_404(models.User, id=user_id)
try:
block = models.UserBlocks.objects.get(
user_subject=request.user,
user_object=to_unblock,
)
except models.UserBlocks.DoesNotExist:
return HttpResponseNotFound()
block = get_object_or_404(
models.UserBlocks,
user_subject=request.user,
user_object=to_unblock,
)
block.delete()
return redirect("/preferences/block")
return redirect("prefs-block")

View file

@ -0,0 +1,31 @@
""" class views for password management """
from django.contrib.auth import login
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views import View
# pylint: disable= no-self-use
@method_decorator(login_required, name="dispatch")
class ChangePassword(View):
"""change password as logged in user"""
def get(self, request):
"""change password page"""
data = {"user": request.user}
return TemplateResponse(request, "preferences/change_password.html", data)
def post(self, request):
"""allow a user to change their password"""
new_password = request.POST.get("password")
confirm_password = request.POST.get("confirm-password")
if new_password != confirm_password:
return redirect("prefs-password")
request.user.set_password(new_password)
request.user.save(broadcast=False, update_fields=["password"])
login(request, request.user)
return redirect("user-feed", request.user.localname)

View file

@ -0,0 +1,38 @@
""" edit your own account """
from django.contrib.auth import logout
from django.contrib.auth.decorators import login_required
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 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)
# 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)

View file

@ -1,9 +1,8 @@
""" edit or delete ones own account"""
""" edit your 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
@ -11,7 +10,7 @@ from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views import View
from bookwyrm import forms, models
from bookwyrm import forms
# pylint: disable=no-self-use
@ -34,38 +33,9 @@ class EditUser(View):
data = {"form": form, "user": request.user}
return TemplateResponse(request, "preferences/edit_user.html", data)
user = save_user_form(form)
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)
# 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)
return redirect("user-feed", request.user.localname)
def save_user_form(form):

View file

@ -1,10 +1,6 @@
""" the good stuff! the books! """
from datetime import datetime
import dateutil.parser
import dateutil.tz
from dateutil.parser import ParserError
from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotFound
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
@ -12,8 +8,10 @@ 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 models
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
@method_decorator(login_required, name="dispatch")
@ -35,7 +33,7 @@ class ReadingStatus(View):
return TemplateResponse(request, f"reading_progress/{template}", {"book": book})
def post(self, request, status, book_id):
"""desire a book"""
"""Change the state of a book by shelving it and adding reading dates"""
identifier = {
"want": models.Shelf.TO_READ,
"start": models.Shelf.READING,
@ -44,22 +42,25 @@ class ReadingStatus(View):
if not identifier:
return HttpResponseBadRequest()
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()
desired_shelf = get_object_or_404(
models.Shelf, identifier=identifier, user=request.user
)
book = (
models.Edition.viewer_aware_objects(request.user)
.prefetch_related("shelfbook_set__shelf")
.get(id=book_id)
)
# gets the first shelf that indicates a reading status, or None
shelves = [
s
for s in book.current_shelves
if s.shelf.identifier in models.Shelf.READ_STATUS_IDENTIFIERS
]
current_status_shelfbook = shelves[0] if shelves else None
# checking the referer prevents redirecting back to the modal page
referer = request.headers.get("Referer", "/")
referer = "/" if "reading-status" in referer else referer
if current_status_shelfbook is not None:
@ -72,49 +73,55 @@ class ReadingStatus(View):
book=book, shelf=desired_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()
update_readthrough_on_shelve(
request.user,
book,
desired_shelf.identifier,
start_date=request.POST.get("start_date"),
finish_date=request.POST.get("finish_date"),
)
# post about it (if you want)
if request.POST.get("post-status"):
# is it a comment?
if request.POST.get("content"):
form = forms.CommentForm(request.POST)
if form.is_valid():
form.save()
else:
# uh oh
raise Exception(form.errors)
else:
privacy = request.POST.get("privacy")
handle_reading_status(request.user, desired_shelf, book, privacy)
return CreateStatus.as_view()(request, "comment")
privacy = request.POST.get("privacy")
handle_reading_status(request.user, desired_shelf, book, privacy)
if is_api_request(request):
return HttpResponse()
return redirect(referer)
@login_required
@require_POST
def edit_readthrough(request):
"""can't use the form because the dates are too finnicky"""
readthrough = update_readthrough(request, create=False)
if not readthrough:
return HttpResponseNotFound()
@transaction.atomic
def update_readthrough_on_shelve(
user, annotated_book, status, start_date=None, finish_date=None
):
"""update the current readthrough for a book when it is re-shelved"""
# there *should* only be one of current active readthrough, but it's a list
active_readthrough = next(iter(annotated_book.active_readthroughs), None)
# don't let people edit other people's data
if request.user != readthrough.user:
return HttpResponseBadRequest()
readthrough.save()
# deactivate all existing active readthroughs
for readthrough in annotated_book.active_readthroughs:
readthrough.is_active = False
readthrough.save()
# record the progress update individually
# use default now for date field
readthrough.create_update()
# if the state is want-to-read, deactivating existing readthroughs is all we need
if status == models.Shelf.TO_READ:
return
return redirect(request.headers.get("Referer", "/"))
# if we're starting a book, we need a fresh clean active readthrough
if status == models.Shelf.READING or not active_readthrough:
active_readthrough = models.ReadThrough.objects.create(
user=user, book=annotated_book
)
# santiize and set dates
active_readthrough.start_date = load_date_in_user_tz_as_utc(start_date, user)
# if the finish date is set, the readthrough will be automatically set as inactive
active_readthrough.finish_date = load_date_in_user_tz_as_utc(finish_date, user)
active_readthrough.save()
@login_required
@ -122,10 +129,7 @@ def edit_readthrough(request):
def delete_readthrough(request):
"""remove a readthrough"""
readthrough = get_object_or_404(models.ReadThrough, id=request.POST.get("id"))
# don't let people edit other people's data
if request.user != readthrough.user:
return HttpResponseBadRequest()
readthrough.raise_not_deletable(request.user)
readthrough.delete()
return redirect(request.headers.get("Referer", "/"))
@ -136,73 +140,20 @@ def delete_readthrough(request):
def create_readthrough(request):
"""can't use the form because the dates are too finnicky"""
book = get_object_or_404(models.Edition, id=request.POST.get("book"))
readthrough = update_readthrough(request, create=True, book=book)
if not readthrough:
return redirect(book.local_path)
readthrough.save()
return redirect(request.headers.get("Referer", "/"))
def load_date_in_user_tz_as_utc(date_str: str, user: models.User) -> datetime:
"""ensures that data is stored consistently in the UTC timezone"""
user_tz = dateutil.tz.gettz(user.preferred_timezone)
start_date = dateutil.parser.parse(date_str, ignoretz=True)
return start_date.replace(tzinfo=user_tz).astimezone(dateutil.tz.UTC)
def update_readthrough(request, book=None, create=True):
"""updates but does not save dates on a readthrough"""
try:
read_id = request.POST.get("id")
if not read_id:
raise models.ReadThrough.DoesNotExist
readthrough = models.ReadThrough.objects.get(id=read_id)
except models.ReadThrough.DoesNotExist:
if not create or not book:
return None
readthrough = models.ReadThrough(
user=request.user,
book=book,
)
start_date = request.POST.get("start_date")
if start_date:
try:
readthrough.start_date = load_date_in_user_tz_as_utc(
start_date, request.user
)
except ParserError:
pass
finish_date = request.POST.get("finish_date")
if finish_date:
try:
readthrough.finish_date = load_date_in_user_tz_as_utc(
finish_date, request.user
)
except ParserError:
pass
progress = request.POST.get("progress")
if progress:
try:
progress = int(progress)
readthrough.progress = progress
except ValueError:
pass
progress_mode = request.POST.get("progress_mode")
if progress_mode:
try:
progress_mode = models.ProgressMode(progress_mode)
readthrough.progress_mode = progress_mode
except ValueError:
pass
if not readthrough.start_date and not readthrough.finish_date:
return None
return readthrough
start_date = load_date_in_user_tz_as_utc(
request.POST.get("start_date"), request.user
)
finish_date = load_date_in_user_tz_as_utc(
request.POST.get("finish_date"), request.user
)
models.ReadThrough.objects.create(
user=request.user,
book=book,
start_date=start_date,
finish_date=finish_date,
)
return redirect("book", book.id)
@login_required
@ -210,10 +161,7 @@ def update_readthrough(request, book=None, create=True):
def delete_progressupdate(request):
"""remove a progress update"""
update = get_object_or_404(models.ProgressUpdate, id=request.POST.get("id"))
# don't let people edit other people's data
if request.user != update.user:
return HttpResponseBadRequest()
update.raise_not_deletable(request.user)
update.delete()
return redirect(request.headers.get("Referer", "/"))

View file

@ -29,11 +29,11 @@ class Register(View):
invite_code = request.POST.get("invite_code")
if not invite_code:
raise PermissionDenied
raise PermissionDenied()
invite = get_object_or_404(models.SiteInvite, code=invite_code)
if not invite.valid():
raise PermissionDenied
raise PermissionDenied()
else:
invite = None

View file

@ -10,6 +10,7 @@ from django.views import View
from bookwyrm import models
from bookwyrm.connectors import connector_manager
from bookwyrm.book_search import search, format_search_result
from bookwyrm.settings import PAGE_LENGTH
from bookwyrm.utils import regex
from .helpers import is_api_request, privacy_filter
@ -31,10 +32,10 @@ class Search(View):
if is_api_request(request):
# only return local book results via json so we don't cascade
book_results = connector_manager.local_search(
query, min_confidence=min_confidence
book_results = search(query, min_confidence=min_confidence)
return JsonResponse(
[format_search_result(r) for r in book_results], safe=False
)
return JsonResponse([r.json() for r in book_results], safe=False)
if query and not search_type:
search_type = "user" if "@" in query else "book"
@ -69,13 +70,13 @@ class Search(View):
def book_search(query, _, min_confidence, search_remote=False):
"""the real business is elsewhere"""
# try a local-only search
if not search_remote:
results = connector_manager.local_search(query, min_confidence=min_confidence)
if results:
# gret, we found something
return [{"results": results}], False
# if there weere no local results, or the request was for remote, search all sources
return connector_manager.search(query, min_confidence=min_confidence), True
results = [{"results": search(query, min_confidence=min_confidence)}]
if results and not search_remote:
return results, False
# if there were no local results, or the request was for remote, search all sources
results += connector_manager.search(query, min_confidence=min_confidence)
return results, True
def user_search(query, viewer, *_):

View file

@ -1,11 +1,11 @@
""" shelf views"""
""" shelf views """
from collections import namedtuple
from django.db import IntegrityError
from django.db import IntegrityError, transaction
from django.db.models import OuterRef, Subquery, F
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.http import HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
@ -16,7 +16,7 @@ from django.views.decorators.http import require_POST
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 is_api_request, get_user_from_username
from .helpers import privacy_filter
@ -31,28 +31,28 @@ class Shelf(View):
is_self = user == request.user
if is_self:
shelves = user.shelf_set
shelves = user.shelf_set.all()
else:
shelves = privacy_filter(request.user, user.shelf_set)
shelves = privacy_filter(request.user, user.shelf_set).all()
# get the shelf and make sure the logged in user should be able to see it
if shelf_identifier:
try:
shelf = user.shelf_set.get(identifier=shelf_identifier)
except models.Shelf.DoesNotExist:
return HttpResponseNotFound()
if not shelf.visible_to_user(request.user):
return HttpResponseNotFound()
shelf = get_object_or_404(user.shelf_set, identifier=shelf_identifier)
shelf.raise_visible_to_user(request.user)
books = shelf.books
# this is a constructed "all books" view, with a fake "shelf" obj
else:
# this is a constructed "all books" view, with a fake "shelf" obj
FakeShelf = namedtuple(
"Shelf", ("identifier", "name", "user", "books", "privacy")
)
books = models.Edition.objects.filter(
# privacy is ensured because the shelves are already filtered above
shelfbook__shelf__in=shelves.all()
).distinct()
books = (
models.Edition.viewer_aware_objects(request.user)
.filter(
# privacy is ensured because the shelves are already filtered above
shelfbook__shelf__in=shelves
)
.distinct()
)
shelf = FakeShelf("all", _("All books"), user, books, "public")
if is_api_request(request):
@ -82,27 +82,27 @@ class Shelf(View):
data = {
"user": user,
"is_self": is_self,
"shelves": shelves.all(),
"shelves": shelves,
"shelf": shelf,
"books": page,
"edit_form": forms.ShelfForm(instance=shelf if shelf_identifier else None),
"create_form": forms.ShelfForm(),
"page_range": paginated.get_elided_page_range(
page.number, on_each_side=2, on_ends=1
),
}
return TemplateResponse(request, "user/shelf/shelf.html", data)
return TemplateResponse(request, "shelf/shelf.html", data)
@method_decorator(login_required, name="dispatch")
# pylint: disable=unused-argument
def post(self, request, username, shelf_identifier):
"""edit a shelf"""
try:
shelf = request.user.shelf_set.get(identifier=shelf_identifier)
except models.Shelf.DoesNotExist:
return HttpResponseNotFound()
user = get_user_from_username(request.user, username)
shelf = get_object_or_404(user.shelf_set, identifier=shelf_identifier)
shelf.raise_not_editable(request.user)
if request.user != shelf.user:
return HttpResponseBadRequest()
# you can't change the name of the default shelves
if not shelf.editable and request.POST.get("name") != shelf.name:
return HttpResponseBadRequest()
@ -130,8 +130,7 @@ def create_shelf(request):
def delete_shelf(request, shelf_id):
"""user generated shelves"""
shelf = get_object_or_404(models.Shelf, id=shelf_id)
if request.user != shelf.user or not shelf.editable:
return HttpResponseBadRequest()
shelf.raise_not_deletable(request.user)
shelf.delete()
return redirect("user-shelves", request.user.localname)
@ -139,25 +138,28 @@ def delete_shelf(request, shelf_id):
@login_required
@require_POST
@transaction.atomic
def shelve(request):
"""put a book on a user's shelf"""
book = get_edition(request.POST.get("book"))
desired_shelf = models.Shelf.objects.filter(
identifier=request.POST.get("shelf"), user=request.user
).first()
if not desired_shelf:
return HttpResponseNotFound()
book = get_object_or_404(models.Edition, id=request.POST.get("book"))
desired_shelf = get_object_or_404(
request.user.shelf_set, identifier=request.POST.get("shelf")
)
# first we need to remove from the specified shelf
change_from_current_identifier = request.POST.get("change-shelf-from")
if change_from_current_identifier is not None:
current_shelf = models.Shelf.objects.get(
user=request.user, identifier=change_from_current_identifier
)
handle_unshelve(book, current_shelf)
if change_from_current_identifier:
# find the shelfbook obj and delete it
get_object_or_404(
models.ShelfBook,
book=book,
user=request.user,
shelf__identifier=change_from_current_identifier,
).delete()
# A book can be on multiple shelves, but only on one read status shelf at a time
if desired_shelf.identifier in models.Shelf.READ_STATUS_IDENTIFIERS:
# figure out where state shelf it's currently on (if any)
current_read_status_shelfbook = (
models.ShelfBook.objects.select_related("shelf")
.filter(
@ -172,14 +174,16 @@ def shelve(request):
current_read_status_shelfbook.shelf.identifier
!= desired_shelf.identifier
):
handle_unshelve(book, current_read_status_shelfbook.shelf)
current_read_status_shelfbook.delete()
else: # It is already on the shelf
return redirect(request.headers.get("Referer", "/"))
# create the new shelf-book entry
models.ShelfBook.objects.create(
book=book, shelf=desired_shelf, user=request.user
)
else:
# we're putting it on a custom shelf
try:
models.ShelfBook.objects.create(
book=book, shelf=desired_shelf, user=request.user
@ -194,15 +198,12 @@ def shelve(request):
@login_required
@require_POST
def unshelve(request):
"""put a on a user's shelf"""
book = models.Edition.objects.get(id=request.POST["book"])
current_shelf = models.Shelf.objects.get(id=request.POST["shelf"])
"""put a on a user's shelf"""
book = get_object_or_404(models.Edition, id=request.POST.get("book"))
shelf_book = get_object_or_404(
models.ShelfBook, book=book, shelf__id=request.POST["shelf"]
)
shelf_book.raise_not_deletable(request.user)
handle_unshelve(book, current_shelf)
shelf_book.delete()
return redirect(request.headers.get("Referer", "/"))
def handle_unshelve(book, shelf):
"""unshelve a book"""
row = models.ShelfBook.objects.get(book=book, shelf=shelf)
row.delete()

View file

@ -5,11 +5,12 @@ from urllib.parse import urlparse
from django.contrib.auth.decorators import login_required
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
from django.http import HttpResponse, HttpResponseBadRequest
from django.http import HttpResponse, HttpResponseBadRequest, Http404
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 markdown import markdown
from bookwyrm import forms, models
@ -17,7 +18,7 @@ from bookwyrm.sanitize_html import InputHtmlParser
from bookwyrm.settings import DOMAIN
from bookwyrm.utils import regex
from .helpers import handle_remote_webfinger, is_api_request
from .reading import edit_readthrough
from .helpers import load_date_in_user_tz_as_utc
# pylint: disable= no-self-use
@ -79,7 +80,10 @@ class CreateStatus(View):
status.save(created=True)
# update a readthorugh, if needed
edit_readthrough(request)
try:
edit_readthrough(request)
except Http404:
pass
if is_api_request(request):
return HttpResponse()
@ -95,8 +99,7 @@ class DeleteStatus(View):
status = get_object_or_404(models.Status, id=status_id)
# don't let people delete other people's statuses
if status.user != request.user and not request.user.has_perm("moderate_post"):
return HttpResponseBadRequest()
status.raise_not_deletable(request.user)
# perform deletion
status.delete()
@ -112,12 +115,8 @@ class DeleteAndRedraft(View):
status = get_object_or_404(
models.Status.objects.select_subclasses(), id=status_id
)
if isinstance(status, (models.GeneratedNote, models.ReviewRating)):
return HttpResponseBadRequest()
# don't let people redraft other people's statuses
if status.user != request.user:
return HttpResponseBadRequest()
status.raise_not_editable(request.user)
status_type = status.status_type.lower()
if status.reply_parent:
@ -137,6 +136,54 @@ class DeleteAndRedraft(View):
return TemplateResponse(request, "compose.html", data)
@login_required
@require_POST
def update_progress(request, book_id): # pylint: disable=unused-argument
"""Either it's just a progress update, or it's a comment with a progress update"""
if request.POST.get("post-status"):
return CreateStatus.as_view()(request, "comment")
return edit_readthrough(request)
@login_required
@require_POST
def edit_readthrough(request):
"""can't use the form because the dates are too finnicky"""
readthrough = get_object_or_404(models.ReadThrough, id=request.POST.get("id"))
readthrough.raise_not_editable(request.user)
readthrough.start_date = load_date_in_user_tz_as_utc(
request.POST.get("start_date"), request.user
)
readthrough.finish_date = load_date_in_user_tz_as_utc(
request.POST.get("finish_date"), request.user
)
progress = request.POST.get("progress")
try:
progress = int(progress)
readthrough.progress = progress
except (ValueError, TypeError):
pass
progress_mode = request.POST.get("progress_mode")
try:
progress_mode = models.ProgressMode(progress_mode)
readthrough.progress_mode = progress_mode
except ValueError:
pass
readthrough.save()
# record the progress update individually
# use default now for date field
readthrough.create_update()
if is_api_request(request):
return HttpResponse()
return redirect(request.headers.get("Referer", "/"))
def find_mentions(content):
"""detect @mentions in raw status content"""
if not content:

View file

@ -1,6 +1,7 @@
""" non-interactive pages """
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import Http404
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.utils import timezone
@ -77,8 +78,12 @@ class User(View):
goal = models.AnnualGoal.objects.filter(
user=user, year=timezone.now().year
).first()
if goal and not goal.visible_to_user(request.user):
goal = None
if goal:
try:
goal.raise_visible_to_user(request.user)
except Http404:
goal = None
data = {
"user": user,
"is_self": is_self,

View file

@ -3,6 +3,7 @@
from dateutil.relativedelta import relativedelta
from django.http import HttpResponseNotFound
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.template.response import TemplateResponse
from django.utils import timezone
from django.views.decorators.http import require_GET
@ -19,10 +20,7 @@ def webfinger(request):
return HttpResponseNotFound()
username = resource.replace("acct:", "")
try:
user = models.User.objects.get(username__iexact=username)
except models.User.DoesNotExist:
return HttpResponseNotFound("No account found")
user = get_object_or_404(models.User, username__iexact=username)
return JsonResponse(
{
@ -130,3 +128,14 @@ def peers(_):
def host_meta(request):
"""meta of the host"""
return TemplateResponse(request, "host_meta.xml", {"DOMAIN": DOMAIN})
@require_GET
def opensearch(request):
"""Open Search xml spec"""
site = models.SiteSettings.get()
logo_path = site.favicon or "images/favicon.png"
logo = f"{MEDIA_FULL_URL}{logo_path}"
return TemplateResponse(
request, "opensearch.xml", {"image": logo, "DOMAIN": DOMAIN}
)