Merge branch 'main' into book-file-links

This commit is contained in:
Mouse Reeve
2022-01-08 16:58:18 -08:00
262 changed files with 13490 additions and 5262 deletions

View File

@ -40,7 +40,8 @@ from .books.editions import Editions, switch_edition
from .books.links import FileLink
# landing
from .landing.landing import About, Home, Landing
from .landing.about import about, privacy, conduct
from .landing.landing import Home, Landing
from .landing.login import Login, Logout
from .landing.register import Register, ConfirmEmail, ConfirmEmailCode, resend_link
from .landing.password import PasswordResetRequest, PasswordReset
@ -100,5 +101,11 @@ from .search import Search
from .status import CreateStatus, EditStatus, DeleteStatus, update_progress
from .status import edit_readthrough
from .updates import get_notification_count, get_unread_status_count
from .user import User, Followers, Following, hide_suggestions
from .user import User, Followers, Following, hide_suggestions, user_redirect
from .wellknown import *
from .annual_summary import (
AnnualSummary,
personal_annual_summary,
summary_add_key,
summary_revoke_key,
)

View File

@ -0,0 +1,228 @@
"""end-of-year read books stats"""
from datetime import date
from uuid import uuid4
from django.contrib.auth.decorators import login_required
from django.db.models import Avg, Sum, Min, Case, When
from django.http import Http404
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.views import View
from django.views.decorators.http import require_POST
from bookwyrm import models
from .helpers import get_user_from_username
# December day of first availability
FIRST_DAY = 15
# January day of last availability, 0 for no availability in Jan.
LAST_DAY = 15
# pylint: disable= no-self-use
class AnnualSummary(View):
"""display a summary of the year for the current user"""
def get(self, request, username, year): # pylint: disable=too-many-locals
"""get response"""
user = get_user_from_username(request.user, username)
year_key = None
if user.summary_keys and year in user.summary_keys:
year_key = user.summary_keys[year]
privacy_verification(request, user, year, year_key)
paginated_years = (
int(year) - 1 if is_year_available(user, int(year) - 1) else None,
int(year) + 1 if is_year_available(user, int(year) + 1) else None,
)
# get data
read_book_ids_in_year = (
user.readthrough_set.filter(
finish_date__year__gte=year,
finish_date__year__lt=int(year) + 1,
)
.order_by("finish_date")
.values_list("book__id", flat=True)
)
if len(read_book_ids_in_year) == 0:
data = {
"summary_user": user,
"year": year,
"year_key": year_key,
"book_total": 0,
"books": [],
"paginated_years": paginated_years,
}
return TemplateResponse(request, "annual_summary/layout.html", data)
read_books_in_year = get_books_from_shelfbooks(read_book_ids_in_year)
# pages stats queries
page_stats = read_books_in_year.aggregate(Sum("pages"), Avg("pages"))
book_list_by_pages = read_books_in_year.filter(pages__gte=0).order_by("pages")
# books with no pages
no_page_list = len(read_books_in_year.filter(pages__exact=None))
# rating stats queries
ratings = (
models.Review.objects.filter(user=user)
.exclude(deleted=True)
.exclude(rating=None)
.filter(book_id__in=read_book_ids_in_year)
)
ratings_stats = ratings.aggregate(Avg("rating"))
# annual goal status
goal_status = get_goal_status(user, year)
data = {
"summary_user": user,
"year": year,
"year_key": year_key,
"books_total": len(read_books_in_year),
"books": read_books_in_year,
"pages_total": page_stats["pages__sum"] or 0,
"pages_average": round(
page_stats["pages__avg"] if page_stats["pages__avg"] else 0
),
"book_pages_lowest": book_list_by_pages.first(),
"book_pages_highest": book_list_by_pages.last(),
"no_page_number": no_page_list,
"ratings_total": len(ratings),
"rating_average": round(
ratings_stats["rating__avg"] if ratings_stats["rating__avg"] else 0, 2
),
"book_rating_highest": ratings.order_by("-rating").first(),
"best_ratings_books_ids": [
review.book.id for review in ratings.filter(rating=5)
],
"paginated_years": paginated_years,
"goal_status": goal_status,
}
return TemplateResponse(request, "annual_summary/layout.html", data)
@login_required
def personal_annual_summary(request, year):
"""redirect simple URL to URL with username"""
return redirect("annual-summary", request.user.localname, year)
@login_required
@require_POST
def summary_add_key(request):
"""add summary key"""
year = request.POST["year"]
user = request.user
new_key = uuid4().hex
if not user.summary_keys:
user.summary_keys = {
year: new_key,
}
else:
user.summary_keys[year] = new_key
user.save()
response = redirect("annual-summary", user.localname, year)
response["Location"] += f"?key={str(new_key)}"
return response
@login_required
@require_POST
def summary_revoke_key(request):
"""revoke summary key"""
year = request.POST["year"]
user = request.user
if user.summary_keys and year in user.summary_keys:
user.summary_keys.pop(year)
user.save()
return redirect("annual-summary", user.localname, year)
def get_annual_summary_year():
"""return the latest available annual summary year or None"""
today = date.today()
if date(today.year, 12, FIRST_DAY) <= today <= date(today.year, 12, 31):
return today.year
if LAST_DAY > 0 and date(today.year, 1, 1) <= today <= date(
today.year, 1, LAST_DAY
):
return today.year - 1
return None
def privacy_verification(request, user, year, year_key):
"""raises a 404 error if the user should not access the page"""
if user != request.user:
request_key = None
if "key" in request.GET:
request_key = request.GET["key"]
if not request_key or request_key != year_key:
raise Http404(f"The summary for {year} is unavailable")
if not is_year_available(user, year):
raise Http404(f"The summary for {year} is unavailable")
def is_year_available(user, year):
"""return boolean"""
earliest_year = user.readthrough_set.filter(finish_date__isnull=False).aggregate(
Min("finish_date")
)["finish_date__min"]
if not earliest_year:
return True
earliest_year = earliest_year.year
today = date.today()
year = int(year)
if earliest_year <= year < today.year:
return True
if year == today.year and today >= date(today.year, 12, FIRST_DAY):
return True
return False
def get_books_from_shelfbooks(books_ids):
"""return an ordered QuerySet of books from a list"""
ordered = Case(*[When(pk=pk, then=pos) for pos, pk in enumerate(books_ids)])
books = models.Edition.objects.filter(id__in=books_ids).order_by(ordered)
return books
def get_goal_status(user, year):
"""return a dict with the year's goal status"""
try:
goal = models.AnnualGoal.objects.get(user=user, year=year)
except models.AnnualGoal.DoesNotExist:
return None
if goal.privacy != "public":
return None
return dict(**goal.progress, **{"goal": goal.goal})

