1
0
Fork 0

Merge branch 'main' into notifications

This commit is contained in:
Mouse Reeve 2022-07-04 17:38:21 -07:00
commit a718a168a3
168 changed files with 4564 additions and 3141 deletions

View file

@ -1,5 +1,7 @@
""" instance overview """
from datetime import timedelta
import re
from dateutil.parser import parse
from packaging import version
@ -13,6 +15,7 @@ from django.views import View
from bookwyrm import models, settings
from bookwyrm.connectors.abstract_connector import get_data
from bookwyrm.connectors.connector_manager import ConnectorException
from bookwyrm.utils import regex
# pylint: disable= no-self-use
@ -26,90 +29,18 @@ class Dashboard(View):
def get(self, request):
"""list of users"""
interval = int(request.GET.get("days", 1))
now = timezone.now()
start = request.GET.get("start")
if start:
start = timezone.make_aware(parse(start))
else:
start = now - timedelta(days=6 * interval)
data = get_charts_and_stats(request)
end = request.GET.get("end")
end = timezone.make_aware(parse(end)) if end else now
start = start.replace(hour=0, minute=0, second=0)
# Make sure email looks properly configured
email_config_error = re.findall(
r"[\s\@]", settings.EMAIL_SENDER_DOMAIN
) or not re.match(regex.DOMAIN, settings.EMAIL_SENDER_DOMAIN)
user_queryset = models.User.objects.filter(local=True)
user_chart = Chart(
queryset=user_queryset,
queries={
"total": lambda q, s, e: q.filter(
Q(is_active=True) | Q(deactivation_date__gt=e),
created_date__lte=e,
).count(),
"active": lambda q, s, e: q.filter(
Q(is_active=True) | Q(deactivation_date__gt=e),
created_date__lte=e,
)
.filter(
last_active_date__gt=e - timedelta(days=31),
)
.count(),
},
)
status_queryset = models.Status.objects.filter(user__local=True, deleted=False)
status_chart = Chart(
queryset=status_queryset,
queries={
"total": lambda q, s, e: q.filter(
created_date__gt=s,
created_date__lte=e,
).count()
},
)
register_chart = Chart(
queryset=user_queryset,
queries={
"total": lambda q, s, e: q.filter(
created_date__gt=s,
created_date__lte=e,
).count()
},
)
works_chart = Chart(
queryset=models.Work.objects,
queries={
"total": lambda q, s, e: q.filter(
created_date__gt=s,
created_date__lte=e,
).count()
},
)
data = {
"start": start.strftime("%Y-%m-%d"),
"end": end.strftime("%Y-%m-%d"),
"interval": interval,
"users": user_queryset.filter(is_active=True).count(),
"active_users": user_queryset.filter(
is_active=True, last_active_date__gte=now - timedelta(days=31)
).count(),
"statuses": status_queryset.count(),
"works": models.Work.objects.count(),
"reports": models.Report.objects.filter(resolved=False).count(),
"pending_domains": models.LinkDomain.objects.filter(
status="pending"
).count(),
"invite_requests": models.InviteRequest.objects.filter(
ignored=False, invite_sent=False
).count(),
"user_stats": user_chart.get_chart(start, end, interval),
"status_stats": status_chart.get_chart(start, end, interval),
"register_stats": register_chart.get_chart(start, end, interval),
"works_stats": works_chart.get_chart(start, end, interval),
}
data["email_config_error"] = email_config_error
# pylint: disable=line-too-long
data[
"email_sender"
] = f"{settings.EMAIL_SENDER_NAME}@{settings.EMAIL_SENDER_DOMAIN}"
# check version
try:
@ -126,6 +57,91 @@ class Dashboard(View):
return TemplateResponse(request, "settings/dashboard/dashboard.html", data)
def get_charts_and_stats(request):
"""Defines the dashbaord charts"""
interval = int(request.GET.get("days", 1))
now = timezone.now()
start = request.GET.get("start")
if start:
start = timezone.make_aware(parse(start))
else:
start = now - timedelta(days=6 * interval)
end = request.GET.get("end")
end = timezone.make_aware(parse(end)) if end else now
start = start.replace(hour=0, minute=0, second=0)
user_queryset = models.User.objects.filter(local=True)
user_chart = Chart(
queryset=user_queryset,
queries={
"total": lambda q, s, e: q.filter(
Q(is_active=True) | Q(deactivation_date__gt=e),
created_date__lte=e,
).count(),
"active": lambda q, s, e: q.filter(
Q(is_active=True) | Q(deactivation_date__gt=e),
created_date__lte=e,
)
.filter(
last_active_date__gt=e - timedelta(days=31),
)
.count(),
},
)
status_queryset = models.Status.objects.filter(user__local=True, deleted=False)
status_chart = Chart(
queryset=status_queryset,
queries={
"total": lambda q, s, e: q.filter(
created_date__gt=s,
created_date__lte=e,
).count()
},
)
register_chart = Chart(
queryset=user_queryset,
queries={
"total": lambda q, s, e: q.filter(
created_date__gt=s,
created_date__lte=e,
).count()
},
)
works_chart = Chart(
queryset=models.Work.objects,
queries={
"total": lambda q, s, e: q.filter(
created_date__gt=s,
created_date__lte=e,
).count()
},
)
return {
"start": start.strftime("%Y-%m-%d"),
"end": end.strftime("%Y-%m-%d"),
"interval": interval,
"users": user_queryset.filter(is_active=True).count(),
"active_users": user_queryset.filter(
is_active=True, last_active_date__gte=now - timedelta(days=31)
).count(),
"statuses": status_queryset.count(),
"works": models.Work.objects.count(),
"reports": models.Report.objects.filter(resolved=False).count(),
"pending_domains": models.LinkDomain.objects.filter(status="pending").count(),
"invite_requests": models.InviteRequest.objects.filter(
ignored=False, invite__isnull=True
).count(),
"user_stats": user_chart.get_chart(start, end, interval),
"status_stats": status_chart.get_chart(start, end, interval),
"register_stats": register_chart.get_chart(start, end, interval),
"works_stats": works_chart.get_chart(start, end, interval),
}
class Chart:
"""Data for a chart"""

