1
0
Fork 0

Merge branch 'main' into move-ratings-and-reviews-when-switching-editions

This commit is contained in:
Adeodato Simó 2024-02-21 18:42:18 -03:00 committed by GitHub
commit e6b6bd648d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
285 changed files with 27986 additions and 5453 deletions

View file

@ -5,6 +5,7 @@ from .admin.announcements import EditAnnouncement, delete_announcement
from .admin.automod import AutoMod, automod_delete, run_automod
from .admin.automod import schedule_automod_task, unschedule_automod_task
from .admin.celery_status import CeleryStatus, celery_ping
from .admin.schedule import ScheduledTasks
from .admin.dashboard import Dashboard
from .admin.federation import Federation, FederatedServer
from .admin.federation import AddFederatedServer, ImportServerBlocklist
@ -16,6 +17,10 @@ from .admin.imports import (
disable_imports,
enable_imports,
set_import_size_limit,
set_user_import_completed,
set_user_import_limit,
enable_user_exports,
disable_user_exports,
)
from .admin.ip_blocklist import IPBlocklist
from .admin.invite import ManageInvites, Invite, InviteRequest
@ -30,13 +35,13 @@ from .admin.reports import (
moderator_delete_user,
)
from .admin.site import Site, Registration, RegistrationLimited
from .admin.themes import Themes, delete_theme
from .admin.themes import Themes, delete_theme, test_theme
from .admin.user_admin import UserAdmin, UserAdminList, ActivateUserAdmin
# user preferences
from .preferences.change_password import ChangePassword
from .preferences.edit_user import EditUser
from .preferences.export import Export
from .preferences.export import Export, ExportUser, ExportArchive
from .preferences.move_user import MoveUser, AliasUser, remove_alias, unmove
from .preferences.delete_user import DeleteUser, DeactivateUser, ReactivateUser
from .preferences.block import Block, unblock
@ -81,7 +86,7 @@ from .shelf.shelf_actions import create_shelf, delete_shelf
from .shelf.shelf_actions import shelve, unshelve
# csv import
from .imports.import_data import Import
from .imports.import_data import Import, UserImport
from .imports.import_status import ImportStatus, retry_item, stop_import
from .imports.troubleshoot import ImportTroubleshoot
from .imports.manually_review import (
@ -113,6 +118,7 @@ from .feed import DirectMessage, Feed, Replies, Status
from .follow import (
follow,
unfollow,
remove_follow,
ostatus_follow_request,
ostatus_follow_success,
remote_follow,
@ -167,3 +173,4 @@ from .annual_summary import (
summary_revoke_key,
)
from .server_error import server_error
from .permission_denied import permission_denied

View file

@ -6,7 +6,7 @@ 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 django_celery_beat.models import PeriodicTask
from django_celery_beat.models import PeriodicTask, IntervalSchedule
from bookwyrm import forms, models
@ -54,7 +54,7 @@ def schedule_automod_task(request):
return TemplateResponse(request, "settings/automod/rules.html", data)
with transaction.atomic():
schedule = form.save(request)
schedule, _ = IntervalSchedule.objects.get_or_create(**form.cleaned_data)
PeriodicTask.objects.get_or_create(
interval=schedule,
name="automod-task",

View file

@ -6,16 +6,18 @@ from dateutil.parser import parse
from packaging import version
from django.contrib.auth.decorators import login_required, permission_required
from django.db import transaction
from django.db.models import Q
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from django_celery_beat.models import PeriodicTask, IntervalSchedule
from csp.decorators import csp_update
from bookwyrm import models, settings
from bookwyrm.connectors.abstract_connector import get_data
from bookwyrm import forms, models, settings
from bookwyrm.utils import regex
@ -59,21 +61,36 @@ class Dashboard(View):
== site._meta.get_field("privacy_policy").get_default()
)
# check version
if site.available_version and version.parse(
site.available_version
) > version.parse(settings.VERSION):
data["current_version"] = settings.VERSION
data["available_version"] = site.available_version
try:
release = get_data(settings.RELEASE_API, timeout=3)
available_version = release.get("tag_name", None)
if available_version and version.parse(available_version) > version.parse(
settings.VERSION
):
data["current_version"] = settings.VERSION
data["available_version"] = available_version
except: # pylint: disable= bare-except
pass
if not PeriodicTask.objects.filter(name="check-for-updates").exists():
data["schedule_form"] = forms.IntervalScheduleForm(
{"every": 1, "period": "days"}
)
return TemplateResponse(request, "settings/dashboard/dashboard.html", data)
def post(self, request):
"""Create a schedule task to check for updates"""
schedule_form = forms.IntervalScheduleForm(request.POST)
if not schedule_form.is_valid():
raise schedule_form.ValidationError(schedule_form.errors)
with transaction.atomic():
schedule, _ = IntervalSchedule.objects.get_or_create(
**schedule_form.cleaned_data
)
PeriodicTask.objects.get_or_create(
interval=schedule,
name="check-for-updates",
task="bookwyrm.models.site.check_for_updates_task",
)
return redirect("settings-dashboard")
def get_charts_and_stats(request):
"""Defines the dashboard charts"""

View file

@ -9,7 +9,7 @@ from django.views.decorators.http import require_POST
from bookwyrm import models
from bookwyrm.views.helpers import redirect_to_referer
from bookwyrm.settings import PAGE_LENGTH
from bookwyrm.settings import PAGE_LENGTH, USE_S3
# pylint: disable=no-self-use
@ -40,9 +40,17 @@ class ImportList(View):
paginated = Paginator(imports, PAGE_LENGTH)
page = paginated.get_page(request.GET.get("page"))
user_imports = models.BookwyrmImportJob.objects.filter(
complete=complete
).order_by("created_date")
user_paginated = Paginator(user_imports, PAGE_LENGTH)
user_page = user_paginated.get_page(request.GET.get("page"))
site_settings = models.SiteSettings.objects.get()
data = {
"imports": page,
"user_imports": user_page,
"page_range": paginated.get_elided_page_range(
page.number, on_each_side=2, on_ends=1
),
@ -50,6 +58,8 @@ class ImportList(View):
"sort": sort,
"import_size_limit": site_settings.import_size_limit,
"import_limit_reset": site_settings.import_limit_reset,
"user_import_time_limit": site_settings.user_import_time_limit,
"use_s3": USE_S3,
}
return TemplateResponse(request, "settings/imports/imports.html", data)
@ -95,3 +105,47 @@ def set_import_size_limit(request):
site.import_limit_reset = import_limit_reset
site.save(update_fields=["import_size_limit", "import_limit_reset"])
return redirect("settings-imports")
@require_POST
@login_required
@permission_required("bookwyrm.moderate_user", raise_exception=True)
# pylint: disable=unused-argument
def set_user_import_completed(request, import_id):
"""Mark a user import as complete"""
import_job = get_object_or_404(models.BookwyrmImportJob, id=import_id)
import_job.stop_job()
return redirect("settings-imports")
@require_POST
@permission_required("bookwyrm.edit_instance_settings", raise_exception=True)
# pylint: disable=unused-argument
def set_user_import_limit(request):
"""Limit how ofter users can import and export their account"""
site = models.SiteSettings.objects.get()
site.user_import_time_limit = int(request.POST.get("limit"))
site.save(update_fields=["user_import_time_limit"])
return redirect("settings-imports")
@require_POST
@permission_required("bookwyrm.edit_instance_settings", raise_exception=True)
# pylint: disable=unused-argument
def enable_user_exports(request):
"""Allow users to export account data"""
site = models.SiteSettings.objects.get()
site.user_exports_enabled = True
site.save(update_fields=["user_exports_enabled"])
return redirect("settings-imports")
@require_POST
@permission_required("bookwyrm.edit_instance_settings", raise_exception=True)
# pylint: disable=unused-argument
def disable_user_exports(request):
"""Don't allow users to export account data"""
site = models.SiteSettings.objects.get()
site.user_exports_enabled = False
site.save(update_fields=["user_exports_enabled"])
return redirect("settings-imports")

View file

@ -0,0 +1,31 @@
""" Scheduled celery tasks """
from django.contrib.auth.decorators import login_required, permission_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 django_celery_beat.models import PeriodicTask, IntervalSchedule
@method_decorator(login_required, name="dispatch")
@method_decorator(
permission_required("bookwyrm.edit_instance_settings", raise_exception=True),
name="dispatch",
)
# pylint: disable=no-self-use
class ScheduledTasks(View):
"""Manage automated flagging"""
def get(self, request):
"""view schedules"""
data = {}
data["tasks"] = PeriodicTask.objects.all()
data["schedules"] = IntervalSchedule.objects.all()
return TemplateResponse(request, "settings/schedules.html", data)
# pylint: disable=unused-argument
def post(self, request, task_id):
"""un-schedule a task"""
task = PeriodicTask.objects.get(id=task_id)
task.delete()
return redirect("settings-schedules")

View file

@ -6,6 +6,8 @@ from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.http import require_POST
from sass_processor.processor import sass_processor
from bookwyrm import forms, models
@ -40,6 +42,7 @@ class Themes(View):
def get_view_data():
"""data for view"""
return {
"broken_theme": models.Theme.objects.filter(loads=False).exists(),
"themes": models.Theme.objects.all(),
"theme_form": forms.ThemeForm(),
}
@ -52,3 +55,20 @@ def delete_theme(request, theme_id):
"""Remove a theme"""
get_object_or_404(models.Theme, id=theme_id).delete()
return redirect("settings-themes")
@require_POST
@permission_required("bookwyrm.system_administration", raise_exception=True)
# pylint: disable=unused-argument
def test_theme(request, theme_id):
"""Remove a theme"""
theme = get_object_or_404(models.Theme, id=theme_id)
try:
sass_processor(theme.path)
theme.loads = True
except Exception: # pylint: disable=broad-except
theme.loads = False
theme.save()
return redirect("settings-themes")

View file

@ -1,4 +1,5 @@
""" non-interactive pages """
from datetime import date
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Q
@ -52,6 +53,19 @@ class Feed(View):
suggestions = suggested_users.get_suggestions(request.user)
cutoff = (
date(get_annual_summary_year(), 12, 31)
if get_annual_summary_year()
else None
)
readthroughs = (
models.ReadThrough.objects.filter(
user=request.user, finish_date__lte=cutoff
)
if get_annual_summary_year()
else []
)
data = {
**feed_page_data(request.user),
**{
@ -66,6 +80,7 @@ class Feed(View):
"path": f"/{tab['key']}",
"annual_summary_year": get_annual_summary_year(),
"has_tour": True,
"has_summary_read_throughs": len(readthroughs),
},
}
return TemplateResponse(request, "feed/feed.html", data)
@ -185,19 +200,15 @@ class Status(View):
params=[status.id, visible_thread, visible_thread],
)
preview = None
if hasattr(status, "book"):
preview = status.book.preview_image
elif status.mention_books.exists():
preview = status.mention_books.first().preview_image
data = {
**feed_page_data(request.user),
**{
"status": status,
"children": children,
"ancestors": ancestors,
"preview": preview,
"title": status.page_title,
"description": status.page_description,
"page_image": status.page_image,
},
}
return TemplateResponse(request, "feed/status.html", data)

View file

@ -69,6 +69,33 @@ def unfollow(request):
return redirect("/")
@login_required
@require_POST
def remove_follow(request, user_id):
"""remove a previously approved follower without blocking them"""
to_remove = get_object_or_404(models.User, id=user_id)
try:
models.UserFollows.objects.get(
user_subject=to_remove, user_object=request.user
).reject()
except models.UserFollows.DoesNotExist:
clear_cache(to_remove, request.user)
try:
models.UserFollowRequest.objects.get(
user_subject=to_remove, user_object=request.user
).reject()
except models.UserFollowRequest.DoesNotExist:
clear_cache(to_remove, request.user)
if is_api_request(request):
return HttpResponse()
return redirect(f"{request.user.local_path}/followers")
@login_required
@require_POST
def accept_follow_request(request):
@ -100,7 +127,7 @@ def delete_follow_request(request):
)
follow_request.raise_not_deletable(request.user)
follow_request.delete()
follow_request.reject()
return redirect(f"/user/{request.user.localname}")

