2
0
Fork 0

Merge branch 'production' into nix

This commit is contained in:
D Anzorge 2021-12-28 22:20:34 +01:00
commit 71a2124446
106 changed files with 8593 additions and 2722 deletions

View file

@ -69,7 +69,7 @@ AWS_SECRET_ACCESS_KEY=
# or use_dominant_color_light / use_dominant_color_dark
PREVIEW_BG_COLOR=use_dominant_color_light
# Change to #FFF if you use use_dominant_color_dark
PREVIEW_TEXT_COLOR="#363636"
PREVIEW_TEXT_COLOR=#363636
PREVIEW_IMG_WIDTH=1200
PREVIEW_IMG_HEIGHT=630
PREVIEW_DEFAULT_COVER_COLOR="#002549"
PREVIEW_DEFAULT_COVER_COLOR=#002549

View file

@ -69,7 +69,7 @@ AWS_SECRET_ACCESS_KEY=
# or use_dominant_color_light / use_dominant_color_dark
PREVIEW_BG_COLOR=use_dominant_color_light
# Change to #FFF if you use use_dominant_color_dark
PREVIEW_TEXT_COLOR="#363636"
PREVIEW_TEXT_COLOR=#363636
PREVIEW_IMG_WIDTH=1200
PREVIEW_IMG_HEIGHT=630
PREVIEW_DEFAULT_COVER_COLOR="#002549"
PREVIEW_DEFAULT_COVER_COLOR=#002549

View file

@ -111,7 +111,7 @@ class AbstractConnector(AbstractMinimalConnector):
return existing.default_edition
return existing
# load the json
# load the json data from the remote data source
data = self.get_book_data(remote_id)
if self.is_work_data(data):
try:
@ -150,27 +150,37 @@ class AbstractConnector(AbstractMinimalConnector):
"""this allows connectors to override the default behavior"""
return get_data(remote_id)
def create_edition_from_data(self, work, edition_data):
def create_edition_from_data(self, work, edition_data, instance=None):
"""if we already have the work, we're ready"""
mapped_data = dict_from_mappings(edition_data, self.book_mappings)
mapped_data["work"] = work.remote_id
edition_activity = activitypub.Edition(**mapped_data)
edition = edition_activity.to_model(model=models.Edition, overwrite=False)
edition.connector = self.connector
edition.save()
edition = edition_activity.to_model(
model=models.Edition, overwrite=False, instance=instance
)
# if we're updating an existing instance, we don't need to load authors
if instance:
return edition
if not edition.connector:
edition.connector = self.connector
edition.save(broadcast=False, update_fields=["connector"])
for author in self.get_authors_from_data(edition_data):
edition.authors.add(author)
# use the authors from the work if none are found for the edition
if not edition.authors.exists() and work.authors.exists():
edition.authors.set(work.authors.all())
return edition
def get_or_create_author(self, remote_id):
def get_or_create_author(self, remote_id, instance=None):
"""load that author"""
existing = models.Author.find_existing_by_remote_id(remote_id)
if existing:
return existing
if not instance:
existing = models.Author.find_existing_by_remote_id(remote_id)
if existing:
return existing
data = self.get_book_data(remote_id)
@ -181,7 +191,24 @@ class AbstractConnector(AbstractMinimalConnector):
return None
# this will dedupe
return activity.to_model(model=models.Author, overwrite=False)
return activity.to_model(
model=models.Author, overwrite=False, instance=instance
)
def get_remote_id_from_model(self, obj):
"""given the data stored, how can we look this up"""
return getattr(obj, getattr(self, "generated_remote_link_field"))
def update_author_from_remote(self, obj):
"""load the remote data from this connector and add it to an existing author"""
remote_id = self.get_remote_id_from_model(obj)
return self.get_or_create_author(remote_id, instance=obj)
def update_book_from_remote(self, obj):
"""load the remote data from this connector and add it to an existing book"""
remote_id = self.get_remote_id_from_model(obj)
data = self.get_book_data(remote_id)
return self.create_edition_from_data(obj.parent_work, data, instance=obj)
@abstractmethod
def is_work_data(self, data):
@ -227,15 +254,17 @@ def get_data(url, params=None, timeout=10):
resp = requests.get(
url,
params=params,
headers={
"Accept": "application/json; charset=utf-8",
headers={ # pylint: disable=line-too-long
"Accept": (
'application/json, application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'
),
"User-Agent": settings.USER_AGENT,
},
timeout=timeout,
)
except RequestException as err:
logger.exception(err)
raise ConnectorException()
raise ConnectorException(err)
if not resp.ok:
raise ConnectorException()
@ -243,7 +272,7 @@ def get_data(url, params=None, timeout=10):
data = resp.json()
except ValueError as err:
logger.exception(err)
raise ConnectorException()
raise ConnectorException(err)
return data

View file

@ -11,6 +11,8 @@ from .connector_manager import ConnectorException
class Connector(AbstractConnector):
"""instantiate a connector for inventaire"""
generated_remote_link_field = "inventaire_id"
def __init__(self, identifier):
super().__init__(identifier)
@ -210,6 +212,11 @@ class Connector(AbstractConnector):
return ""
return data.get("extract")
def get_remote_id_from_model(self, obj):
"""use get_remote_id to figure out the link from a model obj"""
remote_id_value = obj.inventaire_id
return self.get_remote_id(remote_id_value)
def get_language_code(options, code="en"):
"""when there are a bunch of translation but we need a single field"""

View file

@ -12,6 +12,8 @@ from .openlibrary_languages import languages
class Connector(AbstractConnector):
"""instantiate a connector for OL"""
generated_remote_link_field = "openlibrary_link"
def __init__(self, identifier):
super().__init__(identifier)
@ -66,6 +68,7 @@ class Connector(AbstractConnector):
Mapping("born", remote_field="birth_date"),
Mapping("died", remote_field="death_date"),
Mapping("bio", formatter=get_description),
Mapping("isni", remote_field="remote_ids", formatter=get_isni),
]
def get_book_data(self, remote_id):
@ -224,6 +227,13 @@ def get_languages(language_blob):
return langs
def get_isni(remote_ids_blob):
"""extract the isni from the remote id data for the author"""
if not remote_ids_blob or not isinstance(remote_ids_blob, dict):
return None
return remote_ids_blob.get("isni")
def pick_default_edition(options):
"""favor physical copies with covers in english"""
if not options:

View file

@ -3,4 +3,5 @@
from .importer import Importer
from .goodreads_import import GoodreadsImporter
from .librarything_import import LibrarythingImporter
from .openlibrary_import import OpenLibraryImporter
from .storygraph_import import StorygraphImporter

View file

@ -26,7 +26,7 @@ class Importer:
("authors", ["author", "authors", "primary author"]),
("isbn_10", ["isbn10", "isbn"]),
("isbn_13", ["isbn13", "isbn", "isbns"]),
("shelf", ["shelf", "exclusive shelf", "read status"]),
("shelf", ["shelf", "exclusive shelf", "read status", "bookshelf"]),
("review_name", ["review name"]),
("review_body", ["my review", "review"]),
("rating", ["my rating", "rating", "star rating"]),
@ -36,9 +36,9 @@ class Importer:
]
date_fields = ["date_added", "date_started", "date_finished"]
shelf_mapping_guesses = {
"to-read": ["to-read"],
"read": ["read"],
"reading": ["currently-reading", "reading"],
"to-read": ["to-read", "want to read"],
"read": ["read", "already read"],
"reading": ["currently-reading", "reading", "currently reading"],
}
def create_job(self, user, csv_file, include_reviews, privacy):
@ -90,7 +90,10 @@ class Importer:
def get_shelf(self, normalized_row):
"""determine which shelf to use"""
shelf_name = normalized_row["shelf"]
shelf_name = normalized_row.get("shelf")
if not shelf_name:
return None
shelf_name = shelf_name.lower()
shelf = [
s for (s, gs) in self.shelf_mapping_guesses.items() if shelf_name in gs
]
@ -106,6 +109,7 @@ class Importer:
user=user,
include_reviews=original_job.include_reviews,
privacy=original_job.privacy,
source=original_job.source,
# TODO: allow users to adjust mappings
mappings=original_job.mappings,
retry=True,

View file

@ -0,0 +1,13 @@
""" handle reading a csv from openlibrary"""
from . import Importer
class OpenLibraryImporter(Importer):
"""csv downloads from OpenLibrary"""
service = "OpenLibrary"
def __init__(self, *args, **kwargs):
self.row_mappings_guesses.append(("openlibrary_key", ["edition id"]))
self.row_mappings_guesses.append(("openlibrary_work_key", ["work id"]))
super().__init__(*args, **kwargs)

View file

@ -3,6 +3,6 @@ from . import Importer
class StorygraphImporter(Importer):
"""csv downloads from librarything"""
"""csv downloads from Storygraph"""
service = "Storygraph"

View file

@ -0,0 +1,29 @@
# Generated by Django 3.2.5 on 2021-12-04 10:55
from django.db import migrations, models
import uuid
def gen_uuid(apps, schema_editor):
"""sets an unique UUID for embed_key"""
book_lists = apps.get_model("bookwyrm", "List")
db_alias = schema_editor.connection.alias
for book_list in book_lists.objects.using(db_alias).all():
book_list.embed_key = uuid.uuid4()
book_list.save(broadcast=False)
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0119_user_feed_status_types"),
]
operations = [
migrations.AddField(
model_name="list",
name="embed_key",
field=models.UUIDField(editable=False, null=True, unique=True),
),
migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.5 on 2021-12-22 11:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0120_list_embed_key"),
]
operations = [
migrations.AddField(
model_name="user",
name="summary_keys",
field=models.JSONField(null=True),
),
]

View file

@ -1,4 +1,5 @@
""" database schema for info about authors """
import re
from django.contrib.postgres.indexes import GinIndex
from django.db import models
@ -33,6 +34,17 @@ class Author(BookDataModel):
)
bio = fields.HtmlField(null=True, blank=True)
@property
def isni_link(self):
"""generate the url from the isni id"""
clean_isni = re.sub(r"\s", "", self.isni)
return f"https://isni.org/isni/{clean_isni}"
@property
def openlibrary_link(self):
"""generate the url from the openlibrary id"""
return f"https://openlibrary.org/authors/{self.openlibrary_key}"
def get_remote_id(self):
"""editions and works both use "book" instead of model_name"""
return f"https://{DOMAIN}/author/{self.id}"

View file

@ -52,6 +52,16 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
null=True,
)
@property
def openlibrary_link(self):
"""generate the url from the openlibrary id"""
return f"https://openlibrary.org/books/{self.openlibrary_key}"
@property
def inventaire_link(self):
"""generate the url from the inventaire id"""
return f"https://inventaire.io/entity/{self.inventaire_id}"
class Meta:
"""can't initialize this model, that wouldn't make sense"""

View file

@ -73,7 +73,7 @@ class GroupMember(models.Model):
)
).exists():
raise IntegrityError()
# accepts and requests are handled by the GroupInvitation model
# accepts and requests are handled by the GroupMemberInvitation model
super().save(*args, **kwargs)
@classmethod
@ -150,31 +150,30 @@ class GroupMemberInvitation(models.Model):
notification_type=notification_type,
)
@transaction.atomic
def accept(self):
"""turn this request into the real deal"""
GroupMember.from_request(self)
with transaction.atomic():
GroupMember.from_request(self)
model = apps.get_model("bookwyrm.Notification", require_ready=True)
# tell the group owner
model.objects.create(
user=self.group.user,
related_user=self.user,
related_group=self.group,
notification_type="ACCEPT",
)
model = apps.get_model("bookwyrm.Notification", require_ready=True)
# tell the group owner
model.objects.create(
user=self.group.user,
related_user=self.user,
related_group=self.group,
notification_type="ACCEPT",
)
# let the other members know about it
for membership in self.group.memberships.all():
member = membership.user
if member not in (self.user, self.group.user):
model.objects.create(
user=member,
related_user=self.user,
related_group=self.group,
notification_type="JOIN",
)
# let the other members know about it
for membership in self.group.memberships.all():
member = membership.user
if member not in (self.user, self.group.user):
model.objects.create(
user=member,
related_user=self.user,
related_group=self.group,
notification_type="JOIN",
)
def reject(self):
"""generate a Reject for this membership request"""

View file

@ -25,7 +25,7 @@ def construct_search_term(title, author):
# Strip brackets (usually series title from search term)
title = re.sub(r"\s*\([^)]*\)\s*", "", title)
# Open library doesn't like including author initials in search term.
author = re.sub(r"(\w\.)+\s*", "", author)
author = re.sub(r"(\w\.)+\s*", "", author) if author else ""
return " ".join([title, author])
@ -88,7 +88,9 @@ class ImportItem(models.Model):
return
if self.isbn:
self.book = self.get_book_from_isbn()
self.book = self.get_book_from_identifier()
elif self.openlibrary_key:
self.book = self.get_book_from_identifier(field="openlibrary_key")
else:
# don't fall back on title/author search if isbn is present.
# you're too likely to mismatch
@ -98,10 +100,10 @@ class ImportItem(models.Model):
else:
self.book_guess = book
def get_book_from_isbn(self):
"""search by isbn"""
def get_book_from_identifier(self, field="isbn"):
"""search by isbn or other unique identifier"""
search_result = connector_manager.first_search_result(
self.isbn, min_confidence=0.999
getattr(self, field), min_confidence=0.999
)
if search_result:
# it's already in the right format
@ -114,6 +116,8 @@ class ImportItem(models.Model):
def get_book_from_title_author(self):
"""search by title and author"""
if not self.title:
return None, 0
search_term = construct_search_term(self.title, self.author)
search_result = connector_manager.first_search_result(
search_term, min_confidence=0.1
@ -145,6 +149,13 @@ class ImportItem(models.Model):
self.normalized_data.get("isbn_10")
)
@property
def openlibrary_key(self):
"""the edition identifier is preferable to the work key"""
return self.normalized_data.get("openlibrary_key") or self.normalized_data.get(
"openlibrary_work_key"
)
@property
def shelf(self):
"""the goodreads shelf field"""