View File

@ -16,6 +16,7 @@ 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 .annual_summary import get_annual_summary_year
# pylint: disable= no-self-use
@ -25,15 +26,17 @@ class Feed(View):
def post(self, request, tab):
"""save feed settings form, with a silent validation fail"""
settings_saved = False
filters_applied = False
form = forms.FeedStatusTypesForm(request.POST, instance=request.user)
if form.is_valid():
form.save()
settings_saved = True
# workaround to avoid broadcasting this change
user = form.save(commit=False)
user.save(broadcast=False, update_fields=["feed_status_types"])
filters_applied = True
return self.get(request, tab, settings_saved)
return self.get(request, tab, filters_applied)
def get(self, request, tab, settings_saved=False):
def get(self, request, tab, filters_applied=False):
"""user's homepage with activity feed"""
tab = [s for s in STREAMS if s["key"] == tab]
tab = tab[0] if tab else STREAMS[0]
@ -60,8 +63,9 @@ class Feed(View):
"goal_form": forms.GoalForm(),
"feed_status_types_options": FeedFilterChoices,
"allowed_status_types": request.user.feed_status_types,
"settings_saved": settings_saved,
"filters_applied": filters_applied,
"path": f"/{tab['key']}",
"annual_summary_year": get_annual_summary_year(),
},
}
return TemplateResponse(request, "feed/feed.html", data)
@ -221,7 +225,6 @@ def feed_page_data(user):
goal = models.AnnualGoal.objects.filter(user=user, year=timezone.now().year).first()
return {
"suggested_books": get_suggested_books(user),
"goal": goal,
"goal_form": forms.GoalForm(),
}

View File