View file

@ -15,12 +15,14 @@ from django.views import View
from bookwyrm import forms, models
from bookwyrm.importers import (
BookwyrmImporter,
CalibreImporter,
LibrarythingImporter,
GoodreadsImporter,
StorygraphImporter,
OpenLibraryImporter,
)
from bookwyrm.models.bookwyrm_import_job import BookwyrmImportJob
from bookwyrm.settings import PAGE_LENGTH
from bookwyrm.utils.cache import get_or_set
@ -127,3 +129,61 @@ def get_average_import_time() -> float:
if recent_avg:
return recent_avg.total_seconds()
return None
# pylint: disable= no-self-use
@method_decorator(login_required, name="dispatch")
class UserImport(View):
"""import user view"""
def get(self, request, invalid=False):
"""load user import page"""
jobs = BookwyrmImportJob.objects.filter(user=request.user).order_by(
"-created_date"
)
site = models.SiteSettings.objects.get()
hours = site.user_import_time_limit
allowed = (
jobs.first().created_date < timezone.now() - datetime.timedelta(hours=hours)
if jobs.first()
else True
)
next_available = (
jobs.first().created_date + datetime.timedelta(hours=hours)
if not allowed
else False
)
paginated = Paginator(jobs, PAGE_LENGTH)
page = paginated.get_page(request.GET.get("page"))
data = {
"import_form": forms.ImportUserForm(),
"jobs": page,
"user_import_hours": hours,
"next_available": next_available,
"page_range": paginated.get_elided_page_range(
page.number, on_each_side=2, on_ends=1
),
"invalid": invalid,
}
return TemplateResponse(request, "import/import_user.html", data)
def post(self, request):
"""ingest a Bookwyrm json file"""
importer = BookwyrmImporter()
form = forms.ImportUserForm(request.POST, request.FILES)
if not form.is_valid():
return HttpResponseBadRequest()
job = importer.process_import(
user=request.user,
archive_file=request.FILES["archive_file"],
settings=request.POST,
)
job.start_job()
return redirect("user-import")

