Merge branch 'main' into search-refactor

This commit is contained in:
Mouse Reeve
2021-09-30 10:40:57 -07:00
218 changed files with 8892 additions and 6942 deletions

View File

@ -1,10 +1,12 @@
""" 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
from .admin.federation import AddFederatedServer, ImportServerBlocklist
from .admin.federation import block_server, unblock_server
from .admin.email_blocklist import EmailBlocklist
from .admin.ip_blocklist import IPBlocklist
from .admin.invite import ManageInvites, Invite, InviteRequest
from .admin.invite import ManageInviteRequests, ignore_invite_request
from .admin.reports import (
@ -18,13 +20,19 @@ 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
# 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
@ -46,12 +54,12 @@ from .reading import 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 .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

@ -1,4 +1,4 @@
""" moderation via flagged posts and users """
""" Manage email blocklist"""
from django.contrib.auth.decorators import login_required, permission_required
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
@ -14,7 +14,7 @@ from bookwyrm import forms, models
name="dispatch",
)
class EmailBlocklist(View):
"""Block users by email address"""
"""Block registration by email address"""
def get(self, request):
"""view and compose blocks"""
@ -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

@ -0,0 +1,55 @@
""" Manage IP blocklist """
from django.contrib.auth.decorators import login_required, permission_required
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
# pylint: disable=no-self-use
@method_decorator(login_required, name="dispatch")
@method_decorator(
permission_required("bookwyrm.moderate_user", raise_exception=True),
name="dispatch",
)
class IPBlocklist(View):
"""Block registration by ip address"""
def get(self, request):
"""view and compose blocks"""
data = {
"addresses": models.IPBlocklist.objects.all(),
"form": forms.IPBlocklistForm(),
}
return TemplateResponse(
request, "settings/ip_blocklist/ip_blocklist.html", data
)
def post(self, request, block_id=None):
"""create a new ip address block"""
if block_id:
return self.delete(request, block_id)
form = forms.IPBlocklistForm(request.POST)
data = {
"addresses": models.IPBlocklist.objects.all(),
"form": form,
}
if not form.is_valid():
return TemplateResponse(
request, "settings/ip_blocklist/ip_blocklist.html", data
)
form.save()
data["form"] = forms.IPBlocklistForm()
return TemplateResponse(
request, "settings/ip_blocklist/ip_blocklist.html", data
)
# pylint: disable=unused-argument
def delete(self, request, domain_id):
"""remove a domain block"""
domain = get_object_or_404(models.IPBlocklist, id=domain_id)
domain.delete()
return redirect("settings-ip-blocks")

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

@ -55,4 +55,4 @@ class EditAuthor(View):
return TemplateResponse(request, "author/edit_author.html", data)
author = form.save()
return redirect("/author/%s" % author.id)
return redirect(f"/author/{author.id}")

View File

@ -8,7 +8,7 @@ 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, Http404
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.utils.datastructures import MultiValueDictKeyError
@ -30,25 +30,31 @@ class Book(View):
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):
book = get_object_or_404(
models.Book.objects.select_subclasses(), id=book_id
)
return ActivitypubResponse(book.to_activity())
if isinstance(book, models.Work):
book = book.default_edition
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:
return HttpResponseNotFound()
raise Http404()
work = book.parent_work
# all reviews for the book
# all reviews for all editions of the book
reviews = privacy_filter(
request.user, models.Review.objects.filter(book__in=work.editions.all())
request.user, models.Review.objects.filter(book__parent_work__editions=book)
)
# the reviews to show
@ -174,7 +180,7 @@ class EditBook(View):
# check if this is an edition of an existing work
author_text = book.author_text if book else add_author
data["book_matches"] = connector_manager.local_search(
"%s %s" % (form.cleaned_data.get("title"), author_text),
f'{form.cleaned_data.get("title")} {author_text}',
min_confidence=0.5,
raw=True,
)[:5]
@ -185,6 +191,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()
@ -212,7 +220,7 @@ class EditBook(View):
if image:
book.cover.save(*image, save=False)
book.save()
return redirect("/book/%s" % book.id)
return redirect(f"/book/{book.id}")
@method_decorator(login_required, name="dispatch")
@ -238,14 +246,14 @@ class ConfirmEditBook(View):
# get or create author as needed
for i in range(int(request.POST.get("author-match-count", 0))):
match = request.POST.get("author_match-%d" % i)
match = request.POST.get(f"author_match-{i}")
if not match:
return HttpResponseBadRequest()
try:
# if it's an int, it's an ID
match = int(match)
author = get_object_or_404(
models.Author, id=request.POST["author_match-%d" % i]
models.Author, id=request.POST[f"author_match-{i}"]
)
except ValueError:
# otherwise it's a name
@ -261,13 +269,21 @@ 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)
return redirect("/book/%s" % book.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
@ -283,7 +299,7 @@ def upload_cover(request, book_id):
if image:
book.cover.save(*image)
return redirect("{:s}?cover_error=True".format(book.local_path))
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"):

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

@ -96,4 +96,4 @@ def switch_edition(request):
readthrough.book = new_edition
readthrough.save()
return redirect("/book/%d" % new_edition.id)
return redirect(f"/book/{new_edition.id}")

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
@ -42,7 +43,7 @@ class Feed(View):
"tab": tab,
"streams": STREAMS,
"goal_form": forms.GoalForm(),
"path": "/%s" % tab["key"],
"path": f"/{tab['key']}",
},
}
return TemplateResponse(request, "feed/feed.html", data)
@ -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("/user/%s" % request.user.localname)
return redirect(f"/user/{request.user.localname}")

View File

@ -5,7 +5,6 @@ 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
@ -13,7 +12,7 @@ from django.views import View
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
@ -90,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

@ -79,7 +79,7 @@ def privacy_filter(viewer, queryset, privacy_levels=None, following_only=False):
elif "followers" in privacy_levels:
queryset = queryset.exclude(
~Q( # user isn't following and it isn't their own status
Q(user__in=viewer.following.all()) | Q(user=viewer)
Q(user__followers=viewer) | Q(user=viewer)
),
privacy="followers", # and the status is followers only
)
@ -115,7 +115,7 @@ def handle_remote_webfinger(query):
try:
user = models.User.objects.get(username__iexact=query)
except models.User.DoesNotExist:
url = "https://%s/.well-known/webfinger?resource=acct:%s" % (domain, query)
url = f"https://{domain}/.well-known/webfinger?resource=acct:{query}"
try:
data = get_data(url)
except (ConnectorException, HTTPError):

View File

@ -68,7 +68,7 @@ class Import(View):
importer.start_import(job)
return redirect("/import/%d" % job.id)
return redirect(f"/import/{job.id}")
return HttpResponseBadRequest()
@ -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)
@ -112,4 +112,4 @@ class ImportStatus(View):
items,
)
importer.start_import(job)
return redirect("/import/%d" % job.id)
return redirect(f"/import/{job.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
url = re.search(r"https?://{:s}/?".format(regex.DOMAIN), user_agent)
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

@ -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
@ -35,6 +34,8 @@ class Lists(View):
item_count=Count("listitem", filter=Q(listitem__approved=True))
)
.filter(item_count__gt=0)
.select_related("user")
.prefetch_related("listitem_set")
.order_by("-updated_date")
.distinct()
)
@ -108,8 +109,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))
@ -189,6 +189,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)
@ -203,9 +205,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,
@ -219,6 +219,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:
@ -266,8 +268,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")
@ -278,8 +279,7 @@ def delete_list(request, list_id):
def add_book(request):
"""put a book on a list"""
book_list = get_object_or_404(models.List, id=request.POST.get("list"))
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?
@ -320,23 +320,21 @@ def add_book(request):
path = reverse("list", args=[book_list.id])
params = request.GET.copy()
params["updated"] = True
return redirect("{:s}?{:s}".format(path, urlencode(params)))
return redirect(f"{path}?{urlencode(params)}")
@require_POST
@login_required
def remove_book(request, list_id):
"""remove a book from a list"""
book_list = get_object_or_404(models.List, id=list_id)
item = get_object_or_404(models.ListItem, id=request.POST.get("item"))
item.raise_not_deletable(request.user)
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"))
if not book_list.user == request.user and not item.user == request.user:
return HttpResponseNotFound()
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)
@ -347,34 +345,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()
@ -394,7 +390,7 @@ def set_book_position(request, list_item_id):
def increment_order_in_reverse(
book_list_id: int, start: int, end: Optional[int] = None
):
"""increase the order nu,ber for every item in a list"""
"""increase the order number for every item in a list"""
try:
book_list = models.List.objects.get(id=book_list_id)
except models.List.DoesNotExist:

View File

@ -3,7 +3,6 @@ from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required
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.utils.translation import gettext_lazy as _
from django.views import View
@ -46,7 +45,7 @@ class Login(View):
except models.User.DoesNotExist: # maybe it's a full username?
username = localname
else:
username = "%s@%s" % (localname, DOMAIN)
username = f"{localname}@{DOMAIN}"
password = login_form.data["password"]
# perform authentication
@ -54,8 +53,7 @@ class Login(View):
if user is not None:
# successful login
login(request, user)
user.last_active_date = timezone.now()
user.save(broadcast=False, update_fields=["last_active_date"])
user.update_active_date()
if request.POST.get("first_login"):
return redirect("get-started-profile")
return redirect(request.GET.get("next", "/"))

View File

@ -29,4 +29,4 @@ class Notifications(View):
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)
@ -38,7 +38,7 @@ class PasswordResetRequest(View):
# create a new reset code
code = models.PasswordReset.objects.create(user=user)
password_reset_email(code)
data = {"message": _("A password reset link sent to %s" % email)}
data = {"message": _(f"A password reset link sent to {email}")}
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):
@ -79,7 +49,7 @@ def save_user_form(form):
# set the name to a hash
extension = form.files["avatar"].name.split(".")[-1]
filename = "%s.%s" % (uuid4(), extension)
filename = f"{uuid4()}.{extension}"
user.avatar.save(filename, image, save=False)
user.save()
return user

View File

@ -5,6 +5,7 @@ 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
@ -35,7 +36,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 +45,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,11 +76,13 @@ 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"):
@ -97,23 +103,72 @@ class ReadingStatus(View):
return redirect(referer)
@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)
# deactivate all existing active readthroughs
for readthrough in annotated_book.active_readthroughs:
readthrough.is_active = False
readthrough.save()
# if the state is want-to-read, deactivating existing readthroughs is all we need
if status == models.Shelf.TO_READ:
return
# 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
@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()
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
# don't let people edit other people's data
if request.user != readthrough.user:
return HttpResponseBadRequest()
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", "/"))
@ -122,10 +177,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 +188,32 @@ 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", "/"))
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)
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:
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
return readthrough
@login_required
@ -210,10 +221,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

@ -16,6 +16,10 @@ from bookwyrm.settings import DOMAIN
class Register(View):
"""register a user"""
def get(self, request): # pylint: disable=unused-argument
"""whether or not you're logged in, just go to the home view"""
return redirect("/")
@sensitive_variables("password")
@method_decorator(sensitive_post_parameters("password"))
def post(self, request):
@ -25,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
@ -64,7 +68,7 @@ class Register(View):
return TemplateResponse(request, "invite.html", data)
return TemplateResponse(request, "login.html", data)
username = "%s@%s" % (localname, DOMAIN)
username = f"{localname}@{DOMAIN}"
user = models.User.objects.create_user(
username,
email,

View File

@ -64,7 +64,7 @@ class Search(View):
data["results"] = paginated
data["remote"] = search_remote
return TemplateResponse(request, "search/{:s}.html".format(search_type), data)
return TemplateResponse(request, f"search/{search_type}.html", data)
def book_search(query, _, min_confidence, search_remote=False):

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
@ -36,7 +37,7 @@ class CreateStatus(View):
status_type = status_type[0].upper() + status_type[1:]
try:
form = getattr(forms, "%sForm" % status_type)(request.POST)
form = getattr(forms, f"{status_type}Form")(request.POST)
except AttributeError:
return HttpResponseBadRequest()
if not form.is_valid():
@ -58,8 +59,8 @@ class CreateStatus(View):
# turn the mention into a link
content = re.sub(
r"%s([^@]|$)" % mention_text,
r'<a href="%s">%s</a>\g<1>' % (mention_user.remote_id, mention_text),
rf"{mention_text}([^@]|$)",
rf'<a href="{mention_user.remote_id}">{mention_text}</a>\g<1>',
content,
)
# add reply parent to mentions
@ -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,15 @@ 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)
def find_mentions(content):
"""detect @mentions in raw status content"""
if not content:
@ -182,7 +190,7 @@ def format_links(content):
if url.fragment != "":
link += "#" + url.fragment
formatted_content += '<a href="%s">%s</a>' % (potential_link, link)
formatted_content += f'<a href="{potential_link}">{link}</a>'
except (ValidationError, UnicodeError):
formatted_content += potential_link

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
@ -59,16 +60,30 @@ class User(View):
request.user,
user.status_set.select_subclasses(),
)
.select_related("reply_parent")
.prefetch_related("mention_books", "mention_users")
.select_related(
"user",
"reply_parent",
"review__book",
"comment__book",
"quotation__book",
)
.prefetch_related(
"mention_books",
"mention_users",
"attachments",
)
)
paginated = Paginator(activities, PAGE_LENGTH)
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,14 +20,11 @@ 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(
{
"subject": "acct:%s" % (user.username),
"subject": f"acct:{user.username}",
"links": [
{
"rel": "self",
@ -46,7 +44,7 @@ def nodeinfo_pointer(_):
"links": [
{
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
"href": "https://%s/nodeinfo/2.0" % DOMAIN,
"href": f"https://{DOMAIN}/nodeinfo/2.0",
}
]
}
@ -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}
)