View file

@ -29,6 +29,8 @@ class Federation(View):
filters = {}
if software := request.GET.get("application_type"):
filters["application_type"] = software
if server := request.GET.get("server"):
filters["server_name"] = server
servers = models.FederatedServer.objects.filter(status=status, **filters)
@ -60,7 +62,9 @@ class Federation(View):
"sort": sort,
"software_options": models.FederatedServer.objects.values_list(
"application_type", flat=True
).distinct(),
)
.distinct()
.order_by("application_type"),
"form": forms.ServerForm(),
}
return TemplateResponse(request, "settings/federation/instance_list.html", data)

View file

@ -22,19 +22,16 @@ class UserAdminList(View):
def get(self, request, status="local"):
"""list of users"""
filters = {}
server = request.GET.get("server")
if server:
if server := request.GET.get("server"):
server = models.FederatedServer.objects.filter(server_name=server).first()
filters["federated_server"] = server
filters["federated_server__isnull"] = False
username = request.GET.get("username")
if username:
if username := request.GET.get("username"):
filters["username__icontains"] = username
scope = request.GET.get("scope")
if scope and scope == "local":
filters["local"] = True
email = request.GET.get("email")
if email:
if email := request.GET.get("email"):
filters["email__endswith"] = email
filters["local"] = status == "local"

View file

