Merge pull request #1691 from joachimesque/2021-in-the-books

Feature: Create annual summary page
This commit is contained in:
Mouse Reeve 2021-12-27 13:15:54 -08:00 committed by GitHub
commit c85f881f97
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 672 additions and 8 deletions

View file

@ -603,6 +603,37 @@ ol.ordered-list li::before {
padding: 0 0.75em;
}
/* Breadcrumbs
******************************************************************************/
.books-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(10em, 1fr));
gap: 1.5rem;
align-items: end;
justify-items: center;
}
.books-grid > .is-big {
grid-column: span 2;
grid-row: span 2;
justify-self: stretch;
padding: 1.5rem 1.5rem 0;
}
.books-grid .book-cover {
width: 100%;
}
.books-grid .book-title {
--height-basis: 1.35rem;
display: block;
margin-top: 0.5rem;
line-height: var(--height-basis);
min-height: calc(2 * var(--height-basis));
}
/* Dimensions
* @todo These could be in rem.
******************************************************************************/

View file

@ -0,0 +1,95 @@
Copyright 2014-2018 Adobe (http://www.adobe.com/), with Reserved Font Name
'Source'. All Rights Reserved. Source is a trademark of Adobe in the United
States and/or other countries. Copyright 2019 Google LLC.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,30 @@
# Font Squirrel Font-face Generator Configuration File
# Upload this file to the generator to recreate the settings
# you used to create these fonts.
{
"mode": "optimal",
"formats":
[
"woff",
"woff2"
],
"tt_instructor": "default",
"fix_gasp": "xy",
"fix_vertical_metrics": "Y",
"metrics_ascent": "",
"metrics_descent": "",
"metrics_linegap": "",
"add_spaces": "Y",
"add_hyphens": "Y",
"fallback": "none",
"fallback_custom": "100",
"options_subset": "basic",
"subset_custom": "",
"subset_custom_range": "",
"subset_ot_features_list": "",
"css_stylesheet": "stylesheet.css",
"filename_suffix": "-webfont",
"emsquare": "2048",
"spacing_adjustment": "0"
}

View file

@ -0,0 +1,19 @@
@font-face {
font-family: 'dm_serif_display';
src: url('../fonts/dm_serif_display/dmserifdisplay-italic-webfont.woff2') format('woff2'),
url('../fonts/dm_serif_display/dmserifdisplay-italic-webfont.woff') format('woff');
font-weight: normal;
font-style: italic;
}
@font-face {
font-family: 'dm_serif_display';
src: url('../fonts/dm_serif_display/dmserifdisplay-regular-webfont.woff2') format('woff2'),
url('../fonts/dm_serif_display/dmserifdisplay-regular-webfont.woff') format('woff');
font-weight: normal;
font-style: normal;
}
.is-serif {
font-family: 'dm_serif_display', Georgia, serif;
}

View file

@ -0,0 +1,184 @@
{% extends 'layout.html' %}
{% load i18n %}
{% load static %}
{% block title %}{% blocktrans %}{{ year }} in the books{% endblocktrans %}{% endblock %}
{% block head_links %}
<link rel="stylesheet" href="{% static "css/vendor/dm_serif_display.css" %}">
{% endblock %}
{% block content %}
<div class="columns">
{% with year=paginated_years|first %}
<div class="column">
<a href="{% url 'annual-summary' year %}">
<span class="icon icon-arrow-left" aria-hidden="true"></span>
{% blocktrans %}{{ year }} in the books{% endblocktrans %}
</a>
</div>
{% endwith %}
{% with year=paginated_years|last %}
{% if year %}
<div class="column has-text-right">
<a href="{% url 'annual-summary' year %}">
{% blocktrans %}{{ year }} in the books{% endblocktrans %}
<span class="icon icon-arrow-right" aria-hidden="true"></span>
</a>
</div>
{% endif %}
{% endwith %}
</div>
<h1 class="title is-1 is-serif has-text-centered mb-5">
📚✨
{% blocktrans %}{{ year }} <em>in the books</em>{% endblocktrans %}
✨📚
</h1>
{% if not books %}
<p class="has-text-centered is-size-5">{% blocktrans %}Sadly you didn't finish any book in {{ year }}{% endblocktrans %}</p>
{% else %}
<div class="columns is-mobile">
<div class="column is-8 is-offset-2 has-text-centered">
<h2 class="title is-3 is-serif">
{% blocktrans %}In {{ year }}, you read {{ books_total }} books<br />for a total of {{ pages_total }} pages!{% endblocktrans %}
</h2>
<p class="subtitle is-5">{% trans "Thats great!" %}</p>
<p class="title is-4 is-serif">
{% blocktrans %}That makes an average of {{ pages_average }} pages per book.{% endblocktrans %}
</p>
{% if no_page_number %}
<p class="subtitle is-6">{% blocktrans %}({{ no_page_number }} books dont have pages){% endblocktrans %}</p>
{% endif %}
</div>
</div>
{% if book_pages_lowest and book_pages_highest %}
<div class="columns is-mobile is-align-items-center mt-5">
<div class="column is-2 is-offset-1">
<a href="{{ book_pages_lowest.local_path }}">{% include 'snippets/book_cover.html' with book=book_pages_lowest cover_class='is-w-auto-tablet is-h-l-mobile' %}</a>
</div>
<div class="column is-3">
{% trans "Your shortest read this year" %}
<p class="title is-4 is-serif is-italic">
<a href="{{ book_pages_lowest.local_path }}" class="has-text-success-dark">
{{ book_pages_lowest.title }}
</a>
</p>
{% if book_pages_lowest.authors.exists %}
<p class="subtitle is-5 mb-2">{% trans "by" %}
{% include 'snippets/authors.html' with book=book_pages_lowest link_class="has-text-success-dark" %}
</p>
{% endif %}
<p class="subtitle is-6">
{% with pages=book_pages_lowest.pages %}
{% blocktrans %}<strong>{{ pages }}</strong> pages{% endblocktrans%}
{% endwith %}
</p>
</div>
<div class="column is-2">
<a href="{{ book_pages_highest.local_path }}">{% include 'snippets/book_cover.html' with book=book_pages_highest cover_class='is-w-auto-tablet is-h-l-mobile' %}</a>
</div>
<div class="column is-3">
{% trans "and the longest read" %}
<p class="title is-4 is-serif is-italic">
<a href="{{ book_pages_lowest.local_path }}" class="has-text-success-dark">
{{ book_pages_highest.title }}
</a>
</p>
{% if book_pages_highest.authors.exists %}
<p class="subtitle is-5 mb-2">{% trans "by" %}
{% include 'snippets/authors.html' with book=book_pages_highest link_class="has-text-success-dark" %}
</p>
{% endif %}
<p class="subtitle is-6">
{% with pages=book_pages_highest.pages %}
{% blocktrans %}<strong>{{ pages }}</strong> pages{% endblocktrans%}
{% endwith %}
</p>
</div>
</div>
{% endif %}
<div class="columns">
<div class="column is-one-fifth is-offset-two-fifths">
<hr />
</div>
</div>
<div class="columns">
<div class="column has-text-centered">
<h2 class="title is-3 is-serif">
{% blocktrans %}You left {{ ratings_total }} ratings, <br />your average rating is {{ rating_average }}{% endblocktrans %}
</h2>
</div>
</div>
<div class="columns is-align-items-center">
<div class="column is-3 is-offset-3">
<a href="{{ book_rating_highest.book.local_path }}">{% include 'snippets/book_cover.html' with book=book_rating_highest.book cover_class='is-w-auto-tablet is-h-l-mobile' %}</a>
</div>
<div class="column is-4">
{% trans "Your best rated review" %}
<p class="title is-4 is-serif is-italic">
<a href="{{ book_rating_highest.book.local_path }}" class="has-text-success-dark">
{{ book_rating_highest.book.title }}
</a>
</p>
{% if book_rating_highest.book.authors.exists %}
<p class="subtitle is-5 mb-2">{% trans "by" %}
{% include 'snippets/authors.html' with book=book_rating_highest.book link_class="has-text-success-dark" %}
</p>
{% endif %}
<p class="subtitle is-6">
{% with rating=book_rating_highest.rating|floatformat %}
{% blocktrans %}Your rating: <strong>{{ rating }}</strong>{% endblocktrans%}
{% endwith %}
</p>
</div>
</div>
<div class="columns">
<div class="column is-one-fifth is-offset-two-fifths">
<hr />
</div>
</div>
<div class="columns">
<div class="column has-text-centered">
<h2 class="title is-3 is-serif">
{% blocktrans %}All the books you read in 2021{% endblocktrans %}
</h2>
</div>
</div>
<div class="columns">
<div class="column is-10 is-offset-1">
<div class="books-grid">
{% for book in books %}
{% if book.id in best_ratings_books_ids %}
<a href="{{ book.local_path }}" class="has-text-centered is-big has-text-success-dark">
{% include 'snippets/book_cover.html' with book=book cover_class='is-w-auto' %}
<span class="book-title is-serif is-size-5">
{{ book.title }}
</span>
</a>
{% else %}
<a href="{{ book.local_path }}" class="has-text-centered has-text-success-dark">
{% include 'snippets/book_cover.html' with book=book cover_class='is-w-auto' %}
<span class="book-title is-serif is-size-6">
{{ book.title }}
</span>
</a>
{% endif %}
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% endblock %}