View file

@ -0,0 +1,15 @@
"""custom 403 handler to enable context processors"""
from django.http import HttpResponse
from django.template.response import TemplateResponse
from .helpers import is_api_request
def permission_denied(request, exception): # pylint: disable=unused-argument
"""permission denied page"""
if request.method == "POST" or is_api_request(request):
return HttpResponse(status=403)
return TemplateResponse(request, "403.html")

View file

@ -1,17 +1,24 @@
""" Let users export their book data """
from datetime import timedelta
import csv
import io
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Q
from django.http import HttpResponse
from django.template.response import TemplateResponse
from django.utils import timezone
from django.views import View
from django.utils.decorators import method_decorator
from django.shortcuts import redirect
from bookwyrm import models
from bookwyrm.models.bookwyrm_export_job import BookwyrmExportJob
from bookwyrm.settings import PAGE_LENGTH
# pylint: disable=no-self-use
# pylint: disable=no-self-use,too-many-locals
@method_decorator(login_required, name="dispatch")
class Export(View):
"""Let users export data"""
@ -48,7 +55,19 @@ class Export(View):
fields = (
["title", "author_text"]
+ deduplication_fields
+ ["rating", "review_name", "review_cw", "review_content"]
+ [
"start_date",
"finish_date",
"stopped_date",
"rating",
"review_name",
"review_cw",
"review_content",
"review_published",
"shelf",
"shelf_name",
"shelf_date",
]
)
writer.writerow(fields)
@ -64,6 +83,24 @@ class Export(View):
book.rating = review_rating.rating if review_rating else None
readthrough = (
models.ReadThrough.objects.filter(user=request.user, book=book)
.order_by("-start_date", "-finish_date")
.first()
)
if readthrough:
book.start_date = (
readthrough.start_date.date() if readthrough.start_date else None
)
book.finish_date = (
readthrough.finish_date.date() if readthrough.finish_date else None
)
book.stopped_date = (
readthrough.stopped_date.date()
if readthrough.stopped_date
else None
)
review = (
models.Review.objects.filter(
user=request.user, book=book, content__isnull=False
@ -72,9 +109,27 @@ class Export(View):
.first()
)
if review:
book.review_published = (
review.published_date.date() if review.published_date else None
)
book.review_name = review.name
book.review_cw = review.content_warning
book.review_content = review.raw_content
book.review_content = (
review.raw_content if review.raw_content else review.content
) # GoodReads imported reviews do not have raw_content, but content.
shelfbook = (
models.ShelfBook.objects.filter(user=request.user, book=book)
.order_by("-shelved_date", "-created_date", "-updated_date")
.last()
)
if shelfbook:
book.shelf = shelfbook.shelf.identifier
book.shelf_name = shelfbook.shelf.name
book.shelf_date = (
shelfbook.shelved_date.date() if shelfbook.shelved_date else None
)
writer.writerow([getattr(book, field, "") or "" for field in fields])
return HttpResponse(
@ -84,3 +139,61 @@ class Export(View):
"Content-Disposition": 'attachment; filename="bookwyrm-export.csv"'
},
)
# pylint: disable=no-self-use
@method_decorator(login_required, name="dispatch")
class ExportUser(View):
"""Let users export user data to import into another Bookwyrm instance"""
def get(self, request):
"""Request tar file"""
jobs = BookwyrmExportJob.objects.filter(user=request.user).order_by(
"-created_date"
)
site = models.SiteSettings.objects.get()
hours = site.user_import_time_limit
allowed = (
jobs.first().created_date < timezone.now() - timedelta(hours=hours)
if jobs.first()
else True
)
next_available = (
jobs.first().created_date + timedelta(hours=hours) if not allowed else False
)
paginated = Paginator(jobs, PAGE_LENGTH)
page = paginated.get_page(request.GET.get("page"))
data = {
"jobs": page,
"next_available": next_available,
"page_range": paginated.get_elided_page_range(
page.number, on_each_side=2, on_ends=1
),
}
return TemplateResponse(request, "preferences/export-user.html", data)
def post(self, request):
"""Download the json file of a user's data"""
job = BookwyrmExportJob.objects.create(user=request.user)
job.start_job()
return redirect("prefs-user-export")
@method_decorator(login_required, name="dispatch")
class ExportArchive(View):
"""Serve the archive file"""
def get(self, request, archive_id):
"""download user export file"""
export = BookwyrmExportJob.objects.get(task_id=archive_id, user=request.user)
return HttpResponse(
export.export_data,
content_type="application/gzip",
headers={
"Content-Disposition": 'attachment; filename="bookwyrm-account-export.tar.gz"' # pylint: disable=line-too-long
},
)