@ -11,20 +11,24 @@ from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.connectors import connector_manager
from bookwyrm.settings import PAGE_LENGTH
from bookwyrm.views.helpers import is_api_request
from bookwyrm.views.helpers import is_api_request, maybe_redirect_local_path
# pylint: disable= no-self-use
class Author(View):
"""this person wrote a book"""
def get(self, request, author_id):
# pylint: disable=unused-argument
def get(self, request, author_id, slug=None):
"""landing page for an author"""
author = get_object_or_404(models.Author, id=author_id)
if is_api_request(request):
return ActivitypubResponse(author.to_activity())
if redirect_local_path := maybe_redirect_local_path(request, author):
return redirect_local_path
books = (
models.Work.objects.filter(editions__authors=author)
.order_by("created_date")

View file

@ -15,14 +15,14 @@ from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.connectors import connector_manager, ConnectorException
from bookwyrm.connectors.abstract_connector import get_image
from bookwyrm.settings import PAGE_LENGTH
from bookwyrm.views.helpers import is_api_request
from bookwyrm.views.helpers import is_api_request, maybe_redirect_local_path
# pylint: disable=no-self-use
class Book(View):
"""a book! this is the stuff"""
def get(self, request, book_id, user_statuses=False, update_error=False):
def get(self, request, book_id, **kwargs):
"""info about a book"""
if is_api_request(request):
book = get_object_or_404(
@ -30,7 +30,11 @@ class Book(View):
)
return ActivitypubResponse(book.to_activity())
user_statuses = user_statuses if request.user.is_authenticated else False
user_statuses = (
kwargs.get("user_statuses", False)
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
@ -46,6 +50,11 @@ class Book(View):
if not book or not book.parent_work:
raise Http404()
if redirect_local_path := not user_statuses and maybe_redirect_local_path(
request, book
):
return redirect_local_path
# all reviews for all editions of the book
reviews = models.Review.privacy_filter(request.user).filter(
book__parent_work__editions=book
@ -80,7 +89,7 @@ class Book(View):
else None,
"rating": reviews.aggregate(Avg("rating"))["rating__avg"],
"lists": lists,
"update_error": update_error,
"update_error": kwargs.get("update_error", False),
}
if request.user.is_authenticated:

View file

@ -115,6 +115,7 @@ class CreateBook(View):
# go to confirm mode
if not parent_work_id or data.get("add_author"):
data["confirm_mode"] = True
return TemplateResponse(request, "book/edit/edit_book.html", data)
with transaction.atomic():
@ -189,7 +190,7 @@ def add_authors(request, data):
"existing_isnis": exists,
}
)
return data
return data
@require_POST

View file