View file

@ -64,14 +64,20 @@
{{ allowed_status_types|json_script:"unread-notifications-wrapper" }}
</a>
{% if request.user.show_goal and not goal and tab.key == 'home' %}
{% now 'Y' as year %}
<section class="block">
{% include 'feed/goal_card.html' with year=year %}
<hr>
</section>
{% endif %}
{% if request.user.show_goal and not goal and tab.key == 'home' %}
{% now 'Y' as year %}
<section class="block">
{% include 'feed/goal_card.html' with year=year %}
<hr>
</section>
{% endif %}
{% if annual_summary_year and tab.key == 'home' %}
<section class="block">
{% include 'feed/summary_card.html' with year=annual_summary_year %}
<hr>
</section>
{% endif %}
{% endif %}
{# activity feed #}

View file

@ -0,0 +1,22 @@
{% extends 'components/card.html' %}
{% load i18n %}
{% block card-header %}
<h3 class="card-header-title has-background-success-dark has-text-white">
<span class="icon is-size-3 mr-2" aria-hidden="true">📚</span>
<span class="icon is-size-3 mr-2" aria-hidden="true"></span>
{% blocktrans %}{{ year }} in the books{% endblocktrans %}
</h3>
{% endblock %}
{% block card-content %}
<p class="mb-3">
{% blocktrans %}The end of the year is the best moment to take stock of all the books read during the last 12 months. How many pages have you read? Which book is your best-rated of the year? We compiled these stats, and more!{% endblocktrans %}
</p>
<p>
<a href="{% url 'annual-summary' year %}" class="button is-success has-background-success-dark">
{% blocktrans %}Discover your stats for {{ year }}!{% endblocktrans %}
</a>
</p>
{% endblock %}

View file

@ -28,6 +28,8 @@
{% include 'snippets/opengraph_images.html' %}
{% endblock %}
<meta name="twitter:image:alt" content="BookWyrm Logo">
{% block head_links %}{% endblock %}
</head>
<body>
<nav class="navbar" aria-label="main navigation">

View file

@ -13,7 +13,7 @@
{% for author in book.authors.all|slice:limit %}
<a
href="{{ author.local_path }}"
class="author"
class="author {{ link_class }}"
itemprop="author"
itemscope
itemtype="https://schema.org/Thing"

View file

@ -0,0 +1,125 @@
"""testing the annual summary page"""
from datetime import datetime
import pytz
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
from django.http import Http404
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import models, views
from bookwyrm.tests.validate_html import validate_html
def make_date(*args):
"""helper function to easily generate a date obj"""
return datetime(*args, tzinfo=pytz.UTC)
class AnnualSummary(TestCase):
"""views"""
def setUp(self):
"""we need basic test data and mocks"""
self.factory = RequestFactory()
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
"bookwyrm.activitystreams.populate_stream_task.delay"
):
self.local_user = models.User.objects.create_user(
"mouse@local.com",
"mouse@mouse.com",
"mouseword",
local=True,
localname="mouse",
remote_id="https://example.com/users/mouse",
)
self.work = models.Work.objects.create(title="Test Work")
self.book = models.Edition.objects.create(
title="Example Edition",
remote_id="https://example.com/book/1",
parent_work=self.work,
pages=300,
)
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
self.review = models.Review.objects.create(
name="Review name",
content="test content",
rating=3.0,
user=self.local_user,
book=self.book,
)
self.anonymous_user = AnonymousUser
self.anonymous_user.is_authenticated = False
self.year = 2020
models.SiteSettings.objects.create()
def test_annual_summary_not_authenticated(self, *_):
"""there are so many views, this just makes sure it DOESNT LOAD"""
view = views.AnnualSummary.as_view()
request = self.factory.get("")
request.user = self.anonymous_user
with patch(
"bookwyrm.views.annual_summary.is_year_available"
) as is_year_available:
is_year_available.return_value = True
with self.assertRaises(Http404):
view(request, self.year)
def test_annual_summary_wrong_year(self, *_):
"""there are so many views, this just makes sure it DOESNT LOAD"""
view = views.AnnualSummary.as_view()
request = self.factory.get("")
request.user = self.anonymous_user
with patch(
"bookwyrm.views.annual_summary.is_year_available"
) as is_year_available:
is_year_available.return_value = False
with self.assertRaises(Http404):
view(request, self.year)
def test_annual_summary_empty_page(self, *_):
"""there are so many views, this just makes sure it LOADS"""
view = views.AnnualSummary.as_view()
request = self.factory.get("")
request.user = self.local_user
with patch(
"bookwyrm.views.annual_summary.is_year_available"
) as is_year_available:
is_year_available.return_value = True
result = view(request, self.year)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
def test_annual_summary_page(self, *_):
"""there are so many views, this just makes sure it LOADS"""
shelf = self.local_user.shelf_set.filter(identifier="read").first()
models.ShelfBook.objects.create(
book=self.book,
user=self.local_user,
shelf=shelf,
shelved_date=make_date(2020, 1, 1),
)
view = views.AnnualSummary.as_view()
request = self.factory.get("")
request.user = self.local_user
with patch(
"bookwyrm.views.annual_summary.is_year_available"
) as is_year_available:
is_year_available.return_value = True
result = view(request, self.year)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)

