diff --git a/.css-config-sample/_instance-settings.scss b/.css-config-sample/_instance-settings.scss deleted file mode 100644 index e86c1ce00..000000000 --- a/.css-config-sample/_instance-settings.scss +++ /dev/null @@ -1,3 +0,0 @@ -@charset "utf-8"; - -// Copy this file to bookwyrm/static/css/ and set your instance custom styles. diff --git a/.github/workflows/django-tests.yml b/.github/workflows/django-tests.yml index 00e08dadb..97a744813 100644 --- a/.github/workflows/django-tests.yml +++ b/.github/workflows/django-tests.yml @@ -55,5 +55,6 @@ jobs: EMAIL_HOST_PASSWORD: "" EMAIL_USE_TLS: true ENABLE_PREVIEW_IMAGES: false + ENABLE_THUMBNAIL_GENERATION: true run: | pytest -n 3 diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 1b14149f2..a3117f7cb 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -21,8 +21,7 @@ jobs: 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 + pylint bookwyrm/ diff --git a/.gitignore b/.gitignore index 92fc85bc0..ec2a08f80 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,8 @@ .env /images/ bookwyrm/static/css/bookwyrm.css -bookwyrm/static/css/_instance-settings.scss +bookwyrm/static/css/themes/ +!bookwyrm/static/css/themes/bookwyrm-*.scss # Testing .coverage diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..9fa808eed --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +**/vendor/* diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 000000000..464638853 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,9 @@ +[MAIN] +ignore=migrations +load-plugins=pylint.extensions.no_self_use + +[MESSAGES CONTROL] +disable=E1101,E1135,E1136,R0903,R0901,R0902,W0707,W0511,W0406,R0401,R0801,C3001,import-error + +[FORMAT] +max-line-length=88 diff --git a/Dockerfile b/Dockerfile index 349dd82b1..b3cd26e88 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,7 @@ RUN mkdir /app /app/static /app/images WORKDIR /app +RUN apt-get update && apt-get install -y gettext libgettextpo-dev tidy && apt-get clean + COPY requirements.txt /app/ RUN pip install -r requirements.txt --no-cache-dir -RUN apt-get update && apt-get install -y gettext libgettextpo-dev tidy && apt-get clean diff --git a/README.md b/README.md index bd7344df9..558d42d45 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,45 @@ # BookWyrm -Social reading and reviewing, decentralized with ActivityPub +[![](https://img.shields.io/github/release/bookwyrm-social/bookwyrm.svg?colorB=58839b)](https://github.com/bookwyrm-social/bookwyrm/releases) +[![Run Python Tests](https://github.com/bookwyrm-social/bookwyrm/actions/workflows/django-tests.yml/badge.svg)](https://github.com/bookwyrm-social/bookwyrm/actions/workflows/django-tests.yml) +[![Pylint](https://github.com/bookwyrm-social/bookwyrm/actions/workflows/pylint.yml/badge.svg)](https://github.com/bookwyrm-social/bookwyrm/actions/workflows/pylint.yml) -## Contents -- [Joining BookWyrm](#joining-bookwyrm) -- [Contributing](#contributing) -- [About BookWyrm](#about-bookwyrm) - - [What it is and isn't](#what-it-is-and-isnt) - - [The role of federation](#the-role-of-federation) - - [Features](#features) -- [Book data](#book-data) -- [Set up Bookwyrm](#set-up-bookwyrm) - -## Joining BookWyrm -BookWyrm is still a young piece of software, and isn't at the level of stability and feature-richness that you'd find in a production-ready application. But it does what it says on the box! If you'd like to join an instance, you can check out the [instances](https://joinbookwyrm.com/instances/) list. - -You can request an invite by entering your email address at https://bookwyrm.social. +BookWyrm is a social network for tracking your reading, talking about books, writing reviews, and discovering what to read next. Federation allows BookWyrm users to join small, trusted communities that can connect with one another, and with other ActivityPub services like [Mastodon](https://joinmastodon.org/) and [Pleroma](http://pleroma.social/). -## Contributing -See [contributing](https://docs.joinbookwyrm.com/how-to-contribute.html) for code, translation or monetary contributions. +## Links + +[![Mastodon Follow](https://img.shields.io/mastodon/follow/000146121?domain=https%3A%2F%2Ftech.lgbt&style=social)](https://tech.lgbt/@bookwyrm) +[![Twitter Follow](https://img.shields.io/twitter/follow/BookWyrmSocial?style=social)](https://twitter.com/BookWyrmSocial) + + - [Project homepage](https://joinbookwyrm.com/) + - [Support](https://patreon.com/bookwyrm) + - [Documentation](https://docs.joinbookwyrm.com/) + ## About BookWyrm -### What it is and isn't -BookWyrm is a platform for social reading! You can use it to track what you're reading, review books, and follow your friends. It isn't primarily meant for cataloguing or as a data-source for books, but it does do both of those things to some degree. +BookWyrm is a platform for social reading. You can use it to track what you're reading, review books, and follow your friends. It isn't primarily meant for cataloguing or as a data-source for books, but it does do both of those things to some degree. -### The role of federation +## Federation BookWyrm is built on [ActivityPub](http://activitypub.rocks/). With ActivityPub, it inter-operates with different instances of BookWyrm, and other ActivityPub compliant services, like Mastodon. This means you can run an instance for your book club, and still follow your friend who posts on a server devoted to 20th century Russian speculative fiction. It also means that your friend on mastodon can read and comment on a book review that you post on your BookWyrm instance. Federation makes it possible to have small, self-determining communities, in contrast to the monolithic service you find on GoodReads or Twitter. An instance can be focused on a particular interest, be just for a group of friends, or anything else that brings people together. Each community can choose which other instances they want to federate with, and moderate and run their community autonomously. Check out https://runyourown.social/ to get a sense of the philosophy and logistics behind small, high-trust social networks. -### Features -Since the project is still in its early stages, the features are growing every day, and there is plenty of room for suggestions and ideas. Open an [issue](https://github.com/bookwyrm-social/bookwyrm/issues) to get the conversation going! -- Posting about books - - Compose reviews, with or without ratings, which are aggregated in the book page - - Compose other kinds of statuses about books, such as: - - Comments on a book - - Quotes or excerpts - - Reply to statuses - - View aggregate reviews of a book across connected BookWyrm instances - - Differentiate local and federated reviews and rating in your activity feed -- Track reading activity - - Shelve books on default "to-read," "currently reading," and "read" shelves - - Create custom shelves - - Store started reading/finished reading dates, as well as progress updates along the way - - Update followers about reading activity (optionally, and with granular privacy controls) - - Create lists of books which can be open to submissions from anyone, curated, or only edited by the creator -- Federation with ActivityPub - - Broadcast and receive user statuses and activity - - Share book data between instances to create a networked database of metadata - - Identify shared books across instances and aggregate related content - - Follow and interact with users across BookWyrm instances - - Inter-operate with non-BookWyrm ActivityPub services (currently, Mastodon is supported) -- Granular privacy controls - - Private, followers-only, and public privacy levels for posting, shelves, and lists - - Option for users to manually approve followers - - Allow blocking and flagging for moderation +## Features -### The Tech Stack +### Post about books +Compose reviews, comment on what you're reading, and post quotes from books. You can converse with other BookWyrm users across the network about what they're reading. + +### Track reading activity +Keep track of what books you've read, and what books you'd like to read in the future. + +### Federation with ActivityPub +Federation allows you to interact with users on other instances and services, and also shares metadata about books and authors, which collaboratively builds a decentralized database of books. + +### Privacy and moderation +Users and administrators can control who can see thier posts and what other instances to federate with. + +## Tech Stack Web backend - [Django](https://www.djangoproject.com/) web server - [PostgreSQL](https://www.postgresql.org/) database @@ -78,8 +60,5 @@ Deployment - [Nginx](https://nginx.org/en/) HTTP server -## Book data -The application is set up to share book and author data between instances, and get book data from arbitrary outside sources. Right now, the only connector is to OpenLibrary, but other connectors could be written. - -## Set up Bookwyrm -The [documentation website](https://docs.joinbookwyrm.com/) has instruction on how to set up Bookwyrm in a [developer environment](https://docs.joinbookwyrm.com/developer-environment.html) or [production](https://docs.joinbookwyrm.com/installing-in-production.html). +## Set up BookWyrm +The [documentation website](https://docs.joinbookwyrm.com/) has instruction on how to set up BookWyrm in a [developer environment](https://docs.joinbookwyrm.com/install-dev.html) or [production](https://docs.joinbookwyrm.com/install-prod.html). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..c4e5e9cf9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report security issues to `mousereeve@riseup.net` \ No newline at end of file diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index 179eeaab5..0833a692c 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -1,18 +1,21 @@ """ basics for an activitypub serializer """ from dataclasses import dataclass, fields, MISSING from json import JSONEncoder +import requests +import logging from django.apps import apps from django.db import IntegrityError, transaction +from django.utils.http import http_date +from bookwyrm import models from bookwyrm.connectors import ConnectorException, get_data +from bookwyrm.signatures import make_signature +from bookwyrm.settings import DOMAIN from bookwyrm.tasks import app -import requests -from django.utils.http import http_date -from bookwyrm import models -from bookwyrm.signatures import make_signature -from bookwyrm.settings import DOMAIN +logger = logging.getLogger(__name__) + class ActivitySerializerError(ValueError): """routine problems serializing activitypub json""" @@ -44,12 +47,12 @@ def naive_parse(activity_objects, activity_json, serializer=None): activity_json["type"] = "PublicKey" activity_type = activity_json.get("type") + if activity_type in ["Question", "Article"]: + return None try: serializer = activity_objects[activity_type] except KeyError as err: # we know this exists and that we can't handle it - if activity_type in ["Question"]: - return None raise ActivitySerializerError(err) return serializer(activity_objects=activity_objects, **activity_json) @@ -70,7 +73,7 @@ class ActivityObject: try: value = kwargs[field.name] if value in (None, MISSING, {}): - raise KeyError() + raise KeyError("Missing required field", field.name) try: is_subclass = issubclass(field.type, ActivityObject) except TypeError: @@ -279,9 +282,9 @@ def resolve_remote_id( else: raise e except ConnectorException: - raise ActivitySerializerError( - f"Could not connect to host for remote_id: {remote_id}" - ) + logger.exception("Could not connect to host for remote_id: %s", remote_id) + return None + # determine the model implicitly, if not provided # or if it's a model with subclasses like Status, check again if not model or hasattr(model.objects, "select_subclasses"): diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py index f2dd43fb2..a90d7943b 100644 --- a/bookwyrm/activitystreams.py +++ b/bookwyrm/activitystreams.py @@ -298,8 +298,9 @@ def add_status_on_create_command(sender, instance, created): priority = HIGH # check if this is an old status, de-prioritize if so # (this will happen if federation is very slow, or, more expectedly, on csv import) - one_day = 60 * 60 * 24 - if (instance.created_date - instance.published_date).seconds > one_day: + if instance.published_date < timezone.now() - timedelta( + days=1 + ) or instance.created_date < instance.published_date - timedelta(days=1): priority = LOW add_status_task.apply_async( diff --git a/bookwyrm/apps.py b/bookwyrm/apps.py index d494877d6..786f86e1c 100644 --- a/bookwyrm/apps.py +++ b/bookwyrm/apps.py @@ -19,11 +19,11 @@ def download_file(url, destination): with open(destination, "b+w") as outfile: outfile.write(stream.read()) except (urllib.error.HTTPError, urllib.error.URLError): - logger.error("Failed to download file %s", url) + logger.info("Failed to download file %s", url) except OSError: - logger.error("Couldn't open font file %s for writing", destination) + logger.info("Couldn't open font file %s for writing", destination) except: # pylint: disable=bare-except - logger.exception("Unknown error in file download") + logger.info("Unknown error in file download") class BookwyrmConfig(AppConfig): diff --git a/bookwyrm/book_search.py b/bookwyrm/book_search.py index e42a6d8c3..4b0a6eab9 100644 --- a/bookwyrm/book_search.py +++ b/bookwyrm/book_search.py @@ -148,8 +148,8 @@ class SearchResult: def __repr__(self): # pylint: disable=consider-using-f-string - return "".format( - self.key, self.title, self.author + return "".format( + self.key, self.title, self.author, self.confidence ) def json(self): diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py index d8b9c6300..dc4be4b3d 100644 --- a/bookwyrm/connectors/abstract_connector.py +++ b/bookwyrm/connectors/abstract_connector.py @@ -1,9 +1,8 @@ """ functionality outline for a book data connector """ from abc import ABC, abstractmethod import imghdr -import ipaddress import logging -from urllib.parse import urlparse +import re from django.core.files.base import ContentFile from django.db import transaction @@ -11,7 +10,7 @@ import requests from requests.exceptions import RequestException from bookwyrm import activitypub, models, settings -from .connector_manager import load_more_data, ConnectorException +from .connector_manager import load_more_data, ConnectorException, raise_not_valid_url from .format_mappings import format_mappings @@ -39,62 +38,34 @@ class AbstractMinimalConnector(ABC): for field in self_fields: setattr(self, field, getattr(info, field)) - def search(self, query, min_confidence=None, timeout=settings.QUERY_TIMEOUT): - """free text search""" - params = {} - if min_confidence: - params["min_confidence"] = min_confidence + def get_search_url(self, query): + """format the query url""" + # Check if the query resembles an ISBN + if maybe_isbn(query) and self.isbn_search_url and self.isbn_search_url != "": + return f"{self.isbn_search_url}{query}" - data = self.get_search_data( - f"{self.search_url}{query}", - params=params, - timeout=timeout, - ) - results = [] + # NOTE: previously, we tried searching isbn and if that produces no results, + # searched as free text. This, instead, only searches isbn if it's isbn-y + return f"{self.search_url}{query}" - for doc in self.parse_search_data(data)[:10]: - results.append(self.format_search_result(doc)) - return results - - def isbn_search(self, query, timeout=settings.QUERY_TIMEOUT): - """isbn search""" - params = {} - data = self.get_search_data( - f"{self.isbn_search_url}{query}", - params=params, - timeout=timeout, - ) - results = [] - - # this shouldn't be returning mutliple results, but just in case - for doc in self.parse_isbn_search_data(data)[:10]: - results.append(self.format_isbn_search_result(doc)) - return results - - def get_search_data(self, remote_id, **kwargs): # pylint: disable=no-self-use - """this allows connectors to override the default behavior""" - return get_data(remote_id, **kwargs) + def process_search_response(self, query, data, min_confidence): + """Format the search results based on the formt of the query""" + if maybe_isbn(query): + return list(self.parse_isbn_search_data(data))[:10] + return list(self.parse_search_data(data, min_confidence))[:10] @abstractmethod def get_or_create_book(self, remote_id): """pull up a book record by whatever means possible""" @abstractmethod - def parse_search_data(self, data): + def parse_search_data(self, data, min_confidence): """turn the result json from a search into a list""" - @abstractmethod - def format_search_result(self, search_result): - """create a SearchResult obj from json""" - @abstractmethod def parse_isbn_search_data(self, data): """turn the result json from a search into a list""" - @abstractmethod - def format_isbn_search_result(self, search_result): - """create a SearchResult obj from json""" - class AbstractConnector(AbstractMinimalConnector): """generic book data connector""" @@ -131,7 +102,7 @@ class AbstractConnector(AbstractMinimalConnector): try: work_data = self.get_work_from_edition_data(data) except (KeyError, ConnectorException) as err: - logger.exception(err) + logger.info(err) work_data = data if not work_data or not edition_data: @@ -254,9 +225,6 @@ def get_data(url, params=None, timeout=10): # check if the url is blocked raise_not_valid_url(url) - if models.FederatedServer.is_blocked(url): - raise ConnectorException(f"Attempting to load data from blocked url: {url}") - try: resp = requests.get( url, @@ -270,7 +238,7 @@ def get_data(url, params=None, timeout=10): timeout=timeout, ) except RequestException as err: - logger.exception(err) + logger.info(err) raise ConnectorException(err) if not resp.ok: @@ -278,7 +246,7 @@ def get_data(url, params=None, timeout=10): try: data = resp.json() except ValueError as err: - logger.exception(err) + logger.info(err) raise ConnectorException(err) return data @@ -296,7 +264,7 @@ def get_image(url, timeout=10): timeout=timeout, ) except RequestException as err: - logger.exception(err) + logger.info(err) return None, None if not resp.ok: @@ -305,26 +273,12 @@ def get_image(url, timeout=10): image_content = ContentFile(resp.content) extension = imghdr.what(None, image_content.read()) if not extension: - logger.exception("File requested was not an image: %s", url) + logger.info("File requested was not an image: %s", url) return None, None return image_content, extension -def raise_not_valid_url(url): - """do some basic reality checks on the url""" - parsed = urlparse(url) - if not parsed.scheme in ["http", "https"]: - raise ConnectorException("Invalid scheme: ", url) - - try: - ipaddress.ip_address(parsed.netloc) - raise ConnectorException("Provided url is an IP address: ", url) - except ValueError: - # it's not an IP address, which is good - pass - - class Mapping: """associate a local database field with a field in an external dataset""" @@ -366,3 +320,9 @@ def unique_physical_format(format_text): # try a direct match, so saving this would be redundant return None return format_text + + +def maybe_isbn(query): + """check if a query looks like an isbn""" + isbn = re.sub(r"[\W_]", "", query) # removes filler characters + return len(isbn) in [10, 13] # ISBN10 or ISBN13 diff --git a/bookwyrm/connectors/bookwyrm_connector.py b/bookwyrm/connectors/bookwyrm_connector.py index 6dcba7c31..e07a0b281 100644 --- a/bookwyrm/connectors/bookwyrm_connector.py +++ b/bookwyrm/connectors/bookwyrm_connector.py @@ -10,15 +10,12 @@ class Connector(AbstractMinimalConnector): def get_or_create_book(self, remote_id): return activitypub.resolve_remote_id(remote_id, model=models.Edition) - def parse_search_data(self, data): - return data - - def format_search_result(self, search_result): - search_result["connector"] = self - return SearchResult(**search_result) + def parse_search_data(self, data, min_confidence): + for search_result in data: + search_result["connector"] = self + yield SearchResult(**search_result) def parse_isbn_search_data(self, data): - return data - - def format_isbn_search_result(self, search_result): - return self.format_search_result(search_result) + for search_result in data: + search_result["connector"] = self + yield SearchResult(**search_result) diff --git a/bookwyrm/connectors/connector_manager.py b/bookwyrm/connectors/connector_manager.py index 3bdd5cb41..385880e5a 100644 --- a/bookwyrm/connectors/connector_manager.py +++ b/bookwyrm/connectors/connector_manager.py @@ -1,17 +1,18 @@ """ interface with whatever connectors the app has """ -from datetime import datetime +import asyncio import importlib +import ipaddress import logging -import re from urllib.parse import urlparse +import aiohttp from django.dispatch import receiver from django.db.models import signals from requests import HTTPError from bookwyrm import book_search, models -from bookwyrm.settings import SEARCH_TIMEOUT +from bookwyrm.settings import SEARCH_TIMEOUT, USER_AGENT from bookwyrm.tasks import app logger = logging.getLogger(__name__) @@ -21,53 +22,85 @@ class ConnectorException(HTTPError): """when the connector can't do what was asked""" +async def get_results(session, url, min_confidence, query, connector): + """try this specific connector""" + # pylint: disable=line-too-long + headers = { + "Accept": ( + 'application/json, application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8' + ), + "User-Agent": USER_AGENT, + } + params = {"min_confidence": min_confidence} + try: + async with session.get(url, headers=headers, params=params) as response: + if not response.ok: + logger.info("Unable to connect to %s: %s", url, response.reason) + return + + try: + raw_data = await response.json() + except aiohttp.client_exceptions.ContentTypeError as err: + logger.exception(err) + return + + return { + "connector": connector, + "results": connector.process_search_response( + query, raw_data, min_confidence + ), + } + except asyncio.TimeoutError: + logger.info("Connection timed out for url: %s", url) + except aiohttp.ClientError as err: + logger.info(err) + + +async def async_connector_search(query, items, min_confidence): + """Try a number of requests simultaneously""" + timeout = aiohttp.ClientTimeout(total=SEARCH_TIMEOUT) + async with aiohttp.ClientSession(timeout=timeout) as session: + tasks = [] + for url, connector in items: + tasks.append( + asyncio.ensure_future( + get_results(session, url, min_confidence, query, connector) + ) + ) + + results = await asyncio.gather(*tasks) + return results + + def search(query, min_confidence=0.1, return_first=False): """find books based on arbitary keywords""" if not query: return [] results = [] - # Have we got a ISBN ? - isbn = re.sub(r"[\W_]", "", query) - maybe_isbn = len(isbn) in [10, 13] # ISBN10 or ISBN13 - - start_time = datetime.now() + items = [] for connector in get_connectors(): - result_set = None - 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 err: # pylint: disable=broad-except - logger.exception(err) - # if this fails, we can still try regular search + # get the search url from the connector before sending + url = connector.get_search_url(query) + try: + raise_not_valid_url(url) + except ConnectorException: + # if this URL is invalid we should skip it and move on + logger.info("Request denied to blocked domain: %s", url) + continue + items.append((url, connector)) - # 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 err: # pylint: disable=broad-except - # we don't want *any* error to crash the whole search page - logger.exception(err) - continue - - if return_first and result_set: - # if we found anything, return it - return result_set[0] - - if result_set: - results.append( - { - "connector": connector, - "results": result_set, - } - ) - if (datetime.now() - start_time).seconds >= SEARCH_TIMEOUT: - break + # load as many results as we can + results = asyncio.run(async_connector_search(query, items, min_confidence)) + results = [r for r in results if r] if return_first: - return None + # find the best result from all the responses and return that + all_results = [r for con in results for r in con["results"]] + all_results = sorted(all_results, key=lambda r: r.confidence, reverse=True) + return all_results[0] if all_results else None + # failed requests will return None, so filter those out return results @@ -119,6 +152,15 @@ def load_more_data(connector_id, book_id): connector.expand_book_data(book) +@app.task(queue="low_priority") +def create_edition_task(connector_id, work_id, data): + """separate task for each of the 10,000 editions of LoTR""" + connector_info = models.Connector.objects.get(id=connector_id) + connector = load_connector(connector_info) + work = models.Work.objects.select_subclasses().get(id=work_id) + connector.create_edition_from_data(work, data) + + def load_connector(connector_info): """instantiate the connector class""" connector = importlib.import_module( @@ -133,3 +175,20 @@ def create_connector(sender, instance, created, *args, **kwargs): """create a connector to an external bookwyrm server""" if instance.application_type == "bookwyrm": get_or_create_connector(f"https://{instance.server_name}") + + +def raise_not_valid_url(url): + """do some basic reality checks on the url""" + parsed = urlparse(url) + if not parsed.scheme in ["http", "https"]: + raise ConnectorException("Invalid scheme: ", url) + + try: + ipaddress.ip_address(parsed.netloc) + raise ConnectorException("Provided url is an IP address: ", url) + except ValueError: + # it's not an IP address, which is good + pass + + if models.FederatedServer.is_blocked(url): + raise ConnectorException(f"Attempting to load data from blocked url: {url}") diff --git a/bookwyrm/connectors/inventaire.py b/bookwyrm/connectors/inventaire.py index a9aeb94f9..df9b2e43a 100644 --- a/bookwyrm/connectors/inventaire.py +++ b/bookwyrm/connectors/inventaire.py @@ -5,7 +5,7 @@ from bookwyrm import models from bookwyrm.book_search import SearchResult from .abstract_connector import AbstractConnector, Mapping from .abstract_connector import get_data -from .connector_manager import ConnectorException +from .connector_manager import ConnectorException, create_edition_task class Connector(AbstractConnector): @@ -77,53 +77,42 @@ class Connector(AbstractConnector): **{k: data.get(k) for k in ["uri", "image", "labels", "sitelinks", "type"]}, } - 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: - # filter the search results after the fact - return [r for r in results if r.confidence >= min_confidence] - return results - - def parse_search_data(self, data): - return data.get("results") - - def format_search_result(self, search_result): - images = search_result.get("image") - cover = f"{self.covers_url}/img/entities/{images[0]}" if images else None - # a deeply messy translation of inventaire's scores - confidence = float(search_result.get("_score", 0.1)) - confidence = 0.1 if confidence < 150 else 0.999 - return SearchResult( - title=search_result.get("label"), - key=self.get_remote_id(search_result.get("uri")), - author=search_result.get("description"), - view_link=f"{self.base_url}/entity/{search_result.get('uri')}", - cover=cover, - confidence=confidence, - connector=self, - ) + def parse_search_data(self, data, min_confidence): + for search_result in data.get("results", []): + images = search_result.get("image") + cover = f"{self.covers_url}/img/entities/{images[0]}" if images else None + # a deeply messy translation of inventaire's scores + confidence = float(search_result.get("_score", 0.1)) + confidence = 0.1 if confidence < 150 else 0.999 + if confidence < min_confidence: + continue + yield SearchResult( + title=search_result.get("label"), + key=self.get_remote_id(search_result.get("uri")), + author=search_result.get("description"), + view_link=f"{self.base_url}/entity/{search_result.get('uri')}", + cover=cover, + confidence=confidence, + connector=self, + ) def parse_isbn_search_data(self, data): """got some daaaata""" results = data.get("entities") if not results: - return [] - return list(results.values()) - - def format_isbn_search_result(self, search_result): - """totally different format than a regular search result""" - title = search_result.get("claims", {}).get("wdt:P1476", []) - if not title: - return None - return SearchResult( - title=title[0], - key=self.get_remote_id(search_result.get("uri")), - author=search_result.get("description"), - view_link=f"{self.base_url}/entity/{search_result.get('uri')}", - cover=self.get_cover_url(search_result.get("image")), - connector=self, - ) + return + for search_result in list(results.values()): + title = search_result.get("claims", {}).get("wdt:P1476", []) + if not title: + continue + yield SearchResult( + title=title[0], + key=self.get_remote_id(search_result.get("uri")), + author=search_result.get("description"), + view_link=f"{self.base_url}/entity/{search_result.get('uri')}", + cover=self.get_cover_url(search_result.get("image")), + connector=self, + ) def is_work_data(self, data): return data.get("type") == "work" @@ -167,12 +156,17 @@ class Connector(AbstractConnector): for edition_uri in edition_options.get("uris"): remote_id = self.get_remote_id(edition_uri) + create_edition_task.delay(self.connector.id, work.id, remote_id) + + def create_edition_from_data(self, work, edition_data, instance=None): + """pass in the url as data and then call the version in abstract connector""" + if isinstance(edition_data, str): try: - data = self.get_book_data(remote_id) + edition_data = self.get_book_data(edition_data) except ConnectorException: # who, indeed, knows - continue - self.create_edition_from_data(work, data) + return + super().create_edition_from_data(work, edition_data, instance=instance) def get_cover_url(self, cover_blob, *_): """format the relative cover url into an absolute one: diff --git a/bookwyrm/connectors/openlibrary.py b/bookwyrm/connectors/openlibrary.py index 118222a16..0fd786660 100644 --- a/bookwyrm/connectors/openlibrary.py +++ b/bookwyrm/connectors/openlibrary.py @@ -5,7 +5,7 @@ from bookwyrm import models from bookwyrm.book_search import SearchResult from .abstract_connector import AbstractConnector, Mapping from .abstract_connector import get_data, infer_physical_format, unique_physical_format -from .connector_manager import ConnectorException +from .connector_manager import ConnectorException, create_edition_task from .openlibrary_languages import languages @@ -152,39 +152,41 @@ class Connector(AbstractConnector): image_name = f"{cover_id}-{size}.jpg" return f"{self.covers_url}/b/id/{image_name}" - def parse_search_data(self, data): - return data.get("docs") + def parse_search_data(self, data, min_confidence): + for idx, search_result in enumerate(data.get("docs")): + # build the remote id from the openlibrary key + key = self.books_url + search_result["key"] + author = search_result.get("author_name") or ["Unknown"] + cover_blob = search_result.get("cover_i") + cover = self.get_cover_url([cover_blob], size="M") if cover_blob else None - def format_search_result(self, search_result): - # build the remote id from the openlibrary key - key = self.books_url + search_result["key"] - author = search_result.get("author_name") or ["Unknown"] - cover_blob = search_result.get("cover_i") - cover = self.get_cover_url([cover_blob], size="M") if cover_blob else None - return SearchResult( - title=search_result.get("title"), - key=key, - author=", ".join(author), - connector=self, - year=search_result.get("first_publish_year"), - cover=cover, - ) + # OL doesn't provide confidence, but it does sort by an internal ranking, so + # this confidence value is relative to the list position + confidence = 1 / (idx + 1) + + yield SearchResult( + title=search_result.get("title"), + key=key, + author=", ".join(author), + connector=self, + year=search_result.get("first_publish_year"), + cover=cover, + confidence=confidence, + ) def parse_isbn_search_data(self, data): - return list(data.values()) - - def format_isbn_search_result(self, search_result): - # build the remote id from the openlibrary key - key = self.books_url + search_result["key"] - authors = search_result.get("authors") or [{"name": "Unknown"}] - author_names = [author.get("name") for author in authors] - return SearchResult( - title=search_result.get("title"), - key=key, - author=", ".join(author_names), - connector=self, - year=search_result.get("publish_date"), - ) + for search_result in list(data.values()): + # build the remote id from the openlibrary key + key = self.books_url + search_result["key"] + authors = search_result.get("authors") or [{"name": "Unknown"}] + author_names = [author.get("name") for author in authors] + yield SearchResult( + title=search_result.get("title"), + key=key, + author=", ".join(author_names), + connector=self, + year=search_result.get("publish_date"), + ) def load_edition_data(self, olkey): """query openlibrary for editions of a work""" @@ -208,7 +210,7 @@ class Connector(AbstractConnector): # does this edition have ANY interesting data? if ignore_edition(edition_data): continue - self.create_edition_from_data(work, edition_data) + create_edition_task.delay(self.connector.id, work.id, edition_data) def ignore_edition(edition_data): diff --git a/bookwyrm/context_processors.py b/bookwyrm/context_processors.py index 309e84ed1..0047bfce1 100644 --- a/bookwyrm/context_processors.py +++ b/bookwyrm/context_processors.py @@ -8,8 +8,20 @@ def site_settings(request): # pylint: disable=unused-argument if not request.is_secure(): request_protocol = "http://" + site = models.SiteSettings.objects.get() + theme = "css/themes/bookwyrm-light.scss" + if ( + hasattr(request, "user") + and request.user.is_authenticated + and request.user.theme + ): + theme = request.user.theme.path + elif site.default_theme: + theme = site.default_theme.path + return { - "site": models.SiteSettings.objects.get(), + "site": site, + "site_theme": theme, "active_announcements": models.Announcement.active_announcements(), "thumbnail_generation_enabled": settings.ENABLE_THUMBNAIL_GENERATION, "media_full_url": settings.MEDIA_FULL_URL, diff --git a/bookwyrm/emailing.py b/bookwyrm/emailing.py index 80aca071b..9349b8ae2 100644 --- a/bookwyrm/emailing.py +++ b/bookwyrm/emailing.py @@ -45,7 +45,8 @@ def moderation_report_email(report): """a report was created""" data = email_data() data["reporter"] = report.reporter.localname or report.reporter.username - data["reportee"] = report.user.localname or report.user.username + if report.user: + data["reportee"] = report.user.localname or report.user.username data["report_link"] = report.remote_id for admin in models.User.objects.filter( diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py deleted file mode 100644 index 33d453e60..000000000 --- a/bookwyrm/forms.py +++ /dev/null @@ -1,559 +0,0 @@ -""" using django model forms """ -import datetime -from collections import defaultdict -from urllib.parse import urlparse - -from django import forms -from django.forms import ModelForm, PasswordInput, widgets, ChoiceField -from django.forms.widgets import Textarea -from django.utils import timezone -from django.utils.translation import gettext_lazy as _ - -from bookwyrm import models -from bookwyrm.models.fields import ClearableFileInputWithWarning -from bookwyrm.models.user import FeedFilterChoices - - -class CustomForm(ModelForm): - """add css classes to the forms""" - - def __init__(self, *args, **kwargs): - css_classes = defaultdict(lambda: "") - css_classes["text"] = "input" - css_classes["password"] = "input" - css_classes["email"] = "input" - 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"): - input_type = visible.field.widget.input_type - if isinstance(visible.field.widget, Textarea): - input_type = "textarea" - visible.field.widget.attrs["rows"] = 5 - visible.field.widget.attrs["class"] = css_classes[input_type] - - -# pylint: disable=missing-class-docstring -class LoginForm(CustomForm): - class Meta: - model = models.User - fields = ["localname", "password"] - help_texts = {f: None for f in fields} - widgets = { - "password": PasswordInput(), - } - - -class RegisterForm(CustomForm): - class Meta: - model = models.User - fields = ["localname", "email", "password"] - help_texts = {f: None for f in fields} - widgets = {"password": PasswordInput()} - - def clean(self): - """Check if the username is taken""" - cleaned_data = super().clean() - localname = cleaned_data.get("localname").strip() - if models.User.objects.filter(localname=localname).first(): - self.add_error("localname", _("User with this username already exists")) - - -class RatingForm(CustomForm): - class Meta: - model = models.ReviewRating - fields = ["user", "book", "rating", "privacy"] - - -class ReviewForm(CustomForm): - class Meta: - model = models.Review - fields = [ - "user", - "book", - "name", - "content", - "rating", - "content_warning", - "sensitive", - "privacy", - ] - - -class CommentForm(CustomForm): - class Meta: - model = models.Comment - fields = [ - "user", - "book", - "content", - "content_warning", - "sensitive", - "privacy", - "progress", - "progress_mode", - "reading_status", - ] - - -class QuotationForm(CustomForm): - class Meta: - model = models.Quotation - fields = [ - "user", - "book", - "quote", - "content", - "content_warning", - "sensitive", - "privacy", - "position", - "position_mode", - ] - - -class ReplyForm(CustomForm): - class Meta: - model = models.Status - fields = [ - "user", - "content", - "content_warning", - "sensitive", - "reply_parent", - "privacy", - ] - - -class StatusForm(CustomForm): - class Meta: - model = models.Status - fields = ["user", "content", "content_warning", "sensitive", "privacy"] - - -class DirectForm(CustomForm): - class Meta: - model = models.Status - fields = ["user", "content", "content_warning", "sensitive", "privacy"] - - -class EditUserForm(CustomForm): - class Meta: - model = models.User - fields = [ - "avatar", - "name", - "email", - "summary", - "show_goal", - "show_suggested_users", - "manually_approves_followers", - "default_post_privacy", - "discoverable", - "hide_follows", - "preferred_timezone", - "preferred_language", - ] - help_texts = {f: None for f in fields} - widgets = { - "avatar": ClearableFileInputWithWarning( - attrs={"aria-describedby": "desc_avatar"} - ), - "name": forms.TextInput(attrs={"aria-describedby": "desc_name"}), - "summary": forms.Textarea(attrs={"aria-describedby": "desc_summary"}), - "email": forms.EmailInput(attrs={"aria-describedby": "desc_email"}), - "discoverable": forms.CheckboxInput( - attrs={"aria-describedby": "desc_discoverable"} - ), - } - - -class LimitedEditUserForm(CustomForm): - class Meta: - model = models.User - fields = [ - "avatar", - "name", - "summary", - "manually_approves_followers", - "discoverable", - ] - help_texts = {f: None for f in fields} - widgets = { - "avatar": ClearableFileInputWithWarning( - attrs={"aria-describedby": "desc_avatar"} - ), - "name": forms.TextInput(attrs={"aria-describedby": "desc_name"}), - "summary": forms.Textarea(attrs={"aria-describedby": "desc_summary"}), - "discoverable": forms.CheckboxInput( - attrs={"aria-describedby": "desc_discoverable"} - ), - } - - -class DeleteUserForm(CustomForm): - class Meta: - model = models.User - fields = ["password"] - - -class UserGroupForm(CustomForm): - class Meta: - model = models.User - fields = ["groups"] - - -class FeedStatusTypesForm(CustomForm): - class Meta: - model = models.User - fields = ["feed_status_types"] - help_texts = {f: None for f in fields} - widgets = { - "feed_status_types": widgets.CheckboxSelectMultiple( - choices=FeedFilterChoices, - ), - } - - -class CoverForm(CustomForm): - class Meta: - model = models.Book - fields = ["cover"] - help_texts = {f: None for f in fields} - - -class LinkDomainForm(CustomForm): - class Meta: - model = models.LinkDomain - fields = ["name"] - - -class FileLinkForm(CustomForm): - class Meta: - model = models.FileLink - fields = ["url", "filetype", "availability", "book", "added_by"] - - def clean(self): - """make sure the domain isn't blocked or pending""" - cleaned_data = super().clean() - url = cleaned_data.get("url") - filetype = cleaned_data.get("filetype") - book = cleaned_data.get("book") - domain = urlparse(url).netloc - if models.LinkDomain.objects.filter(domain=domain).exists(): - status = models.LinkDomain.objects.get(domain=domain).status - if status == "blocked": - # pylint: disable=line-too-long - self.add_error( - "url", - _( - "This domain is blocked. Please contact your administrator if you think this is an error." - ), - ) - elif models.FileLink.objects.filter( - url=url, book=book, filetype=filetype - ).exists(): - # pylint: disable=line-too-long - self.add_error( - "url", - _( - "This link with file type has already been added for this book. If it is not visible, the domain is still pending." - ), - ) - - -class EditionForm(CustomForm): - class Meta: - model = models.Edition - exclude = [ - "remote_id", - "origin_id", - "created_date", - "updated_date", - "edition_rank", - "authors", - "parent_work", - "shelves", - "connector", - "search_vector", - "links", - "file_links", - ] - widgets = { - "title": forms.TextInput(attrs={"aria-describedby": "desc_title"}), - "subtitle": forms.TextInput(attrs={"aria-describedby": "desc_subtitle"}), - "description": forms.Textarea( - attrs={"aria-describedby": "desc_description"} - ), - "series": forms.TextInput(attrs={"aria-describedby": "desc_series"}), - "series_number": forms.TextInput( - attrs={"aria-describedby": "desc_series_number"} - ), - "languages": forms.TextInput( - attrs={"aria-describedby": "desc_languages_help desc_languages"} - ), - "publishers": forms.TextInput( - attrs={"aria-describedby": "desc_publishers_help desc_publishers"} - ), - "first_published_date": forms.SelectDateWidget( - attrs={"aria-describedby": "desc_first_published_date"} - ), - "published_date": forms.SelectDateWidget( - attrs={"aria-describedby": "desc_published_date"} - ), - "cover": ClearableFileInputWithWarning( - attrs={"aria-describedby": "desc_cover"} - ), - "physical_format": forms.Select( - attrs={"aria-describedby": "desc_physical_format"} - ), - "physical_format_detail": forms.TextInput( - attrs={"aria-describedby": "desc_physical_format_detail"} - ), - "pages": forms.NumberInput(attrs={"aria-describedby": "desc_pages"}), - "isbn_13": forms.TextInput(attrs={"aria-describedby": "desc_isbn_13"}), - "isbn_10": forms.TextInput(attrs={"aria-describedby": "desc_isbn_10"}), - "openlibrary_key": forms.TextInput( - attrs={"aria-describedby": "desc_openlibrary_key"} - ), - "inventaire_id": forms.TextInput( - attrs={"aria-describedby": "desc_inventaire_id"} - ), - "oclc_number": forms.TextInput( - attrs={"aria-describedby": "desc_oclc_number"} - ), - "ASIN": forms.TextInput(attrs={"aria-describedby": "desc_ASIN"}), - } - - -class AuthorForm(CustomForm): - class Meta: - model = models.Author - fields = [ - "last_edited_by", - "name", - "aliases", - "bio", - "wikipedia_link", - "born", - "died", - "openlibrary_key", - "inventaire_id", - "librarything_key", - "goodreads_key", - "isni", - ] - widgets = { - "name": forms.TextInput(attrs={"aria-describedby": "desc_name"}), - "aliases": forms.TextInput(attrs={"aria-describedby": "desc_aliases"}), - "bio": forms.Textarea(attrs={"aria-describedby": "desc_bio"}), - "wikipedia_link": forms.TextInput( - attrs={"aria-describedby": "desc_wikipedia_link"} - ), - "born": forms.SelectDateWidget(attrs={"aria-describedby": "desc_born"}), - "died": forms.SelectDateWidget(attrs={"aria-describedby": "desc_died"}), - "oepnlibrary_key": forms.TextInput( - attrs={"aria-describedby": "desc_oepnlibrary_key"} - ), - "inventaire_id": forms.TextInput( - attrs={"aria-describedby": "desc_inventaire_id"} - ), - "librarything_key": forms.TextInput( - attrs={"aria-describedby": "desc_librarything_key"} - ), - "goodreads_key": forms.TextInput( - attrs={"aria-describedby": "desc_goodreads_key"} - ), - } - - -class ImportForm(forms.Form): - csv_file = forms.FileField() - - -class ExpiryWidget(widgets.Select): - def value_from_datadict(self, data, files, name): - """human-readable exiration time buckets""" - selected_string = super().value_from_datadict(data, files, name) - - if selected_string == "day": - interval = datetime.timedelta(days=1) - elif selected_string == "week": - interval = datetime.timedelta(days=7) - elif selected_string == "month": - interval = datetime.timedelta(days=31) # Close enough? - elif selected_string == "forever": - return None - else: - return selected_string # This will raise - - return timezone.now() + interval - - -class InviteRequestForm(CustomForm): - def clean(self): - """make sure the email isn't in use by a registered user""" - cleaned_data = super().clean() - email = cleaned_data.get("email") - if email and models.User.objects.filter(email=email).exists(): - self.add_error("email", _("A user with this email already exists.")) - - class Meta: - model = models.InviteRequest - fields = ["email"] - - -class CreateInviteForm(CustomForm): - class Meta: - model = models.SiteInvite - exclude = ["code", "user", "times_used", "invitees"] - widgets = { - "expiry": ExpiryWidget( - choices=[ - ("day", _("One Day")), - ("week", _("One Week")), - ("month", _("One Month")), - ("forever", _("Does Not Expire")), - ] - ), - "use_limit": widgets.Select( - choices=[(i, _(f"{i} uses")) for i in [1, 5, 10, 25, 50, 100]] - + [(None, _("Unlimited"))] - ), - } - - -class ShelfForm(CustomForm): - class Meta: - model = models.Shelf - fields = ["user", "name", "privacy", "description"] - - -class GoalForm(CustomForm): - class Meta: - model = models.AnnualGoal - fields = ["user", "year", "goal", "privacy"] - - -class SiteForm(CustomForm): - class Meta: - model = models.SiteSettings - exclude = ["admin_code", "install_mode"] - widgets = { - "instance_short_description": forms.TextInput( - attrs={"aria-describedby": "desc_instance_short_description"} - ), - "require_confirm_email": forms.CheckboxInput( - attrs={"aria-describedby": "desc_require_confirm_email"} - ), - "invite_request_text": forms.Textarea( - attrs={"aria-describedby": "desc_invite_request_text"} - ), - } - - -class AnnouncementForm(CustomForm): - class Meta: - model = models.Announcement - exclude = ["remote_id"] - widgets = { - "preview": forms.TextInput(attrs={"aria-describedby": "desc_preview"}), - "content": forms.Textarea(attrs={"aria-describedby": "desc_content"}), - "event_date": forms.SelectDateWidget( - attrs={"aria-describedby": "desc_event_date"} - ), - "start_date": forms.SelectDateWidget( - attrs={"aria-describedby": "desc_start_date"} - ), - "end_date": forms.SelectDateWidget( - attrs={"aria-describedby": "desc_end_date"} - ), - "active": forms.CheckboxInput(attrs={"aria-describedby": "desc_active"}), - } - - -class ListForm(CustomForm): - class Meta: - model = models.List - fields = ["user", "name", "description", "curation", "privacy", "group"] - - -class ListItemForm(CustomForm): - class Meta: - model = models.ListItem - fields = ["user", "book", "book_list", "notes"] - - -class GroupForm(CustomForm): - class Meta: - model = models.Group - fields = ["user", "privacy", "name", "description"] - - -class ReportForm(CustomForm): - class Meta: - model = models.Report - fields = ["user", "reporter", "status", "links", "note"] - - -class EmailBlocklistForm(CustomForm): - class Meta: - model = models.EmailBlocklist - fields = ["domain"] - widgets = { - "avatar": forms.TextInput(attrs={"aria-describedby": "desc_domain"}), - } - - -class IPBlocklistForm(CustomForm): - class Meta: - model = models.IPBlocklist - fields = ["address"] - - -class ServerForm(CustomForm): - class Meta: - model = models.FederatedServer - exclude = ["remote_id"] - - -class SortListForm(forms.Form): - sort_by = ChoiceField( - choices=( - ("order", _("List Order")), - ("title", _("Book Title")), - ("rating", _("Rating")), - ), - label=_("Sort By"), - ) - direction = ChoiceField( - choices=( - ("ascending", _("Ascending")), - ("descending", _("Descending")), - ), - ) - - -class ReadThroughForm(CustomForm): - def clean(self): - """make sure the email isn't in use by a registered user""" - cleaned_data = super().clean() - start_date = cleaned_data.get("start_date") - finish_date = cleaned_data.get("finish_date") - if start_date and finish_date and start_date > finish_date: - self.add_error( - "finish_date", _("Reading finish date cannot be before start date.") - ) - - class Meta: - model = models.ReadThrough - fields = ["user", "book", "start_date", "finish_date"] - - -class AutoModRuleForm(CustomForm): - class Meta: - model = models.AutoMod - fields = ["string_match", "flag_users", "flag_statuses", "created_by"] diff --git a/bookwyrm/forms/__init__.py b/bookwyrm/forms/__init__.py new file mode 100644 index 000000000..a37d126ac --- /dev/null +++ b/bookwyrm/forms/__init__.py @@ -0,0 +1,13 @@ +""" make forms available to the app """ +# site admin +from .admin import * +from .author import * +from .books import * +from .edit_user import * +from .forms import * +from .groups import * +from .landing import * +from .links import * +from .lists import * +from .status import * +from .user_admin import * diff --git a/bookwyrm/forms/admin.py b/bookwyrm/forms/admin.py new file mode 100644 index 000000000..4141327d3 --- /dev/null +++ b/bookwyrm/forms/admin.py @@ -0,0 +1,141 @@ +""" using django model forms """ +import datetime + +from django import forms +from django.forms import widgets +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from django_celery_beat.models import IntervalSchedule + +from bookwyrm import models +from .custom_form import CustomForm + + +# pylint: disable=missing-class-docstring +class ExpiryWidget(widgets.Select): + def value_from_datadict(self, data, files, name): + """human-readable exiration time buckets""" + selected_string = super().value_from_datadict(data, files, name) + + if selected_string == "day": + interval = datetime.timedelta(days=1) + elif selected_string == "week": + interval = datetime.timedelta(days=7) + elif selected_string == "month": + interval = datetime.timedelta(days=31) # Close enough? + elif selected_string == "forever": + return None + else: + return selected_string # This will raise + + return timezone.now() + interval + + +class CreateInviteForm(CustomForm): + class Meta: + model = models.SiteInvite + exclude = ["code", "user", "times_used", "invitees"] + widgets = { + "expiry": ExpiryWidget( + choices=[ + ("day", _("One Day")), + ("week", _("One Week")), + ("month", _("One Month")), + ("forever", _("Does Not Expire")), + ] + ), + "use_limit": widgets.Select( + choices=[(i, _(f"{i} uses")) for i in [1, 5, 10, 25, 50, 100]] + + [(None, _("Unlimited"))] + ), + } + + +class SiteForm(CustomForm): + class Meta: + model = models.SiteSettings + exclude = ["admin_code", "install_mode"] + widgets = { + "instance_short_description": forms.TextInput( + attrs={"aria-describedby": "desc_instance_short_description"} + ), + "require_confirm_email": forms.CheckboxInput( + attrs={"aria-describedby": "desc_require_confirm_email"} + ), + "invite_request_text": forms.Textarea( + attrs={"aria-describedby": "desc_invite_request_text"} + ), + } + + +class ThemeForm(CustomForm): + class Meta: + model = models.Theme + fields = ["name", "path"] + widgets = { + "name": forms.TextInput(attrs={"aria-describedby": "desc_name"}), + "path": forms.TextInput( + attrs={ + "aria-describedby": "desc_path", + "placeholder": "css/themes/theme-name.scss", + } + ), + } + + +class AnnouncementForm(CustomForm): + class Meta: + model = models.Announcement + exclude = ["remote_id"] + widgets = { + "preview": forms.TextInput(attrs={"aria-describedby": "desc_preview"}), + "content": forms.Textarea(attrs={"aria-describedby": "desc_content"}), + "event_date": forms.SelectDateWidget( + attrs={"aria-describedby": "desc_event_date"} + ), + "start_date": forms.SelectDateWidget( + attrs={"aria-describedby": "desc_start_date"} + ), + "end_date": forms.SelectDateWidget( + attrs={"aria-describedby": "desc_end_date"} + ), + "active": forms.CheckboxInput(attrs={"aria-describedby": "desc_active"}), + } + + +class EmailBlocklistForm(CustomForm): + class Meta: + model = models.EmailBlocklist + fields = ["domain"] + widgets = { + "avatar": forms.TextInput(attrs={"aria-describedby": "desc_domain"}), + } + + +class IPBlocklistForm(CustomForm): + class Meta: + model = models.IPBlocklist + fields = ["address"] + + +class ServerForm(CustomForm): + class Meta: + model = models.FederatedServer + exclude = ["remote_id"] + + +class AutoModRuleForm(CustomForm): + class Meta: + model = models.AutoMod + fields = ["string_match", "flag_users", "flag_statuses", "created_by"] + + +class IntervalScheduleForm(CustomForm): + class Meta: + model = IntervalSchedule + fields = ["every", "period"] + + widgets = { + "every": forms.NumberInput(attrs={"aria-describedby": "desc_every"}), + "period": forms.Select(attrs={"aria-describedby": "desc_period"}), + } diff --git a/bookwyrm/forms/author.py b/bookwyrm/forms/author.py new file mode 100644 index 000000000..ca59426de --- /dev/null +++ b/bookwyrm/forms/author.py @@ -0,0 +1,47 @@ +""" using django model forms """ +from django import forms + +from bookwyrm import models +from .custom_form import CustomForm + + +# pylint: disable=missing-class-docstring +class AuthorForm(CustomForm): + class Meta: + model = models.Author + fields = [ + "last_edited_by", + "name", + "aliases", + "bio", + "wikipedia_link", + "born", + "died", + "openlibrary_key", + "inventaire_id", + "librarything_key", + "goodreads_key", + "isni", + ] + widgets = { + "name": forms.TextInput(attrs={"aria-describedby": "desc_name"}), + "aliases": forms.TextInput(attrs={"aria-describedby": "desc_aliases"}), + "bio": forms.Textarea(attrs={"aria-describedby": "desc_bio"}), + "wikipedia_link": forms.TextInput( + attrs={"aria-describedby": "desc_wikipedia_link"} + ), + "born": forms.SelectDateWidget(attrs={"aria-describedby": "desc_born"}), + "died": forms.SelectDateWidget(attrs={"aria-describedby": "desc_died"}), + "oepnlibrary_key": forms.TextInput( + attrs={"aria-describedby": "desc_oepnlibrary_key"} + ), + "inventaire_id": forms.TextInput( + attrs={"aria-describedby": "desc_inventaire_id"} + ), + "librarything_key": forms.TextInput( + attrs={"aria-describedby": "desc_librarything_key"} + ), + "goodreads_key": forms.TextInput( + attrs={"aria-describedby": "desc_goodreads_key"} + ), + } diff --git a/bookwyrm/forms/books.py b/bookwyrm/forms/books.py new file mode 100644 index 000000000..9b3c84010 --- /dev/null +++ b/bookwyrm/forms/books.py @@ -0,0 +1,104 @@ +""" using django model forms """ +from django import forms + +from bookwyrm import models +from bookwyrm.models.fields import ClearableFileInputWithWarning +from .custom_form import CustomForm +from .widgets import ArrayWidget, SelectDateWidget, Select + + +# pylint: disable=missing-class-docstring +class CoverForm(CustomForm): + class Meta: + model = models.Book + fields = ["cover"] + help_texts = {f: None for f in fields} + + +class EditionForm(CustomForm): + class Meta: + model = models.Edition + exclude = [ + "remote_id", + "origin_id", + "created_date", + "updated_date", + "edition_rank", + "authors", + "parent_work", + "shelves", + "connector", + "search_vector", + "links", + "file_links", + ] + widgets = { + "title": forms.TextInput(attrs={"aria-describedby": "desc_title"}), + "subtitle": forms.TextInput(attrs={"aria-describedby": "desc_subtitle"}), + "description": forms.Textarea( + attrs={"aria-describedby": "desc_description"} + ), + "series": forms.TextInput(attrs={"aria-describedby": "desc_series"}), + "series_number": forms.TextInput( + attrs={"aria-describedby": "desc_series_number"} + ), + "subjects": ArrayWidget(), + "languages": forms.TextInput( + attrs={"aria-describedby": "desc_languages_help desc_languages"} + ), + "publishers": forms.TextInput( + attrs={"aria-describedby": "desc_publishers_help desc_publishers"} + ), + "first_published_date": SelectDateWidget( + attrs={"aria-describedby": "desc_first_published_date"} + ), + "published_date": SelectDateWidget( + attrs={"aria-describedby": "desc_published_date"} + ), + "cover": ClearableFileInputWithWarning( + attrs={"aria-describedby": "desc_cover"} + ), + "physical_format": Select( + attrs={"aria-describedby": "desc_physical_format"} + ), + "physical_format_detail": forms.TextInput( + attrs={"aria-describedby": "desc_physical_format_detail"} + ), + "pages": forms.NumberInput(attrs={"aria-describedby": "desc_pages"}), + "isbn_13": forms.TextInput(attrs={"aria-describedby": "desc_isbn_13"}), + "isbn_10": forms.TextInput(attrs={"aria-describedby": "desc_isbn_10"}), + "openlibrary_key": forms.TextInput( + attrs={"aria-describedby": "desc_openlibrary_key"} + ), + "inventaire_id": forms.TextInput( + attrs={"aria-describedby": "desc_inventaire_id"} + ), + "oclc_number": forms.TextInput( + attrs={"aria-describedby": "desc_oclc_number"} + ), + "ASIN": forms.TextInput(attrs={"aria-describedby": "desc_ASIN"}), + } + + +class EditionFromWorkForm(CustomForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # make all fields hidden + for visible in self.visible_fields(): + visible.field.widget = forms.HiddenInput() + + class Meta: + model = models.Work + fields = [ + "title", + "subtitle", + "authors", + "description", + "languages", + "series", + "series_number", + "subjects", + "subject_places", + "cover", + "first_published_date", + ] diff --git a/bookwyrm/forms/custom_form.py b/bookwyrm/forms/custom_form.py new file mode 100644 index 000000000..74a3417a2 --- /dev/null +++ b/bookwyrm/forms/custom_form.py @@ -0,0 +1,26 @@ +""" Overrides django's default form class """ +from collections import defaultdict +from django.forms import ModelForm +from django.forms.widgets import Textarea + + +class CustomForm(ModelForm): + """add css classes to the forms""" + + def __init__(self, *args, **kwargs): + css_classes = defaultdict(lambda: "") + css_classes["text"] = "input" + css_classes["password"] = "input" + css_classes["email"] = "input" + 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"): + input_type = visible.field.widget.input_type + if isinstance(visible.field.widget, Textarea): + input_type = "textarea" + visible.field.widget.attrs["rows"] = 5 + visible.field.widget.attrs["class"] = css_classes[input_type] diff --git a/bookwyrm/forms/edit_user.py b/bookwyrm/forms/edit_user.py new file mode 100644 index 000000000..a291c6441 --- /dev/null +++ b/bookwyrm/forms/edit_user.py @@ -0,0 +1,101 @@ +""" using django model forms """ +from django import forms +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +from bookwyrm import models +from bookwyrm.models.fields import ClearableFileInputWithWarning +from .custom_form import CustomForm + + +# pylint: disable=missing-class-docstring +class EditUserForm(CustomForm): + class Meta: + model = models.User + fields = [ + "avatar", + "name", + "email", + "summary", + "show_goal", + "show_suggested_users", + "manually_approves_followers", + "default_post_privacy", + "discoverable", + "hide_follows", + "preferred_timezone", + "preferred_language", + "theme", + ] + help_texts = {f: None for f in fields} + widgets = { + "avatar": ClearableFileInputWithWarning( + attrs={"aria-describedby": "desc_avatar"} + ), + "name": forms.TextInput(attrs={"aria-describedby": "desc_name"}), + "summary": forms.Textarea(attrs={"aria-describedby": "desc_summary"}), + "email": forms.EmailInput(attrs={"aria-describedby": "desc_email"}), + "discoverable": forms.CheckboxInput( + attrs={"aria-describedby": "desc_discoverable"} + ), + } + + +class LimitedEditUserForm(CustomForm): + class Meta: + model = models.User + fields = [ + "avatar", + "name", + "summary", + "manually_approves_followers", + "discoverable", + ] + help_texts = {f: None for f in fields} + widgets = { + "avatar": ClearableFileInputWithWarning( + attrs={"aria-describedby": "desc_avatar"} + ), + "name": forms.TextInput(attrs={"aria-describedby": "desc_name"}), + "summary": forms.Textarea(attrs={"aria-describedby": "desc_summary"}), + "discoverable": forms.CheckboxInput( + attrs={"aria-describedby": "desc_discoverable"} + ), + } + + +class DeleteUserForm(CustomForm): + class Meta: + model = models.User + fields = ["password"] + + +class ChangePasswordForm(CustomForm): + current_password = forms.CharField(widget=forms.PasswordInput) + confirm_password = forms.CharField(widget=forms.PasswordInput) + + class Meta: + model = models.User + fields = ["password"] + widgets = { + "password": forms.PasswordInput(), + } + + def clean(self): + """Make sure passwords match and are valid""" + current_password = self.data.get("current_password") + if not self.instance.check_password(current_password): + self.add_error("current_password", _("Incorrect password")) + + cleaned_data = super().clean() + new_password = cleaned_data.get("password") + confirm_password = self.data.get("confirm_password") + + if new_password != confirm_password: + self.add_error("confirm_password", _("Password does not match")) + + try: + validate_password(new_password) + except ValidationError as err: + self.add_error("password", err) diff --git a/bookwyrm/forms/forms.py b/bookwyrm/forms/forms.py new file mode 100644 index 000000000..4aa1e5758 --- /dev/null +++ b/bookwyrm/forms/forms.py @@ -0,0 +1,64 @@ +""" using django model forms """ +from django import forms +from django.forms import widgets +from django.utils.translation import gettext_lazy as _ + +from bookwyrm import models +from bookwyrm.models.user import FeedFilterChoices +from .custom_form import CustomForm + + +# pylint: disable=missing-class-docstring +class FeedStatusTypesForm(CustomForm): + class Meta: + model = models.User + fields = ["feed_status_types"] + help_texts = {f: None for f in fields} + widgets = { + "feed_status_types": widgets.CheckboxSelectMultiple( + choices=FeedFilterChoices, + ), + } + + +class ImportForm(forms.Form): + csv_file = forms.FileField() + + +class ShelfForm(CustomForm): + class Meta: + model = models.Shelf + fields = ["user", "name", "privacy", "description"] + + +class GoalForm(CustomForm): + class Meta: + model = models.AnnualGoal + fields = ["user", "year", "goal", "privacy"] + + +class ReportForm(CustomForm): + class Meta: + model = models.Report + fields = ["user", "reporter", "status", "links", "note"] + + +class ReadThroughForm(CustomForm): + def clean(self): + """don't let readthroughs end before they start""" + cleaned_data = super().clean() + start_date = cleaned_data.get("start_date") + finish_date = cleaned_data.get("finish_date") + if start_date and finish_date and start_date > finish_date: + self.add_error( + "finish_date", _("Reading finish date cannot be before start date.") + ) + stopped_date = cleaned_data.get("stopped_date") + if start_date and stopped_date and start_date > stopped_date: + self.add_error( + "stopped_date", _("Reading stopped date cannot be before start date.") + ) + + class Meta: + model = models.ReadThrough + fields = ["user", "book", "start_date", "finish_date", "stopped_date"] diff --git a/bookwyrm/forms/groups.py b/bookwyrm/forms/groups.py new file mode 100644 index 000000000..90aace3ba --- /dev/null +++ b/bookwyrm/forms/groups.py @@ -0,0 +1,10 @@ +""" using django model forms """ +from bookwyrm import models +from .custom_form import CustomForm + + +# pylint: disable=missing-class-docstring +class GroupForm(CustomForm): + class Meta: + model = models.Group + fields = ["user", "privacy", "name", "description"] diff --git a/bookwyrm/forms/landing.py b/bookwyrm/forms/landing.py new file mode 100644 index 000000000..a31e8a7c4 --- /dev/null +++ b/bookwyrm/forms/landing.py @@ -0,0 +1,76 @@ +""" Forms for the landing pages """ +from django import forms +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +from bookwyrm import models +from .custom_form import CustomForm + + +# pylint: disable=missing-class-docstring +class LoginForm(CustomForm): + class Meta: + model = models.User + fields = ["localname", "password"] + help_texts = {f: None for f in fields} + widgets = { + "password": forms.PasswordInput(), + } + + +class RegisterForm(CustomForm): + class Meta: + model = models.User + fields = ["localname", "email", "password"] + help_texts = {f: None for f in fields} + widgets = {"password": forms.PasswordInput()} + + def clean(self): + """Check if the username is taken""" + cleaned_data = super().clean() + localname = cleaned_data.get("localname").strip() + try: + validate_password(cleaned_data.get("password")) + except ValidationError as err: + self.add_error("password", err) + if models.User.objects.filter(localname=localname).first(): + self.add_error("localname", _("User with this username already exists")) + + +class InviteRequestForm(CustomForm): + def clean(self): + """make sure the email isn't in use by a registered user""" + cleaned_data = super().clean() + email = cleaned_data.get("email") + if email and models.User.objects.filter(email=email).exists(): + self.add_error("email", _("A user with this email already exists.")) + + class Meta: + model = models.InviteRequest + fields = ["email", "answer"] + + +class PasswordResetForm(CustomForm): + confirm_password = forms.CharField(widget=forms.PasswordInput) + + class Meta: + model = models.User + fields = ["password"] + widgets = { + "password": forms.PasswordInput(), + } + + def clean(self): + """Make sure the passwords match and are valid""" + cleaned_data = super().clean() + new_password = cleaned_data.get("password") + confirm_password = self.data.get("confirm_password") + + if new_password != confirm_password: + self.add_error("confirm_password", _("Password does not match")) + + try: + validate_password(new_password) + except ValidationError as err: + self.add_error("password", err) diff --git a/bookwyrm/forms/links.py b/bookwyrm/forms/links.py new file mode 100644 index 000000000..de229bc2d --- /dev/null +++ b/bookwyrm/forms/links.py @@ -0,0 +1,48 @@ +""" using django model forms """ +from urllib.parse import urlparse + +from django.utils.translation import gettext_lazy as _ + +from bookwyrm import models +from .custom_form import CustomForm + + +# pylint: disable=missing-class-docstring +class LinkDomainForm(CustomForm): + class Meta: + model = models.LinkDomain + fields = ["name"] + + +class FileLinkForm(CustomForm): + class Meta: + model = models.FileLink + fields = ["url", "filetype", "availability", "book", "added_by"] + + def clean(self): + """make sure the domain isn't blocked or pending""" + cleaned_data = super().clean() + url = cleaned_data.get("url") + filetype = cleaned_data.get("filetype") + book = cleaned_data.get("book") + domain = urlparse(url).netloc + if models.LinkDomain.objects.filter(domain=domain).exists(): + status = models.LinkDomain.objects.get(domain=domain).status + if status == "blocked": + # pylint: disable=line-too-long + self.add_error( + "url", + _( + "This domain is blocked. Please contact your administrator if you think this is an error." + ), + ) + elif models.FileLink.objects.filter( + url=url, book=book, filetype=filetype + ).exists(): + # pylint: disable=line-too-long + self.add_error( + "url", + _( + "This link with file type has already been added for this book. If it is not visible, the domain is still pending." + ), + ) diff --git a/bookwyrm/forms/lists.py b/bookwyrm/forms/lists.py new file mode 100644 index 000000000..647db3bfe --- /dev/null +++ b/bookwyrm/forms/lists.py @@ -0,0 +1,37 @@ +""" using django model forms """ +from django import forms +from django.forms import ChoiceField +from django.utils.translation import gettext_lazy as _ + +from bookwyrm import models +from .custom_form import CustomForm + + +# pylint: disable=missing-class-docstring +class ListForm(CustomForm): + class Meta: + model = models.List + fields = ["user", "name", "description", "curation", "privacy", "group"] + + +class ListItemForm(CustomForm): + class Meta: + model = models.ListItem + fields = ["user", "book", "book_list", "notes"] + + +class SortListForm(forms.Form): + sort_by = ChoiceField( + choices=( + ("order", _("List Order")), + ("title", _("Book Title")), + ("rating", _("Rating")), + ), + label=_("Sort By"), + ) + direction = ChoiceField( + choices=( + ("ascending", _("Ascending")), + ("descending", _("Descending")), + ), + ) diff --git a/bookwyrm/forms/status.py b/bookwyrm/forms/status.py new file mode 100644 index 000000000..0800166bf --- /dev/null +++ b/bookwyrm/forms/status.py @@ -0,0 +1,82 @@ +""" using django model forms """ +from bookwyrm import models +from .custom_form import CustomForm + + +# pylint: disable=missing-class-docstring +class RatingForm(CustomForm): + class Meta: + model = models.ReviewRating + fields = ["user", "book", "rating", "privacy"] + + +class ReviewForm(CustomForm): + class Meta: + model = models.Review + fields = [ + "user", + "book", + "name", + "content", + "rating", + "content_warning", + "sensitive", + "privacy", + ] + + +class CommentForm(CustomForm): + class Meta: + model = models.Comment + fields = [ + "user", + "book", + "content", + "content_warning", + "sensitive", + "privacy", + "progress", + "progress_mode", + "reading_status", + ] + + +class QuotationForm(CustomForm): + class Meta: + model = models.Quotation + fields = [ + "user", + "book", + "quote", + "content", + "content_warning", + "sensitive", + "privacy", + "position", + "position_mode", + ] + + +class ReplyForm(CustomForm): + class Meta: + model = models.Status + fields = [ + "user", + "content", + "content_warning", + "sensitive", + "reply_parent", + "privacy", + ] + + +class StatusForm(CustomForm): + class Meta: + model = models.Status + fields = ["user", "content", "content_warning", "sensitive", "privacy"] + + +class DirectForm(CustomForm): + class Meta: + model = models.Status + fields = ["user", "content", "content_warning", "sensitive", "privacy"] diff --git a/bookwyrm/forms/user_admin.py b/bookwyrm/forms/user_admin.py new file mode 100644 index 000000000..a3bf6fa8e --- /dev/null +++ b/bookwyrm/forms/user_admin.py @@ -0,0 +1,10 @@ +""" using django model forms """ +from bookwyrm import models +from .custom_form import CustomForm + + +# pylint: disable=missing-class-docstring +class UserGroupForm(CustomForm): + class Meta: + model = models.User + fields = ["groups"] diff --git a/bookwyrm/forms/widgets.py b/bookwyrm/forms/widgets.py new file mode 100644 index 000000000..ee9345aa0 --- /dev/null +++ b/bookwyrm/forms/widgets.py @@ -0,0 +1,70 @@ +""" using django model forms """ +from django import forms + + +class ArrayWidget(forms.widgets.TextInput): + """Inputs for postgres array fields""" + + # pylint: disable=unused-argument + # pylint: disable=no-self-use + def value_from_datadict(self, data, files, name): + """get all values for this name""" + return [i for i in data.getlist(name) if i] + + +class Select(forms.Select): + """custom template for select widget""" + + template_name = "widgets/select.html" + + +class SelectDateWidget(forms.SelectDateWidget): + """ + A widget that splits date input into two diff --git a/bookwyrm/templates/author/sync_modal.html b/bookwyrm/templates/author/sync_modal.html index a6e032cbf..44b85ef4e 100644 --- a/bookwyrm/templates/author/sync_modal.html +++ b/bookwyrm/templates/author/sync_modal.html @@ -19,8 +19,10 @@ {% endblock %} {% block modal-footer %} - - +
+ + +
{% endblock %} {% block modal-form-close %}{% endblock %} diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html index 0e2fd5d39..e7d10f4f3 100644 --- a/bookwyrm/templates/book/book.html +++ b/bookwyrm/templates/book/book.html @@ -12,6 +12,15 @@ {% endblock %} {% block content %} +{% if update_error %} +
+ + + {% trans "Unable to connect to remote source." %} + +
+{% endif %} + {% with user_authenticated=request.user.is_authenticated can_edit_book=perms.bookwyrm.edit_book %}
@@ -199,9 +208,17 @@ {% endif %} - {% if book.parent_work.editions.count > 1 %} -