@ -15,7 +15,7 @@ from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import PAGE_LENGTH, STREAMS
from bookwyrm.suggested_users import suggested_users
from .helpers import filter_stream_by_status_type, get_user_from_username
from .helpers import is_api_request, is_bookwyrm_request
from .helpers import is_api_request, is_bookwyrm_request, maybe_redirect_local_path
from .annual_summary import get_annual_summary_year
@ -113,7 +113,8 @@ class DirectMessage(View):
class Status(View):
"""get posting"""
def get(self, request, username, status_id):
# pylint: disable=unused-argument
def get(self, request, username, status_id, slug=None):
"""display a particular status (and replies, etc)"""
user = get_user_from_username(request.user, username)
status = get_object_or_404(
@ -130,6 +131,9 @@ class Status(View):
status.to_activity(pure=not is_bookwyrm_request(request))
)
if redirect_local_path := maybe_redirect_local_path(request, status):
return redirect_local_path
visible_thread = (
models.Status.privacy_filter(request.user)
.filter(thread_id=status.thread_id)

View file

@ -14,17 +14,22 @@ from django.db.models.functions import Greatest
from bookwyrm import forms, models
from bookwyrm.suggested_users import suggested_users
from .helpers import get_user_from_username
from .helpers import get_user_from_username, maybe_redirect_local_path
# pylint: disable=no-self-use
class Group(View):
"""group page"""
def get(self, request, group_id):
# pylint: disable=unused-argument
def get(self, request, group_id, slug=None):
"""display a group"""
group = get_object_or_404(models.Group, id=group_id)
group.raise_visible_to_user(request.user)
if redirect_local_path := maybe_redirect_local_path(request, group):
return redirect_local_path
lists = (
models.List.privacy_filter(request.user)
.filter(group=group)
@ -80,7 +85,8 @@ class Group(View):
class UserGroups(View):
"""a user's groups page"""
def get(self, request, username):
# pylint: disable=unused-argument
def get(self, request, username, slug=None):
"""display a group"""
user = get_user_from_username(request.user, username)
groups = (

View file

@ -8,6 +8,7 @@ from dateutil.parser import ParserError
from requests import HTTPError
from django.db.models import Q
from django.conf import settings as django_settings
from django.shortcuts import redirect
from django.http import Http404
from django.utils import translation
@ -137,6 +138,7 @@ def handle_reading_status(user, shelf, book, privacy):
"to-read": "wants to read",
"reading": "started reading",
"read": "finished reading",
"stopped-reading": "stopped reading",
}[shelf.identifier]
except KeyError:
# it's a non-standard shelf, don't worry about it
@ -201,3 +203,21 @@ def filter_stream_by_status_type(activities, allowed_types=None):
)
return activities
def maybe_redirect_local_path(request, model):
"""
if the request had an invalid path, return a permanent redirect response to the
correct one, including a slug if any.
if path is valid, returns False.
"""
# don't redirect empty path for unit tests which currently have this
if request.path in ("/", model.local_path):
return False
new_path = model.local_path
if len(request.GET) > 0:
new_path = f"{model.local_path}?{request.GET.urlencode()}"
return redirect(new_path, permanent=True)

View file

@ -11,6 +11,7 @@ from django.views import View
from bookwyrm import forms, models
from bookwyrm.importers import (
CalibreImporter,
LibrarythingImporter,
GoodreadsImporter,
StorygraphImporter,
@ -52,6 +53,8 @@ class Import(View):
importer = StorygraphImporter()
elif source == "OpenLibrary":
importer = OpenLibraryImporter()
elif source == "Calibre":
importer = CalibreImporter()
else:
# Default : Goodreads
importer = GoodreadsImporter()

View file

@ -91,9 +91,12 @@ class ConfirmEmailCode(View):
def get(self, request, code): # pylint: disable=unused-argument
"""you got the code! good work"""
settings = models.SiteSettings.get()
if request.user.is_authenticated or not settings.require_confirm_email:
if request.user.is_authenticated:
return redirect("/")
if not settings.require_confirm_email:
return redirect("login")
# look up the user associated with this code
try:
user = models.User.objects.get(confirmation_code=code)

View file

@ -18,21 +18,27 @@ from django.views.decorators.http import require_POST
from bookwyrm import book_search, forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import PAGE_LENGTH
from bookwyrm.views.helpers import is_api_request
from bookwyrm.views.helpers import is_api_request, maybe_redirect_local_path
# pylint: disable=no-self-use
class List(View):
"""book list page"""
def get(self, request, list_id, add_failed=False, add_succeeded=False):
def get(self, request, list_id, **kwargs):
"""display a book list"""
add_failed = kwargs.get("add_failed", False)
add_succeeded = kwargs.get("add_succeeded", False)
book_list = get_object_or_404(models.List, id=list_id)
book_list.raise_visible_to_user(request.user)
if is_api_request(request):
return ActivitypubResponse(book_list.to_activity(**request.GET))
if r := maybe_redirect_local_path(request, book_list):
return r
query = request.GET.get("q")
suggestions = None

View file

@ -1,4 +1,5 @@
""" the good stuff! the books! """
import logging
from django.contrib.auth.decorators import login_required
from django.core.cache import cache
from django.db import transaction
@ -15,6 +16,8 @@ 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
logger = logging.getLogger(__name__)
# pylint: disable=no-self-use
# pylint: disable=too-many-return-statements
@ -29,20 +32,24 @@ class ReadingStatus(View):
"want": "want.html",
"start": "start.html",
"finish": "finish.html",
"stop": "stop.html",
}.get(status)
if not template:
return HttpResponseNotFound()
# redirect if we're already on this shelf
return TemplateResponse(request, f"reading_progress/{template}", {"book": book})
@transaction.atomic
def post(self, request, status, book_id):
"""Change the state of a book by shelving it and adding reading dates"""
identifier = {
"want": models.Shelf.TO_READ,
"start": models.Shelf.READING,
"finish": models.Shelf.READ_FINISHED,
"stop": models.Shelf.STOPPED_READING,
}.get(status)
if not identifier:
logger.exception("Invalid reading status type: %s", status)
return HttpResponseBadRequest()
# invalidate related caches
@ -85,6 +92,7 @@ class ReadingStatus(View):
desired_shelf.identifier,
start_date=request.POST.get("start_date"),
finish_date=request.POST.get("finish_date"),
stopped_date=request.POST.get("stopped_date"),
)
# post about it (if you want)
@ -153,8 +161,9 @@ class ReadThrough(View):
@transaction.atomic
# pylint: disable=too-many-arguments
def update_readthrough_on_shelve(
user, annotated_book, status, start_date=None, finish_date=None
user, annotated_book, status, start_date=None, finish_date=None, stopped_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
@ -176,8 +185,9 @@ def update_readthrough_on_shelve(
)
# 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
# if the stop or finish date is set, the readthrough will be set as inactive
active_readthrough.finish_date = load_date_in_user_tz_as_utc(finish_date, user)
active_readthrough.stopped_date = load_date_in_user_tz_as_utc(stopped_date, user)
active_readthrough.save()

View file

@ -24,6 +24,8 @@ class Search(View):
def get(self, request):
"""that search bar up top"""
query = request.GET.get("q")
# check if query is isbn
query = isbn_check(query)
min_confidence = request.GET.get("min_confidence", 0)
search_type = request.GET.get("type")
search_remote = (
@ -123,3 +125,35 @@ def list_search(query, viewer, *_):
)
.order_by("-similarity")
), None
def isbn_check(query):
"""isbn10 or isbn13 check, if so remove separators"""
if query:
su_num = re.sub(r"(?<=\d)\D(?=\d|[xX])", "", query)
if len(su_num) == 13 and su_num.isdecimal():
# Multiply every other digit by 3
# Add these numbers and the other digits
product = sum(int(ch) for ch in su_num[::2]) + sum(
int(ch) * 3 for ch in su_num[1::2]
)
if product % 10 == 0:
return su_num
elif (
len(su_num) == 10
and su_num[:-1].isdecimal()
and (su_num[-1].isdecimal() or su_num[-1].lower() == "x")
):
product = 0
# Iterate through code_string
for i in range(9):
# for each character, multiply by a different decreasing number: 10 - x
product = product + int(su_num[i]) * (10 - i)
# Handle last character
if su_num[9].lower() == "x":
product += 10
else:
product += int(su_num[9])
if product % 11 == 0:
return su_num
return query