@ -113,13 +113,16 @@ class GetStartedUsers(View):
.filter(
similarity__gt=0.5,
)
.exclude(
id=request.user.id,
)
.order_by("-similarity")[:5]
)
data = {"no_results": not user_results}
if user_results.count() < 5:
user_results = list(user_results) + suggested_users.get_suggestions(
request.user
user_results = list(user_results) + list(
suggested_users.get_suggestions(request.user)
)
data["suggested_users"] = user_results

View File

@ -34,7 +34,8 @@ class Group(View):
data = {
"group": group,
"lists": lists,
"group_form": forms.GroupForm(instance=group),
"group_form": forms.GroupForm(instance=group, auto_id="group_form_id_%s"),
"list_form": forms.ListForm(),
"path": "/group",
}
return TemplateResponse(request, "groups/group.html", data)
@ -121,6 +122,11 @@ class FindUsers(View):
"""basic profile info"""
user_query = request.GET.get("user_query")
group = get_object_or_404(models.Group, id=group_id)
lists = (
models.List.privacy_filter(request.user)
.filter(group=group)
.order_by("-updated_date")
)
if not group:
return HttpResponseBadRequest()
@ -142,7 +148,7 @@ class FindUsers(View):
.filter(similarity__gt=0.5, local=True)
.order_by("-similarity")[:5]
)
data = {"no_results": not user_results}
no_results = not user_results
if user_results.count() < 5:
user_results = list(user_results) + suggested_users.get_suggestions(
@ -151,8 +157,11 @@ class FindUsers(View):
data = {
"suggested_users": user_results,
"no_results": no_results,
"group": group,
"group_form": forms.GroupForm(instance=group),
"lists": lists,
"group_form": forms.GroupForm(instance=group, auto_id="group_form_id_%s"),
"list_form": forms.ListForm(),
"user_query": user_query,
"requestor_is_manager": request.user == group.user,
}

View File

@ -1,12 +1,13 @@
""" helper functions used in various views """
import re
from datetime import datetime
from datetime import datetime, timedelta
import dateutil.parser
import dateutil.tz
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.http import Http404
from django.utils import translation
@ -186,7 +187,11 @@ def set_language(user, response):
"""Updates a user's language"""
if user.preferred_language:
translation.activate(user.preferred_language)
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, user.preferred_language)
response.set_cookie(
settings.LANGUAGE_COOKIE_NAME,
user.preferred_language,
expires=datetime.now() + timedelta(seconds=django_settings.SESSION_COOKIE_AGE),
)
return response

View File