{% blocktrans with path=book.parent_work.local_path count=book.parent_work.editions.count %}{{ count }} editions{% endblocktrans %}

- {% endif %} + {% with work=book.parent_work %} +

+ + {% blocktrans trimmed count counter=work.editions.count with count=work.editions.count|intcomma %} + {{ count }} edition + {% plural %} + {{ count }} editions + {% endblocktrans %} + +

+ {% endwith %}
{# user's relationship to the book #} @@ -267,7 +284,7 @@ {% if user_statuses.review_count or user_statuses.comment_count or user_statuses.quotation_count %}
@@ -60,7 +63,7 @@ {% trans "Series number:" %} {{ form.series_number }} - + {% include 'snippets/form_errors.html' with errors_list=form.series_number.errors id="desc_series_number" %} @@ -74,9 +77,60 @@ {% trans "Separate multiple values with commas." %} - + {% include 'snippets/form_errors.html' with errors_list=form.languages.errors id="desc_languages" %} + +
+ + {% for subject in book.subjects %} + +
+
+ +
+
+ +
+
+ {% endfor %} + + + {% include 'snippets/form_errors.html' with errors_list=form.subjects.errors id="desc_subjects" %} +
+ + + + @@ -93,7 +147,7 @@ {% trans "Separate multiple values with commas." %} - + {% include 'snippets/form_errors.html' with errors_list=form.publishers.errors id="desc_publishers" %} @@ -101,8 +155,7 @@ - - + {{ form.first_published_date }} {% include 'snippets/form_errors.html' with errors_list=form.first_published_date.errors id="desc_first_published_date" %} @@ -110,8 +163,8 @@ - - + {{ form.published_date }} + {% include 'snippets/form_errors.html' with errors_list=form.published_date.errors id="desc_published_date" %} @@ -123,6 +176,8 @@
{% if book.authors.exists %} + {# preserve authors if the book is unsaved #} +
{% for author in book.authors.all %}
@@ -149,7 +204,12 @@ {% endfor %}
- + + +
@@ -180,7 +240,7 @@ - + {% include 'snippets/form_errors.html' with errors_list=form.cover.errors id="desc_cover" %} @@ -198,10 +258,8 @@ -
- {{ form.physical_format }} -
- + {{ form.physical_format }} + {% include 'snippets/form_errors.html' with errors_list=form.physical_format.errors id="desc_physical_format" %} @@ -211,7 +269,7 @@ {% trans "Format details:" %} {{ form.physical_format_detail }} - + {% include 'snippets/form_errors.html' with errors_list=form.physical_format_detail.errors id="desc_physical_format_detail" %} @@ -222,7 +280,7 @@ {% trans "Pages:" %} {{ form.pages }} - + {% include 'snippets/form_errors.html' with errors_list=form.pages.errors id="desc_pages" %} @@ -238,7 +296,7 @@ {% trans "ISBN 13:" %} {{ form.isbn_13 }} - + {% include 'snippets/form_errors.html' with errors_list=form.isbn_13.errors id="desc_isbn_13" %} @@ -247,7 +305,7 @@ {% trans "ISBN 10:" %} {{ form.isbn_10 }} - + {% include 'snippets/form_errors.html' with errors_list=form.isbn_10.errors id="desc_isbn_10" %} @@ -256,7 +314,7 @@ {% trans "Openlibrary ID:" %} {{ form.openlibrary_key }} - + {% include 'snippets/form_errors.html' with errors_list=form.openlibrary_key.errors id="desc_openlibrary_key" %} @@ -265,7 +323,7 @@ {% trans "Inventaire ID:" %} {{ form.inventaire_id }} - + {% include 'snippets/form_errors.html' with errors_list=form.inventaire_id.errors id="desc_inventaire_id" %} @@ -274,7 +332,7 @@ {% trans "OCLC Number:" %} {{ form.oclc_number }} - + {% include 'snippets/form_errors.html' with errors_list=form.oclc_number.errors id="desc_oclc_number" %} @@ -283,10 +341,14 @@ {% trans "ASIN:" %} {{ form.asin }} - + {% include 'snippets/form_errors.html' with errors_list=form.ASIN.errors id="desc_ASIN" %} + +{% block scripts %} + +{% endblock %} diff --git a/bookwyrm/templates/book/editions/editions.html b/bookwyrm/templates/book/editions/editions.html index a3ff08022..16f65e459 100644 --- a/bookwyrm/templates/book/editions/editions.html +++ b/bookwyrm/templates/book/editions/editions.html @@ -21,7 +21,7 @@

- + {{ book|book_title }}

@@ -46,7 +46,36 @@ {% endfor %}
-
+
{% include 'snippets/pagination.html' with page=editions path=request.path %}
+ +
+

+ {% trans "Can't find the edition you're looking for?" %} +

+ + + {% csrf_token %} + {{ work_form.title }} + {{ work_form.subtitle }} + {{ work_form.authors }} + {{ work_form.description }} + {{ work_form.languages }} + {{ work_form.series }} + {{ work_form.cover }} + {{ work_form.first_published_date }} + {% for subject in work.subjects %} + + {% endfor %} + + +
+ +
+ +
+ {% endblock %} diff --git a/bookwyrm/templates/book/file_links/add_link_modal.html b/bookwyrm/templates/book/file_links/add_link_modal.html index d5b3fcd0d..67b437bd7 100644 --- a/bookwyrm/templates/book/file_links/add_link_modal.html +++ b/bookwyrm/templates/book/file_links/add_link_modal.html @@ -55,8 +55,10 @@ {% endblock %} {% block modal-footer %} - - - +
+ + +
{% endblock %} + {% block modal-form-close %}{% endblock %} diff --git a/bookwyrm/templates/book/file_links/edit_links.html b/bookwyrm/templates/book/file_links/edit_links.html index 9048ce7a2..fb722753f 100644 --- a/bookwyrm/templates/book/file_links/edit_links.html +++ b/bookwyrm/templates/book/file_links/edit_links.html @@ -8,14 +8,14 @@

- {% blocktrans with title=book|book_title %} + {% blocktrans trimmed with title=book|book_title %} Links for "{{ title }}" {% endblocktrans %}

diff --git a/bookwyrm/templates/confirm_email/resend.html b/bookwyrm/templates/confirm_email/resend.html new file mode 100644 index 000000000..221f07565 --- /dev/null +++ b/bookwyrm/templates/confirm_email/resend.html @@ -0,0 +1,10 @@ +{% extends 'landing/layout.html' %} +{% load i18n %} + +{% block title %} +{% trans "Resend confirmation link" %} +{% endblock %} + +{% block content %} +{% include "confirm_email/resend_modal.html" with active=True static=True id="resend-modal" %} +{% endblock %} diff --git a/bookwyrm/templates/confirm_email/resend_form.html b/bookwyrm/templates/confirm_email/resend_form.html deleted file mode 100644 index 7c0c10980..000000000 --- a/bookwyrm/templates/confirm_email/resend_form.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends "components/inline_form.html" %} -{% load i18n %} -{% block header %} -{% trans "Resend confirmation link" %} -{% endblock %} - -{% block form %} -
- {% csrf_token %} -
- -
- -
-
-
- -
-
-{% endblock %} diff --git a/bookwyrm/templates/confirm_email/resend_modal.html b/bookwyrm/templates/confirm_email/resend_modal.html new file mode 100644 index 000000000..4d155cbb6 --- /dev/null +++ b/bookwyrm/templates/confirm_email/resend_modal.html @@ -0,0 +1,36 @@ +{% extends "components/modal.html" %} +{% load i18n %} + +{% block modal-title %} +{% trans "Resend confirmation link" %} +{% endblock %} + +{% block modal-form-open %} +
+{% endblock %} + +{% block modal-body %} +{% csrf_token %} +
+ +
+ +
+
+{% endblock %} + +{% block modal-footer %} +
+ +
+{% endblock %} + +{% block modal-form-close %} +
+{% endblock %} diff --git a/bookwyrm/templates/email/moderation_report/html_content.html b/bookwyrm/templates/email/moderation_report/html_content.html index 10df380f2..3828ff70c 100644 --- a/bookwyrm/templates/email/moderation_report/html_content.html +++ b/bookwyrm/templates/email/moderation_report/html_content.html @@ -3,7 +3,19 @@ {% block content %}

-{% blocktrans %}@{{ reporter }} has flagged behavior by @{{ reportee }} for moderation. {% endblocktrans %} +{% if report_link %} + + {% blocktrans trimmed %} + @{{ reporter }} has flagged a link domain for moderation. + {% endblocktrans %} + +{% else %} + + {% blocktrans trimmed %} + @{{ reporter }} has flagged behavior by @{{ reportee }} for moderation. + {% endblocktrans %} + +{% endif %}

{% trans "View report" as text %} diff --git a/bookwyrm/templates/email/moderation_report/text_content.html b/bookwyrm/templates/email/moderation_report/text_content.html index 57d37d446..764a3c72a 100644 --- a/bookwyrm/templates/email/moderation_report/text_content.html +++ b/bookwyrm/templates/email/moderation_report/text_content.html @@ -2,7 +2,15 @@ {% load i18n %} {% block content %} -{% blocktrans %}@{{ reporter}} has flagged behavior by @{{ reportee }} for moderation. {% endblocktrans %} +{% if report_link %} +{% blocktrans trimmed %} +@{{ reporter }} has flagged a link domain for moderation. +{% endblocktrans %} +{% else %} +{% blocktrans trimmed %} +@{{ reporter }} has flagged behavior by @{{ reportee }} for moderation. +{% endblocktrans %} +{% endif %} {% trans "View report" %} {{ report_link }} diff --git a/bookwyrm/templates/embed-layout.html b/bookwyrm/templates/embed-layout.html index e0234494c..233ba387f 100644 --- a/bookwyrm/templates/embed-layout.html +++ b/bookwyrm/templates/embed-layout.html @@ -43,7 +43,7 @@ {% endif %}

- {% trans "Join Bookwyrm" %} + {% trans "Join BookWyrm" %}

diff --git a/bookwyrm/templates/feed/direct_messages.html b/bookwyrm/templates/feed/direct_messages.html index 77f9aac19..115e1e6f4 100644 --- a/bookwyrm/templates/feed/direct_messages.html +++ b/bookwyrm/templates/feed/direct_messages.html @@ -14,7 +14,7 @@
- {% include 'snippets/create_status/status.html' with type="direct" uuid=1 mention=partner no_script=True %} + {% include 'snippets/create_status/status.html' with type="direct" uuid=1 mention=partner %}
diff --git a/bookwyrm/templates/feed/suggested_books.html b/bookwyrm/templates/feed/suggested_books.html index 2582dcf06..12e478201 100644 --- a/bookwyrm/templates/feed/suggested_books.html +++ b/bookwyrm/templates/feed/suggested_books.html @@ -5,7 +5,19 @@

{% trans "Your Books" %}

{% if not suggested_books %} -

{% trans "There are no books here right now! Try searching for a book to get started" %}

+ +
+

{% trans "There are no books here right now! Try searching for a book to get started" %}

+ + +
+ {% else %} {% with active_book=request.GET.book %}
diff --git a/bookwyrm/templates/get_started/book_preview.html b/bookwyrm/templates/get_started/book_preview.html index 8a20d0d77..9cfb56b00 100644 --- a/bookwyrm/templates/get_started/book_preview.html +++ b/bookwyrm/templates/get_started/book_preview.html @@ -10,6 +10,7 @@ {% if shelf.identifier == 'to-read' %}{% trans "To Read" %} {% elif shelf.identifier == 'reading' %}{% trans "Currently Reading" %} {% elif shelf.identifier == 'read' %}{% trans "Read" %} + {% elif shelf.identifier == 'stopped-reading' %}{% trans "Stopped Reading" %} {% else %}{{ shelf.name }}{% endif %} {% endfor %} diff --git a/bookwyrm/templates/groups/create_form.html b/bookwyrm/templates/groups/create_form.html index dbb8ad88b..5aace9d1d 100644 --- a/bookwyrm/templates/groups/create_form.html +++ b/bookwyrm/templates/groups/create_form.html @@ -2,7 +2,7 @@ {% load i18n %} {% block header %} -{% trans "Create Group" %} +{% trans "Create group" %} {% endblock %} {% block form %} diff --git a/bookwyrm/templates/groups/delete_group_modal.html b/bookwyrm/templates/groups/delete_group_modal.html index 592a861fd..f166eec21 100644 --- a/bookwyrm/templates/groups/delete_group_modal.html +++ b/bookwyrm/templates/groups/delete_group_modal.html @@ -8,13 +8,15 @@ {% endblock %} {% block modal-footer %} -
+ {% csrf_token %} - - +
+ + +
{% endblock %} diff --git a/bookwyrm/templates/groups/form.html b/bookwyrm/templates/groups/form.html index 6767d1b10..a0d057582 100644 --- a/bookwyrm/templates/groups/form.html +++ b/bookwyrm/templates/groups/form.html @@ -16,18 +16,21 @@
{% if group.id %} -
+
{% endif %} -
-
- {% include 'snippets/privacy_select_no_followers.html' with current=group.privacy %} -
-
- + +
+
+
+ {% include 'snippets/privacy_select_no_followers.html' with current=group.privacy %} +
+
+ +
diff --git a/bookwyrm/templates/groups/members.html b/bookwyrm/templates/groups/members.html index 2c256c618..e9c9047c9 100644 --- a/bookwyrm/templates/groups/members.html +++ b/bookwyrm/templates/groups/members.html @@ -38,7 +38,7 @@ {% for membership in group.memberships.all %} {% with member=membership.user %}
- + {% include 'snippets/avatar.html' with user=member large=True %} {{ member.display_name|truncatechars:10 }} @{{ member|username|truncatechars:8 }} diff --git a/bookwyrm/templates/groups/suggested_users.html b/bookwyrm/templates/groups/suggested_users.html index 06f31b44f..27001073c 100644 --- a/bookwyrm/templates/groups/suggested_users.html +++ b/bookwyrm/templates/groups/suggested_users.html @@ -9,7 +9,7 @@
{% for user in suggested_users %}
- + {% include 'snippets/avatar.html' with user=user large=True %} {{ user.display_name|truncatechars:10 }} @{{ user|username|truncatechars:8 }} diff --git a/bookwyrm/templates/import/import.html b/bookwyrm/templates/import/import.html index fdeb0e55b..fc00389c5 100644 --- a/bookwyrm/templates/import/import.html +++ b/bookwyrm/templates/import/import.html @@ -14,28 +14,35 @@
-
- -
{{ import_form.csv_file }} @@ -63,7 +70,7 @@

{% trans "Recent Imports" %}

{% if not jobs %} -

{% trans "No recent imports" %}

+

{% trans "No recent imports" %}

{% endif %}
    {% for job in jobs %} diff --git a/bookwyrm/templates/import/tooltip.html b/bookwyrm/templates/import/tooltip.html deleted file mode 100644 index f2712b7e9..000000000 --- a/bookwyrm/templates/import/tooltip.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends 'components/tooltip.html' %} -{% load i18n %} - -{% block tooltip_content %} - -{% trans 'You can download your Goodreads data from the Import/Export page of your Goodreads account.' %} - -{% endblock %} diff --git a/bookwyrm/templates/landing/layout.html b/bookwyrm/templates/landing/layout.html index 6d69cafc2..bf0a6b2a1 100644 --- a/bookwyrm/templates/landing/layout.html +++ b/bookwyrm/templates/landing/layout.html @@ -70,6 +70,14 @@ {% include 'snippets/form_errors.html' with errors_list=request_form.email.errors id="desc_request_email" %}