View file

@ -1,5 +1,6 @@
""" what are we here for if not for posting """
import re
import logging
from urllib.parse import urlparse
from django.contrib.auth.decorators import login_required
@ -15,12 +16,13 @@ from django.views.decorators.http import require_POST
from markdown import markdown
from bookwyrm import forms, models
from bookwyrm.sanitize_html import InputHtmlParser
from bookwyrm.settings import DOMAIN
from bookwyrm.utils import regex
from bookwyrm.utils import regex, sanitizer
from .helpers import handle_remote_webfinger, is_api_request
from .helpers import load_date_in_user_tz_as_utc
logger = logging.getLogger(__name__)
# pylint: disable= no-self-use
@method_decorator(login_required, name="dispatch")
@ -72,11 +74,14 @@ class CreateStatus(View):
form = getattr(forms, f"{status_type}Form")(
request.POST, instance=existing_status
)
except AttributeError:
except AttributeError as err:
logger.exception(err)
return HttpResponseBadRequest()
if not form.is_valid():
if is_api_request(request):
return HttpResponse(status=500)
logger.exception(form.errors)
return HttpResponseBadRequest()
return redirect(request.headers.get("Referer", "/"))
status = form.save(commit=False)
@ -262,6 +267,4 @@ def to_markdown(content):
content = format_links(content)
content = markdown(content)
# sanitize resulting html
sanitizer = InputHtmlParser()
sanitizer.feed(content)
return sanitizer.get_output()
return sanitizer.clean(content)

View file

@ -106,7 +106,7 @@ class Followers(View):
if is_api_request(request):
return ActivitypubResponse(user.to_followers_activity(**request.GET))
if user.hide_follows:
if user.hide_follows and user != request.user:
raise PermissionDenied()
followers = annotate_if_follows(request.user, user.followers)
@ -129,7 +129,7 @@ class Following(View):
if is_api_request(request):
return ActivitypubResponse(user.to_following_activity(**request.GET))
if user.hide_follows:
if user.hide_follows and user != request.user:
raise PermissionDenied()
following = annotate_if_follows(request.user, user.following)