diff --git a/.env.dev.example b/.env.dev.example index 9c068ef8f..427b1130a 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -13,16 +13,10 @@ DEFAULT_LANGUAGE="English" ## Leave unset to allow all hosts # ALLOWED_HOSTS="localhost,127.0.0.1,[::1]" -OL_URL=https://openlibrary.org - -## Database backend to use. -## Default is postgres, sqlite is for dev quickstart only (NOT production!!!) -BOOKWYRM_DATABASE_BACKEND=postgres - MEDIA_ROOT=images/ POSTGRES_PORT=5432 -POSTGRES_PASSWORD=fedireads +POSTGRES_PASSWORD=securedbypassword123 POSTGRES_USER=fedireads POSTGRES_DB=fedireads POSTGRES_HOST=db @@ -51,5 +45,14 @@ EMAIL_USE_SSL=false # Thumbnails Generation ENABLE_THUMBNAIL_GENERATION=false -# Set this to true when initializing certbot for domain, false when not -CERTBOT_INIT=false +# Preview image generation can be computing and storage intensive +# ENABLE_PREVIEW_IMAGES=True + +# Specify RGB tuple or RGB hex strings, +# 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_IMG_WIDTH=1200 +PREVIEW_IMG_HEIGHT=630 +PREVIEW_DEFAULT_COVER_COLOR="#002549" diff --git a/.env.prod.example b/.env.prod.example index 598d8c50c..6d7d57ad2 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -13,16 +13,10 @@ DEFAULT_LANGUAGE="English" ## Leave unset to allow all hosts # ALLOWED_HOSTS="localhost,127.0.0.1,[::1]" -OL_URL=https://openlibrary.org - -## Database backend to use. -## Default is postgres, sqlite is for dev quickstart only (NOT production!!!) -BOOKWYRM_DATABASE_BACKEND=postgres - MEDIA_ROOT=images/ POSTGRES_PORT=5432 -POSTGRES_PASSWORD=securedbpassword123 +POSTGRES_PASSWORD=securedbypassword123 POSTGRES_USER=fedireads POSTGRES_DB=fedireads POSTGRES_HOST=db @@ -51,5 +45,14 @@ EMAIL_USE_SSL=false # Thumbnails Generation ENABLE_THUMBNAIL_GENERATION=false -# Set this to true when initializing certbot for domain, false when not -CERTBOT_INIT=false +# Preview image generation can be computing and storage intensive +# ENABLE_PREVIEW_IMAGES=True + +# Specify RGB tuple or RGB hex strings, +# 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_IMG_WIDTH=1200 +PREVIEW_IMG_HEIGHT=630 +PREVIEW_DEFAULT_COVER_COLOR="#002549" diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml index 25af1e08d..fb681dcd5 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -1,4 +1,4 @@ -name: Lint Python +name: Python Formatting (run ./bw-dev black to fix) on: [push, pull_request] diff --git a/.github/workflows/django-tests.yml b/.github/workflows/django-tests.yml index 75c1c4b41..b5b319f53 100644 --- a/.github/workflows/django-tests.yml +++ b/.github/workflows/django-tests.yml @@ -50,7 +50,6 @@ jobs: SECRET_KEY: beepbeep DEBUG: true DOMAIN: your.domain.here - OL_URL: https://openlibrary.org BOOKWYRM_DATABASE_BACKEND: postgres MEDIA_ROOT: images/ POSTGRES_PASSWORD: hunter2 @@ -65,5 +64,6 @@ jobs: EMAIL_HOST_USER: "" EMAIL_HOST_PASSWORD: "" EMAIL_USE_TLS: true + ENABLE_PREVIEW_IMAGES: true run: | python manage.py test diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml new file mode 100644 index 000000000..1a32940f9 --- /dev/null +++ b/.github/workflows/pylint.yml @@ -0,0 +1,24 @@ +name: Pylint + +on: [push, pull_request] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.9 + uses: actions/setup-python@v2 + with: + python-version: 3.9 + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pylint + - name: Analysing the code with pylint + run: | + pylint bookwyrm/ --ignore=migrations,tests --disable=E1101,E1135,E1136,R0903,R0901,R0902,W0707,W0511,W0406,R0401,R0801 + diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index 5349e1dd0..81762388f 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -37,6 +37,7 @@ class Mention(Link): @dataclass +# pylint: disable=invalid-name class Signature: """public key block""" @@ -56,11 +57,11 @@ def naive_parse(activity_objects, activity_json, serializer=None): activity_type = activity_json.get("type") try: serializer = activity_objects[activity_type] - except KeyError as e: + except KeyError as err: # we know this exists and that we can't handle it if activity_type in ["Question"]: return None - raise ActivitySerializerError(e) + raise ActivitySerializerError(err) return serializer(activity_objects=activity_objects, **activity_json) diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py index 1599b408a..bd27c4e60 100644 --- a/bookwyrm/activitypub/book.py +++ b/bookwyrm/activitypub/book.py @@ -6,6 +6,7 @@ from .base_activity import ActivityObject from .image import Document +# pylint: disable=invalid-name @dataclass(init=False) class BookData(ActivityObject): """shared fields for all book data and authors""" @@ -18,6 +19,7 @@ class BookData(ActivityObject): lastEditedBy: str = None +# pylint: disable=invalid-name @dataclass(init=False) class Book(BookData): """serializes an edition or work, abstract""" @@ -40,6 +42,7 @@ class Book(BookData): type: str = "Book" +# pylint: disable=invalid-name @dataclass(init=False) class Edition(Book): """Edition instance of a book object""" @@ -57,6 +60,7 @@ class Edition(Book): type: str = "Edition" +# pylint: disable=invalid-name @dataclass(init=False) class Work(Book): """work instance of a book object""" @@ -66,6 +70,7 @@ class Work(Book): type: str = "Work" +# pylint: disable=invalid-name @dataclass(init=False) class Author(BookData): """author of a book""" diff --git a/bookwyrm/activitypub/note.py b/bookwyrm/activitypub/note.py index ea2e92b6e..916da2d0e 100644 --- a/bookwyrm/activitypub/note.py +++ b/bookwyrm/activitypub/note.py @@ -19,6 +19,7 @@ class Tombstone(ActivityObject): return model.find_existing_by_remote_id(self.id) +# pylint: disable=invalid-name @dataclass(init=False) class Note(ActivityObject): """Note activity""" @@ -52,6 +53,7 @@ class GeneratedNote(Note): type: str = "GeneratedNote" +# pylint: disable=invalid-name @dataclass(init=False) class Comment(Note): """like a note but with a book""" diff --git a/bookwyrm/activitypub/ordered_collection.py b/bookwyrm/activitypub/ordered_collection.py index e3a83be8e..32e37c996 100644 --- a/bookwyrm/activitypub/ordered_collection.py +++ b/bookwyrm/activitypub/ordered_collection.py @@ -5,6 +5,7 @@ from typing import List from .base_activity import ActivityObject +# pylint: disable=invalid-name @dataclass(init=False) class OrderedCollection(ActivityObject): """structure of an ordered collection activity""" @@ -17,6 +18,7 @@ class OrderedCollection(ActivityObject): type: str = "OrderedCollection" +# pylint: disable=invalid-name @dataclass(init=False) class OrderedCollectionPrivate(OrderedCollection): """an ordered collection with privacy settings""" @@ -41,6 +43,7 @@ class BookList(OrderedCollectionPrivate): type: str = "BookList" +# pylint: disable=invalid-name @dataclass(init=False) class OrderedCollectionPage(ActivityObject): """structure of an ordered collection activity""" diff --git a/bookwyrm/activitypub/person.py b/bookwyrm/activitypub/person.py index d5f379461..174ead616 100644 --- a/bookwyrm/activitypub/person.py +++ b/bookwyrm/activitypub/person.py @@ -6,6 +6,7 @@ from .base_activity import ActivityObject from .image import Image +# pylint: disable=invalid-name @dataclass(init=False) class PublicKey(ActivityObject): """public key block""" @@ -15,6 +16,7 @@ class PublicKey(ActivityObject): type: str = "PublicKey" +# pylint: disable=invalid-name @dataclass(init=False) class Person(ActivityObject): """actor activitypub json""" diff --git a/bookwyrm/activitypub/response.py b/bookwyrm/activitypub/response.py index 07f39c7e1..e480b85df 100644 --- a/bookwyrm/activitypub/response.py +++ b/bookwyrm/activitypub/response.py @@ -1,3 +1,4 @@ +""" ActivityPub-specific json response wrapper """ from django.http import JsonResponse from .base_activity import ActivityEncoder diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py index f26936d7b..50a479b71 100644 --- a/bookwyrm/activitypub/verbs.py +++ b/bookwyrm/activitypub/verbs.py @@ -22,6 +22,7 @@ class Verb(ActivityObject): self.object.to_model() +# pylint: disable=invalid-name @dataclass(init=False) class Create(Verb): """Create activity""" @@ -32,6 +33,7 @@ class Create(Verb): type: str = "Create" +# pylint: disable=invalid-name @dataclass(init=False) class Delete(Verb): """Create activity""" @@ -57,6 +59,7 @@ class Delete(Verb): # if we can't find it, we don't need to delete it because we don't have it +# pylint: disable=invalid-name @dataclass(init=False) class Update(Verb): """Update activity""" @@ -192,6 +195,7 @@ class Like(Verb): self.to_model() +# pylint: disable=invalid-name @dataclass(init=False) class Announce(Verb): """boosting a status""" diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py index ff3c55fbd..a49a7ce4d 100644 --- a/bookwyrm/activitystreams.py +++ b/bookwyrm/activitystreams.py @@ -201,6 +201,19 @@ def add_status_on_create(sender, instance, created, *args, **kwargs): for stream in streams.values(): stream.add_status(instance) + if sender != models.Boost: + return + # remove the original post and other, earlier boosts + boosted = instance.boost.boosted_status + old_versions = models.Boost.objects.filter( + boosted_status__id=boosted.id, + created_date__lt=instance.created_date, + ) + for stream in streams.values(): + stream.remove_object_from_related_stores(boosted) + for status in old_versions: + stream.remove_object_from_related_stores(status) + @receiver(signals.post_delete, sender=models.Boost) # pylint: disable=unused-argument @@ -208,7 +221,10 @@ def remove_boost_on_delete(sender, instance, *args, **kwargs): """boosts are deleted""" # we're only interested in new statuses for stream in streams.values(): + # remove the boost stream.remove_object_from_related_stores(instance) + # re-add the original status + stream.add_status(instance.boosted_status) @receiver(signals.post_save, sender=models.UserFollows) diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py index 606678150..6c032b830 100644 --- a/bookwyrm/connectors/abstract_connector.py +++ b/bookwyrm/connectors/abstract_connector.py @@ -37,7 +37,7 @@ class AbstractMinimalConnector(ABC): for field in self_fields: setattr(self, field, getattr(info, field)) - def search(self, query, min_confidence=None): + def search(self, query, min_confidence=None, timeout=5): """free text search""" params = {} if min_confidence: @@ -46,6 +46,7 @@ class AbstractMinimalConnector(ABC): data = self.get_search_data( "%s%s" % (self.search_url, query), params=params, + timeout=timeout, ) results = [] @@ -126,8 +127,8 @@ class AbstractConnector(AbstractMinimalConnector): edition_data = data try: work_data = self.get_work_from_edition_data(data) - except (KeyError, ConnectorException) as e: - logger.exception(e) + except (KeyError, ConnectorException) as err: + logger.exception(err) work_data = data if not work_data or not edition_data: @@ -218,7 +219,7 @@ def dict_from_mappings(data, mappings): return result -def get_data(url, params=None): +def get_data(url, params=None, timeout=10): """wrapper for request.get""" # check if the url is blocked if models.FederatedServer.is_blocked(url): @@ -234,23 +235,24 @@ def get_data(url, params=None): "Accept": "application/json; charset=utf-8", "User-Agent": settings.USER_AGENT, }, + timeout=timeout, ) - except (RequestError, SSLError, ConnectionError) as e: - logger.exception(e) + except (RequestError, SSLError, ConnectionError) as err: + logger.exception(err) raise ConnectorException() if not resp.ok: raise ConnectorException() try: data = resp.json() - except ValueError as e: - logger.exception(e) + except ValueError as err: + logger.exception(err) raise ConnectorException() return data -def get_image(url): +def get_image(url, timeout=10): """wrapper for requesting an image""" try: resp = requests.get( @@ -258,9 +260,10 @@ def get_image(url): headers={ "User-Agent": settings.USER_AGENT, }, + timeout=timeout, ) - except (RequestError, SSLError) as e: - logger.exception(e) + except (RequestError, SSLError) as err: + logger.exception(err) return None if not resp.ok: return None diff --git a/bookwyrm/connectors/connector_manager.py b/bookwyrm/connectors/connector_manager.py index 95c5959df..1a615c9b2 100644 --- a/bookwyrm/connectors/connector_manager.py +++ b/bookwyrm/connectors/connector_manager.py @@ -1,4 +1,5 @@ """ interface with whatever connectors the app has """ +from datetime import datetime import importlib import logging import re @@ -29,23 +30,25 @@ def search(query, min_confidence=0.1, return_first=False): isbn = re.sub(r"[\W_]", "", query) maybe_isbn = len(isbn) in [10, 13] # ISBN10 or ISBN13 + timeout = 15 + start_time = datetime.now() for connector in get_connectors(): result_set = None - if maybe_isbn and connector.isbn_search_url and connector.isbn_search_url == "": + if maybe_isbn and connector.isbn_search_url and connector.isbn_search_url != "": # Search on ISBN try: result_set = connector.isbn_search(isbn) - except Exception as e: # pylint: disable=broad-except - logger.exception(e) + except Exception as err: # pylint: disable=broad-except + logger.exception(err) # if this fails, we can still try regular search # if no isbn search results, we fallback to generic search if not result_set: try: result_set = connector.search(query, min_confidence=min_confidence) - except Exception as e: # pylint: disable=broad-except + except Exception as err: # pylint: disable=broad-except # we don't want *any* error to crash the whole search page - logger.exception(e) + logger.exception(err) continue if return_first and result_set: @@ -59,6 +62,8 @@ def search(query, min_confidence=0.1, return_first=False): "results": result_set, } ) + if (datetime.now() - start_time).seconds >= timeout: + break if return_first: return None diff --git a/bookwyrm/connectors/inventaire.py b/bookwyrm/connectors/inventaire.py index 102c9d727..116aa5c11 100644 --- a/bookwyrm/connectors/inventaire.py +++ b/bookwyrm/connectors/inventaire.py @@ -74,7 +74,7 @@ class Connector(AbstractConnector): **{k: data.get(k) for k in ["uri", "image", "labels", "sitelinks"]}, } - def search(self, query, min_confidence=None): + def search(self, query, min_confidence=None): # pylint: disable=arguments-differ """overrides default search function with confidence ranking""" results = super().search(query) if min_confidence: diff --git a/bookwyrm/connectors/self_connector.py b/bookwyrm/connectors/self_connector.py index a8f858345..930b7cb3d 100644 --- a/bookwyrm/connectors/self_connector.py +++ b/bookwyrm/connectors/self_connector.py @@ -3,7 +3,7 @@ from functools import reduce import operator from django.contrib.postgres.search import SearchRank, SearchVector -from django.db.models import Count, OuterRef, Subquery, F, Q +from django.db.models import OuterRef, Subquery, F, Q from bookwyrm import models from .abstract_connector import AbstractConnector, SearchResult @@ -114,6 +114,7 @@ class Connector(AbstractConnector): def search_identifiers(query, *filters): """tries remote_id, isbn; defined as dedupe fields on the model""" + # pylint: disable=W0212 or_filters = [ {f.name: query} for f in models.Edition._meta.get_fields() @@ -122,6 +123,8 @@ def search_identifiers(query, *filters): results = models.Edition.objects.filter( *filters, reduce(operator.or_, (Q(**f) for f in or_filters)) ).distinct() + if results.count() <= 1: + return results # when there are multiple editions of the same work, pick the default. # it would be odd for this to happen. @@ -146,19 +149,15 @@ def search_title_author(query, min_confidence, *filters): ) results = ( - models.Edition.objects.annotate(search=vector) - .annotate(rank=SearchRank(vector, query)) + models.Edition.objects.annotate(rank=SearchRank(vector, query)) .filter(*filters, rank__gt=min_confidence) .order_by("-rank") ) # when there are multiple editions of the same work, pick the closest - editions_of_work = ( - results.values("parent_work") - .annotate(Count("parent_work")) - .values_list("parent_work") - ) + editions_of_work = results.values("parent_work__id").values_list("parent_work__id") + # filter out multiple editions of the same work for work_id in set(editions_of_work): editions = results.filter(parent_work=work_id) default = editions.order_by("-edition_rank").first() diff --git a/bookwyrm/context_processors.py b/bookwyrm/context_processors.py index b95e1c236..fe81c51ac 100644 --- a/bookwyrm/context_processors.py +++ b/bookwyrm/context_processors.py @@ -4,8 +4,18 @@ from bookwyrm import models, settings def site_settings(request): # pylint: disable=unused-argument """include the custom info about the site""" + request_protocol = "https://" + if not request.is_secure(): + request_protocol = "http://" + return { "site": models.SiteSettings.objects.get(), "active_announcements": models.Announcement.active_announcements(), "enable_thumbnail_generation": settings.ENABLE_THUMBNAIL_GENERATION, + "static_url": settings.STATIC_URL, + "media_url": settings.MEDIA_URL, + "static_path": settings.STATIC_PATH, + "media_path": settings.MEDIA_PATH, + "preview_images_enabled": settings.ENABLE_PREVIEW_IMAGES, + "request_protocol": request_protocol, } diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py index 25b72a119..cb55d229e 100644 --- a/bookwyrm/forms.py +++ b/bookwyrm/forms.py @@ -22,6 +22,7 @@ class CustomForm(ModelForm): css_classes["number"] = "input" css_classes["checkbox"] = "checkbox" css_classes["textarea"] = "textarea" + # pylint: disable=super-with-arguments super(CustomForm, self).__init__(*args, **kwargs) for visible in self.visible_fields(): if hasattr(visible.field.widget, "input_type"): @@ -150,6 +151,12 @@ class LimitedEditUserForm(CustomForm): help_texts = {f: None for f in fields} +class DeleteUserForm(CustomForm): + class Meta: + model = models.User + fields = ["password"] + + class UserGroupForm(CustomForm): class Meta: model = models.User @@ -175,8 +182,6 @@ class EditionForm(CustomForm): "authors", "parent_work", "shelves", - "subjects", # TODO - "subject_places", # TODO "connector", ] diff --git a/bookwyrm/importers/importer.py b/bookwyrm/importers/importer.py index 89c62e735..203db0343 100644 --- a/bookwyrm/importers/importer.py +++ b/bookwyrm/importers/importer.py @@ -67,8 +67,8 @@ def import_data(source, job_id): for item in job.items.all(): try: item.resolve() - except Exception as e: # pylint: disable=broad-except - logger.exception(e) + except Exception as err: # pylint: disable=broad-except + logger.exception(err) item.fail_reason = "Error loading book" item.save() continue diff --git a/bookwyrm/management/commands/generate_preview_images.py b/bookwyrm/management/commands/generate_preview_images.py new file mode 100644 index 000000000..df2186238 --- /dev/null +++ b/bookwyrm/management/commands/generate_preview_images.py @@ -0,0 +1,65 @@ +""" Generate preview images """ +from django.core.management.base import BaseCommand + +from bookwyrm import models, preview_images + + +# pylint: disable=line-too-long +class Command(BaseCommand): + """Creates previews for existing objects""" + + help = "Generate preview images" + + def add_arguments(self, parser): + parser.add_argument( + "--all", + "-a", + action="store_true", + help="Generates images for ALL types: site, users and books. Can use a lot of computing power.", + ) + + # pylint: disable=no-self-use,unused-argument + def handle(self, *args, **options): + """generate preview images""" + self.stdout.write( + " | Hello! I will be generating preview images for your instance." + ) + if options["all"]: + self.stdout.write( + "🧑‍🎨 ⎨ This might take quite long if your instance has a lot of books and users." + ) + self.stdout.write(" | ✧ Thank you for your patience ✧") + else: + self.stdout.write("🧑‍🎨 ⎨ I will only generate the instance preview image.") + self.stdout.write(" | ✧ Be right back! ✧") + + # Site + self.stdout.write(" → Site preview image: ", ending="") + preview_images.generate_site_preview_image_task.delay() + self.stdout.write(" OK 🖼") + + if options["all"]: + # Users + users = models.User.objects.filter( + local=True, + is_active=True, + ) + self.stdout.write( + " → User preview images ({}): ".format(len(users)), ending="" + ) + for user in users: + preview_images.generate_user_preview_image_task.delay(user.id) + self.stdout.write(".", ending="") + self.stdout.write(" OK 🖼") + + # Books + books = models.Book.objects.select_subclasses().filter() + self.stdout.write( + " → Book preview images ({}): ".format(len(books)), ending="" + ) + for book in books: + preview_images.generate_edition_preview_image_task.delay(book.id) + self.stdout.write(".", ending="") + self.stdout.write(" OK 🖼") + + self.stdout.write("🧑‍🎨 ⎨ I’m all done! ✧ Enjoy ✧") diff --git a/bookwyrm/migrations/0076_preview_images.py b/bookwyrm/migrations/0076_preview_images.py new file mode 100644 index 000000000..8812e9b22 --- /dev/null +++ b/bookwyrm/migrations/0076_preview_images.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2 on 2021-05-26 12:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0075_announcement"), + ] + + operations = [ + migrations.AddField( + model_name="book", + name="preview_image", + field=models.ImageField( + blank=True, null=True, upload_to="previews/covers/" + ), + ), + migrations.AddField( + model_name="sitesettings", + name="preview_image", + field=models.ImageField(blank=True, null=True, upload_to="previews/logos/"), + ), + migrations.AddField( + model_name="user", + name="preview_image", + field=models.ImageField( + blank=True, null=True, upload_to="previews/avatars/" + ), + ), + ] diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index b0d5d476e..03ff572f6 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -2,11 +2,14 @@ import re from django.db import models +from django.dispatch import receiver +from model_utils import FieldTracker from model_utils.managers import InheritanceManager from imagekit.models import ImageSpecField from bookwyrm import activitypub -from bookwyrm.settings import DOMAIN, DEFAULT_LANGUAGE, ENABLE_THUMBNAIL_GENERATION +from bookwyrm.preview_images import generate_edition_preview_image_task +from bookwyrm.settings import DOMAIN, DEFAULT_LANGUAGE, ENABLE_PREVIEW_IMAGES, ENABLE_THUMBNAIL_GENERATION from .activitypub_mixin import OrderedCollectionPageMixin, ObjectMixin from .base_model import BookWyrmModel @@ -83,10 +86,14 @@ class Book(BookDataModel): cover = fields.ImageField( upload_to="covers/", blank=True, null=True, alt_field="alt_text" ) + preview_image = models.ImageField( + upload_to="previews/covers/", blank=True, null=True + ) first_published_date = fields.DateTimeField(blank=True, null=True) published_date = fields.DateTimeField(blank=True, null=True) objects = InheritanceManager() + field_tracker = FieldTracker(fields=["authors", "title", "subtitle", "cover"]) if ENABLE_THUMBNAIL_GENERATION: cover_bw_book_xsmall_webp = ImageSpecField( @@ -328,3 +335,17 @@ def isbn_13_to_10(isbn_13): if checkdigit == 10: checkdigit = "X" return converted + str(checkdigit) + + +# pylint: disable=unused-argument +@receiver(models.signals.post_save, sender=Edition) +def preview_image(instance, *args, **kwargs): + """create preview image on book create""" + if not ENABLE_PREVIEW_IMAGES: + return + changed_fields = {} + if instance.field_tracker: + changed_fields = instance.field_tracker.changed() + + if len(changed_fields) > 0: + generate_edition_preview_image_task.delay(instance.id) diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index caa22fcd3..4ec46504c 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -1,5 +1,6 @@ """ activitypub-aware django model fields """ from dataclasses import MISSING +import imghdr import re from uuid import uuid4 @@ -9,7 +10,7 @@ from django.contrib.postgres.fields import ArrayField as DjangoArrayField from django.core.exceptions import ValidationError from django.core.files.base import ContentFile from django.db import models -from django.forms import ClearableFileInput, ImageField +from django.forms import ClearableFileInput, ImageField as DjangoImageField from django.utils import timezone from django.utils.translation import gettext_lazy as _ from bookwyrm import activitypub @@ -201,6 +202,7 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField): *args, max_length=255, choices=PrivacyLevels.choices, default="public" ) + # pylint: disable=invalid-name def set_field_from_activity(self, instance, data): to = data.to cc = data.cc @@ -219,6 +221,7 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField): if hasattr(instance, "mention_users"): mentions = [u.remote_id for u in instance.mention_users.all()] # this is a link to the followers list + # pylint: disable=protected-access followers = instance.user.__class__._meta.get_field( "followers" ).field_to_activity(instance.user.followers) @@ -334,10 +337,14 @@ class TagField(ManyToManyField): class ClearableFileInputWithWarning(ClearableFileInput): + """max file size warning""" + template_name = "widgets/clearable_file_input_with_warning.html" -class CustomImageField(ImageField): +class CustomImageField(DjangoImageField): + """overwrites image field for form""" + widget = ClearableFileInputWithWarning @@ -400,11 +407,12 @@ class ImageField(ActivitypubFieldMixin, models.ImageField): if not response: return None - image_name = str(uuid4()) + "." + url.split(".")[-1] image_content = ContentFile(response.content) + image_name = str(uuid4()) + "." + imghdr.what(None, image_content.read()) return [image_name, image_content] def formfield(self, **kwargs): + """special case for forms""" return super().formfield( **{ "form_class": CustomImageField, diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py index c8130af26..f29938469 100644 --- a/bookwyrm/models/import_job.py +++ b/bookwyrm/models/import_job.py @@ -75,7 +75,12 @@ class ImportItem(models.Model): def resolve(self): """try various ways to lookup a book""" - self.book = self.get_book_from_isbn() or self.get_book_from_title_author() + if self.isbn: + self.book = self.get_book_from_isbn() + else: + # don't fall back on title/author search is isbn is present. + # you're too likely to mismatch + self.get_book_from_title_author() def get_book_from_isbn(self): """search by isbn""" diff --git a/bookwyrm/models/list.py b/bookwyrm/models/list.py index 2a5c3382a..bbad5ba9b 100644 --- a/bookwyrm/models/list.py +++ b/bookwyrm/models/list.py @@ -93,7 +93,8 @@ class ListItem(CollectionItemMixin, BookWyrmModel): ) class Meta: - # A book may only be placed into a list once, and each order in the list may be used only - # once + """A book may only be placed into a list once, + and each order in the list may be used only once""" + unique_together = (("book", "book_list"), ("order", "book_list")) ordering = ("-created_date",) diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py index 12f4c51af..edb89d13c 100644 --- a/bookwyrm/models/relationship.py +++ b/bookwyrm/models/relationship.py @@ -99,7 +99,7 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship): status = "follow_request" activity_serializer = activitypub.Follow - def save(self, *args, broadcast=True, **kwargs): + def save(self, *args, broadcast=True, **kwargs): # pylint: disable=arguments-differ """make sure the follow or block relationship doesn't already exist""" # if there's a request for a follow that already exists, accept it # without changing the local database state diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py index 2c5a21642..872f6b454 100644 --- a/bookwyrm/models/site.py +++ b/bookwyrm/models/site.py @@ -4,9 +4,12 @@ import datetime from Crypto import Random from django.db import models, IntegrityError +from django.dispatch import receiver from django.utils import timezone +from model_utils import FieldTracker -from bookwyrm.settings import DOMAIN +from bookwyrm.preview_images import generate_site_preview_image_task +from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES from .base_model import BookWyrmModel from .user import User @@ -35,6 +38,9 @@ class SiteSettings(models.Model): logo = models.ImageField(upload_to="logos/", null=True, blank=True) logo_small = models.ImageField(upload_to="logos/", null=True, blank=True) favicon = models.ImageField(upload_to="logos/", null=True, blank=True) + preview_image = models.ImageField( + upload_to="previews/logos/", null=True, blank=True + ) # footer support_link = models.CharField(max_length=255, null=True, blank=True) @@ -42,6 +48,8 @@ class SiteSettings(models.Model): admin_email = models.EmailField(max_length=255, null=True, blank=True) footer_item = models.TextField(null=True, blank=True) + field_tracker = FieldTracker(fields=["name", "instance_tagline", "logo"]) + @classmethod def get(cls): """gets the site settings db entry or defaults""" @@ -119,3 +127,15 @@ class PasswordReset(models.Model): def link(self): """formats the invite link""" return "https://{}/password-reset/{}".format(DOMAIN, self.code) + + +# pylint: disable=unused-argument +@receiver(models.signals.post_save, sender=SiteSettings) +def preview_image(instance, *args, **kwargs): + """Update image preview for the default site image""" + if not ENABLE_PREVIEW_IMAGES: + return + changed_fields = instance.field_tracker.changed() + + if len(changed_fields) > 0: + generate_site_preview_image_task.delay() diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index bd21ec563..3c25f1af8 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -5,11 +5,15 @@ import re from django.apps import apps from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from django.dispatch import receiver from django.template.loader import get_template from django.utils import timezone +from model_utils import FieldTracker from model_utils.managers import InheritanceManager from bookwyrm import activitypub +from bookwyrm.preview_images import generate_edition_preview_image_task +from bookwyrm.settings import ENABLE_PREVIEW_IMAGES from .activitypub_mixin import ActivitypubMixin, ActivityMixin from .activitypub_mixin import OrderedCollectionPageMixin from .base_model import BookWyrmModel @@ -304,6 +308,8 @@ class Review(Status): max_digits=3, ) + field_tracker = FieldTracker(fields=["rating"]) + @property def pure_name(self): """clarify review names for mastodon serialization""" @@ -398,3 +404,17 @@ class Boost(ActivityMixin, Status): # This constraint can't work as it would cross tables. # class Meta: # unique_together = ('user', 'boosted_status') + + +# pylint: disable=unused-argument +@receiver(models.signals.post_save) +def preview_image(instance, sender, *args, **kwargs): + """Updates book previews if the rating has changed""" + if not ENABLE_PREVIEW_IMAGES or sender not in (Review, ReviewRating): + return + + changed_fields = instance.field_tracker.changed() + + if len(changed_fields) > 0: + edition = instance.book + generate_edition_preview_image_task.delay(edition.id) diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index d9f3eba99..49458a2e0 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -6,15 +6,18 @@ from django.apps import apps from django.contrib.auth.models import AbstractUser, Group from django.contrib.postgres.fields import CICharField from django.core.validators import MinValueValidator +from django.dispatch import receiver from django.db import models from django.utils import timezone +from model_utils import FieldTracker import pytz from bookwyrm import activitypub from bookwyrm.connectors import get_data, ConnectorException from bookwyrm.models.shelf import Shelf from bookwyrm.models.status import Status, Review -from bookwyrm.settings import DOMAIN +from bookwyrm.preview_images import generate_user_preview_image_task +from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES from bookwyrm.signatures import create_key_pair from bookwyrm.tasks import app from bookwyrm.utils import regex @@ -70,6 +73,9 @@ class User(OrderedCollectionPageMixin, AbstractUser): activitypub_field="icon", alt_field="alt_text", ) + preview_image = models.ImageField( + upload_to="previews/avatars/", blank=True, null=True + ) followers = fields.ManyToManyField( "self", link_only=True, @@ -117,6 +123,7 @@ class User(OrderedCollectionPageMixin, AbstractUser): name_field = "username" property_fields = [("following_link", "following")] + field_tracker = FieldTracker(fields=["name", "avatar"]) @property def following_link(self): @@ -232,7 +239,7 @@ class User(OrderedCollectionPageMixin, AbstractUser): def save(self, *args, **kwargs): """populate fields for new local users""" created = not bool(self.id) - if not self.local and not re.match(regex.full_username, self.username): + if not self.local and not re.match(regex.FULL_USERNAME, self.username): # generate a username that uses the domain (webfinger format) actor_parts = urlparse(self.remote_id) self.username = "%s@%s" % (self.username, actor_parts.netloc) @@ -356,7 +363,7 @@ class AnnualGoal(BookWyrmModel): def get_remote_id(self): """put the year in the path""" - return "%s/goal/%d" % (self.user.remote_id, self.year) + return "{:s}/goal/{:d}".format(self.user.remote_id, self.year) @property def books(self): @@ -443,3 +450,15 @@ def get_remote_reviews(outbox): if not activity["type"] == "Review": continue activitypub.Review(**activity).to_model() + + +# pylint: disable=unused-argument +@receiver(models.signals.post_save, sender=User) +def preview_image(instance, *args, **kwargs): + """create preview images when user is updated""" + if not ENABLE_PREVIEW_IMAGES: + return + changed_fields = instance.field_tracker.changed() + + if len(changed_fields) > 0: + generate_user_preview_image_task.delay(instance.id) diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py new file mode 100644 index 000000000..510625e39 --- /dev/null +++ b/bookwyrm/preview_images.py @@ -0,0 +1,424 @@ +""" Generate social media preview images for twitter/mastodon/etc """ +import math +import os +import textwrap +from io import BytesIO +from uuid import uuid4 + +import colorsys +from colorthief import ColorThief +from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageColor + +from django.core.files.base import ContentFile +from django.core.files.uploadedfile import InMemoryUploadedFile +from django.db.models import Avg + +from bookwyrm import models, settings +from bookwyrm.tasks import app + + +IMG_WIDTH = settings.PREVIEW_IMG_WIDTH +IMG_HEIGHT = settings.PREVIEW_IMG_HEIGHT +BG_COLOR = settings.PREVIEW_BG_COLOR +TEXT_COLOR = settings.PREVIEW_TEXT_COLOR +DEFAULT_COVER_COLOR = settings.PREVIEW_DEFAULT_COVER_COLOR +TRANSPARENT_COLOR = (0, 0, 0, 0) + +margin = math.floor(IMG_HEIGHT / 10) +gutter = math.floor(margin / 2) +inner_img_height = math.floor(IMG_HEIGHT * 0.8) +inner_img_width = math.floor(inner_img_height * 0.7) +font_dir = os.path.join(settings.STATIC_ROOT, "fonts/public_sans") + + +def get_font(font_name, size=28): + """Loads custom font""" + if font_name == "light": + font_path = os.path.join(font_dir, "PublicSans-Light.ttf") + if font_name == "regular": + font_path = os.path.join(font_dir, "PublicSans-Regular.ttf") + elif font_name == "bold": + font_path = os.path.join(font_dir, "PublicSans-Bold.ttf") + + try: + font = ImageFont.truetype(font_path, size) + except OSError: + font = ImageFont.load_default() + + return font + + +def generate_texts_layer(texts, content_width): + """Adds text for images""" + font_text_zero = get_font("bold", size=20) + font_text_one = get_font("bold", size=48) + font_text_two = get_font("bold", size=40) + font_text_three = get_font("regular", size=40) + + text_layer = Image.new("RGBA", (content_width, IMG_HEIGHT), color=TRANSPARENT_COLOR) + text_layer_draw = ImageDraw.Draw(text_layer) + + text_y = 0 + + if "text_zero" in texts and texts["text_zero"]: + # Text one (Book title) + text_zero = textwrap.fill(texts["text_zero"], width=72) + text_layer_draw.multiline_text( + (0, text_y), text_zero, font=font_text_zero, fill=TEXT_COLOR + ) + + try: + text_y = text_y + font_text_zero.getsize_multiline(text_zero)[1] + 16 + except (AttributeError, IndexError): + text_y = text_y + 26 + + if "text_one" in texts and texts["text_one"]: + # Text one (Book title) + text_one = textwrap.fill(texts["text_one"], width=28) + text_layer_draw.multiline_text( + (0, text_y), text_one, font=font_text_one, fill=TEXT_COLOR + ) + + try: + text_y = text_y + font_text_one.getsize_multiline(text_one)[1] + 16 + except (AttributeError, IndexError): + text_y = text_y + 26 + + if "text_two" in texts and texts["text_two"]: + # Text one (Book subtitle) + text_two = textwrap.fill(texts["text_two"], width=36) + text_layer_draw.multiline_text( + (0, text_y), text_two, font=font_text_two, fill=TEXT_COLOR + ) + + try: + text_y = text_y + font_text_one.getsize_multiline(text_two)[1] + 16 + except (AttributeError, IndexError): + text_y = text_y + 26 + + if "text_three" in texts and texts["text_three"]: + # Text three (Book authors) + text_three = textwrap.fill(texts["text_three"], width=36) + text_layer_draw.multiline_text( + (0, text_y), text_three, font=font_text_three, fill=TEXT_COLOR + ) + + text_layer_box = text_layer.getbbox() + return text_layer.crop(text_layer_box) + + +def generate_instance_layer(content_width): + """Places components for instance preview""" + font_instance = get_font("light", size=28) + + site = models.SiteSettings.objects.get() + + if site.logo_small: + logo_img = Image.open(site.logo_small) + else: + try: + static_path = os.path.join(settings.STATIC_ROOT, "images/logo-small.png") + logo_img = Image.open(static_path) + except FileNotFoundError: + logo_img = None + + instance_layer = Image.new("RGBA", (content_width, 62), color=TRANSPARENT_COLOR) + + instance_text_x = 0 + + if logo_img: + logo_img.thumbnail((50, 50), Image.ANTIALIAS) + + instance_layer.paste(logo_img, (0, 0)) + + instance_text_x = instance_text_x + 60 + + instance_layer_draw = ImageDraw.Draw(instance_layer) + instance_layer_draw.text( + (instance_text_x, 10), site.name, font=font_instance, fill=TEXT_COLOR + ) + + line_width = 50 + 10 + font_instance.getsize(site.name)[0] + + line_layer = Image.new( + "RGBA", (line_width, 2), color=(*(ImageColor.getrgb(TEXT_COLOR)), 50) + ) + instance_layer.alpha_composite(line_layer, (0, 60)) + + return instance_layer + + +def generate_rating_layer(rating, content_width): + """Places components for rating preview""" + try: + icon_star_full = Image.open( + os.path.join(settings.STATIC_ROOT, "images/icons/star-full.png") + ) + icon_star_empty = Image.open( + os.path.join(settings.STATIC_ROOT, "images/icons/star-empty.png") + ) + icon_star_half = Image.open( + os.path.join(settings.STATIC_ROOT, "images/icons/star-half.png") + ) + except FileNotFoundError: + return None + + icon_size = 64 + icon_margin = 10 + + rating_layer_base = Image.new( + "RGBA", (content_width, icon_size), color=TRANSPARENT_COLOR + ) + rating_layer_color = Image.new("RGBA", (content_width, icon_size), color=TEXT_COLOR) + rating_layer_mask = Image.new( + "RGBA", (content_width, icon_size), color=TRANSPARENT_COLOR + ) + + position_x = 0 + + for _ in range(math.floor(rating)): + rating_layer_mask.alpha_composite(icon_star_full, (position_x, 0)) + position_x = position_x + icon_size + icon_margin + + if math.floor(rating) != math.ceil(rating): + rating_layer_mask.alpha_composite(icon_star_half, (position_x, 0)) + position_x = position_x + icon_size + icon_margin + + for _ in range(5 - math.ceil(rating)): + rating_layer_mask.alpha_composite(icon_star_empty, (position_x, 0)) + position_x = position_x + icon_size + icon_margin + + rating_layer_mask = rating_layer_mask.getchannel("A") + rating_layer_mask = ImageOps.invert(rating_layer_mask) + + rating_layer_composite = Image.composite( + rating_layer_base, rating_layer_color, rating_layer_mask + ) + + return rating_layer_composite + + +def generate_default_inner_img(): + """Adds cover image""" + font_cover = get_font("light", size=28) + + default_cover = Image.new( + "RGB", (inner_img_width, inner_img_height), color=DEFAULT_COVER_COLOR + ) + default_cover_draw = ImageDraw.Draw(default_cover) + + text = "no image :(" + text_dimensions = font_cover.getsize(text) + text_coords = ( + math.floor((inner_img_width - text_dimensions[0]) / 2), + math.floor((inner_img_height - text_dimensions[1]) / 2), + ) + default_cover_draw.text(text_coords, text, font=font_cover, fill="white") + + return default_cover + + +# pylint: disable=too-many-locals +def generate_preview_image( + texts=None, picture=None, rating=None, show_instance_layer=True +): + """Puts everything together""" + texts = texts or {} + # Cover + try: + inner_img_layer = Image.open(picture) + inner_img_layer.thumbnail((inner_img_width, inner_img_height), Image.ANTIALIAS) + color_thief = ColorThief(picture) + dominant_color = color_thief.get_color(quality=1) + except: # pylint: disable=bare-except + inner_img_layer = generate_default_inner_img() + dominant_color = ImageColor.getrgb(DEFAULT_COVER_COLOR) + + # Color + if BG_COLOR in ["use_dominant_color_light", "use_dominant_color_dark"]: + image_bg_color = "rgb(%s, %s, %s)" % dominant_color + + # Adjust color + image_bg_color_rgb = [x / 255.0 for x in ImageColor.getrgb(image_bg_color)] + image_bg_color_hls = colorsys.rgb_to_hls(*image_bg_color_rgb) + + if BG_COLOR == "use_dominant_color_light": + lightness = max(0.9, image_bg_color_hls[1]) + else: + lightness = min(0.15, image_bg_color_hls[1]) + + image_bg_color_hls = ( + image_bg_color_hls[0], + lightness, + image_bg_color_hls[2], + ) + image_bg_color = tuple( + math.ceil(x * 255) for x in colorsys.hls_to_rgb(*image_bg_color_hls) + ) + else: + image_bg_color = BG_COLOR + + # Background (using the color) + img = Image.new("RGBA", (IMG_WIDTH, IMG_HEIGHT), color=image_bg_color) + + # Contents + inner_img_x = margin + inner_img_width - inner_img_layer.width + inner_img_y = math.floor((IMG_HEIGHT - inner_img_layer.height) / 2) + content_x = margin + inner_img_width + gutter + content_width = IMG_WIDTH - content_x - margin + + contents_layer = Image.new( + "RGBA", (content_width, IMG_HEIGHT), color=TRANSPARENT_COLOR + ) + contents_composite_y = 0 + + if show_instance_layer: + instance_layer = generate_instance_layer(content_width) + contents_layer.alpha_composite(instance_layer, (0, contents_composite_y)) + contents_composite_y = contents_composite_y + instance_layer.height + gutter + + texts_layer = generate_texts_layer(texts, content_width) + contents_layer.alpha_composite(texts_layer, (0, contents_composite_y)) + contents_composite_y = contents_composite_y + texts_layer.height + gutter + + if rating: + # Add some more margin + contents_composite_y = contents_composite_y + gutter + rating_layer = generate_rating_layer(rating, content_width) + + if rating_layer: + contents_layer.alpha_composite(rating_layer, (0, contents_composite_y)) + contents_composite_y = contents_composite_y + rating_layer.height + gutter + + contents_layer_box = contents_layer.getbbox() + contents_layer_height = contents_layer_box[3] - contents_layer_box[1] + + contents_y = math.floor((IMG_HEIGHT - contents_layer_height) / 2) + + if show_instance_layer: + # Remove Instance Layer from centering calculations + contents_y = contents_y - math.floor((instance_layer.height + gutter) / 2) + + contents_y = max(contents_y, margin) + + # Composite layers + img.paste( + inner_img_layer, (inner_img_x, inner_img_y), inner_img_layer.convert("RGBA") + ) + img.alpha_composite(contents_layer, (content_x, contents_y)) + + return img.convert("RGB") + + +def save_and_cleanup(image, instance=None): + """Save and close the file""" + if not isinstance(instance, (models.Book, models.User, models.SiteSettings)): + return False + file_name = "%s-%s.jpg" % (str(instance.id), str(uuid4())) + image_buffer = BytesIO() + + try: + try: + old_path = instance.preview_image.path + except ValueError: + old_path = "" + + # Save + image.save(image_buffer, format="jpeg", quality=75) + + instance.preview_image = InMemoryUploadedFile( + ContentFile(image_buffer.getvalue()), + "preview_image", + file_name, + "image/jpg", + image_buffer.tell(), + None, + ) + + save_without_broadcast = isinstance(instance, (models.Book, models.User)) + if save_without_broadcast: + instance.save(broadcast=False) + else: + instance.save() + + # Clean up old file after saving + if os.path.exists(old_path): + os.remove(old_path) + + finally: + image_buffer.close() + return True + + +# pylint: disable=invalid-name +@app.task +def generate_site_preview_image_task(): + """generate preview_image for the website""" + if not settings.ENABLE_PREVIEW_IMAGES: + return + + site = models.SiteSettings.objects.get() + + if site.logo: + logo = site.logo + else: + logo = os.path.join(settings.STATIC_ROOT, "images/logo.png") + + texts = { + "text_zero": settings.DOMAIN, + "text_one": site.name, + "text_three": site.instance_tagline, + } + + image = generate_preview_image(texts=texts, picture=logo, show_instance_layer=False) + + save_and_cleanup(image, instance=site) + + +# pylint: disable=invalid-name +@app.task +def generate_edition_preview_image_task(book_id): + """generate preview_image for a book""" + if not settings.ENABLE_PREVIEW_IMAGES: + return + + book = models.Book.objects.select_subclasses().get(id=book_id) + + rating = models.Review.objects.filter( + privacy="public", + deleted=False, + book__in=[book_id], + ).aggregate(Avg("rating"))["rating__avg"] + + texts = { + "text_one": book.title, + "text_two": book.subtitle, + "text_three": book.author_text, + } + + image = generate_preview_image(texts=texts, picture=book.cover, rating=rating) + + save_and_cleanup(image, instance=book) + + +@app.task +def generate_user_preview_image_task(user_id): + """generate preview_image for a book""" + if not settings.ENABLE_PREVIEW_IMAGES: + return + + user = models.User.objects.get(id=user_id) + + texts = { + "text_one": user.display_name, + "text_three": "@{}@{}".format(user.localname, settings.DOMAIN), + } + + if user.avatar: + avatar = user.avatar + else: + avatar = os.path.join(settings.STATIC_ROOT, "images/default_avi.jpg") + + image = generate_preview_image(texts=texts, picture=avatar) + + save_and_cleanup(image, instance=user) diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index e8771546b..5bdd41021 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -42,6 +42,14 @@ LOCALE_PATHS = [ DEFAULT_AUTO_FIELD = "django.db.models.AutoField" +# Preview image +ENABLE_PREVIEW_IMAGES = env.bool("ENABLE_PREVIEW_IMAGES", False) +PREVIEW_BG_COLOR = env.str("PREVIEW_BG_COLOR", "use_dominant_color_light") +PREVIEW_TEXT_COLOR = env.str("PREVIEW_TEXT_COLOR", "#363636") +PREVIEW_IMG_WIDTH = env.int("PREVIEW_IMG_WIDTH", 1200) +PREVIEW_IMG_HEIGHT = env.int("PREVIEW_IMG_HEIGHT", 630) +PREVIEW_DEFAULT_COVER_COLOR = env.str("PREVIEW_DEFAULT_COVER_COLOR", "#002549") + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ @@ -52,7 +60,6 @@ SECRET_KEY = env("SECRET_KEY") DEBUG = env.bool("DEBUG", True) ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", ["*"]) -OL_URL = env("OL_URL") # Application definition @@ -115,10 +122,8 @@ STREAMS = ["home", "local", "federated"] # Database # https://docs.djangoproject.com/en/3.2/ref/settings/#databases -BOOKWYRM_DATABASE_BACKEND = env("BOOKWYRM_DATABASE_BACKEND", "postgres") - -BOOKWYRM_DBS = { - "postgres": { +DATABASES = { + "default": { "ENGINE": "django.db.backends.postgresql_psycopg2", "NAME": env("POSTGRES_DB", "fedireads"), "USER": env("POSTGRES_USER", "fedireads"), @@ -128,8 +133,6 @@ BOOKWYRM_DBS = { }, } -DATABASES = {"default": BOOKWYRM_DBS[BOOKWYRM_DATABASE_BACKEND]} - LOGIN_URL = "/login/" AUTH_USER_MODEL = "bookwyrm.User" @@ -137,6 +140,7 @@ AUTH_USER_MODEL = "bookwyrm.User" # Password validation # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators +# pylint: disable=line-too-long AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", @@ -180,8 +184,10 @@ USE_TZ = True PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) STATIC_URL = "/static/" +STATIC_PATH = "%s/%s" % (DOMAIN, env("STATIC_ROOT", "static")) STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static")) MEDIA_URL = "/images/" +MEDIA_PATH = "%s/%s" % (DOMAIN, env("MEDIA_ROOT", "images")) MEDIA_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images")) USER_AGENT = "%s (BookWyrm/%s; +https://%s/)" % ( diff --git a/bookwyrm/signatures.py b/bookwyrm/signatures.py index 5488cf9be..c8c900283 100644 --- a/bookwyrm/signatures.py +++ b/bookwyrm/signatures.py @@ -73,6 +73,7 @@ class Signature: self.headers = headers self.signature = signature + # pylint: disable=invalid-name @classmethod def parse(cls, request): """extract and parse a signature from an http request""" diff --git a/bookwyrm/static/fonts/public_sans/OFL.txt b/bookwyrm/static/fonts/public_sans/OFL.txt new file mode 100644 index 000000000..ba4ea0b96 --- /dev/null +++ b/bookwyrm/static/fonts/public_sans/OFL.txt @@ -0,0 +1,94 @@ +Copyright (c) 2015, Pablo Impallari, Rodrigo Fuenzalida +(Modified by Dan O. Williams and USWDS) (https://github.com/uswds/public-sans) + +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. diff --git a/bookwyrm/static/fonts/public_sans/PublicSans-Bold.ttf b/bookwyrm/static/fonts/public_sans/PublicSans-Bold.ttf new file mode 100644 index 000000000..3eb5ac24e Binary files /dev/null and b/bookwyrm/static/fonts/public_sans/PublicSans-Bold.ttf differ diff --git a/bookwyrm/static/fonts/public_sans/PublicSans-Light.ttf b/bookwyrm/static/fonts/public_sans/PublicSans-Light.ttf new file mode 100644 index 000000000..13fd7edbc Binary files /dev/null and b/bookwyrm/static/fonts/public_sans/PublicSans-Light.ttf differ diff --git a/bookwyrm/static/fonts/public_sans/PublicSans-Regular.ttf b/bookwyrm/static/fonts/public_sans/PublicSans-Regular.ttf new file mode 100644 index 000000000..25c1646a4 Binary files /dev/null and b/bookwyrm/static/fonts/public_sans/PublicSans-Regular.ttf differ diff --git a/bookwyrm/static/images/icons/star-empty.png b/bookwyrm/static/images/icons/star-empty.png new file mode 100755 index 000000000..896417ef6 Binary files /dev/null and b/bookwyrm/static/images/icons/star-empty.png differ diff --git a/bookwyrm/static/images/icons/star-full.png b/bookwyrm/static/images/icons/star-full.png new file mode 100755 index 000000000..6d78caf0c Binary files /dev/null and b/bookwyrm/static/images/icons/star-full.png differ diff --git a/bookwyrm/static/images/icons/star-half.png b/bookwyrm/static/images/icons/star-half.png new file mode 100755 index 000000000..75e4eadc6 Binary files /dev/null and b/bookwyrm/static/images/icons/star-half.png differ diff --git a/bookwyrm/static/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js index 598dd93ac..e43ed134b 100644 --- a/bookwyrm/static/js/bookwyrm.js +++ b/bookwyrm/static/js/bookwyrm.js @@ -3,7 +3,7 @@ let BookWyrm = new class { constructor() { - this.MAX_FILE_SIZE_BYTES = 10 * 1000000 + this.MAX_FILE_SIZE_BYTES = 10 * 1000000; this.initOnDOMLoaded(); this.initReccuringTasks(); this.initEventListeners(); @@ -45,14 +45,14 @@ let BookWyrm = new class { * Execute code once the DOM is loaded. */ initOnDOMLoaded() { - const bookwyrm = this + const bookwyrm = this; window.addEventListener('DOMContentLoaded', function() { document.querySelectorAll('.tab-group') .forEach(tabs => new TabGroup(tabs)); document.querySelectorAll('input[type="file"]').forEach( bookwyrm.disableIfTooLarge.bind(bookwyrm) - ) + ); }); } @@ -138,6 +138,7 @@ let BookWyrm = new class { * @return {undefined} */ toggleAction(event) { + event.preventDefault(); let trigger = event.currentTarget; let pressed = trigger.getAttribute('aria-pressed') === 'false'; let targetId = trigger.dataset.controls; @@ -182,6 +183,8 @@ let BookWyrm = new class { if (focus) { this.toggleFocus(focus); } + + return false; } /** @@ -298,25 +301,25 @@ let BookWyrm = new class { } disableIfTooLarge(eventOrElement) { - const { addRemoveClass, MAX_FILE_SIZE_BYTES } = this - const element = eventOrElement.currentTarget || eventOrElement + const { addRemoveClass, MAX_FILE_SIZE_BYTES } = this; + const element = eventOrElement.currentTarget || eventOrElement; - const submits = element.form.querySelectorAll('[type="submit"]') - const warns = element.parentElement.querySelectorAll('.file-too-big') + const submits = element.form.querySelectorAll('[type="submit"]'); + const warns = element.parentElement.querySelectorAll('.file-too-big'); const isTooBig = element.files && element.files[0] && - element.files[0].size > MAX_FILE_SIZE_BYTES + element.files[0].size > MAX_FILE_SIZE_BYTES; if (isTooBig) { - submits.forEach(submitter => submitter.disabled = true) + submits.forEach(submitter => submitter.disabled = true); warns.forEach( sib => addRemoveClass(sib, 'is-hidden', false) - ) + ); } else { - submits.forEach(submitter => submitter.disabled = false) + submits.forEach(submitter => submitter.disabled = false); warns.forEach( sib => addRemoveClass(sib, 'is-hidden', true) - ) + ); } } -} +}(); diff --git a/bookwyrm/static/js/localstorage.js b/bookwyrm/static/js/localstorage.js index 059557799..b485ed7ea 100644 --- a/bookwyrm/static/js/localstorage.js +++ b/bookwyrm/static/js/localstorage.js @@ -17,7 +17,7 @@ let LocalStorageTools = new class { * @return {undefined} */ updateDisplay(event) { - // used in set reading goal + // Used in set reading goal let key = event.target.dataset.id; let value = event.target.dataset.value; @@ -34,10 +34,10 @@ let LocalStorageTools = new class { * @return {undefined} */ setDisplay(node) { - // used in set reading goal + // Used in set reading goal let key = node.dataset.hide; let value = window.localStorage.getItem(key); BookWyrm.addRemoveClass(node, 'is-hidden', value); } -} +}(); diff --git a/bookwyrm/templates/author/author.html b/bookwyrm/templates/author/author.html index f4f308f2d..0bc427758 100644 --- a/bookwyrm/templates/author/author.html +++ b/bookwyrm/templates/author/author.html @@ -15,7 +15,7 @@
- {% trans "Edit Author" %} + {% trans "Edit Author" %}
{% endif %} diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html index 187fd5b6b..eb9e9cb87 100644 --- a/bookwyrm/templates/book/book.html +++ b/bookwyrm/templates/book/book.html @@ -1,35 +1,47 @@ {% extends 'layout.html' %} -{% load i18n %}{% load bookwyrm_tags %}{% load humanize %}{% load utilities %} +{% load i18n %}{% load bookwyrm_tags %}{% load humanize %}{% load utilities %}{% load layout %} {% block title %}{{ book|book_title }}{% endblock %} +{% block opengraph_images %} + {% include 'snippets/opengraph_images.html' with image=book.preview_image %} +{% endblock %} + {% block content %} {% with user_authenticated=request.user.is_authenticated can_edit_book=perms.bookwyrm.edit_book %}
-