View file

@ -51,7 +51,7 @@ class Search(View):
def api_book_search(request):
"""Return books via API response"""
query = request.GET.get("q")
query = isbn_check(query)
query = isbn_check_and_format(query)
min_confidence = request.GET.get("min_confidence", 0)
# only return local book results via json so we don't cascade
book_results = search(query, min_confidence=min_confidence)
@ -64,7 +64,7 @@ def book_search(request):
"""the real business is elsewhere"""
query = request.GET.get("q")
# check if query is isbn
query = isbn_check(query)
query = isbn_check_and_format(query)
min_confidence = request.GET.get("min_confidence", 0)
search_remote = request.GET.get("remote", False) and request.user.is_authenticated
@ -159,7 +159,7 @@ def list_search(request):
return TemplateResponse(request, "search/list.html", data)
def isbn_check(query):
def isbn_check_and_format(query):
"""isbn10 or isbn13 check, if so remove separators"""
if query:
su_num = re.sub(r"(?<=\d)\D(?=\d|[xX])", "", query)

View file

@ -15,12 +15,14 @@ from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import PAGE_LENGTH
from bookwyrm.views.helpers import is_api_request, get_user_from_username
from bookwyrm.book_search import search
# pylint: disable=no-self-use
class Shelf(View):
"""shelf page"""
# pylint: disable=R0914
def get(self, request, username, shelf_identifier=None):
"""display a shelf"""
user = get_user_from_username(request.user, username)
@ -32,6 +34,8 @@ class Shelf(View):
else:
shelves = models.Shelf.privacy_filter(request.user).filter(user=user).all()
shelves_filter_query = request.GET.get("filter")
# get the shelf and make sure the logged in user should be able to see it
if shelf_identifier:
shelf = get_object_or_404(user.shelf_set, identifier=shelf_identifier)
@ -42,6 +46,7 @@ class Shelf(View):
FakeShelf = namedtuple(
"Shelf", ("identifier", "name", "user", "books", "privacy")
)
books = (
models.Edition.viewer_aware_objects(request.user)
.filter(
@ -50,6 +55,7 @@ class Shelf(View):
)
.distinct()
)
shelf = FakeShelf("all", _("All books"), user, books, "public")
if is_api_request(request) and shelf_identifier:
@ -86,6 +92,9 @@ class Shelf(View):
books = sort_books(books, request.GET.get("sort"))
if shelves_filter_query:
books = search(shelves_filter_query, books=books)
paginated = Paginator(
books,
PAGE_LENGTH,
@ -103,6 +112,8 @@ class Shelf(View):
"page_range": paginated.get_elided_page_range(
page.number, on_each_side=2, on_ends=1
),
"shelves_filter_query": shelves_filter_query,
"size": "small",
}
return TemplateResponse(request, "shelf/shelf.html", data)