View file

@ -1,4 +1,6 @@
""" make a list of books!! """
import uuid
from django.apps import apps
from django.db import models
from django.db.models import Q
@ -43,6 +45,7 @@ class List(OrderedCollectionMixin, BookWyrmModel):
through="ListItem",
through_fields=("book_list", "book"),
)
embed_key = models.UUIDField(unique=True, null=True, editable=False)
activity_serializer = activitypub.BookList
def get_remote_id(self):
@ -105,6 +108,12 @@ class List(OrderedCollectionMixin, BookWyrmModel):
group=None, curation="closed"
)
def save(self, *args, **kwargs):
"""on save, update embed_key and avoid clash with existing code"""
if not self.embed_key:
self.embed_key = uuid.uuid4()
return super().save(*args, **kwargs)
class ListItem(CollectionItemMixin, BookWyrmModel):
"""ok"""

View file

@ -148,6 +148,8 @@ class User(OrderedCollectionPageMixin, AbstractUser):
size=8,
default=get_feed_filter_choices,
)
# annual summary keys
summary_keys = models.JSONField(null=True)
preferred_timezone = models.CharField(
choices=[(str(tz), str(tz)) for tz in pytz.all_timezones],

View file

@ -14,7 +14,7 @@ VERSION = "0.1.0"
PAGE_LENGTH = env("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
JS_CACHE = "3891b373"
JS_CACHE = "2d3181e1"
# email
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")

View file

@ -20,6 +20,10 @@ body {
overflow: visible;
}
.card.has-border {
border: 1px solid #eee;
}
.scroll-x {
overflow: hidden;
overflow-x: auto;
@ -89,6 +93,9 @@ body {
display: inline !important;
}
/** File input styles
******************************************************************************/
input[type=file]::file-selector-button {
-moz-appearance: none;
-webkit-appearance: none;
@ -115,17 +122,15 @@ input[type=file]::file-selector-button:hover {
color: #363636;
}
details .dropdown-menu {
display: block !important;
/** General `details` element styles
******************************************************************************/
summary {
cursor: pointer;
}
details.dropdown[open] summary.dropdown-trigger::before {
content: "";
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
summary::-webkit-details-marker {
display: none;
}
summary::marker {
@ -143,6 +148,57 @@ summary::marker {
margin-top: 1em;
}
/** Details dropdown
******************************************************************************/
details.dropdown[open] summary.dropdown-trigger::before {
content: "";
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
details .dropdown-menu {
display: block !important;
}
details .dropdown-menu button {
/* Fix weird Safari defaults */
box-sizing: border-box;
}
details.dropdown .dropdown-menu button:focus-visible,
details.dropdown .dropdown-menu a:focus-visible {
outline-style: auto;
outline-offset: -2px;
}
@media only screen and (max-width: 768px) {
details.dropdown[open] summary.dropdown-trigger::before {
background-color: rgba(0, 0, 0, 0.5);
z-index: 30;
}
details .dropdown-menu {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex !important;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 100;
}
details .dropdown-menu > * {
pointer-events: all;
}
}
/** Shelving
******************************************************************************/
@ -551,6 +607,68 @@ 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: stretch;
}
.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));
}
/* Copy
******************************************************************************/
.horizontal-copy {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
}
.horizontal-copy textarea {
min-width: initial;
white-space: nowrap;
}
.horizontal-copy button {
align-self: stretch;
height: unset;
}
.vertical-copy {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
}
.vertical-copy button {
width: 100%;
}
/* 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

@ -46,4 +46,5 @@
<glyph unicode="&#xe9d9;" glyph-name="star-full" d="M1024 562.95l-353.78 51.408-158.22 320.582-158.216-320.582-353.784-51.408 256-249.538-60.432-352.352 316.432 166.358 316.432-166.358-60.434 352.352 256.002 249.538z" />
<glyph unicode="&#xe9da;" glyph-name="heart" d="M755.188 896c-107.63 0-200.258-87.554-243.164-179-42.938 91.444-135.578 179-243.216 179-148.382 0-268.808-120.44-268.808-268.832 0-301.846 304.5-380.994 512.022-679.418 196.154 296.576 511.978 387.206 511.978 679.418 0 148.392-120.43 268.832-268.812 268.832z" />
<glyph unicode="&#xea0a;" glyph-name="plus" d="M992 576h-352v352c0 17.672-14.328 32-32 32h-192c-17.672 0-32-14.328-32-32v-352h-352c-17.672 0-32-14.328-32-32v-192c0-17.672 14.328-32 32-32h352v-352c0-17.672 14.328-32 32-32h192c17.672 0 32 14.328 32 32v352h352c17.672 0 32 14.328 32 32v192c0 17.672-14.328 32-32 32z" />
<glyph unicode="&#xea36;" glyph-name="download" d="M512-32l480 480h-288v512h-384v-512h-288z" />
</font></defs></svg>

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

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

@ -1,10 +1,10 @@
@font-face {
font-family: 'icomoon';
src: url('../fonts/icomoon.eot?36x4a3');
src: url('../fonts/icomoon.eot?36x4a3#iefix') format('embedded-opentype'),
url('../fonts/icomoon.ttf?36x4a3') format('truetype'),
url('../fonts/icomoon.woff?36x4a3') format('woff'),
url('../fonts/icomoon.svg?36x4a3#icomoon') format('svg');
src: url('../fonts/icomoon.eot?r7jc98');
src: url('../fonts/icomoon.eot?r7jc98#iefix') format('embedded-opentype'),
url('../fonts/icomoon.ttf?r7jc98') format('truetype'),
url('../fonts/icomoon.woff?r7jc98') format('woff'),
url('../fonts/icomoon.svg?r7jc98#icomoon') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
@ -142,3 +142,6 @@
.icon-spinner:before {
content: "\e97a";
}
.icon-download:before {
content: "\ea36";
}

View file

@ -51,6 +51,12 @@ let BookWyrm = new class {
'click',
this.duplicateInput.bind(this)
))
document.querySelectorAll('details.dropdown')
.forEach(node => node.addEventListener(
'toggle',
this.handleDetailsDropdown.bind(this)
))
}
@ -66,6 +72,9 @@ let BookWyrm = new class {
document.querySelectorAll('input[type="file"]').forEach(
bookwyrm.disableIfTooLarge.bind(bookwyrm)
);
document.querySelectorAll('[data-copytext]').forEach(
bookwyrm.copyText.bind(bookwyrm)
);
});
}
@ -411,6 +420,21 @@ let BookWyrm = new class {
}
}
/**
* Display pop up window.
*
* @param {string} url Url to open
* @param {string} windowName windowName
* @return {undefined}
*/
displayPopUp(url, windowName) {
window.open(
url,
windowName,
"left=100,top=100,width=430,height=600"
);
}
duplicateInput (event ) {
const trigger = event.currentTarget;
const input_id = trigger.dataset['duplicate']
@ -430,4 +454,133 @@ let BookWyrm = new class {
parent.appendChild(label)
parent.appendChild(input)
}
/**
* Set up a "click-to-copy" component from a textarea element
* with `data-copytext`, `data-copytext-label`, `data-copytext-success`
* attributes.
*
* @param {object} node - DOM node of the text container
* @return {undefined}
*/
copyText(textareaEl) {
const text = textareaEl.textContent;
const copyButtonEl = document.createElement('button');
copyButtonEl.textContent = textareaEl.dataset.copytextLabel;
copyButtonEl.classList.add(
"button",
"is-small",
"is-primary",
"is-light"
);
copyButtonEl.addEventListener('click', () => {
navigator.clipboard.writeText(text).then(function() {
textareaEl.classList.add('is-success');
copyButtonEl.classList.replace('is-primary', 'is-success');
copyButtonEl.textContent = textareaEl.dataset.copytextSuccess;
});
});
textareaEl.parentNode.appendChild(copyButtonEl)
}
/**
* Handle the details dropdown component.
*
* @param {Event} event - Event fired by a `details` element
* with the `dropdown` class name, on toggle.
* @return {undefined}
*/
handleDetailsDropdown(event) {
const detailsElement = event.target;
const summaryElement = detailsElement.querySelector('summary');
const menuElement = detailsElement.querySelector('.dropdown-menu');
const htmlElement = document.querySelector('html');
if (detailsElement.open) {
// Focus first menu element
menuElement.querySelectorAll(
'a[href]:not([disabled]), button:not([disabled])'
)[0].focus();
// Enable focus trap
menuElement.addEventListener('keydown', this.handleFocusTrap);
// Close on Esc
detailsElement.addEventListener('keydown', handleEscKey);
// Clip page if Mobile
if (this.isMobile()) {
htmlElement.classList.add('is-clipped');
}
} else {
summaryElement.focus();
// Disable focus trap
menuElement.removeEventListener('keydown', this.handleFocusTrap);
// Unclip page
if (this.isMobile()) {
htmlElement.classList.remove('is-clipped');
}
}
function handleEscKey(event) {
if (event.key !== 'Escape') {
return;
}
summaryElement.click();
}
}
/**
* Check if windows matches mobile media query.
*
* @return {Boolean}
*/
isMobile() {
return window.matchMedia("(max-width: 768px)").matches;
}
/**
* Focus trap handler
*
* @param {Event} event - Keydown event.
* @return {undefined}
*/
handleFocusTrap(event) {
if (event.key !== 'Tab') {
return;
}
const focusableEls = event.currentTarget.querySelectorAll(
[
'a[href]:not([disabled])',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([type="hidden"]):not([disabled])',
'select:not([disabled])',
'details:not([disabled])',
'[tabindex]:not([tabindex="-1"]):not([disabled])'
].join(',')
);
const firstFocusableEl = focusableEls[0];
const lastFocusableEl = focusableEls[focusableEls.length - 1];
if (event.shiftKey ) /* Shift + tab */ {
if (document.activeElement === firstFocusableEl) {
lastFocusableEl.focus();
event.preventDefault();
}
} else /* Tab */ {
if (document.activeElement === lastFocusableEl) {
firstFocusableEl.focus();
event.preventDefault();
}
}
}
}();

View file

@ -1,34 +0,0 @@
(function() {
'use strict';
/**
* Toggle all descendant checkboxes of a target.
*
* Use `data-target="ID_OF_TARGET"` on the node on which the event is listened
* to (checkbox, button, link), where_ID_OF_TARGET_ should be the ID of an
* ancestor for the checkboxes.
*
* @example
* <input
* type="checkbox"
* data-action="toggle-all"
* data-target="failed-imports"
* >
* @param {Event} event
* @return {undefined}
*/
function toggleAllCheckboxes(event) {
const mainCheckbox = event.target;
document
.querySelectorAll(`#${mainCheckbox.dataset.target} [type="checkbox"]`)
.forEach(checkbox => checkbox.checked = mainCheckbox.checked);
}
document
.querySelectorAll('[data-action="toggle-all"]')
.forEach(input => {
input.addEventListener('change', toggleAllCheckboxes);
});
})();

View file

@ -0,0 +1,257 @@
{% extends 'layout.html' %}
{% load i18n %}
{% load static %}
{% load humanize %}
{% 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 %}
{% with display_name=summary_user.display_name %}
{% if user == summary_user %}
<div class="columns">
{% with year=paginated_years|first %}
{% if year %}
<div class="column">
<a href="{% url 'annual-summary' summary_user.localname year %}">
<span class="icon icon-arrow-left" aria-hidden="true"></span>
{{ year }}
</a>
</div>
{% endif %}
{% endwith %}
{% with year=paginated_years|last %}
{% if year %}
<div class="column has-text-right">
<a href="{% url 'annual-summary' summary_user.localname year %}">
{{ year }}
<span class="icon icon-arrow-right" aria-hidden="true"></span>
</a>
</div>
{% endif %}
{% endwith %}
</div>
{% endif %}
<h1 class="title is-1 is-serif has-text-centered">
📚✨
{% blocktrans %}{{ year }} <em>in the books</em>{% endblocktrans %}
✨📚
</h1>
<p class="subtitle is-3 is-serif has-text-centered mb-5">
{% blocktrans %}<em>{{ display_name }}s</em> year of reading{% endblocktrans %}
</p>
<details>
<summary class="has-text-centered">
<span role="heading" aria-level="2" class="title is-6 has-text-success-dark">
{% trans "Share this page" %}
</span>
</summary>
<div class="columns mt-3">
<div class="column is-three-fifths is-offset-one-fifth">
{% if year_key %}
<div class="horizontal-copy mb-5">
<textarea rows="1" readonly class="textarea is-small" aria-labelledby="embed-label" data-copytext data-copytext-label="{% trans 'Copy address' %}" data-copytext-success="{% trans 'Copied!' %}">{{ request.scheme|add:"://"|add:request.get_host|add:request.path }}?key={{ year_key }}</textarea>
</div>
{% endif %}
{% if user == summary_user %}
{% if year_key %}
<div class="columns mb-2">
<div class="column pb-0">
<p>{% trans "Sharing status: <strong>public with key</strong>" %}</p>
<p>{% trans "The page can be seen by anyone with the complete address." %}</p>
</div>
<form class="column pb-0 is-narrow" method="post" action="{% url "summary-revoke-key" %}" id="revoke-key">
{% csrf_token %}
<input type="hidden" name="year" value="{{ year }}" />
<button class="button is-danger is-outlined" type="submit">{% trans "Make page private" %}</button>
</form>
</div>
{% else %}
<div class="columns">
<div class="column pb-0">
<p>{% trans "Sharing status: <strong>private</strong>" %}</p>
<p>{% trans "The page is private, only you can see it." %}</p>
</div>
<form class="column pb-0 is-narrow" method="post" action="{% url "summary-add-key" %}" id="add-key">
{% csrf_token %}
<input type="hidden" name="year" value="{{ year }}" />
<button class="button is-primary is-outlined" type="submit">{% trans "Make page public" %}</button>
</form>
</div>
{% endif %}
<p class="help">{% trans "When you make your page private, the old key wont give access to the page anymore. A new key will be created if the page is once again made public." %}</p>
{% endif %}
</div>
</div>
</details>
<div class="columns mt-1">
<div class="column is-one-fifth is-offset-two-fifths">
<hr />
</div>
</div>
{% if not books %}
<p class="has-text-centered is-size-5">{% blocktrans %}Sadly {{ display_name }} didnt 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 with pages_total=pages_total|intcomma %}In {{ year }}, {{ display_name }} 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 with pages=pages_average|intcomma %}That makes an average of {{ pages }} pages per book.{% endblocktrans %}
</p>
{% if no_page_number %}
<p class="subtitle is-6">
{% blocktrans trimmed count counter=no_page_number %}
({{ no_page_number }} book doesnt have pages)
{% plural %}
({{ no_page_number }} books dont have pages)
{% endblocktrans %}
</p>
{% endif %}
</div>
</div>
{% if book_pages_lowest and book_pages_highest %}
<div class="columns 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 "Their 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" %}
<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>
{% if ratings_total > 0 %}
<div class="columns">
<div class="column has-text-centered">
<h2 class="title is-3 is-serif">
{% blocktrans %}{{ display_name }} left {{ ratings_total }} ratings, <br />their 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>
{% if book_rating_highest %}
<div class="column is-4">
{% trans "Their 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 %}Their rating: <strong>{{ rating }}</strong>{% endblocktrans%}
{% endwith %}
</p>
</div>
{% endif %}
</div>
<div class="columns">
<div class="column is-one-fifth is-offset-two-fifths">
<hr />
</div>
</div>
{% endif %}
<div class="columns">
<div class="column has-text-centered">
<h2 class="title is-3 is-serif">
{% blocktrans %}All the books {{ display_name }} 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' size='xxlarge' %}
<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' size='large' %}
<span class="book-title is-serif is-size-6">
{{ book.title }}
</span>
</a>
{% endif %}
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% endwith %}
{% endblock %}

View file

@ -14,7 +14,7 @@
</div>
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
<div class="column is-narrow">
<a href="{{ author.local_path }}/edit">
<a href="{% url 'edit-author' author.id %}">
<span class="icon icon-pencil" title="{% trans 'Edit Author' %}" aria-hidden="True"></span>
<span class="is-hidden-mobile">{% trans "Edit Author" %}</span>
</a>
@ -23,102 +23,130 @@
</div>
</div>
<div class="block columns content" itemscope itemtype="https://schema.org/Person">
<div class="block columns is-flex-direction-row-reverse" itemscope itemtype="https://schema.org/Person">
<meta itemprop="name" content="{{ author.name }}">
{% if author.bio %}
<div class="column">
{% include "snippets/trimmed_text.html" with full=author.bio trim_length=200 %}
</div>
{% endif %}
{% if author.aliases or author.born or author.died or author.wikipedia_link or author.openlibrary_key or author.inventaire_id or author.isni %}
{% firstof author.aliases author.born author.died as details %}
{% firstof author.wikipedia_link author.openlibrary_key author.inventaire_id author.isni as links %}
{% if details or links %}
<div class="column is-two-fifths">
<div class="box py-2">
<dl>
{% if details %}
<section class="block content">
<h2 class="title is-4">{% trans "Author details" %}</h2>
<dl class="box">
{% if author.aliases %}
<div class="is-flex is-flex-wrap-wrap my-1">
<div class="is-flex is-flex-wrap-wrap mr-1">
<dt class="has-text-weight-bold mr-1">{% trans "Aliases:" %}</dt>
{% for alias in author.aliases %}
<dd itemprop="alternateName" content="{{alias}}">
{{alias}}{% if not forloop.last %},&nbsp;{% endif %}
</dd>
{% endfor %}
<dd>
{% include "snippets/trimmed_list.html" with items=author.aliases itemprop="alternateName" %}
</dd>
</div>
{% endif %}
{% if author.born %}
<div class="is-flex my-1">
<div class="is-flex mt-1">
<dt class="has-text-weight-bold mr-1">{% trans "Born:" %}</dt>
<dd itemprop="birthDate">{{ author.born|naturalday }}</dd>
</div>
{% endif %}
{% if author.died %}
<div class="is-flex my-1">
<div class="is-flex mt-1">
<dt class="has-text-weight-bold mr-1">{% trans "Died:" %}</dt>
<dd itemprop="deathDate">{{ author.died|naturalday }}</dd>
</div>
{% endif %}
</dl>
</section>
{% endif %}
{% if author.wikipedia_link %}
<p class="my-1">
<a itemprop="sameAs" href="{{ author.wikipedia_link }}" rel="noopener" target="_blank">
{% trans "Wikipedia" %}
</a>
</p>
{% endif %}
{% if links %}
<section>
<h2 class="title is-4">{% trans "External links" %}</h2>
<div class="box">
{% if author.wikipedia_link %}
<div>
<a itemprop="sameAs" href="{{ author.wikipedia_link }}" rel="noopener" target="_blank">
{% trans "Wikipedia" %}
</a>
</div>
{% endif %}
{% if author.isni %}
<p class="my-1">
<a itemprop="sameAs" href="https://isni.org/isni/{{ author.isni|remove_spaces }}" rel="noopener" target="_blank">
{% trans "View ISNI record" %}
</a>
</p>
{% endif %}
{% if author.isni %}
<div class="mt-1">
<a itemprop="sameAs" href="{{ author.isni_link }}" rel="noopener" target="_blank">
{% trans "View ISNI record" %}
</a>
</div>
{% endif %}
{% if author.openlibrary_key %}
<p class="my-1">
<a itemprop="sameAs" href="https://openlibrary.org/authors/{{ author.openlibrary_key }}" target="_blank" rel="noopener">
{% trans "View on OpenLibrary" %}
</a>
</p>
{% endif %}
{% trans "Load data" as button_text %}
{% if author.openlibrary_key %}
<div class="mt-1 is-flex">
<a class="mr-3" itemprop="sameAs" href="{{ author.openlibrary_link }}" target="_blank" rel="noopener">
{% trans "View on OpenLibrary" %}
</a>
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
{% with controls_text="ol_sync" controls_uid=author.id %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text focus="modal_title_ol_sync" class="is-small" icon_with_text="download" %}
{% include "author/sync_modal.html" with source="openlibrary.org" source_name="OpenLibrary" %}
{% endwith %}
{% endif %}
</div>
{% endif %}
{% if author.inventaire_id %}
<p class="my-1">
<a itemprop="sameAs" href="https://inventaire.io/entity/{{ author.inventaire_id }}" target="_blank" rel="noopener">
{% trans "View on Inventaire" %}
</a>
</p>
{% endif %}
{% if author.inventaire_id %}
<div class="mt-1 is-flex">
<a class="mr-3" itemprop="sameAs" href="{{ author.inventaire_link }}" target="_blank" rel="noopener">
{% trans "View on Inventaire" %}
</a>
{% if author.librarything_key %}
<p class="my-1">
<a itemprop="sameAs" href="https://www.librarything.com/author/{{ author.librarything_key }}" target="_blank" rel="noopener">
{% trans "View on LibraryThing" %}
</a>
</p>
{% endif %}
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
{% with controls_text="iv_sync" controls_uid=author.id %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text focus="modal_title_iv_sync" class="is-small" icon_with_text="download" %}
{% include "author/sync_modal.html" with source="inventaire.io" source_name="Inventaire" %}
{% endwith %}
{% endif %}
</div>
{% endif %}
{% if author.goodreads_key %}
<p class="my-1">
<a itemprop="sameAs" href="https://www.goodreads.com/author/show/{{ author.goodreads_key }}" target="_blank" rel="noopener">
{% trans "View on Goodreads" %}
</a>
</p>
{% endif %}
</div>
</div>
{% endif %}
<div class="column">
{% if author.bio %}
{{ author.bio|to_markdown|safe }}
{% if author.librarything_key %}
<div class="mt-1">
<a itemprop="sameAs" href="https://www.librarything.com/author/{{ author.librarything_key }}" target="_blank" rel="noopener">
{% trans "View on LibraryThing" %}
</a>
</div>
{% endif %}
{% if author.goodreads_key %}
<div>
<a itemprop="sameAs" href="https://www.goodreads.com/author/show/{{ author.goodreads_key }}" target="_blank" rel="noopener">
{% trans "View on Goodreads" %}
</a>
</div>
{% endif %}
</div>
</section>
{% endif %}
</div>
{% endif %}
</div>
<hr aria-hidden="true">
<div class="block">
<h3 class="title is-4">{% blocktrans with name=author.name %}Books by {{ name }}{% endblocktrans %}</h3>
<h2 class="title is-4">{% blocktrans with name=author.name %}Books by {{ name }}{% endblocktrans %}</h2>
<div class="columns is-multiline is-mobile">
{% for book in books %}
<div class="column is-one-fifth">
{% include 'landing/small-book.html' with book=book %}
<div class="column is-one-fifth-tablet is-half-mobile is-flex is-flex-direction-column">
<div class="is-flex-grow-1">
{% include 'landing/small-book.html' with book=book %}
</div>
{% include 'snippets/shelve_button/shelve_button.html' with book=book %}
</div>
{% endfor %}

View file

@ -0,0 +1,30 @@
{% extends 'components/modal.html' %}
{% load i18n %}
{% block modal-title %}
{% trans "Load data" %}
{% endblock %}
{% block modal-form-open %}
<form name="{{ source }}-update" method="POST" action="{% url 'author-update-remote' author.id source %}">
{% csrf_token %}
{% endblock %}
{% block modal-body %}
<p>
{% blocktrans trimmed %}
Loading data will connect to <strong>{{ source_name }}</strong> and check for any metadata about this author which aren't present here. Existing metadata will not be overwritten.
{% endblocktrans %}
</p>
{% endblock %}
{% block modal-footer %}
<button class="button is-primary" type="submit">
<span>{% trans "Confirm" %}</span>
</button>
{% trans "Cancel" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text %}
{% endblock %}
{% block modal-form-close %}</form>{% endblock %}

View file

@ -90,11 +90,28 @@
</div>
{% endwith %}
{% trans "Load data" as button_text %}
{% if book.openlibrary_key %}
<p><a href="https://openlibrary.org/books/{{ book.openlibrary_key }}" target="_blank" rel="noopener">{% trans "View on OpenLibrary" %}</a></p>
<p>
<a href="{{ book.openlibrary_link }}" target="_blank" rel="noopener">{% trans "View on OpenLibrary" %}</a>
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
{% with controls_text="ol_sync" controls_uid=book.id %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text focus="modal_title_ol_sync" class="is-small" icon_with_text="download" %}
{% include "book/sync_modal.html" with source="openlibrary.org" source_name="OpenLibrary" %}
{% endwith %}
{% endif %}
</p>
{% endif %}
{% if book.inventaire_id %}
<p><a href="https://inventaire.io/entity/{{ book.inventaire_id }}" target="_blank" rel="noopener">{% trans "View on Inventaire" %}</a></p>
<p>
<a href="{{ book.inventaire_link }}" target="_blank" rel="noopener">{% trans "View on Inventaire" %}</a>
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
{% with controls_text="iv_sync" controls_uid=book.id %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text focus="modal_title_iv_sync" class="is-small" icon_with_text="download" %}
{% include "book/sync_modal.html" with source="inventaire.io" source_name="Inventaire" %}
{% endwith %}
{% endif %}
</p>
{% endif %}
</section>
</div>

View file

@ -0,0 +1,30 @@
{% extends 'components/modal.html' %}
{% load i18n %}
{% block modal-title %}
{% trans "Load data" %}
{% endblock %}
{% block modal-form-open %}
<form name="{{ source }}-update" method="POST" action="{% url 'book-update-remote' book.id source %}">
{% csrf_token %}
{% endblock %}
{% block modal-body %}
<p>
{% blocktrans trimmed %}
Loading data will connect to <strong>{{ source_name }}</strong> and check for any metadata about this book which aren't present here. Existing metadata will not be overwritten.
{% endblocktrans %}
</p>
{% endblock %}
{% block modal-footer %}
<button class="button is-primary" type="submit">
<span>{% trans "Confirm" %}</span>
</button>
{% trans "Cancel" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text %}
{% endblock %}
{% block modal-form-close %}</form>{% endblock %}

View file

@ -7,6 +7,7 @@
class="
dropdown control
{% if right %}is-right{% endif %}
has-text-left
"
>
<summary
@ -15,7 +16,7 @@
{% block dropdown-trigger %}{% endblock %}
</summary>
<div class="dropdown-menu control">
<div class="dropdown-menu">
<ul
id="menu_options_{{ uuid }}"
class="dropdown-content p-0 is-clipped"

View file

@ -0,0 +1,53 @@
{% load layout %}
{% load i18n %}
{% load static %}
<!DOCTYPE html>
<html lang="{% get_lang %}">
<head>
<title>{% block title %}BookWyrm{% endblock %} - {{ site.name }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="{% static "css/vendor/bulma.min.css" %}">
<link rel="stylesheet" href="{% static "css/vendor/icons.css" %}">
<link rel="stylesheet" href="{% static "css/bookwyrm.css" %}">
<base target="_blank">
<link rel="shortcut icon" type="image/x-icon" href="{% if site.favicon %}{% get_media_prefix %}{{ site.favicon }}{% else %}{% static "images/favicon.ico" %}{% endif %}">
</head>
<body>
<header class="section py-3">
<a href="/" class="is-flex is-align-items-center">
<img class="image logo is-flex-shrink-0" style="height: 32px" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="{% blocktrans with site_name=site.name %}{{ site_name }} home page{% endblocktrans %}">
<span class="title is-5 ml-2">{{ site.name }}</span>
</a>
</header>
<main class="section py-3">
{% block content %}
{% endblock %}
</main>
<footer class="section py-3">
<p>
<a href="{% url 'about' %}">
{% blocktrans with site_name=site.name %}About {{ site_name }}{% endblocktrans %}
</a>
</p>
{% if site.admin_email %}
<p>
<a href="mailto:{{ site.admin_email }}">
{% trans "Contact site admin" %}
</a>
</p>
{% endif %}
<p>
<a href="https://joinbookwyrm.com/">
{% trans "Join Bookwyrm" %}
</a>
</p>
</footer>
{% block scripts %}{% endblock %}
</body>
</html>

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" data-hide="hide_annual_summary_{{ annual_summary_year }}">
{% include 'feed/summary_card.html' with year=annual_summary_year %}
<hr>
</section>
{% endif %}
{% endif %}
{# activity feed #}

View file

@ -0,0 +1,29 @@
{% 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>
<div class="card-header-icon has-background-success-dark has-text-white">
{% trans "Dismiss message" as button_text %}
<button class="delete set-display" type="button" data-id="hide_annual_summary_{{ year }}" data-value="true">
<span>{% trans "Dismiss message" %}</span>
</button>
</div>
{% 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' request.user.localname year %}" class="button is-success has-background-success-dark">
{% blocktrans %}Discover your stats for {{ year }}!{% endblocktrans %}
</a>
</p>
{% endblock %}

View file

@ -31,6 +31,9 @@
<option value="LibraryThing" {% if current == 'LibraryThing' %}selected{% endif %}>
LibraryThing (TSV)
</option>
<option value="OpenLibrary" {% if current == 'OpenLibrary' %}selected{% endif %}>
OpenLibrary (CSV)
</option>
</select>
</div>
<div class="field">

View file

@ -105,6 +105,11 @@
<th>
{% trans "ISBN" %}
</th>
{% if job.source == "OpenLibrary" %}
<th>
{% trans "Openlibrary key" %}
</th>
{% endif %}
<th>
{% trans "Author" %}
</th>
@ -145,6 +150,11 @@
<td>
{{ item.isbn|default:'' }}
</td>
{% if job.source == "OpenLibrary" %}
<td>
{{ item.openlibrary_key }}
</td>
{% endif %}
<td>
{{ item.normalized_data.authors }}
</td>
@ -224,7 +234,3 @@
</div>
{% endif %}
{% endspaceless %}{% endblock %}
{% block scripts %}
<script src="{% static "js/check_all.js" %}?v={{ js_cache }}"></script>
{% endblock %}

View file

@ -28,13 +28,15 @@
{% 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">
<div class="container">
<div class="navbar-brand">
<a class="navbar-item" href="/">
<img class="image logo" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="Home page">
<img class="image logo" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="{% blocktrans with site_name=site.name %}{{ site_name }} home page{% endblocktrans %}">
</a>
<form class="navbar-item column" action="{% url 'search' %}">
<div class="field has-addons">
@ -267,5 +269,6 @@
<script src="{% static "js/status_cache.js" %}?v={{ js_cache }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,59 @@
{% extends 'embed-layout.html' %}
{% load i18n %}
{% load bookwyrm_tags %}
{% load bookwyrm_group_tags %}
{% load markdown %}
{% block title %}{% blocktrans with list_name=list.name owner=list.user.display_name %}{{ list_name }}, a list by {{owner}}{% endblocktrans %}{% endblock title %}
{% block content %}
<div class="mt-3">
<h1 class="title is-4">
{{ list.name }}
<span class="subtitle">{% include 'snippets/privacy-icons.html' with item=list %}</span>
</h1>
<p class="subtitle is-size-6">
{% include 'lists/created_text.html' with list=list %}
{% blocktrans with site_name=site.name %}on <a href="/">{{ site_name }}</a>{% endblocktrans %}
</p>
<div class="block content">
{% include 'snippets/trimmed_text.html' with full=list.description %}
</div>
<section>
{% if not items.object_list.exists %}
<p>{% trans "This list is currently empty" %}</p>
{% else %}
<ol start="{{ items.start_index }}" class="ordered-list">
{% for item in items %}
{% with book=item.book %}
<li class="mb-5 card is-shadowless has-border">
<div class="card-content p-0 mb-0 columns is-gapless is-mobile">
<div class="column is-3-mobile is-2-tablet is-cover align to-t">
<a href="{{ item.book.local_path }}" aria-hidden="true">
{% include 'snippets/book_cover.html' with cover_class='is-w-auto is-h-m-tablet is-align-items-flex-start' size='medium' %}
</a>
</div>
<div class="column mx-3 my-2">
<h2 class="title is-6 mb-1">
{% include 'snippets/book_titleby.html' %}
</h2>
<p>
{% include 'snippets/stars.html' with rating=item.book|rating:request.user %}
</p>
<div>
{{ book|book_description|to_markdown|default:""|safe|truncatewords_html:20 }}
</div>
</div>
</div>
</li>
{% endwith %}
{% endfor %}
</ol>
{% endif %}
{% include "snippets/pagination.html" with page=items %}
</section>
</div>
{% endblock %}

View file

@ -186,6 +186,15 @@
{% endfor %}
{% endif %}
{% endif %}
<div>
<h2 class="title is-5 mt-6" id="embed-label">
{% trans "Embed this list on a website" %}
</h2>
<div class="vertical-copy">
<textarea readonly class="textarea is-small" aria-labelledby="embed-label" data-copytext data-copytext-label="{% trans 'Copy embed code' %}" data-copytext-success="{% trans 'Copied!' %}"><iframe style="border-width:0;" id="bookwyrm_list_embed" width="400" height="600" title="{% blocktrans with list_name=list.name site_name=site.name owner=list.user.display_name %}{{ list_name }}, a list by {{owner}} on {{ site_name }}{% endblocktrans %}" src="{{ embed_url }}"></iframe></textarea>
</div>
</div>
</section>
</div>
{% endblock %}

View file

@ -0,0 +1,70 @@
{% load i18n %}
{% block content %}
<div class="block">
{% if error == 'invalid_username' %}
<div class="notification is-warning has-text-centered" role="status">
<p>{% blocktrans %}<strong>{{ account }}</strong> is not a valid username{% endblocktrans %}.</p>
<p>{% trans 'Check you have the correct username before trying again' %}.</p>
</div>
{% elif error == 'user_not_found' %}
<div class="notification is-warning has-text-centered" role="status">
<p>{% blocktrans %}<strong>{{ account }}</strong> could not be found or <code>{{ remote_domain }}</code> does not support identity discovery{% endblocktrans %}.</p>
<p>{% trans 'Check you have the correct username before trying again' %}.</p>
</div>
{% elif error == 'not_supported' %}
<div class="notification is-warning has-text-centered" role="status">
<p>{% blocktrans %}<strong>{{ account }}</strong> was found but <code>{{ remote_domain }}</code> does not support 'remote follow'{% endblocktrans %}.</p>
<p>{% blocktrans %}Try searching for <strong>{{ user }}</strong> on <code>{{ remote_domain }}</code> instead{% endblocktrans %}.</p>
</div>
{% elif not request.user.is_authenticated %}
<div class="navbar-item">
<div class="columns">
<div class="column">
<form name="login" method="post" action="{% url 'login' %}?next={{ request.path }}?acct={{ user.remote_id }}">
{% csrf_token %}
<div class="columns is-variable is-1">
<div class="column">
<label class="is-sr-only" for="id_localname">{% trans "Username:" %}</label>
<input type="text" name="localname" maxlength="150" class="input" required="" id="id_localname" placeholder="{% trans 'username' %}">
</div>
<div class="column">
<label class="is-sr-only" for="id_password">{% trans "Password:" %}</label>
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password" placeholder="{% trans 'password' %}">
<p class="help"><a href="{% url 'password-reset' %}">{% trans "Forgot your password?" %}</a></p>
</div>
<div class="column is-narrow">
<button class="button is-primary" type="submit">{% trans "Log in" %}</button>
</div>
</div>
</form>
</div>
</div>
</div>
{% elif error == 'ostatus_subscribe' %}
<div class="notification is-warning has-text-centered" role="status">
<p>{% blocktrans %}Something went wrong trying to follow <strong>{{ account }}</strong>{% endblocktrans %}</p>
<p>{% trans 'Check you have the correct username before trying again.' %}</p>
</div>
{% elif error == 'is_blocked' %}
<div class="notification is-danger has-text-centered" role="status">
<p>{% blocktrans %}You have blocked <strong>{{ account }}</strong>{% endblocktrans %}</p>
</div>
{% elif error == 'has_blocked' %}
<div class="notification is-danger has-text-centered" role="status">
<p>{% blocktrans %}<strong>{{ account }}</strong> has blocked you{% endblocktrans %}</p>
</div>
{% elif error == 'already_following' %}
<div class="notification is-success has-text-centered" role="status">
<p>{% blocktrans %}You are already following <strong>{{ account }}</strong>{% endblocktrans %}</p>
</div>
{% elif error == 'already_requested' %}
<div class="notification is-success has-text-centered" role="status">
<p>{% blocktrans %}You have already requested to follow <strong>{{ account }}</strong>{% endblocktrans %}</p>
</div>
{% endif %}
</div>
<div class="block is-pulled-right">
<button type="button" class="button" onclick="closeWindow()">Close window</button>
</div>
{% endblock %}

View file

@ -0,0 +1,46 @@
{% extends 'ostatus/template.html' %}
{% load i18n %}
{% load utilities %}
{% block heading %}
{% blocktrans with username=user.localname sitename=site.name %}Follow {{ username }} on the fediverse{% endblocktrans %}
{% endblock %}
{% block content %}
<div class="block card">
<div class="card-content">
<div class="media">
<a href="{{ user.local_path }}" class="media-left">
{% include 'snippets/avatar.html' with user=user large=True %}
</a>
<div class="media-content">
<a href="{{ user.local_path }}" class="is-block mb-2">
<span class="title is-4 is-block">
{{ user.display_name }}
{% if user.manually_approves_followers %}
<span class="icon icon-lock is-size-7" title="{% trans 'Locked account' %}">
<span class="is-sr-only">{% trans "Locked account" %}</span>
</span>
{% endif %}
</span>
<span class="subtitle is-7 is-block">@{{ user|username }}</span>
</a>
</div>
</div>
</div>
</div>
<div class="block">
<p>{% blocktrans with username=user.display_name %}Follow {{ username }} from another Fediverse account like BookWyrm, Mastodon, or Pleroma.{% endblocktrans %}</p>
</div>
<div class="card">
<section class="card-content content">
<form name="remote-follow" method="post" action="{% url 'remote-follow' %}">
{% csrf_token %}
<input type="hidden" name="user" value="{{ user.id }}">
<label class="label" for="remote_user">{% trans 'User handle to follow from:' %}</label>
<input class="input" type="text" name="remote_user" id="remote_user" placeholder="user@example.social" required>
<button class="button mt-1 is-primary" type="submit">{% trans 'Follow!' %}</button>
</form>
</section>
</div>
{% endblock %}

View file

@ -0,0 +1,15 @@
{% load i18n %}
{% if request.user == user %}
{% else %}
<div class="field mb-0">
<div class="control">
<a class="button is-small is-link" href="{% url 'remote-follow-page' %}?user={{ user.username }}" target="_blank" rel="noopener noreferrer" onclick="BookWyrm.displayPopUp(`{% url 'remote-follow-page' %}?user={{ user.username }}`, `remoteFollow`); return false;" aria-describedby="remote_follow_warning">
{% blocktrans with username=user.localname %}Follow on Fediverse{% endblocktrans %}
</a>
</div>
<p id="remote_follow_warning" class="mt-1 is-size-7 has-text-weight-light">
{% trans 'This link opens in a pop-up window' %}
</p>
</div>
{% endif %}

View file

@ -0,0 +1,63 @@
{% extends 'ostatus/template.html' %}
{% load i18n %}
{% load utilities %}
{% load markdown %}
{% block title %}
{% if not request.user.is_authenticated %}
{% blocktrans with sitename=site.name %}Log in to {{ sitename }}{% endblocktrans %}
{% elif error %}
{% blocktrans with sitename=site.name %}Error following from {{ sitename }}{% endblocktrans %}
{% else %}
{% blocktrans with sitename=site.name %}Follow from {{ sitename }}{% endblocktrans %}
{% endif %}
{% endblock %}
{% block heading %}
{% if error %}
{% trans 'Uh oh...' %}
{% elif not request.user.is_authenticated %}
{% trans "Let's log in first..." %}
{% else %}
{% blocktrans with sitename=site.name %}Follow from {{ sitename }}{% endblocktrans %}
{% endif %}
{% endblock %}
{% block content %}
{% if error or not request.user.is_authenticated %}
{% include 'ostatus/error.html' with error=error user=user account=account %}
{% else %}
<div class="block card">
<div class="card-content">
<div class="media">
<a href="{{ user.local_path }}" class="media-left">
{% include 'snippets/avatar.html' with user=user large=True %}
</a>
<div class="media-content">
<a href="{{ user.local_path }}" class="is-block mb-2">
<span class="title is-4 is-block">
{{ user.display_name }}
{% if user.manually_approves_followers %}
<span class="icon icon-lock is-size-7" title="{% trans 'Locked account' %}">
<span class="is-sr-only">{% trans 'Locked account' %}</span>
</span>
{% endif %}
</span>
<span class="subtitle is-7 is-block">@{{ user|username }}</span>
</a>
<form name="follow" method="post" action="{% url 'follow' %}/?next={% url 'ostatus-success' %}?following={{ user.username }}">
{% csrf_token %}
<input name="user" value="{{ user.username }}" hidden>
<button class="button is-link" type="submit">{% blocktrans with username=user.display_name %}Follow {{ username }}{% endblocktrans %}</button>
</form>
</div>
</div>
<div>
{% if user.summary %}
{{ user.summary|to_markdown|safe|truncatechars_html:120 }}
{% else %}&nbsp;{% endif %}
</div>
</div>
</div>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,35 @@
{% extends 'ostatus/template.html' %}
{% load i18n %}
{% load utilities %}
{% block content %}
<div class="block card">
<div class="card-content">
<div class="media">
<a href="{{ user.local_path }}" class="media-left">
{% include 'snippets/avatar.html' with user=user large=True %}
</a>
<div class="media-content">
<a href="{{ user.local_path }}" class="is-block mb-2">
<span class="title is-4 is-block">
{{ user.display_name }}
{% if user.manually_approves_followers %}
<span class="icon icon-lock is-size-7" title="{% trans 'Locked account' %}">
<span class="is-sr-only">{% trans "Locked account" %}</span>
</span>
{% endif %}
</span>
<span class="subtitle is-7 is-block">@{{ user|username }}</span>
</a>
</div>
</div>
<p class="notification is-success">
<span class="icon icon-check m-0-mobile" aria-hidden="true"></span>
<span>{% blocktrans with display_name=user.display_name %}You are now following {{ display_name }}!{% endblocktrans %}</span>
</p>
</div>
</div>
<div class="block is-pulled-right">
<button type="button" class="button" onclick="closeWindow()">Close window</button>
</div>
{% endblock %}

View file

@ -0,0 +1,41 @@
{% load layout %}
{% load i18n %}
{% load static %}
{% load utilities %}
{% load markdown %}
<!DOCTYPE html>
<html lang="{% get_lang %}">
<head>
<title>{% block title %}{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="{% static 'css/vendor/bulma.min.css' %}">
<link rel="stylesheet" href="{% static 'css/vendor/icons.css' %}">
<link rel="stylesheet" href="{% static 'css/bookwyrm.css' %}">
<script>
function closeWindow() {
window.close();
}
</script>
</head>
<body>
<nav class="navbar" aria-label="main navigation">
<div class="container">
<div class="navbar-brand">
<img class="image logo navbar-item" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static 'images/logo-small.png' %}{% endif %}" alt="Home page">
<h2 class="navbar-item subtitle">{% block heading %}{% endblock %}</h2>
</div>
</div>
</nav>
<div class="section is-flex-grow-1 columns is-centered">
<div class="block column is-one-third">
{% block content%}{% endblock %}
</div>
</div>
<script>
var csrf_token = '{{ csrf_token }}';
</script>
<script src="{% static 'js/bookwyrm.js' %}?v={{ js_cache }}"></script>
</body>
</html>

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,34 @@
{% spaceless %}
{% load i18n %}
{% load humanize %}
{% firstof limit 3 as limit %}
{% with subtraction_value='-'|add:limit %}
{% with remainder_count=items|length|add:subtraction_value %}
{% with remainder_count_display=remainder_count|intcomma %}
<details>
<summary>
{% for item in items|slice:limit %}
<span
{% if itemprop %}itemprop="{{ itemprop }}"{% endif %}
>{{ item }}</span>{% if not forloop.last %}, {% elif remainder_count > 0 %}, {% blocktrans trimmed count counter=remainder_count %}
and {{ remainder_count_display }} other
{% plural %}
and {{ remainder_count_display }} others
{% endblocktrans %}
{% endif %}
{% endfor %}
</summary>
{% for item in items|slice:"3:" %}
<span
{% if itemprop %}itemprop="{{ itemprop }}"{% endif %}
>{{ item }}</span>{% if not forloop.last %}, {% endif %}
{% endfor %}
</details>
{% endwith %}
{% endwith %}
{% endwith %}
{% endspaceless %}

View file

@ -39,6 +39,9 @@
{% if not is_self and request.user.is_authenticated %}
{% include 'snippets/follow_button.html' with user=user %}
{% endif %}
{% if not is_self %}
{% include 'ostatus/remote_follow_button.html' with user=user %}
{% endif %}
{% if is_self and user.follower_requests.all %}
<div class="follow-requests">

View file

@ -5,7 +5,6 @@ from uuid import uuid4
from django import template
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django.template.defaultfilters import stringfilter
from django.templatetags.static import static
@ -98,10 +97,3 @@ def get_isni(existing, author, autoescape=True):
f'<input type="text" name="isni-for-{author.id}" value="{isni}" hidden>'
)
return ""
@register.filter(name="remove_spaces")
@stringfilter
def remove_spaces(arg):
"""Removes spaces from argument passed in"""
return re.sub(r"\s", "", str(arg))

View file

@ -40,6 +40,8 @@ class AbstractConnector(TestCase):
class TestConnector(abstract_connector.AbstractConnector):
"""nothing added here"""
generated_remote_link_field = "openlibrary_link"
def format_search_result(self, search_result):
return search_result
@ -87,9 +89,7 @@ class AbstractConnector(TestCase):
def test_get_or_create_book_existing(self):
"""find an existing book by remote/origin id"""
self.assertEqual(models.Book.objects.count(), 1)
self.assertEqual(
self.book.remote_id, "https://%s/book/%d" % (DOMAIN, self.book.id)
)
self.assertEqual(self.book.remote_id, f"https://{DOMAIN}/book/{self.book.id}")
self.assertEqual(self.book.origin_id, "https://example.com/book/1234")
# dedupe by origin id
@ -99,7 +99,7 @@ class AbstractConnector(TestCase):
# dedupe by remote id
result = self.connector.get_or_create_book(
"https://%s/book/%d" % (DOMAIN, self.book.id)
f"https://{DOMAIN}/book/{self.book.id}"
)
self.assertEqual(models.Book.objects.count(), 1)
self.assertEqual(result, self.book)
@ -119,7 +119,8 @@ class AbstractConnector(TestCase):
@responses.activate
def test_get_or_create_author(self):
"""load an author"""
self.connector.author_mappings = [ # pylint: disable=attribute-defined-outside-init # pylint: disable=attribute-defined-outside-init
# pylint: disable=attribute-defined-outside-init
self.connector.author_mappings = [
Mapping("id"),
Mapping("name"),
]
@ -139,3 +140,26 @@ class AbstractConnector(TestCase):
author = models.Author.objects.create(name="Test Author")
result = self.connector.get_or_create_author(author.remote_id)
self.assertEqual(author, result)
@responses.activate
def test_update_author_from_remote(self):
"""trigger the function that looks up the remote data"""
author = models.Author.objects.create(name="Test", openlibrary_key="OL123A")
# pylint: disable=attribute-defined-outside-init
self.connector.author_mappings = [
Mapping("id"),
Mapping("name"),
Mapping("isni"),
]
responses.add(
responses.GET,
"https://openlibrary.org/authors/OL123A",
json={"id": "https://www.example.com/author", "name": "Beep", "isni": "hi"},
)
self.connector.update_author_from_remote(author)
author.refresh_from_db()
self.assertEqual(author.name, "Test")
self.assertEqual(author.isni, "hi")

View file

@ -306,3 +306,11 @@ class Inventaire(TestCase):
extract = self.connector.get_description({"enwiki": "test_path"})
self.assertEqual(extract, "hi hi")
def test_remote_id_from_model(self):
"""figure out a url from an id"""
obj = models.Author.objects.create(name="hello", inventaire_id="123")
self.assertEqual(
self.connector.get_remote_id_from_model(obj),
"https://inventaire.io?action=by-uris&uris=123",
)

View file

@ -98,6 +98,9 @@ class Openlibrary(TestCase):
"type": "/type/datetime",
"value": "2008-08-31 10:09:33.413686",
},
"remote_ids": {
"isni": "000111",
},
"key": "/authors/OL453734A",
"type": {"key": "/type/author"},
"id": 1259965,
@ -110,6 +113,7 @@ class Openlibrary(TestCase):
self.assertIsInstance(result, models.Author)
self.assertEqual(result.name, "George Elliott")
self.assertEqual(result.openlibrary_key, "OL453734A")
self.assertEqual(result.isni, "000111")
def test_get_cover_url(self):
"""formats a url that should contain the cover image"""

View file

@ -0,0 +1,5 @@
Work Id,Edition Id,Bookshelf
OL102749W,,Currently Reading
OL361393W,OL7798182M,Currently Reading
OL1652392W,OL7194114M,Want to Read
OL17062644W,OL25726365M,Already Read
1 Work Id Edition Id Bookshelf
2 OL102749W Currently Reading
3 OL361393W OL7798182M Currently Reading
4 OL1652392W OL7194114M Want to Read
5 OL17062644W OL25726365M Already Read

View file

@ -128,7 +128,7 @@ class GenericImporter(TestCase):
import_item = models.ImportItem.objects.get(job=import_job, index=0)
with patch(
"bookwyrm.models.import_job.ImportItem.get_book_from_isbn"
"bookwyrm.models.import_job.ImportItem.get_book_from_identifier"
) as resolve:
resolve.return_value = self.book
@ -158,7 +158,7 @@ class GenericImporter(TestCase):
).exists()
)
item = items[3]
item = items.last()
item.fail_reason = "hello"
item.save()
item.update_job()

View file

@ -0,0 +1,85 @@
""" testing import """
import pathlib
from unittest.mock import patch
import datetime
import pytz
from django.test import TestCase
from bookwyrm import models
from bookwyrm.importers import OpenLibraryImporter
from bookwyrm.importers.importer import handle_imported_book
def make_date(*args):
"""helper function to easily generate a date obj"""
return datetime.datetime(*args, tzinfo=pytz.UTC)
# pylint: disable=consider-using-with
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
class OpenLibraryImport(TestCase):
"""importing from openlibrary csv"""
def setUp(self):
"""use a test csv"""
self.importer = OpenLibraryImporter()
datafile = pathlib.Path(__file__).parent.joinpath("../data/openlibrary.csv")
self.csv = open(datafile, "r", encoding=self.importer.encoding)
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", "mouse@mouse.mouse", "password", local=True
)
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=work,
)
def test_create_job(self, *_):
"""creates the import job entry and checks csv"""
import_job = self.importer.create_job(
self.local_user, self.csv, False, "public"
)
import_items = models.ImportItem.objects.filter(job=import_job).all()
self.assertEqual(len(import_items), 4)
self.assertEqual(import_items[0].index, 0)
self.assertEqual(import_items[0].data["Work Id"], "OL102749W")
self.assertEqual(import_items[1].data["Work Id"], "OL361393W")
self.assertEqual(import_items[1].data["Edition Id"], "OL7798182M")
self.assertEqual(import_items[0].normalized_data["shelf"], "reading")
self.assertEqual(import_items[0].normalized_data["openlibrary_key"], "")
self.assertEqual(
import_items[0].normalized_data["openlibrary_work_key"], "OL102749W"
)
self.assertEqual(
import_items[1].normalized_data["openlibrary_key"], "OL7798182M"
)
self.assertEqual(import_items[2].normalized_data["shelf"], "to-read")
self.assertEqual(import_items[3].normalized_data["shelf"], "read")
def test_handle_imported_book(self, *_):
"""openlibrary import added a book, this adds related connections"""
shelf = self.local_user.shelf_set.filter(identifier="reading").first()
self.assertIsNone(shelf.books.first())
import_job = self.importer.create_job(
self.local_user, self.csv, False, "public"
)
import_item = import_job.items.first()
import_item.book = self.book
import_item.save()
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
handle_imported_book(import_item)
shelf.refresh_from_db()
self.assertEqual(shelf.books.first(), self.book)

View file

@ -1,5 +1,7 @@
""" testing models """
from dateutil.parser import parse
from imagekit.models import ImageSpecField
from django.test import TestCase
from django.utils import timezone
@ -26,11 +28,22 @@ class Book(TestCase):
def test_remote_id(self):
"""fanciness with remote/origin ids"""
remote_id = "https://%s/book/%d" % (settings.DOMAIN, self.work.id)
remote_id = f"https://{settings.DOMAIN}/book/{self.work.id}"
self.assertEqual(self.work.get_remote_id(), remote_id)
self.assertEqual(self.work.remote_id, remote_id)
def test_create_book(self):
def test_generated_links(self):
"""links produced from identifiers"""
book = models.Edition.objects.create(
title="ExEd",
parent_work=self.work,
openlibrary_key="OL123M",
inventaire_id="isbn:123",
)
self.assertEqual(book.openlibrary_link, "https://openlibrary.org/books/OL123M")
self.assertEqual(book.inventaire_link, "https://inventaire.io/entity/isbn:123")
def test_create_book_invalid(self):
"""you shouldn't be able to create Books (only editions and works)"""
self.assertRaises(ValueError, models.Book.objects.create, title="Invalid Book")

View file

@ -2,7 +2,7 @@
from unittest.mock import patch
from django.test import TestCase
from bookwyrm import models, settings
from bookwyrm import models
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
@ -76,7 +76,8 @@ class Group(TestCase):
models.GroupMember.objects.create(group=self.public_group, user=self.capybara)
def test_group_members_can_see_private_groups(self, _):
"""direct privacy group should not be excluded from group listings for group members viewing"""
"""direct privacy group should not be excluded from group listings for group
members viewing"""
rat_groups = models.Group.privacy_filter(self.rat).all()
badger_groups = models.Group.privacy_filter(self.badger).all()
@ -85,7 +86,8 @@ class Group(TestCase):
self.assertTrue(self.private_group in badger_groups)
def test_group_members_can_see_followers_only_lists(self, _):
"""follower-only group booklists should not be excluded from group booklist listing for group members who do not follower list owner"""
"""follower-only group booklists should not be excluded from group booklist
listing for group members who do not follower list owner"""
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
followers_list = models.List.objects.create(
@ -105,7 +107,8 @@ class Group(TestCase):
self.assertTrue(followers_list in capybara_lists)
def test_group_members_can_see_private_lists(self, _):
"""private group booklists should not be excluded from group booklist listing for group members"""
"""private group booklists should not be excluded from group booklist listing
for group members"""
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):

View file

@ -139,7 +139,7 @@ class ImportJob(TestCase):
self.assertEqual(item.reads, expected)
@responses.activate
def test_get_book_from_isbn(self):
def test_get_book_from_identifier(self):
"""search and load books by isbn (9780356506999)"""
item = models.ImportItem.objects.create(
index=1,
@ -197,6 +197,6 @@ class ImportJob(TestCase):
with patch(
"bookwyrm.connectors.openlibrary.Connector." "get_authors_from_data"
):
book = item.get_book_from_isbn()
book = item.get_book_from_identifier()
self.assertEqual(book.title, "Sabriel")

View file

@ -1,6 +1,7 @@
""" testing models """
from unittest.mock import patch
from django.test import TestCase
from uuid import UUID
from bookwyrm import models, settings
@ -80,3 +81,12 @@ class List(TestCase):
self.assertEqual(item.book_list.privacy, "public")
self.assertEqual(item.privacy, "direct")
self.assertEqual(item.recipients, [])
def test_embed_key(self, _):
"""embed_key should never be empty"""
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
book_list = models.List.objects.create(
name="Test List", user=self.local_user
)
self.assertIsInstance(book_list.embed_key, UUID)

View file

@ -209,6 +209,54 @@ class BookViews(TestCase):
self.assertEqual(self.book.description, "new description hi")
self.assertEqual(self.book.last_edited_by, self.local_user)
def test_update_book_from_remote(self):
"""call out to sync with remote connector"""
models.Connector.objects.create(
identifier="openlibrary.org",
name="OpenLibrary",
connector_file="openlibrary",
base_url="https://openlibrary.org",
books_url="https://openlibrary.org",
covers_url="https://covers.openlibrary.org",
search_url="https://openlibrary.org/search?q=",
isbn_search_url="https://openlibrary.org/isbn",
)
self.local_user.groups.add(self.group)
request = self.factory.post("")
request.user = self.local_user
with patch(
"bookwyrm.connectors.openlibrary.Connector.update_book_from_remote"
) as mock:
views.update_book_from_remote(request, self.book.id, "openlibrary.org")
self.assertEqual(mock.call_count, 1)
def test_resolve_book(self):
"""load a book from search results"""
models.Connector.objects.create(
identifier="openlibrary.org",
name="OpenLibrary",
connector_file="openlibrary",
base_url="https://openlibrary.org",
books_url="https://openlibrary.org",
covers_url="https://covers.openlibrary.org",
search_url="https://openlibrary.org/search?q=",
isbn_search_url="https://openlibrary.org/isbn",
)
request = self.factory.post(
"", {"remote_id": "https://openlibrary.org/book/123"}
)
request.user = self.local_user
with patch(
"bookwyrm.connectors.openlibrary.Connector.get_or_create_book"
) as mock:
mock.return_value = self.book
result = views.resolve_book(request)
self.assertEqual(mock.call_count, 1)
self.assertEqual(mock.call_args[0][0], "https://openlibrary.org/book/123")
self.assertEqual(result.status_code, 302)
def _setup_cover_url():
"""creates cover url mock"""

View file

@ -1,13 +1,14 @@
""" test for app action functionality """
import pathlib
from unittest.mock import patch
from django.core.files.uploadedfile import SimpleUploadedFile
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm.tests.validate_html import validate_html
from bookwyrm import forms, models, views
from bookwyrm.tests.validate_html import validate_html
class ImportViews(TestCase):
@ -44,15 +45,29 @@ class ImportViews(TestCase):
import_job = models.ImportJob.objects.create(user=self.local_user, mappings={})
request = self.factory.get("")
request.user = self.local_user
with patch("bookwyrm.tasks.app.AsyncResult") as async_result:
async_result.return_value = []
result = view(request, import_job.id)
result = view(request, import_job.id)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_import_status_reformat(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.ImportStatus.as_view()
import_job = models.ImportJob.objects.create(user=self.local_user, mappings={})
request = self.factory.post("")
request.user = self.local_user
with patch(
"bookwyrm.importers.goodreads_import.GoodreadsImporter.update_legacy_job"
) as mock:
result = view(request, import_job.id)
self.assertEqual(mock.call_args[0][0], import_job)
self.assertEqual(result.status_code, 302)
def test_start_import(self):
"""retry failed items"""
"""start a job"""
view = views.Import.as_view()
form = forms.ImportForm()
form.data["source"] = "Goodreads"
@ -74,3 +89,19 @@ class ImportViews(TestCase):
job = models.ImportJob.objects.get()
self.assertFalse(job.include_reviews)
self.assertEqual(job.privacy, "public")
def test_retry_item(self):
"""try again on a single row"""
job = models.ImportJob.objects.create(user=self.local_user, mappings={})
item = models.ImportItem.objects.create(
index=0,
job=job,
fail_reason="no match",
data={},
normalized_data={},
)
request = self.factory.post("")
request.user = self.local_user
with patch("bookwyrm.importers.importer.import_item_task.delay") as mock:
views.retry_item(request, job.id, item.id)
self.assertEqual(mock.call_count, 1)

View file

@ -0,0 +1,151 @@
"""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",
summary_keys={"2020": "0123456789"},
)
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,
)
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 self.assertRaises(Http404):
view(request, self.local_user.localname, self.year)
def test_annual_summary_not_authenticated_with_key(self, *_):
"""there are so many views, this just makes sure it DOES LOAD"""
key = self.local_user.summary_keys[self.year]
view = views.AnnualSummary.as_view()
request_url = (
f"user/{self.local_user.localname}/{self.year}-in-the-books?key={key}"
)
request = self.factory.get(request_url)
request.user = self.anonymous_user
result = view(request, self.local_user.localname, self.year)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
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 self.assertRaises(Http404):
view(request, self.local_user.localname, 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
result = view(request, self.local_user.localname, 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
result = view(request, self.local_user.localname, 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_with_review(self, *_):
"""there are so many views, this just makes sure it LOADS"""
self.review = models.Review.objects.create(
name="Review name",
content="test content",
rating=3.0,
user=self.local_user,
book=self.book,
)
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
result = view(request, self.local_user.localname, self.year)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)

View file

@ -148,3 +148,26 @@ class AuthorViews(TestCase):
self.assertEqual(author.name, "Test Author")
validate_html(resp.render())
self.assertEqual(resp.status_code, 200)
def test_update_author_from_remote(self):
"""call out to sync with remote connector"""
author = models.Author.objects.create(name="Test Author")
models.Connector.objects.create(
identifier="openlibrary.org",
name="OpenLibrary",
connector_file="openlibrary",
base_url="https://openlibrary.org",
books_url="https://openlibrary.org",
covers_url="https://covers.openlibrary.org",
search_url="https://openlibrary.org/search?q=",
isbn_search_url="https://openlibrary.org/isbn",
)
self.local_user.groups.add(self.group)
request = self.factory.post("")
request.user = self.local_user
with patch(
"bookwyrm.connectors.openlibrary.Connector.update_author_from_remote"
) as mock:
views.update_author_from_remote(request, author.id, "openlibrary.org")
self.assertEqual(mock.call_count, 1)

View file

@ -4,10 +4,12 @@ from unittest.mock import patch
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
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
@patch("bookwyrm.activitystreams.add_user_statuses_task.delay")
@ -16,6 +18,7 @@ class FollowViews(TestCase):
def setUp(self):
"""we need basic test data and mocks"""
models.SiteSettings.objects.create()
self.factory = RequestFactory()
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
"bookwyrm.activitystreams.populate_stream_task.delay"
@ -174,3 +177,43 @@ class FollowViews(TestCase):
self.assertEqual(models.UserFollowRequest.objects.filter(id=rel.id).count(), 0)
# follow relationship should not exist
self.assertEqual(models.UserFollows.objects.filter(id=rel.id).count(), 0)
def test_ostatus_follow_request(self, _):
"""check ostatus subscribe template loads"""
request = self.factory.get(
"", {"acct": "https%3A%2F%2Fexample.com%2Fusers%2Frat"}
)
request.user = self.local_user
result = views.ostatus_follow_request(request)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_remote_follow_page(self, _):
"""check remote follow page loads"""
request = self.factory.get("", {"acct": "mouse@local.com"})
request.user = self.remote_user
result = views.remote_follow_page(request)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_ostatus_follow_success(self, _):
"""check remote follow success page loads"""
request = self.factory.get("")
request.user = self.remote_user
request.following = "mouse@local.com"
result = views.ostatus_follow_success(request)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_remote_follow(self, _):
"""check follow from remote page loads"""
request = self.factory.post("", {"user": self.remote_user.id})
request.user = self.remote_user
request.remote_user = "mouse@local.com"
result = views.remote_follow(request)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)

View file

@ -1,12 +1,11 @@
""" test for app action functionality """
from unittest.mock import patch
from django.contrib.auth import decorators
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import models, views, forms
from bookwyrm import models, views
from bookwyrm.tests.validate_html import validate_html
@ -27,16 +26,23 @@ class GroupViews(TestCase):
local=True,
localname="mouse",
)
self.rat = models.User.objects.create_user(
"rat@local.com",
"rat@rat.rat",
"password",
local=True,
localname="rat",
)
self.testgroup = models.Group.objects.create(
name="Test Group",
description="Initial description",
user=self.local_user,
privacy="public",
)
self.membership = models.GroupMember.objects.create(
group=self.testgroup, user=self.local_user
)
self.testgroup = models.Group.objects.create(
name="Test Group",
description="Initial description",
user=self.local_user,
privacy="public",
)
self.membership = models.GroupMember.objects.create(
group=self.testgroup, user=self.local_user
)
models.SiteSettings.objects.create()
@ -98,7 +104,6 @@ class GroupViews(TestCase):
def test_group_edit(self, _):
"""test editing a "group" database entry"""
view = views.Group.as_view()
request = self.factory.post(
"",
@ -117,3 +122,137 @@ class GroupViews(TestCase):
self.assertEqual(self.testgroup.name, "Updated Group name")
self.assertEqual(self.testgroup.description, "wow")
self.assertEqual(self.testgroup.privacy, "direct")
def test_delete_group(self, _):
"""delete a group"""
request = self.factory.post("")
request.user = self.local_user
views.delete_group(request, self.testgroup.id)
self.assertFalse(models.Group.objects.exists())
def test_invite_member(self, _):
"""invite a member to a group"""
request = self.factory.post(
"",
{
"group": self.testgroup.id,
"user": self.rat.localname,
},
)
request.user = self.local_user
result = views.invite_member(request)
self.assertEqual(result.status_code, 302)
invite = models.GroupMemberInvitation.objects.get()
self.assertEqual(invite.user, self.rat)
self.assertEqual(invite.group, self.testgroup)
def test_invite_member_twice(self, _):
"""invite a member to a group again"""
request = self.factory.post(
"",
{
"group": self.testgroup.id,
"user": self.rat.localname,
},
)
request.user = self.local_user
result = views.invite_member(request)
self.assertEqual(result.status_code, 302)
result = views.invite_member(request)
self.assertEqual(result.status_code, 302)
def test_remove_member_denied(self, _):
"""remove member"""
request = self.factory.post(
"",
{
"group": self.testgroup.id,
"user": self.local_user.localname,
},
)
request.user = self.local_user
result = views.remove_member(request)
self.assertEqual(result.status_code, 400)
def test_remove_member_non_member(self, _):
"""remove member but wait, that's not a member"""
request = self.factory.post(
"",
{
"group": self.testgroup.id,
"user": self.rat.localname,
},
)
request.user = self.local_user
result = views.remove_member(request)
# nothing happens
self.assertEqual(result.status_code, 302)
def test_remove_member_invited(self, _):
"""remove an invited member"""
models.GroupMemberInvitation.objects.create(
user=self.rat,
group=self.testgroup,
)
request = self.factory.post(
"",
{
"group": self.testgroup.id,
"user": self.rat.localname,
},
)
request.user = self.local_user
result = views.remove_member(request)
self.assertEqual(result.status_code, 302)
self.assertFalse(models.GroupMemberInvitation.objects.exists())
def test_remove_member_existing_member(self, _):
"""remove an invited member"""
models.GroupMember.objects.create(
user=self.rat,
group=self.testgroup,
)
request = self.factory.post(
"",
{
"group": self.testgroup.id,
"user": self.rat.localname,
},
)
request.user = self.local_user
result = views.remove_member(request)
self.assertEqual(result.status_code, 302)
self.assertEqual(models.GroupMember.objects.count(), 1)
self.assertEqual(models.GroupMember.objects.first().user, self.local_user)
notification = models.Notification.objects.get()
self.assertEqual(notification.user, self.rat)
self.assertEqual(notification.related_group, self.testgroup)
self.assertEqual(notification.notification_type, "REMOVE")
def test_accept_membership(self, _):
"""accept an invite"""
models.GroupMemberInvitation.objects.create(
user=self.rat,
group=self.testgroup,
)
request = self.factory.post("", {"group": self.testgroup.id})
request.user = self.rat
views.accept_membership(request)
self.assertFalse(models.GroupMemberInvitation.objects.exists())
self.assertTrue(self.rat in [m.user for m in self.testgroup.memberships.all()])
def test_reject_membership(self, _):
"""reject an invite"""
models.GroupMemberInvitation.objects.create(
user=self.rat,
group=self.testgroup,
)
request = self.factory.post("", {"group": self.testgroup.id})
request.user = self.rat
views.reject_membership(request)
self.testgroup.refresh_from_db()
self.assertFalse(models.GroupMemberInvitation.objects.exists())
self.assertFalse(self.rat in [m.user for m in self.testgroup.memberships.all()])

View file

@ -3,6 +3,7 @@ import json
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
from django.http.response import Http404
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
@ -385,3 +386,46 @@ class ListViews(TestCase):
result = view(request, self.local_user.username)
self.assertEqual(result.status_code, 302)
def test_embed_call_without_key(self):
"""there are so many views, this just makes sure it DOESNT load"""
view = views.unsafe_embed_list
request = self.factory.get("")
request.user = self.anonymous_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
models.ListItem.objects.create(
book_list=self.list,
user=self.local_user,
book=self.book,
approved=True,
order=1,
)
with patch("bookwyrm.views.list.is_api_request") as is_api:
is_api.return_value = False
with self.assertRaises(Http404):
result = view(request, self.list.id, "")
def test_embed_call_with_key(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.unsafe_embed_list
request = self.factory.get("")
request.user = self.anonymous_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
models.ListItem.objects.create(
book_list=self.list,
user=self.local_user,
book=self.book,
approved=True,
order=1,
)
embed_key = str(self.list.embed_key.hex)
with patch("bookwyrm.views.list.is_api_request") as is_api:
is_api.return_value = False
result = view(request, self.list.id, embed_key)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)

View file

@ -51,6 +51,11 @@ class UserViews(TestCase):
def test_user_page(self):
"""there are so many views, this just makes sure it LOADS"""
# extras that are rendered on the user page
models.AnnualGoal.objects.create(
user=self.local_user, goal=12, privacy="followers"
)
view = views.User.as_view()
request = self.factory.get("")
request.user = self.local_user
@ -104,6 +109,18 @@ class UserViews(TestCase):
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_followers_page_anonymous(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.Followers.as_view()
request = self.factory.get("")
request.user = self.anonymous_user
with patch("bookwyrm.views.user.is_api_request") as is_api:
is_api.return_value = False
result = view(request, "mouse")
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
def test_followers_page_blocked(self, *_):
@ -135,6 +152,18 @@ class UserViews(TestCase):
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_following_page_anonymous(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.Following.as_view()
request = self.factory.get("")
request.user = self.anonymous_user
with patch("bookwyrm.views.user.is_api_request") as is_api:
is_api.return_value = False
result = view(request, "mouse")
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_following_page_blocked(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.Following.as_view()
@ -145,3 +174,15 @@ class UserViews(TestCase):
is_api.return_value = False
with self.assertRaises(Http404):
view(request, "rat")
def test_hide_suggestions(self):
"""update suggestions settings"""
self.assertTrue(self.local_user.show_suggested_users)
request = self.factory.post("")
request.user = self.local_user
result = views.hide_suggestions(request)
self.assertEqual(result.status_code, 302)
self.local_user.refresh_from_db()
self.assertFalse(self.local_user.show_suggested_users)

View file

@ -44,6 +44,7 @@ urlpatterns = [
re_path(r"^api/v1/instance/?$", views.instance_info),
re_path(r"^api/v1/instance/peers/?$", views.peers),
re_path(r"^opensearch.xml$", views.opensearch, name="opensearch"),
re_path(r"^ostatus_subscribe/?$", views.ostatus_follow_request),
# polling updates
re_path("^api/updates/notifications/?$", views.get_notification_count),
re_path("^api/updates/stream/(?P<stream>[a-z]+)/?$", views.get_unread_status_count),
@ -336,6 +337,11 @@ urlpatterns = [
),
re_path(r"^save-list/(?P<list_id>\d+)/?$", views.save_list, name="list-save"),
re_path(r"^unsave-list/(?P<list_id>\d+)/?$", views.unsave_list, name="list-unsave"),
re_path(
r"^list/(?P<list_id>\d+)/embed/(?P<list_key>[0-9a-f]+)?$",
views.unsafe_embed_list,
name="embed-list",
),
# User books
re_path(rf"{USER_PATH}/books/?$", views.Shelf.as_view(), name="user-shelves"),
re_path(
@ -422,13 +428,29 @@ urlpatterns = [
r"^upload-cover/(?P<book_id>\d+)/?$", views.upload_cover, name="upload-cover"
),
re_path(r"^add-description/(?P<book_id>\d+)/?$", views.add_description),
re_path(r"^resolve-book/?$", views.resolve_book),
re_path(r"^switch-edition/?$", views.switch_edition),
re_path(r"^resolve-book/?$", views.resolve_book, name="resolve-book"),
re_path(r"^switch-edition/?$", views.switch_edition, name="switch-edition"),
re_path(
rf"{BOOK_PATH}/update/(?P<connector_identifier>[\w\.]+)/?$",
views.update_book_from_remote,
name="book-update-remote",
),
re_path(
r"^author/(?P<author_id>\d+)/update/(?P<connector_identifier>[\w\.]+)/?$",
views.update_author_from_remote,
name="author-update-remote",
),
# isbn
re_path(r"^isbn/(?P<isbn>\d+)(.json)?/?$", views.Isbn.as_view()),
# author
re_path(r"^author/(?P<author_id>\d+)(.json)?/?$", views.Author.as_view()),
re_path(r"^author/(?P<author_id>\d+)/edit/?$", views.EditAuthor.as_view()),
re_path(
r"^author/(?P<author_id>\d+)(.json)?/?$", views.Author.as_view(), name="author"
),
re_path(
r"^author/(?P<author_id>\d+)/edit/?$",
views.EditAuthor.as_view(),
name="edit-author",
),
# reading progress
re_path(r"^edit-readthrough/?$", views.edit_readthrough, name="edit-readthrough"),
re_path(r"^delete-readthrough/?$", views.delete_readthrough),
@ -450,4 +472,23 @@ urlpatterns = [
re_path(r"^unfollow/?$", views.unfollow, name="unfollow"),
re_path(r"^accept-follow-request/?$", views.accept_follow_request),
re_path(r"^delete-follow-request/?$", views.delete_follow_request),
re_path(r"^ostatus_follow/?$", views.remote_follow, name="remote-follow"),
re_path(r"^remote_follow/?$", views.remote_follow_page, name="remote-follow-page"),
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+)/?$",
views.personal_annual_summary,
),
re_path(
rf"{LOCAL_USER_PATH}/(?P<year>\d+)-in-the-books/?$",
views.AnnualSummary.as_view(),
name="annual-summary",
),
re_path(r"^summary_add_key/?$", views.summary_add_key, name="summary-add-key"),
re_path(
r"^summary_revoke_key/?$", views.summary_revoke_key, name="summary-revoke-key"
),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View file

@ -29,6 +29,7 @@ from .preferences.block import Block, unblock
# books
from .books.books import Book, upload_cover, add_description, resolve_book
from .books.books import update_book_from_remote
from .books.edit_book import EditBook, ConfirmEditBook
from .books.editions import Editions, switch_edition
@ -54,11 +55,18 @@ from .imports.manually_review import (
)
# misc views
from .author import Author, EditAuthor
from .author import Author, EditAuthor, update_author_from_remote
from .directory import Directory
from .discover import Discover
from .feed import DirectMessage, Feed, Replies, Status
from .follow import follow, unfollow
from .follow import (
follow,
unfollow,
ostatus_follow_request,
ostatus_follow_success,
remote_follow,
remote_follow_page,
)
from .follow import accept_follow_request, delete_follow_request
from .get_started import GetStartedBooks, GetStartedProfile, GetStartedUsers
from .goal import Goal, hide_goal
@ -76,7 +84,7 @@ from .inbox import Inbox
from .interaction import Favorite, Unfavorite, Boost, Unboost
from .isbn import Isbn
from .list import Lists, SavedLists, List, Curate, UserLists
from .list import save_list, unsave_list, delete_list
from .list import save_list, unsave_list, delete_list, unsafe_embed_list
from .notifications import Notifications
from .outbox import Outbox
from .reading import create_readthrough, delete_readthrough, delete_progressupdate
@ -88,3 +96,9 @@ 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,
personal_annual_summary,
summary_add_key,
summary_revoke_key,
)

View file

@ -0,0 +1,238 @@
"""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 Case, When, Avg, Sum
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):
"""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"))
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,
}
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 = int(get_earliest_year(user, 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_earliest_year(user, year):
"""return the earliest finish_date or shelved_date year for user books in read shelf"""
read_shelfbooks = models.ShelfBook.objects.filter(user__id=user.id).filter(
shelf__identifier__exact="read"
)
read_shelfbooks_list = list(read_shelfbooks.values("book", "shelved_date"))
book_dates = []
for book in read_shelfbooks_list:
earliest_finished = (
models.ReadThrough.objects.filter(user__id=user.id)
.filter(book_id=book["book"])
.exclude(finish_date__exact=None)
.order_by("finish_date")
.values("finish_date")
.first()
)
if earliest_finished:
book_dates.append(
min(earliest_finished["finish_date"], book["shelved_date"])
)
else:
book_dates.append(book["shelved_date"])
if book_dates:
return min(book_dates).year
return 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

@ -6,9 +6,11 @@ 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 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
@ -73,3 +75,19 @@ class EditAuthor(View):
author = form.save()
return redirect(f"/author/{author.id}")
@login_required
@require_POST
@permission_required("bookwyrm.edit_book", raise_exception=True)
# pylint: disable=unused-argument
def update_author_from_remote(request, author_id, connector_identifier):
"""load the remote data for this author"""
connector = connector_manager.load_connector(
get_object_or_404(models.Connector, identifier=connector_identifier)
)
author = get_object_or_404(models.Author, id=author_id)
connector.update_author_from_remote(author)
return redirect("author", author.id)

View file

@ -178,3 +178,19 @@ def resolve_book(request):
book = connector.get_or_create_book(remote_id)
return redirect("book", book.id)
@login_required
@require_POST
@permission_required("bookwyrm.edit_book", raise_exception=True)
# pylint: disable=unused-argument
def update_book_from_remote(request, book_id, connector_identifier):
"""load the remote data for this book"""
connector = connector_manager.load_connector(
get_object_or_404(models.Connector, identifier=connector_identifier)
)
book = get_object_or_404(models.Book.objects.select_subclasses(), id=book_id)
connector.update_book_from_remote(book)
return redirect("book", book.id)

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)

View file

@ -1,11 +1,19 @@
""" views for actions you can take in the application """
import urllib.parse
import re
from django.contrib.auth.decorators import login_required
from django.db import IntegrityError
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.views.decorators.http import require_POST
from bookwyrm import models
from .helpers import get_user_from_username
from .helpers import (
get_user_from_username,
handle_remote_webfinger,
subscribe_remote_webfinger,
WebFingerError,
)
@login_required
@ -23,6 +31,9 @@ def follow(request):
except IntegrityError:
pass
if request.GET.get("next"):
return redirect(request.GET.get("next", "/"))
return redirect(to_follow.local_path)
@ -84,3 +95,91 @@ def delete_follow_request(request):
follow_request.delete()
return redirect(f"/user/{request.user.localname}")
def ostatus_follow_request(request):
"""prepare an outgoing remote follow request"""
uri = urllib.parse.unquote(request.GET.get("acct"))
username_parts = re.search(
r"(?:^http(?:s?):\/\/)([\w\-\.]*)(?:.)*(?:(?:\/)([\w]*))", uri
)
account = f"{username_parts[2]}@{username_parts[1]}"
user = handle_remote_webfinger(account)
error = None
if user is None or user == "":
error = "ostatus_subscribe"
# don't do these checks for AnonymousUser before they sign in
if request.user.is_authenticated:
# you have blocked them so you probably don't want to follow
if hasattr(request.user, "blocks") and user in request.user.blocks.all():
error = "is_blocked"
# they have blocked you
if hasattr(user, "blocks") and request.user in user.blocks.all():
error = "has_blocked"
# you're already following them
if hasattr(user, "followers") and request.user in user.followers.all():
error = "already_following"
# you're not following yet but you already asked
if (
hasattr(user, "follower_requests")
and request.user in user.follower_requests.all()
):
error = "already_requested"
data = {"account": account, "user": user, "error": error}
return TemplateResponse(request, "ostatus/subscribe.html", data)
@login_required
def ostatus_follow_success(request):
"""display success message for remote follow"""
user = get_user_from_username(request.user, request.GET.get("following"))
data = {"account": user.name, "user": user, "error": None}
return TemplateResponse(request, "ostatus/success.html", data)
def remote_follow_page(request):
"""display remote follow page"""
user = get_user_from_username(request.user, request.GET.get("user"))
data = {"user": user}
return TemplateResponse(request, "ostatus/remote_follow.html", data)
@require_POST
def remote_follow(request):
"""direct user to follow from remote account using ostatus subscribe protocol"""
remote_user = request.POST.get("remote_user")
try:
if remote_user[0] == "@":
remote_user = remote_user[1:]
remote_domain = remote_user.split("@")[1]
except (TypeError, IndexError):
remote_domain = None
wf_response = subscribe_remote_webfinger(remote_user)
user = get_object_or_404(models.User, id=request.POST.get("user"))
if wf_response is None:
data = {
"account": remote_user,
"user": user,
"error": "not_supported",
"remote_domain": remote_domain,
}
return TemplateResponse(request, "ostatus/subscribe.html", data)
if isinstance(wf_response, WebFingerError):
data = {
"account": remote_user,
"user": user,
"error": str(wf_response),
"remote_domain": remote_domain,
}
return TemplateResponse(request, "ostatus/subscribe.html", data)
url = wf_response.replace("{uri}", urllib.parse.quote(user.remote_id))
return redirect(url)

View file

@ -179,21 +179,14 @@ def delete_group(request, group_id):
@login_required
def invite_member(request):
"""invite a member to the group"""
group = get_object_or_404(models.Group, id=request.POST.get("group"))
if not group:
return HttpResponseBadRequest()
user = get_user_from_username(request.user, request.POST["user"])
if not user:
return HttpResponseBadRequest()
if not group.user == request.user:
return HttpResponseBadRequest()
try:
models.GroupMemberInvitation.objects.create(user=user, group=group)
except IntegrityError:
pass
@ -204,17 +197,11 @@ def invite_member(request):
@login_required
def remove_member(request):
"""remove a member from the group"""
group = get_object_or_404(models.Group, id=request.POST.get("group"))
if not group:
return HttpResponseBadRequest()
user = get_user_from_username(request.user, request.POST["user"])
if not user:
return HttpResponseBadRequest()
# you can't be removed from your own group
if request.POST["user"] == group.user:
if user == group.user:
return HttpResponseBadRequest()
is_member = models.GroupMember.objects.filter(group=group, user=user).exists()
@ -234,11 +221,9 @@ def remove_member(request):
pass
if is_member:
try:
models.List.remove_from_group(group.user, user)
models.GroupMember.remove(group.user, user)
except IntegrityError:
pass
@ -271,18 +256,13 @@ def remove_member(request):
@login_required
def accept_membership(request):
"""accept an invitation to join a group"""
group = models.Group.objects.get(id=request.POST["group"])
if not group:
return HttpResponseBadRequest()
invite = models.GroupMemberInvitation.objects.get(group=group, user=request.user)
if not invite:
return HttpResponseBadRequest()
group = get_object_or_404(models.Group, id=request.POST.get("group"))
invite = get_object_or_404(
models.GroupMemberInvitation, group=group, user=request.user
)
try:
invite.accept()
except IntegrityError:
pass
@ -293,19 +273,10 @@ def accept_membership(request):
@login_required
def reject_membership(request):
"""reject an invitation to join a group"""
group = get_object_or_404(models.Group, id=request.POST.get("group"))
invite = get_object_or_404(
models.GroupMemberInvitation, group=group, user=request.user
)
group = models.Group.objects.get(id=request.POST["group"])
if not group:
return HttpResponseBadRequest()
invite = models.GroupMemberInvitation.objects.get(group=group, user=request.user)
if not invite:
return HttpResponseBadRequest()
try:
invite.reject()
except IntegrityError:
pass
invite.reject()
return redirect(request.user.local_path)

View file

@ -16,6 +16,13 @@ from bookwyrm.status import create_generated_note
from bookwyrm.utils import regex
# pylint: disable=unnecessary-pass
class WebFingerError(Exception):
"""empty error class for problems finding user information with webfinger"""
pass
def get_user_from_username(viewer, username):
"""helper function to resolve a localname or a username to a user"""
if viewer.is_authenticated and viewer.localname == username:
@ -57,10 +64,8 @@ def handle_remote_webfinger(query):
# usernames could be @user@domain or user@domain
if not query:
return None
if query[0] == "@":
query = query[1:]
try:
domain = query.split("@")[1]
except IndexError:
@ -86,6 +91,35 @@ def handle_remote_webfinger(query):
return user
def subscribe_remote_webfinger(query):
"""get subscribe template from other servers"""
template = None
# usernames could be @user@domain or user@domain
if not query:
return WebFingerError("invalid_username")
if query[0] == "@":
query = query[1:]
try:
domain = query.split("@")[1]
except IndexError:
return WebFingerError("invalid_username")
url = f"https://{domain}/.well-known/webfinger?resource=acct:{query}"
try:
data = get_data(url)
except (ConnectorException, HTTPError):
return WebFingerError("user_not_found")
for link in data.get("links"):
if link.get("rel") == "http://ostatus.org/schema/1.0/subscribe":
template = link["template"]
return template
def get_edition(book_id):
"""look up a book in the db and return an edition"""
book = models.Book.objects.select_subclasses().get(id=book_id)

View file

@ -14,6 +14,7 @@ from bookwyrm.importers import (
LibrarythingImporter,
GoodreadsImporter,
StorygraphImporter,
OpenLibraryImporter,
)
# pylint: disable= no-self-use
@ -37,33 +38,34 @@ class Import(View):
def post(self, request):
"""ingest a goodreads csv"""
form = forms.ImportForm(request.POST, request.FILES)
if form.is_valid():
include_reviews = request.POST.get("include_reviews") == "on"
privacy = request.POST.get("privacy")
source = request.POST.get("source")
if not form.is_valid():
return HttpResponseBadRequest()
importer = None
if source == "LibraryThing":
importer = LibrarythingImporter()
elif source == "Storygraph":
importer = StorygraphImporter()
else:
# Default : Goodreads
importer = GoodreadsImporter()
include_reviews = request.POST.get("include_reviews") == "on"
privacy = request.POST.get("privacy")
source = request.POST.get("source")
try:
job = importer.create_job(
request.user,
TextIOWrapper(
request.FILES["csv_file"], encoding=importer.encoding
),
include_reviews,
privacy,
)
except (UnicodeDecodeError, ValueError, KeyError):
return HttpResponseBadRequest(_("Not a valid csv file"))
importer = None
if source == "LibraryThing":
importer = LibrarythingImporter()
elif source == "Storygraph":
importer = StorygraphImporter()
elif source == "OpenLibrary":
importer = OpenLibraryImporter()
else:
# Default : Goodreads
importer = GoodreadsImporter()
importer.start_import(job)
try:
job = importer.create_job(
request.user,
TextIOWrapper(request.FILES["csv_file"], encoding=importer.encoding),
include_reviews,
privacy,
)
except (UnicodeDecodeError, ValueError, KeyError):
return HttpResponseBadRequest(_("Not a valid csv file"))
return redirect(f"/import/{job.id}")
return HttpResponseBadRequest()
importer.start_import(job)
return redirect(f"/import/{job.id}")

View file

@ -7,13 +7,14 @@ 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 HttpResponseBadRequest, HttpResponse
from django.http import HttpResponseBadRequest, HttpResponse, Http404
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.http import require_POST
from django.views.decorators.clickjacking import xframe_options_exempt
from bookwyrm import book_search, forms, models
from bookwyrm.activitypub import ActivitypubResponse
@ -167,6 +168,14 @@ class List(View):
][: 5 - len(suggestions)]
page = paginated.get_page(request.GET.get("page"))
embed_key = str(book_list.embed_key.hex)
embed_url = reverse("embed-list", args=[book_list.id, embed_key])
embed_url = request.build_absolute_uri(embed_url)
if request.GET:
embed_url = f"{embed_url}?{request.GET.urlencode()}"
data = {
"list": book_list,
"items": page,
@ -180,6 +189,7 @@ class List(View):
"sort_form": forms.SortListForm(
{"direction": direction, "sort_by": sort_by}
),
"embed_url": embed_url,
}
return TemplateResponse(request, "lists/list.html", data)
@ -200,6 +210,60 @@ class List(View):
return redirect(book_list.local_path)
class EmbedList(View):
"""embeded book list page"""
def get(self, request, list_id, list_key):
"""display a book list"""
book_list = get_object_or_404(models.List, id=list_id)
embed_key = str(book_list.embed_key.hex)
if list_key != embed_key:
raise Http404()
# sort_by shall be "order" unless a valid alternative is given
sort_by = request.GET.get("sort_by", "order")
if sort_by not in ("order", "title", "rating"):
sort_by = "order"
# direction shall be "ascending" unless a valid alternative is given
direction = request.GET.get("direction", "ascending")
if direction not in ("ascending", "descending"):
direction = "ascending"
directional_sort_by = {
"order": "order",
"title": "book__title",
"rating": "average_rating",
}[sort_by]
if direction == "descending":
directional_sort_by = "-" + directional_sort_by
items = book_list.listitem_set.prefetch_related("user", "book", "book__authors")
if sort_by == "rating":
items = items.annotate(
average_rating=Avg(
Coalesce("book__review__rating", 0.0),
output_field=DecimalField(),
)
)
items = items.filter(approved=True).order_by(directional_sort_by)
paginated = Paginator(items, PAGE_LENGTH)
page = paginated.get_page(request.GET.get("page"))
data = {
"list": book_list,
"items": page,
"page_range": paginated.get_elided_page_range(
page.number, on_each_side=2, on_ends=1
),
}
return TemplateResponse(request, "lists/embed-list.html", data)
class Curate(View):
"""approve or discard list suggestsions"""
@ -447,3 +511,11 @@ def normalize_book_list_ordering(book_list_id, start=0, add_offset=0):
if item.order != effective_order:
item.order = effective_order
item.save()
@xframe_options_exempt
def unsafe_embed_list(request, *args, **kwargs):
"""allows the EmbedList view to be loaded through unsafe iframe origins"""
embed_list_view = EmbedList.as_view()
return embed_list_view(request, *args, **kwargs)

View file

@ -34,11 +34,9 @@ class User(View):
shelves = user.shelf_set
is_self = request.user.id == user.id
if not is_self:
follower = user.followers.filter(id=request.user.id).exists()
if follower:
shelves = shelves.filter(privacy__in=["public", "followers"])
else:
shelves = shelves.filter(privacy="public")
shelves = models.Shelf.privacy_filter(
request.user, privacy_levels=["public", "followers"]
).filter(user=user)
for user_shelf in shelves.all():
if not user_shelf.books.count():
@ -146,25 +144,6 @@ def annotate_if_follows(user, queryset):
).order_by("-request_user_follows", "-created_date")
class Groups(View):
"""list of user's groups view"""
def get(self, request, username):
"""list of groups"""
user = get_user_from_username(request.user, username)
paginated = Paginator(
models.Group.memberships.filter(user=user).order_by("-created_date"),
PAGE_LENGTH,
)
data = {
"user": user,
"is_self": request.user.id == user.id,
"group_list": paginated.get_page(request.GET.get("page")),
}
return TemplateResponse(request, "user/groups.html", data)
@require_POST
@login_required
def hide_suggestions(request):

View file

@ -30,7 +30,11 @@ def webfinger(request):
"rel": "self",
"type": "application/activity+json",
"href": user.remote_id,
}
},
{
"rel": "http://ostatus.org/schema/1.0/subscribe",
"template": f"https://{DOMAIN}/ostatus_subscribe?acct={{uri}}",
},
],
}
)