+ {% if site.invite_request_question %} +
+ + + {% include 'snippets/form_errors.html' with errors_list=request_form.answer.errors id="desc_answer_register" %} +
+ {% endif %} + {% endif %} diff --git a/bookwyrm/templates/landing/password_reset.html b/bookwyrm/templates/landing/password_reset.html index 8348efd4f..786eaa0ab 100644 --- a/bookwyrm/templates/landing/password_reset.html +++ b/bookwyrm/templates/landing/password_reset.html @@ -26,7 +26,16 @@ {% trans "Password:" %}
- + + {% include 'snippets/form_errors.html' with errors_list=form.password.errors id="desc_password" %}
@@ -34,7 +43,8 @@ {% trans "Confirm password:" %}
- + {{ form.confirm_password }} + {% include 'snippets/form_errors.html' with errors_list=form.confirm_password.errors id="desc_confirm_password" %}
diff --git a/bookwyrm/templates/landing/password_reset_request.html b/bookwyrm/templates/landing/password_reset_request.html index 5d877442f..b06668e57 100644 --- a/bookwyrm/templates/landing/password_reset_request.html +++ b/bookwyrm/templates/landing/password_reset_request.html @@ -9,7 +9,13 @@

{% trans "Reset Password" %}

- {% if message %}

{{ message }}