@ -1,6 +1,7 @@
""" boosts and favs """
from django.db import IntegrityError
from django.contrib.auth.decorators import login_required
from django.core.cache import cache
from django.db import IntegrityError
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotFound
from django.shortcuts import redirect
from django.utils.decorators import method_decorator
@ -17,6 +18,7 @@ class Favorite(View):
def post(self, request, status_id):
"""create a like"""
cache.delete(f"fav-{request.user.id}-{status_id}")
status = models.Status.objects.get(id=status_id)
try:
models.Favorite.objects.create(status=status, user=request.user)
@ -35,6 +37,7 @@ class Unfavorite(View):
def post(self, request, status_id):
"""unlike a status"""
cache.delete(f"fav-{request.user.id}-{status_id}")
status = models.Status.objects.get(id=status_id)
try:
favorite = models.Favorite.objects.get(status=status, user=request.user)
@ -54,6 +57,7 @@ class Boost(View):
def post(self, request, status_id):
"""boost a status"""
cache.delete(f"boost-{request.user.id}-{status_id}")
status = models.Status.objects.get(id=status_id)
# is it boostable?
if not status.boostable:
@ -81,6 +85,7 @@ class Unboost(View):
def post(self, request, status_id):
"""boost a status"""
cache.delete(f"boost-{request.user.id}-{status_id}")
status = models.Status.objects.get(id=status_id)
boost = models.Boost.objects.filter(
boosted_status=status, user=request.user

View File

@ -0,0 +1,38 @@
""" non-interactive pages """
from dateutil.relativedelta import relativedelta
from django.template.response import TemplateResponse
from django.utils import timezone
from django.views.decorators.http import require_GET
from bookwyrm import models, settings
@require_GET
def about(request):
"""more information about the instance"""
six_months_ago = timezone.now() - relativedelta(months=6)
six_month_count = models.User.objects.filter(
is_active=True, local=True, last_active_date__gt=six_months_ago
).count()
data = {
"active_users": six_month_count,
"status_count": models.Status.objects.filter(
user__local=True, deleted=False
).count(),
"admins": models.User.objects.filter(groups__name__in=["admin", "moderator"]),
"version": settings.VERSION,
}
return TemplateResponse(request, "about/about.html", data)
@require_GET
def conduct(request):
"""more information about the instance"""
return TemplateResponse(request, "about/conduct.html")
@require_GET
def privacy(request):
"""more information about the instance"""
return TemplateResponse(request, "about/privacy.html")

View File

@ -8,14 +8,6 @@ from bookwyrm.views.feed import Feed
# pylint: disable= no-self-use
class About(View):
"""create invites"""
def get(self, request):
"""more information about the instance"""
return TemplateResponse(request, "landing/about.html")
class Home(View):
"""landing page or home feed depending on auth"""

View File

@ -39,7 +39,8 @@ class Login(View):
return redirect("/")
login_form = forms.LoginForm(request.POST)
localname = login_form.data["localname"]
localname = login_form.data.get("localname")
if "@" in localname: # looks like an email address to me
try:
username = models.User.objects.get(email=localname).username
@ -47,7 +48,7 @@ class Login(View):
username = localname
else:
username = f"{localname}@{DOMAIN}"
password = login_form.data["password"]
password = login_form.data.get("password")
# perform authentication
user = authenticate(request, username=username, password=password)

View File

@ -5,7 +5,7 @@ from urllib.parse import urlencode
from django.contrib.auth.decorators import login_required
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 import Avg, DecimalField, Q, Max
from django.db.models.functions import Coalesce
from django.http import HttpResponseBadRequest, HttpResponse, Http404
from django.shortcuts import get_object_or_404, redirect
@ -18,6 +18,7 @@ from django.views.decorators.clickjacking import xframe_options_exempt
from bookwyrm import book_search, forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.lists_stream import ListsStream
from bookwyrm.settings import PAGE_LENGTH
from .helpers import is_api_request
from .helpers import get_user_from_username
@ -29,18 +30,7 @@ class Lists(View):
def get(self, request):
"""display a book list"""
# hide lists with no approved books
lists = (
models.List.privacy_filter(
request.user, privacy_levels=["public", "followers"]
)
.annotate(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()
)
lists = ListsStream().get_list_stream(request.user)
paginated = Paginator(lists, 12)
data = {
"lists": paginated.get_page(request.GET.get("page")),
@ -264,10 +254,10 @@ class EmbedList(View):
return TemplateResponse(request, "lists/embed-list.html", data)
@method_decorator(login_required, name="dispatch")
class Curate(View):
"""approve or discard list suggestsions"""
@method_decorator(login_required, name="dispatch")
def get(self, request, list_id):
"""display a pending list"""
book_list = get_object_or_404(models.List, id=list_id)
@ -280,8 +270,6 @@ class Curate(View):
}
return TemplateResponse(request, "lists/curate.html", data)
@method_decorator(login_required, name="dispatch")
# pylint: disable=unused-argument
def post(self, request, list_id):
"""edit a book_list"""
book_list = get_object_or_404(models.List, id=list_id)

View File

@ -1,5 +1,7 @@
""" the good stuff! the books! """
from django.contrib.auth.decorators import login_required
from django.core.cache import cache
from django.core.cache.utils import make_template_fragment_key
from django.db import transaction
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotFound
from django.shortcuts import get_object_or_404, redirect
@ -44,6 +46,13 @@ class ReadingStatus(View):
if not identifier:
return HttpResponseBadRequest()
# invalidate the template cache
cache_keys = [
make_template_fragment_key("shelve_button", [request.user.id, book_id]),
make_template_fragment_key("suggested_books", [request.user.id]),
]
cache.delete_many(cache_keys)
desired_shelf = get_object_or_404(
models.Shelf, identifier=identifier, user=request.user
)

View File

@ -52,7 +52,7 @@ class Shelf(View):
)
shelf = FakeShelf("all", _("All books"), user, books, "public")
if is_api_request(request):
if is_api_request(request) and shelf_identifier:
return ActivitypubResponse(shelf.to_activity(**request.GET))
reviews = models.Review.objects
@ -72,9 +72,13 @@ class Shelf(View):
"start_date"
)
if shelf_identifier:
books = books.annotate(shelved_date=F("shelfbook__shelved_date"))
else:
# sorting by shelved date will cause duplicates in the "all books" view
books = books.annotate(shelved_date=F("updated_date"))
books = books.annotate(
rating=Subquery(reviews.values("rating")[:1]),
shelved_date=F("shelfbook__shelved_date"),
start_date=Subquery(reading.values("start_date")[:1]),
finish_date=Subquery(reading.values("finish_date")[:1]),
author=Subquery(

View File

@ -1,6 +1,6 @@
""" endpoints for getting updates about activity """
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.http import Http404, JsonResponse
from bookwyrm import activitystreams
@ -21,7 +21,7 @@ def get_unread_status_count(request, stream="home"):
"""any unread statuses for this feed?"""
stream = activitystreams.streams.get(stream)
if not stream:
return JsonResponse({})
raise Http404
return JsonResponse(
{
"count": stream.get_unread_count(request.user),

View File

@ -151,3 +151,9 @@ def hide_suggestions(request):
request.user.show_suggested_users = False
request.user.save(broadcast=False, update_fields=["show_suggested_users"])
return redirect(request.headers.get("Referer", "/"))
# pylint: disable=unused-argument
def user_redirect(request, username):
"""redirect to a user's feed"""
return redirect("user-feed", username=username)