- - {{ book.title }}{% if book.subtitle %}: - {{ book.subtitle }} - {% endif %} - - - {% if book.series %} - - - - - ({{ book.series }} - {% if book.series_number %} #{{ book.series_number }}{% endif %}) - -
- {% endif %} +

+ {{ book.title }}

+ + {% if book.subtitle or book.series %} +

+ {% if book.subtitle %} + + + + {{ book.subtitle }} + + {% endif %} + + {% if book.series %} + + + + ({{ book.series }}{% if book.series_number %} #{{ book.series_number }}{% endif %}) + {% endif %} +

+ {% endif %} + {% if book.authors %} -

- {% trans "by" %} {% include 'snippets/authors.html' with book=book %} -

+
+ {% trans "by" %} {% include 'snippets/authors.html' with book=book %} +
{% endif %}
@@ -37,7 +49,7 @@ {% endif %} @@ -85,7 +97,7 @@
-

+

{% with full=book|book_description itemprop='abstract' %} {% include 'snippets/trimmed_text.html' %} @@ -181,7 +193,7 @@

{% trans "You don't have any reading activity for this book." %}

{% endif %} {% for readthrough in readthroughs %} - {% include 'snippets/readthrough.html' with readthrough=readthrough %} + {% include 'book/readthrough.html' with readthrough=readthrough %} {% endfor %} diff --git a/bookwyrm/templates/book/editions.html b/bookwyrm/templates/book/editions.html index d22dd71a7..7a4338f12 100644 --- a/bookwyrm/templates/book/editions.html +++ b/bookwyrm/templates/book/editions.html @@ -40,7 +40,7 @@
- {% include 'snippets/shelve_button/shelve_button.html' with book=book switch_mode=True %} + {% include 'snippets/shelve_button/shelve_button.html' with book=book switch_mode=True right=True %}
{% endfor %} diff --git a/bookwyrm/templates/snippets/readthrough.html b/bookwyrm/templates/book/readthrough.html similarity index 97% rename from bookwyrm/templates/snippets/readthrough.html rename to bookwyrm/templates/book/readthrough.html index d5e79b864..751407461 100644 --- a/bookwyrm/templates/snippets/readthrough.html +++ b/bookwyrm/templates/book/readthrough.html @@ -1,8 +1,8 @@ {% load i18n %} {% load humanize %} {% load tz %} -
-
+
+
{% trans "Progress Updates:" %} diff --git a/bookwyrm/templates/components/modal.html b/bookwyrm/templates/components/modal.html index 74dcadacd..b29ff8d91 100644 --- a/bookwyrm/templates/components/modal.html +++ b/bookwyrm/templates/components/modal.html @@ -1,7 +1,7 @@ {% load i18n %} diff --git a/bookwyrm/templates/import_status.html b/bookwyrm/templates/import_status.html index 8c71bf606..7941f7b9b 100644 --- a/bookwyrm/templates/import_status.html +++ b/bookwyrm/templates/import_status.html @@ -7,6 +7,7 @@ {% block content %}{% spaceless %}

{% trans "Import Status" %}

+ {% trans "Back to imports" %}
@@ -106,7 +107,11 @@ {% endif %}
+ {% if job.complete %}

{% trans "Successfully imported" %}

+ {% else %} +

{% trans "Import Progress" %}

+ {% endif %}
diff --git a/bookwyrm/templates/layout.html b/bookwyrm/templates/layout.html index 56530a841..5e66ff799 100644 --- a/bookwyrm/templates/layout.html +++ b/bookwyrm/templates/layout.html @@ -8,16 +8,21 @@ - + + {% if preview_images_enabled is True %} + + {% else %} + {% endif %} - - + {% block opengraph_images %} + {% include 'snippets/opengraph_images.html' %} + {% endblock %} @@ -25,7 +30,7 @@