22
bw-dev
View file

@ -86,7 +86,7 @@ case "$CMD" in
docker-compose restart
;;
populate_streams)
runweb python manage.py populate_streams $@
runweb python manage.py populate_streams "$@"
;;
populate_suggestions)
runweb python manage.py populate_suggestions
@ -95,20 +95,33 @@ case "$CMD" in
runweb python manage.py generateimages
;;
generate_preview_images)
runweb python manage.py generate_preview_images $@
runweb python manage.py generate_preview_images "$@"
;;
copy_media_to_s3)
awscommand "bookwyrm_media_volume:/images"\
"s3 cp /images s3://${AWS_STORAGE_BUCKET_NAME}/images\
--endpoint-url ${AWS_S3_ENDPOINT_URL}\
--recursive --acl public-read"
--recursive --acl public-read" "$@"
;;
sync_media_to_s3)
awscommand "bookwyrm_media_volume:/images"\
"s3 sync /images s3://${AWS_STORAGE_BUCKET_NAME}/images\
--endpoint-url ${AWS_S3_ENDPOINT_URL}\
--acl public-read" "$@"
;;
set_cors_to_s3)
set +x
config_file=$1
if [ -z "$config_file" ]; then
echo "This command requires a JSON file containing a CORS configuration as an argument"
exit 1
fi
set -x
awscommand "$(pwd):/bw"\
"s3api put-bucket-cors\
--bucket ${AWS_STORAGE_BUCKET_NAME}\
--endpoint-url ${AWS_S3_ENDPOINT_URL}\
--cors-configuration file:///bw/$@"
--cors-configuration file:///bw/$config_file" "$@"
;;
runweb)
runweb "$@"
@ -132,6 +145,7 @@ case "$CMD" in
echo " generate_thumbnails"
echo " generate_preview_images [--all]"
echo " copy_media_to_s3"
echo " sync_media_to_s3"
echo " set_cors_to_s3 [cors file]"
echo " runweb [command]"
;;

Binary file not shown.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more