{% endif %} + {% if sent_message %} +

+ {% blocktrans trimmed %} + A password reset link will be sent to {{ email }} if there is an account using that email address. + {% endblocktrans %} +

+ {% endif %}

{% trans "A link to reset your password will be sent to your email address" %}

diff --git a/bookwyrm/templates/layout.html b/bookwyrm/templates/layout.html index 0e8740724..6b9e4daa1 100644 --- a/bookwyrm/templates/layout.html +++ b/bookwyrm/templates/layout.html @@ -1,14 +1,14 @@ {% load layout %} +{% load sass_tags %} {% load i18n %} {% load static %} -{% load sass_tags %} {% block title %}BookWyrm{% endblock %} - {{ site.name }} - + @@ -56,8 +56,16 @@
+
+ +
+ {% include "search/barcode_modal.html" with id="barcode-scanner-modal" %} - +
+ + +
{% endblock %} {% block modal-form-close %}{% endblock %} diff --git a/bookwyrm/templates/lists/curate.html b/bookwyrm/templates/lists/curate.html index 9b484eb40..1aefd91f7 100644 --- a/bookwyrm/templates/lists/curate.html +++ b/bookwyrm/templates/lists/curate.html @@ -6,7 +6,7 @@
-
+
{% if list.id %}
diff --git a/bookwyrm/templates/preferences/edit_user.html b/bookwyrm/templates/preferences/edit_user.html index 13cbd1392..9138f50d8 100644 --- a/bookwyrm/templates/preferences/edit_user.html +++ b/bookwyrm/templates/preferences/edit_user.html @@ -10,7 +10,7 @@ {% block profile-tabs %} {% endblock %} @@ -61,7 +61,7 @@
-

{% trans "Display preferences" %}

+

{% trans "Display" %}

+
+ +
+ {{ form.theme }} +
+
diff --git a/bookwyrm/templates/preferences/export.html b/bookwyrm/templates/preferences/export.html new file mode 100644 index 000000000..61933be3e --- /dev/null +++ b/bookwyrm/templates/preferences/export.html @@ -0,0 +1,25 @@ +{% extends 'preferences/layout.html' %} +{% load i18n %} + +{% block title %}{% trans "CSV Export" %}{% endblock %} + +{% block header %} +{% trans "CSV Export" %} +{% endblock %} + +{% block panel %} +
+

+ {% trans "Your export will include all the books on your shelves, books you have reviewed, and books with reading activity." %} +

+

+

+ {% csrf_token %} + +
+

+
+{% endblock %} diff --git a/bookwyrm/templates/preferences/layout.html b/bookwyrm/templates/preferences/layout.html index bf4fed7d5..27d91c480 100644 --- a/bookwyrm/templates/preferences/layout.html +++ b/bookwyrm/templates/preferences/layout.html @@ -24,6 +24,17 @@ {% trans "Delete Account" %} + +