View file

@ -477,4 +477,10 @@ urlpatterns = [
re_path(
r"^ostatus_success/?$", views.ostatus_follow_success, name="ostatus-success"
),
# annual summary
re_path(
r"^my-year-in-the-books/(?P<year>\d{4})/?$",
views.AnnualSummary.as_view(),
name="annual-summary",
),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View file

@ -96,3 +96,4 @@ from .status import edit_readthrough
from .updates import get_notification_count, get_unread_status_count
from .user import User, Followers, Following, hide_suggestions
from .wellknown import *
from .annual_summary import AnnualSummary

View file

@ -0,0 +1,141 @@
"""end-of-year read books stats"""
from datetime import date
from django.db.models import Case, When, Avg, Sum
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.template.response import TemplateResponse
from django.views import View
from bookwyrm import models
# December day of first availability
FIRST_DAY = 15
# January day of last availability, 0 for no availability in Jan.
LAST_DAY = 15
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 is_year_available(year):
"""return boolean"""
today = date.today()
year = int(year)
if year < today.year:
return True
if year == today.year and today >= date(today.year, 12, FIRST_DAY):
return True
return False
# pylint: disable= no-self-use
class AnnualSummary(View):
"""display a summary of the year for the current user"""
def get(self, request, year):
"""get response"""
if not is_year_available(year):
raise Http404(f"The summary for {year} is unavailable")
paginated_years = (
int(year) - 1,
int(year) + 1 if is_year_available(int(year) + 1) else None,
)
user = request.user
if not user.is_authenticated:
raise Http404(f"Login or register {year} to access this page")
read_book_ids_in_year = get_read_book_ids_in_year(user, year)
if len(read_book_ids_in_year) == 0:
data = {
"year": year,
"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"))
data = {
"year": year,
"books_total": len(read_books_in_year),
"books": read_books_in_year,
"pages_total": page_stats["pages__sum"],
"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,
}
return TemplateResponse(request, "annual_summary/layout.html", data)
def get_read_book_ids_in_year(user, year):
"""return an ordered QuerySet of the read book ids"""
read_shelf = get_object_or_404(user.shelf_set, identifier="read")
read_book_ids_in_year = (
models.ShelfBook.objects.filter(shelf=read_shelf)
.filter(user=user)
.filter(shelved_date__year=year)
.order_by("shelved_date", "created_date", "updated_date")
.values_list("book", flat=True)
)
return read_book_ids_in_year
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

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
@ -62,6 +63,7 @@ class Feed(View):
"allowed_status_types": request.user.feed_status_types,
"settings_saved": settings_saved,
"path": f"/{tab['key']}",
"annual_summary_year": get_annual_summary_year(),
},
}
return TemplateResponse(request, "feed/feed.html", data)