diff --git a/.env.example b/.env.example index bb2d677ef..58c53b5bf 100644 --- a/.env.example +++ b/.env.example @@ -21,8 +21,8 @@ MEDIA_ROOT=images/ # Database configuration PGPORT=5432 POSTGRES_PASSWORD=securedbypassword123 -POSTGRES_USER=fedireads -POSTGRES_DB=fedireads +POSTGRES_USER=bookwyrm +POSTGRES_DB=bookwyrm POSTGRES_HOST=db # Redis activity stream manager @@ -56,7 +56,7 @@ EMAIL_SENDER_NAME=admin EMAIL_SENDER_DOMAIN= # Query timeouts -SEARCH_TIMEOUT=15 +SEARCH_TIMEOUT=5 QUERY_TIMEOUT=5 # Thumbnails Generation @@ -79,7 +79,7 @@ AWS_SECRET_ACCESS_KEY= # Preview image generation can be computing and storage intensive -# ENABLE_PREVIEW_IMAGES=True +ENABLE_PREVIEW_IMAGES=False # Specify RGB tuple or RGB hex strings, # or use_dominant_color_light / use_dominant_color_dark 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/lint-frontend.yaml b/.github/workflows/lint-frontend.yaml index ed106d6a8..c97ee02ad 100644 --- a/.github/workflows/lint-frontend.yaml +++ b/.github/workflows/lint-frontend.yaml @@ -25,10 +25,10 @@ jobs: run: npm install stylelint stylelint-config-recommended stylelint-config-standard stylelint-order eslint # See .stylelintignore for files that are not linted. - - name: Run stylelint - run: > - npx stylelint bookwyrm/static/css/*.scss bookwyrm/static/css/bookwyrm/**/*.scss \ - --config dev-tools/.stylelintrc.js + # - name: Run stylelint + # run: > + # npx stylelint bookwyrm/static/css/*.scss bookwyrm/static/css/bookwyrm/**/*.scss \ + # --config dev-tools/.stylelintrc.js # See .eslintignore for files that are not linted. - name: Run ESLint diff --git a/README.md b/README.md index cf40d284d..f8b2eb1f6 100644 --- a/README.md +++ b/README.md @@ -1,60 +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) -- [Set up BookWyrm](#set-up-bookwyrm) - -## Joining BookWyrm -If you'd like to join an instance, you can check out the [instances](https://joinbookwyrm.com/instances/) list. +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/contributing.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. -### 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 their 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 diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index fa1535694..fa845f124 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -7,7 +7,7 @@ from django.apps import apps from django.db import IntegrityError, transaction from bookwyrm.connectors import ConnectorException, get_data -from bookwyrm.tasks import app +from bookwyrm.tasks import app, MEDIUM logger = logging.getLogger(__name__) @@ -202,7 +202,7 @@ class ActivityObject: return data -@app.task(queue="medium_priority") +@app.task(queue=MEDIUM) @transaction.atomic def set_related_field( model_name, origin_model_name, related_field_name, related_remote_id, data @@ -271,7 +271,7 @@ def resolve_remote_id( try: data = get_data(remote_id) except ConnectorException: - logger.exception("Could not connect to host for remote_id: %s", remote_id) + logger.info("Could not connect to host for remote_id: %s", remote_id) return None # determine the model implicitly, if not provided diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py index a90d7943b..1765f7e34 100644 --- a/bookwyrm/activitystreams.py +++ b/bookwyrm/activitystreams.py @@ -117,6 +117,17 @@ class ActivityStream(RedisStore): Q(id=status.user.id) # if the user is the post's author | Q(id__in=status.mention_users.all()) # if the user is mentioned ) + + # don't show replies to statuses the user can't see + elif status.reply_parent and status.reply_parent.privacy == "followers": + audience = audience.filter( + Q(id=status.user.id) # if the user is the post's author + | Q(id=status.reply_parent.user.id) # if the user is the OG author + | ( + Q(following=status.user) & Q(following=status.reply_parent.user) + ) # if the user is following both authors + ).distinct() + # only visible to the poster's followers and tagged users elif status.privacy == "followers": audience = audience.filter( @@ -287,6 +298,12 @@ def add_status_on_create(sender, instance, created, *args, **kwargs): remove_status_task.delay(instance.id) return + # To avoid creating a zillion unnecessary tasks caused by re-saving the model, + # check if it's actually ready to send before we go. We're trusting this was + # set correctly by the inbox or view + if not instance.ready: + return + # when creating new things, gotta wait on the transaction transaction.on_commit( lambda: add_status_on_create_command(sender, instance, created) diff --git a/bookwyrm/book_search.py b/bookwyrm/book_search.py index 4b0a6eab9..f9bb57dfc 100644 --- a/bookwyrm/book_search.py +++ b/bookwyrm/book_search.py @@ -4,9 +4,10 @@ from functools import reduce import operator from django.contrib.postgres.search import SearchRank, SearchQuery -from django.db.models import OuterRef, Subquery, F, Q +from django.db.models import F, Q from bookwyrm import models +from bookwyrm import connectors from bookwyrm.settings import MEDIA_FULL_URL @@ -16,8 +17,15 @@ def search(query, min_confidence=0, filters=None, return_first=False): filters = filters or [] if not query: return [] + query = query.strip() + + results = None # first, try searching unqiue identifiers - results = search_identifiers(query, *filters, return_first=return_first) + # unique identifiers never have spaces, title/author usually do + if not " " in query: + results = search_identifiers(query, *filters, return_first=return_first) + + # if there were no identifier results... if not results: # then try searching title/author results = search_title_author( @@ -30,26 +38,14 @@ def isbn_search(query): """search your local database""" if not query: return [] - + # Up-case the ISBN string to ensure any 'X' check-digit is correct + # If the ISBN has only 9 characters, prepend missing zero + query = query.strip().upper().rjust(10, "0") filters = [{f: query} for f in ["isbn_10", "isbn_13"]] - results = models.Edition.objects.filter( + return models.Edition.objects.filter( reduce(operator.or_, (Q(**f) for f in filters)) ).distinct() - # when there are multiple editions of the same work, pick the default. - # it would be odd for this to happen. - - default_editions = models.Edition.objects.filter( - parent_work=OuterRef("parent_work") - ).order_by("-edition_rank") - results = ( - results.annotate(default_id=Subquery(default_editions.values("id")[:1])).filter( - default_id=F("id") - ) - or results - ) - return results - def format_search_result(search_result): """convert a book object into a search result object""" @@ -72,6 +68,10 @@ def format_search_result(search_result): def search_identifiers(query, *filters, return_first=False): """tries remote_id, isbn; defined as dedupe fields on the model""" + if connectors.maybe_isbn(query): + # Oh did you think the 'S' in ISBN stood for 'standard'? + normalized_isbn = query.strip().upper().rjust(10, "0") + query = normalized_isbn # pylint: disable=W0212 or_filters = [ {f.name: query} @@ -81,22 +81,7 @@ def search_identifiers(query, *filters, return_first=False): results = models.Edition.objects.filter( *filters, reduce(operator.or_, (Q(**f) for f in or_filters)) ).distinct() - if results.count() <= 1: - if return_first: - return results.first() - return results - # when there are multiple editions of the same work, pick the default. - # it would be odd for this to happen. - default_editions = models.Edition.objects.filter( - parent_work=OuterRef("parent_work") - ).order_by("-edition_rank") - results = ( - results.annotate(default_id=Subquery(default_editions.values("id")[:1])).filter( - default_id=F("id") - ) - or results - ) if return_first: return results.first() return results @@ -113,19 +98,16 @@ def search_title_author(query, min_confidence, *filters, return_first=False): ) # when there are multiple editions of the same work, pick the closest - editions_of_work = results.values("parent_work__id").values_list("parent_work__id") + editions_of_work = results.values_list("parent_work__id", flat=True).distinct() # filter out multiple editions of the same work list_results = [] - for work_id in set(editions_of_work): - editions = results.filter(parent_work=work_id) - default = editions.order_by("-edition_rank").first() - default_rank = default.rank if default else 0 - # if mutliple books have the top rank, pick the default edition - if default_rank == editions.first().rank: - result = default - else: - result = editions.first() + for work_id in set(editions_of_work[:30]): + result = ( + results.filter(parent_work=work_id) + .order_by("-rank", "-edition_rank") + .first() + ) if return_first: return result diff --git a/bookwyrm/connectors/__init__.py b/bookwyrm/connectors/__init__.py index efbdb1666..3a4f5f3e0 100644 --- a/bookwyrm/connectors/__init__.py +++ b/bookwyrm/connectors/__init__.py @@ -1,6 +1,6 @@ """ bring connectors into the namespace """ from .settings import CONNECTORS from .abstract_connector import ConnectorException -from .abstract_connector import get_data, get_image +from .abstract_connector import get_data, get_image, maybe_isbn from .connector_manager import search, first_search_result diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py index dc4be4b3d..8ae93926a 100644 --- a/bookwyrm/connectors/abstract_connector.py +++ b/bookwyrm/connectors/abstract_connector.py @@ -42,8 +42,10 @@ class AbstractMinimalConnector(ABC): """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}" - + # Up-case the ISBN string to ensure any 'X' check-digit is correct + # If the ISBN has only 9 characters, prepend missing zero + normalized_query = query.strip().upper().rjust(10, "0") + return f"{self.isbn_search_url}{normalized_query}" # 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}" @@ -220,7 +222,7 @@ def dict_from_mappings(data, mappings): return result -def get_data(url, params=None, timeout=10): +def get_data(url, params=None, timeout=settings.QUERY_TIMEOUT): """wrapper for request.get""" # check if the url is blocked raise_not_valid_url(url) @@ -325,4 +327,11 @@ def unique_physical_format(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 + # ISBNs must be numeric except an ISBN10 checkdigit can be 'X' + if not isbn.upper().rstrip("X").isnumeric(): + return False + return len(isbn) in [ + 9, + 10, + 13, + ] # ISBN10 or ISBN13, or maybe ISBN10 missing a leading zero diff --git a/bookwyrm/connectors/connector_manager.py b/bookwyrm/connectors/connector_manager.py index 37b093aa9..9a6f834af 100644 --- a/bookwyrm/connectors/connector_manager.py +++ b/bookwyrm/connectors/connector_manager.py @@ -13,7 +13,7 @@ from requests import HTTPError from bookwyrm import book_search, models from bookwyrm.settings import SEARCH_TIMEOUT, USER_AGENT -from bookwyrm.tasks import app +from bookwyrm.tasks import app, LOW logger = logging.getLogger(__name__) @@ -53,7 +53,7 @@ async def get_results(session, url, min_confidence, query, connector): except asyncio.TimeoutError: logger.info("Connection timed out for url: %s", url) except aiohttp.ClientError as err: - logger.exception(err) + logger.info(err) async def async_connector_search(query, items, min_confidence): @@ -143,7 +143,7 @@ def get_or_create_connector(remote_id): return load_connector(connector_info) -@app.task(queue="low_priority") +@app.task(queue=LOW) def load_more_data(connector_id, book_id): """background the work of getting all 10,000 editions of LoTR""" connector_info = models.Connector.objects.get(id=connector_id) @@ -152,7 +152,7 @@ def load_more_data(connector_id, book_id): connector.expand_book_data(book) -@app.task(queue="low_priority") +@app.task(queue=LOW) 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) diff --git a/bookwyrm/connectors/inventaire.py b/bookwyrm/connectors/inventaire.py index 3d5f913bd..df9b2e43a 100644 --- a/bookwyrm/connectors/inventaire.py +++ b/bookwyrm/connectors/inventaire.py @@ -160,12 +160,13 @@ class Connector(AbstractConnector): 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""" - try: - data = self.get_book_data(edition_data) - except ConnectorException: - # who, indeed, knows - return - super().create_edition_from_data(work, data, instance=instance) + if isinstance(edition_data, str): + try: + edition_data = self.get_book_data(edition_data) + except ConnectorException: + # who, indeed, knows + 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/emailing.py b/bookwyrm/emailing.py index 80aca071b..03cf4772e 100644 --- a/bookwyrm/emailing.py +++ b/bookwyrm/emailing.py @@ -3,7 +3,7 @@ from django.core.mail import EmailMultiAlternatives from django.template.loader import get_template from bookwyrm import models, settings -from bookwyrm.tasks import app +from bookwyrm.tasks import app, HIGH from bookwyrm.settings import DOMAIN @@ -23,7 +23,7 @@ def email_confirmation_email(user): data = email_data() data["confirmation_code"] = user.confirmation_code data["confirmation_link"] = user.confirmation_link - send_email.delay(user.email, *format_email("confirm", data)) + send_email(user.email, *format_email("confirm", data)) def invite_email(invite_request): @@ -38,15 +38,17 @@ def password_reset_email(reset_code): data = email_data() data["reset_link"] = reset_code.link data["user"] = reset_code.user.display_name - send_email.delay(reset_code.user.email, *format_email("password_reset", data)) + send_email(reset_code.user.email, *format_email("password_reset", data)) 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 + data["link_domain"] = report.links.exists() for admin in models.User.objects.filter( groups__name__in=["admin", "moderator"] @@ -67,7 +69,7 @@ def format_email(email_name, data): return (subject, html_content, text_content) -@app.task(queue="high_priority") +@app.task(queue=HIGH) def send_email(recipient, subject, html_content, text_content): """use a task to send the email""" email = EmailMultiAlternatives( diff --git a/bookwyrm/forms/__init__.py b/bookwyrm/forms/__init__.py index 075752936..a37d126ac 100644 --- a/bookwyrm/forms/__init__.py +++ b/bookwyrm/forms/__init__.py @@ -10,3 +10,4 @@ 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 index 4141327d3..ae15e011b 100644 --- a/bookwyrm/forms/admin.py +++ b/bookwyrm/forms/admin.py @@ -2,13 +2,14 @@ import datetime from django import forms +from django.core.exceptions import PermissionDenied 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 +from .custom_form import CustomForm, StyledForm # pylint: disable=missing-class-docstring @@ -130,7 +131,7 @@ class AutoModRuleForm(CustomForm): fields = ["string_match", "flag_users", "flag_statuses", "created_by"] -class IntervalScheduleForm(CustomForm): +class IntervalScheduleForm(StyledForm): class Meta: model = IntervalSchedule fields = ["every", "period"] @@ -139,3 +140,10 @@ class IntervalScheduleForm(CustomForm): "every": forms.NumberInput(attrs={"aria-describedby": "desc_every"}), "period": forms.Select(attrs={"aria-describedby": "desc_period"}), } + + # pylint: disable=arguments-differ + def save(self, request, *args, **kwargs): + """This is an outside model so the perms check works differently""" + if not request.user.has_perm("bookwyrm.moderate_user"): + raise PermissionDenied() + return super().save(*args, **kwargs) diff --git a/bookwyrm/forms/custom_form.py b/bookwyrm/forms/custom_form.py index 74a3417a2..c604deea4 100644 --- a/bookwyrm/forms/custom_form.py +++ b/bookwyrm/forms/custom_form.py @@ -4,7 +4,7 @@ from django.forms import ModelForm from django.forms.widgets import Textarea -class CustomForm(ModelForm): +class StyledForm(ModelForm): """add css classes to the forms""" def __init__(self, *args, **kwargs): @@ -16,7 +16,7 @@ class CustomForm(ModelForm): css_classes["checkbox"] = "checkbox" css_classes["textarea"] = "textarea" # pylint: disable=super-with-arguments - super(CustomForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) for visible in self.visible_fields(): if hasattr(visible.field.widget, "input_type"): input_type = visible.field.widget.input_type @@ -24,3 +24,13 @@ class CustomForm(ModelForm): input_type = "textarea" visible.field.widget.attrs["rows"] = 5 visible.field.widget.attrs["class"] = css_classes[input_type] + + +class CustomForm(StyledForm): + """Check permissions on save""" + + # pylint: disable=arguments-differ + def save(self, request, *args, **kwargs): + """Save and check perms""" + self.instance.raise_not_editable(request.user) + return super().save(*args, **kwargs) diff --git a/bookwyrm/forms/edit_user.py b/bookwyrm/forms/edit_user.py index d609f15dc..ce7bb6d07 100644 --- a/bookwyrm/forms/edit_user.py +++ b/bookwyrm/forms/edit_user.py @@ -1,11 +1,13 @@ """ 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: @@ -66,3 +68,51 @@ 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) + + +class ConfirmPasswordForm(CustomForm): + password = forms.CharField(widget=forms.PasswordInput) + + class Meta: + model = models.User + fields = ["password"] + widgets = { + "password": forms.PasswordInput(), + } + + def clean(self): + """Make sure password is correct""" + password = self.data.get("password") + + if not self.instance.check_password(password): + self.add_error("password", _("Incorrect Password")) diff --git a/bookwyrm/forms/forms.py b/bookwyrm/forms/forms.py index 9d8f9f392..7968daf48 100644 --- a/bookwyrm/forms/forms.py +++ b/bookwyrm/forms/forms.py @@ -1,4 +1,5 @@ """ using django model forms """ +import datetime from django import forms from django.forms import widgets from django.utils.translation import gettext_lazy as _ @@ -7,7 +8,6 @@ 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: @@ -53,6 +53,26 @@ class ReadThroughForm(CustomForm): 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.") + ) + current_time = datetime.datetime.now() + if ( + stopped_date is not None + and current_time.timestamp() < stopped_date.timestamp() + ): + self.add_error( + "stopped_date", _("Reading stopped date cannot be in the future.") + ) + if ( + finish_date is not None + and current_time.timestamp() < finish_date.timestamp() + ): + self.add_error( + "finish_date", _("Reading finished date cannot be in the future.") + ) class Meta: model = models.ReadThrough diff --git a/bookwyrm/forms/groups.py b/bookwyrm/forms/groups.py index 15b27c0ae..90aace3ba 100644 --- a/bookwyrm/forms/groups.py +++ b/bookwyrm/forms/groups.py @@ -4,12 +4,6 @@ from .custom_form import CustomForm # pylint: disable=missing-class-docstring -class UserGroupForm(CustomForm): - class Meta: - model = models.User - fields = ["groups"] - - class GroupForm(CustomForm): class Meta: model = models.Group diff --git a/bookwyrm/forms/landing.py b/bookwyrm/forms/landing.py index b01c2cc98..bd9884bc3 100644 --- a/bookwyrm/forms/landing.py +++ b/bookwyrm/forms/landing.py @@ -1,8 +1,13 @@ """ Forms for the landing pages """ -from django.forms import PasswordInput +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 _ +import pyotp + from bookwyrm import models +from bookwyrm.settings import DOMAIN from .custom_form import CustomForm @@ -13,21 +18,40 @@ class LoginForm(CustomForm): fields = ["localname", "password"] help_texts = {f: None for f in fields} widgets = { - "password": PasswordInput(), + "password": forms.PasswordInput(), } + def infer_username(self): + """Users may enter their localname, username, or email""" + localname = self.data.get("localname") + if "@" in localname: # looks like an email address to me + try: + return models.User.objects.get(email=localname).username + except models.User.DoesNotExist: # maybe it's a full username? + return localname + return f"{localname}@{DOMAIN}" + + def add_invalid_password_error(self): + """We don't want to be too specific about this""" + # pylint: disable=attribute-defined-outside-init + self.non_field_errors = _("Username or password are incorrect") + class RegisterForm(CustomForm): class Meta: model = models.User fields = ["localname", "email", "password"] help_texts = {f: None for f in fields} - widgets = {"password": PasswordInput()} + 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")) @@ -43,3 +67,65 @@ class InviteRequestForm(CustomForm): 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) + + +class Confirm2FAForm(CustomForm): + otp = forms.CharField( + max_length=6, min_length=6, widget=forms.TextInput(attrs={"autofocus": True}) + ) + + class Meta: + model = models.User + fields = ["otp_secret", "hotp_count"] + + def clean_otp(self): + """Check otp matches""" + otp = self.data.get("otp") + totp = pyotp.TOTP(self.instance.otp_secret) + + if not totp.verify(otp): + + if self.instance.hotp_secret: + # maybe it's a backup code? + hotp = pyotp.HOTP(self.instance.hotp_secret) + hotp_count = ( + self.instance.hotp_count + if self.instance.hotp_count is not None + else 0 + ) + + if not hotp.verify(otp, hotp_count): + self.add_error("otp", _("Incorrect code")) + + # increment the user hotp_count + else: + self.instance.hotp_count = hotp_count + 1 + self.instance.save(broadcast=False, update_fields=["hotp_count"]) + + else: + self.add_error("otp", _("Incorrect code")) 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/importers/importer.py b/bookwyrm/importers/importer.py index 32800e772..a3cfba198 100644 --- a/bookwyrm/importers/importer.py +++ b/bookwyrm/importers/importer.py @@ -1,15 +1,7 @@ """ handle reading a csv from an external service, defaults are from Goodreads """ import csv -import logging - from django.utils import timezone -from django.utils.translation import gettext_lazy as _ - -from bookwyrm import models from bookwyrm.models import ImportJob, ImportItem -from bookwyrm.tasks import app, LOW - -logger = logging.getLogger(__name__) class Importer: @@ -118,127 +110,3 @@ class Importer: # this will re-normalize the raw data self.create_item(job, item.index, item.data) return job - - def start_import(self, job): # pylint: disable=no-self-use - """initalizes a csv import job""" - result = start_import_task.delay(job.id) - job.task_id = result.id - job.save() - - -@app.task(queue="low_priority") -def start_import_task(job_id): - """trigger the child tasks for each row""" - job = ImportJob.objects.get(id=job_id) - # these are sub-tasks so that one big task doesn't use up all the memory in celery - for item in job.items.values_list("id", flat=True).all(): - import_item_task.delay(item) - - -@app.task(queue="low_priority") -def import_item_task(item_id): - """resolve a row into a book""" - item = models.ImportItem.objects.get(id=item_id) - try: - item.resolve() - except Exception as err: # pylint: disable=broad-except - item.fail_reason = _("Error loading book") - item.save() - item.update_job() - raise err - - if item.book: - # shelves book and handles reviews - handle_imported_book(item) - else: - item.fail_reason = _("Could not find a match for book") - - item.save() - item.update_job() - - -def handle_imported_book(item): - """process a csv and then post about it""" - job = item.job - user = job.user - if isinstance(item.book, models.Work): - item.book = item.book.default_edition - if not item.book: - item.fail_reason = _("Error loading book") - item.save() - return - if not isinstance(item.book, models.Edition): - item.book = item.book.edition - - existing_shelf = models.ShelfBook.objects.filter(book=item.book, user=user).exists() - - # shelve the book if it hasn't been shelved already - if item.shelf and not existing_shelf: - desired_shelf = models.Shelf.objects.get(identifier=item.shelf, user=user) - shelved_date = item.date_added or timezone.now() - models.ShelfBook( - book=item.book, shelf=desired_shelf, user=user, shelved_date=shelved_date - ).save(priority=LOW) - - for read in item.reads: - # check for an existing readthrough with the same dates - if models.ReadThrough.objects.filter( - user=user, - book=item.book, - start_date=read.start_date, - finish_date=read.finish_date, - ).exists(): - continue - read.book = item.book - read.user = user - read.save() - - if job.include_reviews and (item.rating or item.review) and not item.linked_review: - # we don't know the publication date of the review, - # but "now" is a bad guess - published_date_guess = item.date_read or item.date_added - if item.review: - # pylint: disable=consider-using-f-string - review_title = "Review of {!r} on {!r}".format( - item.book.title, - job.source, - ) - review = models.Review.objects.filter( - user=user, - book=item.book, - name=review_title, - rating=item.rating, - published_date=published_date_guess, - ).first() - if not review: - review = models.Review( - user=user, - book=item.book, - name=review_title, - content=item.review, - rating=item.rating, - published_date=published_date_guess, - privacy=job.privacy, - ) - review.save(software="bookwyrm", priority=LOW) - else: - # just a rating - review = models.ReviewRating.objects.filter( - user=user, - book=item.book, - published_date=published_date_guess, - rating=item.rating, - ).first() - if not review: - review = models.ReviewRating( - user=user, - book=item.book, - rating=item.rating, - published_date=published_date_guess, - privacy=job.privacy, - ) - review.save(software="bookwyrm", priority=LOW) - - # only broadcast this review to other bookwyrm instances - item.linked_review = review - item.save() diff --git a/bookwyrm/management/commands/remove_2fa.py b/bookwyrm/management/commands/remove_2fa.py new file mode 100644 index 000000000..1c9d5f71a --- /dev/null +++ b/bookwyrm/management/commands/remove_2fa.py @@ -0,0 +1,22 @@ +"""deactivate two factor auth""" + +from django.core.management.base import BaseCommand, CommandError +from bookwyrm import models + + +class Command(BaseCommand): + """command-line options""" + + help = "Remove Two Factor Authorisation from user" + + def add_arguments(self, parser): + parser.add_argument("username") + + def handle(self, *args, **options): + name = options["username"] + user = models.User.objects.get(localname=name) + user.two_factor_auth = False + user.save(broadcast=False, update_fields=["two_factor_auth"]) + self.stdout.write( + self.style.SUCCESS("Two Factor Authorisation was removed from user") + ) diff --git a/bookwyrm/management/commands/revoke_preview_image_tasks.py b/bookwyrm/management/commands/revoke_preview_image_tasks.py new file mode 100644 index 000000000..6d6e59e8f --- /dev/null +++ b/bookwyrm/management/commands/revoke_preview_image_tasks.py @@ -0,0 +1,31 @@ +""" Actually let's not generate those preview images """ +import json +from django.core.management.base import BaseCommand +from bookwyrm.tasks import app + + +class Command(BaseCommand): + """Find and revoke image tasks""" + + # pylint: disable=unused-argument + def handle(self, *args, **options): + """reveoke nonessential low priority tasks""" + types = [ + "bookwyrm.preview_images.generate_edition_preview_image_task", + "bookwyrm.preview_images.generate_user_preview_image_task", + ] + self.stdout.write(" | Finding tasks of types:") + self.stdout.write("\n".join(types)) + with app.pool.acquire(block=True) as conn: + tasks = conn.default_channel.client.lrange("low_priority", 0, -1) + self.stdout.write(f" | Found {len(tasks)} task(s) in low priority queue") + + revoke_ids = [] + for task in tasks: + task_json = json.loads(task) + task_type = task_json.get("headers", {}).get("task") + if task_type in types: + revoke_ids.append(task_json.get("headers", {}).get("id")) + self.stdout.write(".", ending="") + self.stdout.write(f"\n | Revoking {len(revoke_ids)} task(s)") + app.control.revoke(revoke_ids) diff --git a/bookwyrm/migrations/0122_alter_annualgoal_year.py b/bookwyrm/migrations/0122_alter_annualgoal_year.py index 90af5fccf..60e40a54d 100644 --- a/bookwyrm/migrations/0122_alter_annualgoal_year.py +++ b/bookwyrm/migrations/0122_alter_annualgoal_year.py @@ -14,6 +14,8 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="annualgoal", name="year", - field=models.IntegerField(default=bookwyrm.models.user.get_current_year), + field=models.IntegerField( + default=bookwyrm.models.annual_goal.get_current_year + ), ), ] diff --git a/bookwyrm/migrations/0151_alter_report_user.py b/bookwyrm/migrations/0151_alter_report_user.py new file mode 100644 index 000000000..4c3f9dbda --- /dev/null +++ b/bookwyrm/migrations/0151_alter_report_user.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.13 on 2022-07-05 23:54 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0150_readthrough_stopped_date"), + ] + + operations = [ + migrations.AlterField( + model_name="report", + name="user", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/bookwyrm/migrations/0151_auto_20220705_0049.py b/bookwyrm/migrations/0151_auto_20220705_0049.py new file mode 100644 index 000000000..6010e38e5 --- /dev/null +++ b/bookwyrm/migrations/0151_auto_20220705_0049.py @@ -0,0 +1,90 @@ +# Generated by Django 3.2.13 on 2022-07-05 00:49 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0150_readthrough_stopped_date"), + ] + + operations = [ + migrations.RemoveField( + model_name="notification", + name="related_book", + ), + migrations.AddField( + model_name="notification", + name="related_list_items", + field=models.ManyToManyField( + related_name="notifications", to="bookwyrm.ListItem" + ), + ), + migrations.AddField( + model_name="notification", + name="related_reports", + field=models.ManyToManyField(to="bookwyrm.Report"), + ), + migrations.AddField( + model_name="notification", + name="related_users", + field=models.ManyToManyField( + related_name="notifications", to=settings.AUTH_USER_MODEL + ), + ), + migrations.AlterField( + model_name="notification", + name="related_list_item", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications_tmp", + to="bookwyrm.listitem", + ), + ), + migrations.AlterField( + model_name="notification", + name="related_report", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications_tmp", + to="bookwyrm.report", + ), + ), + migrations.RunSQL( + sql=""" + INSERT INTO bookwyrm_notification_related_users (notification_id, user_id) + SELECT id, related_user_id + FROM bookwyrm_notification + WHERE bookwyrm_notification.related_user_id IS NOT NULL; + + INSERT INTO bookwyrm_notification_related_list_items (notification_id, listitem_id) + SELECT id, related_list_item_id + FROM bookwyrm_notification + WHERE bookwyrm_notification.related_list_item_id IS NOT NULL; + + INSERT INTO bookwyrm_notification_related_reports (notification_id, report_id) + SELECT id, related_report_id + FROM bookwyrm_notification + WHERE bookwyrm_notification.related_report_id IS NOT NULL; + + """, + reverse_sql=migrations.RunSQL.noop, + ), + migrations.RemoveField( + model_name="notification", + name="related_list_item", + ), + migrations.RemoveField( + model_name="notification", + name="related_report", + ), + migrations.RemoveField( + model_name="notification", + name="related_user", + ), + ] diff --git a/bookwyrm/migrations/0152_alter_report_user.py b/bookwyrm/migrations/0152_alter_report_user.py new file mode 100644 index 000000000..1a67871c8 --- /dev/null +++ b/bookwyrm/migrations/0152_alter_report_user.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.13 on 2022-07-06 19:16 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0151_alter_report_user"), + ] + + operations = [ + migrations.AlterField( + model_name="report", + name="user", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/bookwyrm/migrations/0152_remove_notification_notification_type_valid.py b/bookwyrm/migrations/0152_remove_notification_notification_type_valid.py new file mode 100644 index 000000000..f7471c0d2 --- /dev/null +++ b/bookwyrm/migrations/0152_remove_notification_notification_type_valid.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.13 on 2022-07-05 03:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0151_auto_20220705_0049"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="notification", + name="notification_type_valid", + ), + ] diff --git a/bookwyrm/migrations/0153_merge_20220706_2141.py b/bookwyrm/migrations/0153_merge_20220706_2141.py new file mode 100644 index 000000000..03959f9ef --- /dev/null +++ b/bookwyrm/migrations/0153_merge_20220706_2141.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.13 on 2022-07-06 21:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0152_alter_report_user"), + ("bookwyrm", "0152_remove_notification_notification_type_valid"), + ] + + operations = [] diff --git a/bookwyrm/migrations/0154_alter_user_preferred_language.py b/bookwyrm/migrations/0154_alter_user_preferred_language.py new file mode 100644 index 000000000..2002cca66 --- /dev/null +++ b/bookwyrm/migrations/0154_alter_user_preferred_language.py @@ -0,0 +1,40 @@ +# Generated by Django 3.2.14 on 2022-07-15 19:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0153_merge_20220706_2141"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="preferred_language", + field=models.CharField( + blank=True, + choices=[ + ("en-us", "English"), + ("ca-es", "Català (Catalan)"), + ("de-de", "Deutsch (German)"), + ("es-es", "Español (Spanish)"), + ("gl-es", "Galego (Galician)"), + ("it-it", "Italiano (Italian)"), + ("fi-fi", "Suomi (Finnish)"), + ("fr-fr", "Français (French)"), + ("lt-lt", "Lietuvių (Lithuanian)"), + ("no-no", "Norsk (Norwegian)"), + ("pt-br", "Português do Brasil (Brazilian Portuguese)"), + ("pt-pt", "Português Europeu (European Portuguese)"), + ("ro-ro", "Română (Romanian)"), + ("sv-se", "Svenska (Swedish)"), + ("zh-hans", "简体中文 (Simplified Chinese)"), + ("zh-hant", "繁體中文 (Traditional Chinese)"), + ], + max_length=255, + null=True, + ), + ), + ] diff --git a/bookwyrm/migrations/0155_user_show_guided_tour.py b/bookwyrm/migrations/0155_user_show_guided_tour.py new file mode 100644 index 000000000..f7a7f3bd5 --- /dev/null +++ b/bookwyrm/migrations/0155_user_show_guided_tour.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.14 on 2022-07-09 23:33 + +from django.db import migrations, models + + +def existing_users_default(apps, schema_editor): + db_alias = schema_editor.connection.alias + user_model = apps.get_model("bookwyrm", "User") + user_model.objects.using(db_alias).filter(local=True).update(show_guided_tour=False) + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0154_alter_user_preferred_language"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="show_guided_tour", + field=models.BooleanField(default=True), + ), + migrations.RunPython(existing_users_default, migrations.RunPython.noop), + ] diff --git a/bookwyrm/migrations/0156_alter_user_preferred_language.py b/bookwyrm/migrations/0156_alter_user_preferred_language.py new file mode 100644 index 000000000..7cd7221f7 --- /dev/null +++ b/bookwyrm/migrations/0156_alter_user_preferred_language.py @@ -0,0 +1,41 @@ +# Generated by Django 3.2.14 on 2022-08-02 18:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0155_user_show_guided_tour"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="preferred_language", + field=models.CharField( + blank=True, + choices=[ + ("en-us", "English"), + ("ca-es", "Català (Catalan)"), + ("de-de", "Deutsch (German)"), + ("es-es", "Español (Spanish)"), + ("gl-es", "Galego (Galician)"), + ("it-it", "Italiano (Italian)"), + ("fi-fi", "Suomi (Finnish)"), + ("fr-fr", "Français (French)"), + ("lt-lt", "Lietuvių (Lithuanian)"), + ("no-no", "Norsk (Norwegian)"), + ("pl-pl", "Polski (Polish)"), + ("pt-br", "Português do Brasil (Brazilian Portuguese)"), + ("pt-pt", "Português Europeu (European Portuguese)"), + ("ro-ro", "Română (Romanian)"), + ("sv-se", "Svenska (Swedish)"), + ("zh-hans", "简体中文 (Simplified Chinese)"), + ("zh-hant", "繁體中文 (Traditional Chinese)"), + ], + max_length=255, + null=True, + ), + ), + ] diff --git a/bookwyrm/migrations/0157_auto_20220909_2338.py b/bookwyrm/migrations/0157_auto_20220909_2338.py new file mode 100644 index 000000000..86ea8fab3 --- /dev/null +++ b/bookwyrm/migrations/0157_auto_20220909_2338.py @@ -0,0 +1,647 @@ +# Generated by Django 3.2.15 on 2022-09-09 23:38 + +import bookwyrm.models.fields +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0156_alter_user_preferred_language"), + ] + + operations = [ + migrations.AlterField( + model_name="review", + name="rating", + field=bookwyrm.models.fields.DecimalField( + blank=True, + decimal_places=2, + default=None, + max_digits=3, + null=True, + validators=[ + django.core.validators.MinValueValidator(0.5), + django.core.validators.MaxValueValidator(5), + ], + ), + ), + migrations.AlterField( + model_name="user", + name="preferred_timezone", + field=models.CharField( + choices=[ + ("Africa/Abidjan", "Africa/Abidjan"), + ("Africa/Accra", "Africa/Accra"), + ("Africa/Addis_Ababa", "Africa/Addis_Ababa"), + ("Africa/Algiers", "Africa/Algiers"), + ("Africa/Asmara", "Africa/Asmara"), + ("Africa/Asmera", "Africa/Asmera"), + ("Africa/Bamako", "Africa/Bamako"), + ("Africa/Bangui", "Africa/Bangui"), + ("Africa/Banjul", "Africa/Banjul"), + ("Africa/Bissau", "Africa/Bissau"), + ("Africa/Blantyre", "Africa/Blantyre"), + ("Africa/Brazzaville", "Africa/Brazzaville"), + ("Africa/Bujumbura", "Africa/Bujumbura"), + ("Africa/Cairo", "Africa/Cairo"), + ("Africa/Casablanca", "Africa/Casablanca"), + ("Africa/Ceuta", "Africa/Ceuta"), + ("Africa/Conakry", "Africa/Conakry"), + ("Africa/Dakar", "Africa/Dakar"), + ("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"), + ("Africa/Djibouti", "Africa/Djibouti"), + ("Africa/Douala", "Africa/Douala"), + ("Africa/El_Aaiun", "Africa/El_Aaiun"), + ("Africa/Freetown", "Africa/Freetown"), + ("Africa/Gaborone", "Africa/Gaborone"), + ("Africa/Harare", "Africa/Harare"), + ("Africa/Johannesburg", "Africa/Johannesburg"), + ("Africa/Juba", "Africa/Juba"), + ("Africa/Kampala", "Africa/Kampala"), + ("Africa/Khartoum", "Africa/Khartoum"), + ("Africa/Kigali", "Africa/Kigali"), + ("Africa/Kinshasa", "Africa/Kinshasa"), + ("Africa/Lagos", "Africa/Lagos"), + ("Africa/Libreville", "Africa/Libreville"), + ("Africa/Lome", "Africa/Lome"), + ("Africa/Luanda", "Africa/Luanda"), + ("Africa/Lubumbashi", "Africa/Lubumbashi"), + ("Africa/Lusaka", "Africa/Lusaka"), + ("Africa/Malabo", "Africa/Malabo"), + ("Africa/Maputo", "Africa/Maputo"), + ("Africa/Maseru", "Africa/Maseru"), + ("Africa/Mbabane", "Africa/Mbabane"), + ("Africa/Mogadishu", "Africa/Mogadishu"), + ("Africa/Monrovia", "Africa/Monrovia"), + ("Africa/Nairobi", "Africa/Nairobi"), + ("Africa/Ndjamena", "Africa/Ndjamena"), + ("Africa/Niamey", "Africa/Niamey"), + ("Africa/Nouakchott", "Africa/Nouakchott"), + ("Africa/Ouagadougou", "Africa/Ouagadougou"), + ("Africa/Porto-Novo", "Africa/Porto-Novo"), + ("Africa/Sao_Tome", "Africa/Sao_Tome"), + ("Africa/Timbuktu", "Africa/Timbuktu"), + ("Africa/Tripoli", "Africa/Tripoli"), + ("Africa/Tunis", "Africa/Tunis"), + ("Africa/Windhoek", "Africa/Windhoek"), + ("America/Adak", "America/Adak"), + ("America/Anchorage", "America/Anchorage"), + ("America/Anguilla", "America/Anguilla"), + ("America/Antigua", "America/Antigua"), + ("America/Araguaina", "America/Araguaina"), + ( + "America/Argentina/Buenos_Aires", + "America/Argentina/Buenos_Aires", + ), + ("America/Argentina/Catamarca", "America/Argentina/Catamarca"), + ( + "America/Argentina/ComodRivadavia", + "America/Argentina/ComodRivadavia", + ), + ("America/Argentina/Cordoba", "America/Argentina/Cordoba"), + ("America/Argentina/Jujuy", "America/Argentina/Jujuy"), + ("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"), + ("America/Argentina/Mendoza", "America/Argentina/Mendoza"), + ( + "America/Argentina/Rio_Gallegos", + "America/Argentina/Rio_Gallegos", + ), + ("America/Argentina/Salta", "America/Argentina/Salta"), + ("America/Argentina/San_Juan", "America/Argentina/San_Juan"), + ("America/Argentina/San_Luis", "America/Argentina/San_Luis"), + ("America/Argentina/Tucuman", "America/Argentina/Tucuman"), + ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"), + ("America/Aruba", "America/Aruba"), + ("America/Asuncion", "America/Asuncion"), + ("America/Atikokan", "America/Atikokan"), + ("America/Atka", "America/Atka"), + ("America/Bahia", "America/Bahia"), + ("America/Bahia_Banderas", "America/Bahia_Banderas"), + ("America/Barbados", "America/Barbados"), + ("America/Belem", "America/Belem"), + ("America/Belize", "America/Belize"), + ("America/Blanc-Sablon", "America/Blanc-Sablon"), + ("America/Boa_Vista", "America/Boa_Vista"), + ("America/Bogota", "America/Bogota"), + ("America/Boise", "America/Boise"), + ("America/Buenos_Aires", "America/Buenos_Aires"), + ("America/Cambridge_Bay", "America/Cambridge_Bay"), + ("America/Campo_Grande", "America/Campo_Grande"), + ("America/Cancun", "America/Cancun"), + ("America/Caracas", "America/Caracas"), + ("America/Catamarca", "America/Catamarca"), + ("America/Cayenne", "America/Cayenne"), + ("America/Cayman", "America/Cayman"), + ("America/Chicago", "America/Chicago"), + ("America/Chihuahua", "America/Chihuahua"), + ("America/Coral_Harbour", "America/Coral_Harbour"), + ("America/Cordoba", "America/Cordoba"), + ("America/Costa_Rica", "America/Costa_Rica"), + ("America/Creston", "America/Creston"), + ("America/Cuiaba", "America/Cuiaba"), + ("America/Curacao", "America/Curacao"), + ("America/Danmarkshavn", "America/Danmarkshavn"), + ("America/Dawson", "America/Dawson"), + ("America/Dawson_Creek", "America/Dawson_Creek"), + ("America/Denver", "America/Denver"), + ("America/Detroit", "America/Detroit"), + ("America/Dominica", "America/Dominica"), + ("America/Edmonton", "America/Edmonton"), + ("America/Eirunepe", "America/Eirunepe"), + ("America/El_Salvador", "America/El_Salvador"), + ("America/Ensenada", "America/Ensenada"), + ("America/Fort_Nelson", "America/Fort_Nelson"), + ("America/Fort_Wayne", "America/Fort_Wayne"), + ("America/Fortaleza", "America/Fortaleza"), + ("America/Glace_Bay", "America/Glace_Bay"), + ("America/Godthab", "America/Godthab"), + ("America/Goose_Bay", "America/Goose_Bay"), + ("America/Grand_Turk", "America/Grand_Turk"), + ("America/Grenada", "America/Grenada"), + ("America/Guadeloupe", "America/Guadeloupe"), + ("America/Guatemala", "America/Guatemala"), + ("America/Guayaquil", "America/Guayaquil"), + ("America/Guyana", "America/Guyana"), + ("America/Halifax", "America/Halifax"), + ("America/Havana", "America/Havana"), + ("America/Hermosillo", "America/Hermosillo"), + ("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"), + ("America/Indiana/Knox", "America/Indiana/Knox"), + ("America/Indiana/Marengo", "America/Indiana/Marengo"), + ("America/Indiana/Petersburg", "America/Indiana/Petersburg"), + ("America/Indiana/Tell_City", "America/Indiana/Tell_City"), + ("America/Indiana/Vevay", "America/Indiana/Vevay"), + ("America/Indiana/Vincennes", "America/Indiana/Vincennes"), + ("America/Indiana/Winamac", "America/Indiana/Winamac"), + ("America/Indianapolis", "America/Indianapolis"), + ("America/Inuvik", "America/Inuvik"), + ("America/Iqaluit", "America/Iqaluit"), + ("America/Jamaica", "America/Jamaica"), + ("America/Jujuy", "America/Jujuy"), + ("America/Juneau", "America/Juneau"), + ("America/Kentucky/Louisville", "America/Kentucky/Louisville"), + ("America/Kentucky/Monticello", "America/Kentucky/Monticello"), + ("America/Knox_IN", "America/Knox_IN"), + ("America/Kralendijk", "America/Kralendijk"), + ("America/La_Paz", "America/La_Paz"), + ("America/Lima", "America/Lima"), + ("America/Los_Angeles", "America/Los_Angeles"), + ("America/Louisville", "America/Louisville"), + ("America/Lower_Princes", "America/Lower_Princes"), + ("America/Maceio", "America/Maceio"), + ("America/Managua", "America/Managua"), + ("America/Manaus", "America/Manaus"), + ("America/Marigot", "America/Marigot"), + ("America/Martinique", "America/Martinique"), + ("America/Matamoros", "America/Matamoros"), + ("America/Mazatlan", "America/Mazatlan"), + ("America/Mendoza", "America/Mendoza"), + ("America/Menominee", "America/Menominee"), + ("America/Merida", "America/Merida"), + ("America/Metlakatla", "America/Metlakatla"), + ("America/Mexico_City", "America/Mexico_City"), + ("America/Miquelon", "America/Miquelon"), + ("America/Moncton", "America/Moncton"), + ("America/Monterrey", "America/Monterrey"), + ("America/Montevideo", "America/Montevideo"), + ("America/Montreal", "America/Montreal"), + ("America/Montserrat", "America/Montserrat"), + ("America/Nassau", "America/Nassau"), + ("America/New_York", "America/New_York"), + ("America/Nipigon", "America/Nipigon"), + ("America/Nome", "America/Nome"), + ("America/Noronha", "America/Noronha"), + ("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"), + ("America/North_Dakota/Center", "America/North_Dakota/Center"), + ( + "America/North_Dakota/New_Salem", + "America/North_Dakota/New_Salem", + ), + ("America/Nuuk", "America/Nuuk"), + ("America/Ojinaga", "America/Ojinaga"), + ("America/Panama", "America/Panama"), + ("America/Pangnirtung", "America/Pangnirtung"), + ("America/Paramaribo", "America/Paramaribo"), + ("America/Phoenix", "America/Phoenix"), + ("America/Port-au-Prince", "America/Port-au-Prince"), + ("America/Port_of_Spain", "America/Port_of_Spain"), + ("America/Porto_Acre", "America/Porto_Acre"), + ("America/Porto_Velho", "America/Porto_Velho"), + ("America/Puerto_Rico", "America/Puerto_Rico"), + ("America/Punta_Arenas", "America/Punta_Arenas"), + ("America/Rainy_River", "America/Rainy_River"), + ("America/Rankin_Inlet", "America/Rankin_Inlet"), + ("America/Recife", "America/Recife"), + ("America/Regina", "America/Regina"), + ("America/Resolute", "America/Resolute"), + ("America/Rio_Branco", "America/Rio_Branco"), + ("America/Rosario", "America/Rosario"), + ("America/Santa_Isabel", "America/Santa_Isabel"), + ("America/Santarem", "America/Santarem"), + ("America/Santiago", "America/Santiago"), + ("America/Santo_Domingo", "America/Santo_Domingo"), + ("America/Sao_Paulo", "America/Sao_Paulo"), + ("America/Scoresbysund", "America/Scoresbysund"), + ("America/Shiprock", "America/Shiprock"), + ("America/Sitka", "America/Sitka"), + ("America/St_Barthelemy", "America/St_Barthelemy"), + ("America/St_Johns", "America/St_Johns"), + ("America/St_Kitts", "America/St_Kitts"), + ("America/St_Lucia", "America/St_Lucia"), + ("America/St_Thomas", "America/St_Thomas"), + ("America/St_Vincent", "America/St_Vincent"), + ("America/Swift_Current", "America/Swift_Current"), + ("America/Tegucigalpa", "America/Tegucigalpa"), + ("America/Thule", "America/Thule"), + ("America/Thunder_Bay", "America/Thunder_Bay"), + ("America/Tijuana", "America/Tijuana"), + ("America/Toronto", "America/Toronto"), + ("America/Tortola", "America/Tortola"), + ("America/Vancouver", "America/Vancouver"), + ("America/Virgin", "America/Virgin"), + ("America/Whitehorse", "America/Whitehorse"), + ("America/Winnipeg", "America/Winnipeg"), + ("America/Yakutat", "America/Yakutat"), + ("America/Yellowknife", "America/Yellowknife"), + ("Antarctica/Casey", "Antarctica/Casey"), + ("Antarctica/Davis", "Antarctica/Davis"), + ("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"), + ("Antarctica/Macquarie", "Antarctica/Macquarie"), + ("Antarctica/Mawson", "Antarctica/Mawson"), + ("Antarctica/McMurdo", "Antarctica/McMurdo"), + ("Antarctica/Palmer", "Antarctica/Palmer"), + ("Antarctica/Rothera", "Antarctica/Rothera"), + ("Antarctica/South_Pole", "Antarctica/South_Pole"), + ("Antarctica/Syowa", "Antarctica/Syowa"), + ("Antarctica/Troll", "Antarctica/Troll"), + ("Antarctica/Vostok", "Antarctica/Vostok"), + ("Arctic/Longyearbyen", "Arctic/Longyearbyen"), + ("Asia/Aden", "Asia/Aden"), + ("Asia/Almaty", "Asia/Almaty"), + ("Asia/Amman", "Asia/Amman"), + ("Asia/Anadyr", "Asia/Anadyr"), + ("Asia/Aqtau", "Asia/Aqtau"), + ("Asia/Aqtobe", "Asia/Aqtobe"), + ("Asia/Ashgabat", "Asia/Ashgabat"), + ("Asia/Ashkhabad", "Asia/Ashkhabad"), + ("Asia/Atyrau", "Asia/Atyrau"), + ("Asia/Baghdad", "Asia/Baghdad"), + ("Asia/Bahrain", "Asia/Bahrain"), + ("Asia/Baku", "Asia/Baku"), + ("Asia/Bangkok", "Asia/Bangkok"), + ("Asia/Barnaul", "Asia/Barnaul"), + ("Asia/Beirut", "Asia/Beirut"), + ("Asia/Bishkek", "Asia/Bishkek"), + ("Asia/Brunei", "Asia/Brunei"), + ("Asia/Calcutta", "Asia/Calcutta"), + ("Asia/Chita", "Asia/Chita"), + ("Asia/Choibalsan", "Asia/Choibalsan"), + ("Asia/Chongqing", "Asia/Chongqing"), + ("Asia/Chungking", "Asia/Chungking"), + ("Asia/Colombo", "Asia/Colombo"), + ("Asia/Dacca", "Asia/Dacca"), + ("Asia/Damascus", "Asia/Damascus"), + ("Asia/Dhaka", "Asia/Dhaka"), + ("Asia/Dili", "Asia/Dili"), + ("Asia/Dubai", "Asia/Dubai"), + ("Asia/Dushanbe", "Asia/Dushanbe"), + ("Asia/Famagusta", "Asia/Famagusta"), + ("Asia/Gaza", "Asia/Gaza"), + ("Asia/Harbin", "Asia/Harbin"), + ("Asia/Hebron", "Asia/Hebron"), + ("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"), + ("Asia/Hong_Kong", "Asia/Hong_Kong"), + ("Asia/Hovd", "Asia/Hovd"), + ("Asia/Irkutsk", "Asia/Irkutsk"), + ("Asia/Istanbul", "Asia/Istanbul"), + ("Asia/Jakarta", "Asia/Jakarta"), + ("Asia/Jayapura", "Asia/Jayapura"), + ("Asia/Jerusalem", "Asia/Jerusalem"), + ("Asia/Kabul", "Asia/Kabul"), + ("Asia/Kamchatka", "Asia/Kamchatka"), + ("Asia/Karachi", "Asia/Karachi"), + ("Asia/Kashgar", "Asia/Kashgar"), + ("Asia/Kathmandu", "Asia/Kathmandu"), + ("Asia/Katmandu", "Asia/Katmandu"), + ("Asia/Khandyga", "Asia/Khandyga"), + ("Asia/Kolkata", "Asia/Kolkata"), + ("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"), + ("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"), + ("Asia/Kuching", "Asia/Kuching"), + ("Asia/Kuwait", "Asia/Kuwait"), + ("Asia/Macao", "Asia/Macao"), + ("Asia/Macau", "Asia/Macau"), + ("Asia/Magadan", "Asia/Magadan"), + ("Asia/Makassar", "Asia/Makassar"), + ("Asia/Manila", "Asia/Manila"), + ("Asia/Muscat", "Asia/Muscat"), + ("Asia/Nicosia", "Asia/Nicosia"), + ("Asia/Novokuznetsk", "Asia/Novokuznetsk"), + ("Asia/Novosibirsk", "Asia/Novosibirsk"), + ("Asia/Omsk", "Asia/Omsk"), + ("Asia/Oral", "Asia/Oral"), + ("Asia/Phnom_Penh", "Asia/Phnom_Penh"), + ("Asia/Pontianak", "Asia/Pontianak"), + ("Asia/Pyongyang", "Asia/Pyongyang"), + ("Asia/Qatar", "Asia/Qatar"), + ("Asia/Qostanay", "Asia/Qostanay"), + ("Asia/Qyzylorda", "Asia/Qyzylorda"), + ("Asia/Rangoon", "Asia/Rangoon"), + ("Asia/Riyadh", "Asia/Riyadh"), + ("Asia/Saigon", "Asia/Saigon"), + ("Asia/Sakhalin", "Asia/Sakhalin"), + ("Asia/Samarkand", "Asia/Samarkand"), + ("Asia/Seoul", "Asia/Seoul"), + ("Asia/Shanghai", "Asia/Shanghai"), + ("Asia/Singapore", "Asia/Singapore"), + ("Asia/Srednekolymsk", "Asia/Srednekolymsk"), + ("Asia/Taipei", "Asia/Taipei"), + ("Asia/Tashkent", "Asia/Tashkent"), + ("Asia/Tbilisi", "Asia/Tbilisi"), + ("Asia/Tehran", "Asia/Tehran"), + ("Asia/Tel_Aviv", "Asia/Tel_Aviv"), + ("Asia/Thimbu", "Asia/Thimbu"), + ("Asia/Thimphu", "Asia/Thimphu"), + ("Asia/Tokyo", "Asia/Tokyo"), + ("Asia/Tomsk", "Asia/Tomsk"), + ("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"), + ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"), + ("Asia/Ulan_Bator", "Asia/Ulan_Bator"), + ("Asia/Urumqi", "Asia/Urumqi"), + ("Asia/Ust-Nera", "Asia/Ust-Nera"), + ("Asia/Vientiane", "Asia/Vientiane"), + ("Asia/Vladivostok", "Asia/Vladivostok"), + ("Asia/Yakutsk", "Asia/Yakutsk"), + ("Asia/Yangon", "Asia/Yangon"), + ("Asia/Yekaterinburg", "Asia/Yekaterinburg"), + ("Asia/Yerevan", "Asia/Yerevan"), + ("Atlantic/Azores", "Atlantic/Azores"), + ("Atlantic/Bermuda", "Atlantic/Bermuda"), + ("Atlantic/Canary", "Atlantic/Canary"), + ("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"), + ("Atlantic/Faeroe", "Atlantic/Faeroe"), + ("Atlantic/Faroe", "Atlantic/Faroe"), + ("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"), + ("Atlantic/Madeira", "Atlantic/Madeira"), + ("Atlantic/Reykjavik", "Atlantic/Reykjavik"), + ("Atlantic/South_Georgia", "Atlantic/South_Georgia"), + ("Atlantic/St_Helena", "Atlantic/St_Helena"), + ("Atlantic/Stanley", "Atlantic/Stanley"), + ("Australia/ACT", "Australia/ACT"), + ("Australia/Adelaide", "Australia/Adelaide"), + ("Australia/Brisbane", "Australia/Brisbane"), + ("Australia/Broken_Hill", "Australia/Broken_Hill"), + ("Australia/Canberra", "Australia/Canberra"), + ("Australia/Currie", "Australia/Currie"), + ("Australia/Darwin", "Australia/Darwin"), + ("Australia/Eucla", "Australia/Eucla"), + ("Australia/Hobart", "Australia/Hobart"), + ("Australia/LHI", "Australia/LHI"), + ("Australia/Lindeman", "Australia/Lindeman"), + ("Australia/Lord_Howe", "Australia/Lord_Howe"), + ("Australia/Melbourne", "Australia/Melbourne"), + ("Australia/NSW", "Australia/NSW"), + ("Australia/North", "Australia/North"), + ("Australia/Perth", "Australia/Perth"), + ("Australia/Queensland", "Australia/Queensland"), + ("Australia/South", "Australia/South"), + ("Australia/Sydney", "Australia/Sydney"), + ("Australia/Tasmania", "Australia/Tasmania"), + ("Australia/Victoria", "Australia/Victoria"), + ("Australia/West", "Australia/West"), + ("Australia/Yancowinna", "Australia/Yancowinna"), + ("Brazil/Acre", "Brazil/Acre"), + ("Brazil/DeNoronha", "Brazil/DeNoronha"), + ("Brazil/East", "Brazil/East"), + ("Brazil/West", "Brazil/West"), + ("CET", "CET"), + ("CST6CDT", "CST6CDT"), + ("Canada/Atlantic", "Canada/Atlantic"), + ("Canada/Central", "Canada/Central"), + ("Canada/Eastern", "Canada/Eastern"), + ("Canada/Mountain", "Canada/Mountain"), + ("Canada/Newfoundland", "Canada/Newfoundland"), + ("Canada/Pacific", "Canada/Pacific"), + ("Canada/Saskatchewan", "Canada/Saskatchewan"), + ("Canada/Yukon", "Canada/Yukon"), + ("Chile/Continental", "Chile/Continental"), + ("Chile/EasterIsland", "Chile/EasterIsland"), + ("Cuba", "Cuba"), + ("EET", "EET"), + ("EST", "EST"), + ("EST5EDT", "EST5EDT"), + ("Egypt", "Egypt"), + ("Eire", "Eire"), + ("Etc/GMT", "Etc/GMT"), + ("Etc/GMT+0", "Etc/GMT+0"), + ("Etc/GMT+1", "Etc/GMT+1"), + ("Etc/GMT+10", "Etc/GMT+10"), + ("Etc/GMT+11", "Etc/GMT+11"), + ("Etc/GMT+12", "Etc/GMT+12"), + ("Etc/GMT+2", "Etc/GMT+2"), + ("Etc/GMT+3", "Etc/GMT+3"), + ("Etc/GMT+4", "Etc/GMT+4"), + ("Etc/GMT+5", "Etc/GMT+5"), + ("Etc/GMT+6", "Etc/GMT+6"), + ("Etc/GMT+7", "Etc/GMT+7"), + ("Etc/GMT+8", "Etc/GMT+8"), + ("Etc/GMT+9", "Etc/GMT+9"), + ("Etc/GMT-0", "Etc/GMT-0"), + ("Etc/GMT-1", "Etc/GMT-1"), + ("Etc/GMT-10", "Etc/GMT-10"), + ("Etc/GMT-11", "Etc/GMT-11"), + ("Etc/GMT-12", "Etc/GMT-12"), + ("Etc/GMT-13", "Etc/GMT-13"), + ("Etc/GMT-14", "Etc/GMT-14"), + ("Etc/GMT-2", "Etc/GMT-2"), + ("Etc/GMT-3", "Etc/GMT-3"), + ("Etc/GMT-4", "Etc/GMT-4"), + ("Etc/GMT-5", "Etc/GMT-5"), + ("Etc/GMT-6", "Etc/GMT-6"), + ("Etc/GMT-7", "Etc/GMT-7"), + ("Etc/GMT-8", "Etc/GMT-8"), + ("Etc/GMT-9", "Etc/GMT-9"), + ("Etc/GMT0", "Etc/GMT0"), + ("Etc/Greenwich", "Etc/Greenwich"), + ("Etc/UCT", "Etc/UCT"), + ("Etc/UTC", "Etc/UTC"), + ("Etc/Universal", "Etc/Universal"), + ("Etc/Zulu", "Etc/Zulu"), + ("Europe/Amsterdam", "Europe/Amsterdam"), + ("Europe/Andorra", "Europe/Andorra"), + ("Europe/Astrakhan", "Europe/Astrakhan"), + ("Europe/Athens", "Europe/Athens"), + ("Europe/Belfast", "Europe/Belfast"), + ("Europe/Belgrade", "Europe/Belgrade"), + ("Europe/Berlin", "Europe/Berlin"), + ("Europe/Bratislava", "Europe/Bratislava"), + ("Europe/Brussels", "Europe/Brussels"), + ("Europe/Bucharest", "Europe/Bucharest"), + ("Europe/Budapest", "Europe/Budapest"), + ("Europe/Busingen", "Europe/Busingen"), + ("Europe/Chisinau", "Europe/Chisinau"), + ("Europe/Copenhagen", "Europe/Copenhagen"), + ("Europe/Dublin", "Europe/Dublin"), + ("Europe/Gibraltar", "Europe/Gibraltar"), + ("Europe/Guernsey", "Europe/Guernsey"), + ("Europe/Helsinki", "Europe/Helsinki"), + ("Europe/Isle_of_Man", "Europe/Isle_of_Man"), + ("Europe/Istanbul", "Europe/Istanbul"), + ("Europe/Jersey", "Europe/Jersey"), + ("Europe/Kaliningrad", "Europe/Kaliningrad"), + ("Europe/Kiev", "Europe/Kiev"), + ("Europe/Kirov", "Europe/Kirov"), + ("Europe/Kyiv", "Europe/Kyiv"), + ("Europe/Lisbon", "Europe/Lisbon"), + ("Europe/Ljubljana", "Europe/Ljubljana"), + ("Europe/London", "Europe/London"), + ("Europe/Luxembourg", "Europe/Luxembourg"), + ("Europe/Madrid", "Europe/Madrid"), + ("Europe/Malta", "Europe/Malta"), + ("Europe/Mariehamn", "Europe/Mariehamn"), + ("Europe/Minsk", "Europe/Minsk"), + ("Europe/Monaco", "Europe/Monaco"), + ("Europe/Moscow", "Europe/Moscow"), + ("Europe/Nicosia", "Europe/Nicosia"), + ("Europe/Oslo", "Europe/Oslo"), + ("Europe/Paris", "Europe/Paris"), + ("Europe/Podgorica", "Europe/Podgorica"), + ("Europe/Prague", "Europe/Prague"), + ("Europe/Riga", "Europe/Riga"), + ("Europe/Rome", "Europe/Rome"), + ("Europe/Samara", "Europe/Samara"), + ("Europe/San_Marino", "Europe/San_Marino"), + ("Europe/Sarajevo", "Europe/Sarajevo"), + ("Europe/Saratov", "Europe/Saratov"), + ("Europe/Simferopol", "Europe/Simferopol"), + ("Europe/Skopje", "Europe/Skopje"), + ("Europe/Sofia", "Europe/Sofia"), + ("Europe/Stockholm", "Europe/Stockholm"), + ("Europe/Tallinn", "Europe/Tallinn"), + ("Europe/Tirane", "Europe/Tirane"), + ("Europe/Tiraspol", "Europe/Tiraspol"), + ("Europe/Ulyanovsk", "Europe/Ulyanovsk"), + ("Europe/Uzhgorod", "Europe/Uzhgorod"), + ("Europe/Vaduz", "Europe/Vaduz"), + ("Europe/Vatican", "Europe/Vatican"), + ("Europe/Vienna", "Europe/Vienna"), + ("Europe/Vilnius", "Europe/Vilnius"), + ("Europe/Volgograd", "Europe/Volgograd"), + ("Europe/Warsaw", "Europe/Warsaw"), + ("Europe/Zagreb", "Europe/Zagreb"), + ("Europe/Zaporozhye", "Europe/Zaporozhye"), + ("Europe/Zurich", "Europe/Zurich"), + ("GB", "GB"), + ("GB-Eire", "GB-Eire"), + ("GMT", "GMT"), + ("GMT+0", "GMT+0"), + ("GMT-0", "GMT-0"), + ("GMT0", "GMT0"), + ("Greenwich", "Greenwich"), + ("HST", "HST"), + ("Hongkong", "Hongkong"), + ("Iceland", "Iceland"), + ("Indian/Antananarivo", "Indian/Antananarivo"), + ("Indian/Chagos", "Indian/Chagos"), + ("Indian/Christmas", "Indian/Christmas"), + ("Indian/Cocos", "Indian/Cocos"), + ("Indian/Comoro", "Indian/Comoro"), + ("Indian/Kerguelen", "Indian/Kerguelen"), + ("Indian/Mahe", "Indian/Mahe"), + ("Indian/Maldives", "Indian/Maldives"), + ("Indian/Mauritius", "Indian/Mauritius"), + ("Indian/Mayotte", "Indian/Mayotte"), + ("Indian/Reunion", "Indian/Reunion"), + ("Iran", "Iran"), + ("Israel", "Israel"), + ("Jamaica", "Jamaica"), + ("Japan", "Japan"), + ("Kwajalein", "Kwajalein"), + ("Libya", "Libya"), + ("MET", "MET"), + ("MST", "MST"), + ("MST7MDT", "MST7MDT"), + ("Mexico/BajaNorte", "Mexico/BajaNorte"), + ("Mexico/BajaSur", "Mexico/BajaSur"), + ("Mexico/General", "Mexico/General"), + ("NZ", "NZ"), + ("NZ-CHAT", "NZ-CHAT"), + ("Navajo", "Navajo"), + ("PRC", "PRC"), + ("PST8PDT", "PST8PDT"), + ("Pacific/Apia", "Pacific/Apia"), + ("Pacific/Auckland", "Pacific/Auckland"), + ("Pacific/Bougainville", "Pacific/Bougainville"), + ("Pacific/Chatham", "Pacific/Chatham"), + ("Pacific/Chuuk", "Pacific/Chuuk"), + ("Pacific/Easter", "Pacific/Easter"), + ("Pacific/Efate", "Pacific/Efate"), + ("Pacific/Enderbury", "Pacific/Enderbury"), + ("Pacific/Fakaofo", "Pacific/Fakaofo"), + ("Pacific/Fiji", "Pacific/Fiji"), + ("Pacific/Funafuti", "Pacific/Funafuti"), + ("Pacific/Galapagos", "Pacific/Galapagos"), + ("Pacific/Gambier", "Pacific/Gambier"), + ("Pacific/Guadalcanal", "Pacific/Guadalcanal"), + ("Pacific/Guam", "Pacific/Guam"), + ("Pacific/Honolulu", "Pacific/Honolulu"), + ("Pacific/Johnston", "Pacific/Johnston"), + ("Pacific/Kanton", "Pacific/Kanton"), + ("Pacific/Kiritimati", "Pacific/Kiritimati"), + ("Pacific/Kosrae", "Pacific/Kosrae"), + ("Pacific/Kwajalein", "Pacific/Kwajalein"), + ("Pacific/Majuro", "Pacific/Majuro"), + ("Pacific/Marquesas", "Pacific/Marquesas"), + ("Pacific/Midway", "Pacific/Midway"), + ("Pacific/Nauru", "Pacific/Nauru"), + ("Pacific/Niue", "Pacific/Niue"), + ("Pacific/Norfolk", "Pacific/Norfolk"), + ("Pacific/Noumea", "Pacific/Noumea"), + ("Pacific/Pago_Pago", "Pacific/Pago_Pago"), + ("Pacific/Palau", "Pacific/Palau"), + ("Pacific/Pitcairn", "Pacific/Pitcairn"), + ("Pacific/Pohnpei", "Pacific/Pohnpei"), + ("Pacific/Ponape", "Pacific/Ponape"), + ("Pacific/Port_Moresby", "Pacific/Port_Moresby"), + ("Pacific/Rarotonga", "Pacific/Rarotonga"), + ("Pacific/Saipan", "Pacific/Saipan"), + ("Pacific/Samoa", "Pacific/Samoa"), + ("Pacific/Tahiti", "Pacific/Tahiti"), + ("Pacific/Tarawa", "Pacific/Tarawa"), + ("Pacific/Tongatapu", "Pacific/Tongatapu"), + ("Pacific/Truk", "Pacific/Truk"), + ("Pacific/Wake", "Pacific/Wake"), + ("Pacific/Wallis", "Pacific/Wallis"), + ("Pacific/Yap", "Pacific/Yap"), + ("Poland", "Poland"), + ("Portugal", "Portugal"), + ("ROC", "ROC"), + ("ROK", "ROK"), + ("Singapore", "Singapore"), + ("Turkey", "Turkey"), + ("UCT", "UCT"), + ("US/Alaska", "US/Alaska"), + ("US/Aleutian", "US/Aleutian"), + ("US/Arizona", "US/Arizona"), + ("US/Central", "US/Central"), + ("US/East-Indiana", "US/East-Indiana"), + ("US/Eastern", "US/Eastern"), + ("US/Hawaii", "US/Hawaii"), + ("US/Indiana-Starke", "US/Indiana-Starke"), + ("US/Michigan", "US/Michigan"), + ("US/Mountain", "US/Mountain"), + ("US/Pacific", "US/Pacific"), + ("US/Samoa", "US/Samoa"), + ("UTC", "UTC"), + ("Universal", "Universal"), + ("W-SU", "W-SU"), + ("WET", "WET"), + ("Zulu", "Zulu"), + ], + default="UTC", + max_length=255, + ), + ), + ] diff --git a/bookwyrm/migrations/0158_auto_20220919_1634.py b/bookwyrm/migrations/0158_auto_20220919_1634.py new file mode 100644 index 000000000..c7cce19fd --- /dev/null +++ b/bookwyrm/migrations/0158_auto_20220919_1634.py @@ -0,0 +1,65 @@ +# Generated by Django 3.2.15 on 2022-09-19 16:34 + +import bookwyrm.models.fields +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0157_auto_20220909_2338"), + ] + + operations = [ + migrations.AddField( + model_name="automod", + name="created_date", + field=models.DateTimeField( + auto_now_add=True, default=django.utils.timezone.now + ), + preserve_default=False, + ), + migrations.AddField( + model_name="automod", + name="remote_id", + field=bookwyrm.models.fields.RemoteIdField( + max_length=255, + null=True, + validators=[bookwyrm.models.fields.validate_remote_id], + ), + ), + migrations.AddField( + model_name="automod", + name="updated_date", + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name="emailblocklist", + name="remote_id", + field=bookwyrm.models.fields.RemoteIdField( + max_length=255, + null=True, + validators=[bookwyrm.models.fields.validate_remote_id], + ), + ), + migrations.AddField( + model_name="emailblocklist", + name="updated_date", + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name="ipblocklist", + name="remote_id", + field=bookwyrm.models.fields.RemoteIdField( + max_length=255, + null=True, + validators=[bookwyrm.models.fields.validate_remote_id], + ), + ), + migrations.AddField( + model_name="ipblocklist", + name="updated_date", + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/bookwyrm/migrations/0159_auto_20220924_0634.py b/bookwyrm/migrations/0159_auto_20220924_0634.py new file mode 100644 index 000000000..c223d9061 --- /dev/null +++ b/bookwyrm/migrations/0159_auto_20220924_0634.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.15 on 2022-09-24 06:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0158_auto_20220919_1634"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="hotp_count", + field=models.IntegerField(blank=True, default=0, null=True), + ), + migrations.AddField( + model_name="user", + name="hotp_secret", + field=models.CharField(blank=True, default=None, max_length=32, null=True), + ), + migrations.AddField( + model_name="user", + name="otp_secret", + field=models.CharField(blank=True, default=None, max_length=32, null=True), + ), + migrations.AddField( + model_name="user", + name="two_factor_auth", + field=models.BooleanField(blank=True, default=None, null=True), + ), + ] diff --git a/bookwyrm/migrations/0160_auto_20221101_2251.py b/bookwyrm/migrations/0160_auto_20221101_2251.py new file mode 100644 index 000000000..5c3c1d09e --- /dev/null +++ b/bookwyrm/migrations/0160_auto_20221101_2251.py @@ -0,0 +1,52 @@ +# Generated by Django 3.2.15 on 2022-11-01 22:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0159_auto_20220924_0634"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="allow_reactivation", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="connector", + name="deactivation_reason", + field=models.CharField( + blank=True, + choices=[ + ("pending", "Pending"), + ("self_deletion", "Self deletion"), + ("self_deactivation", "Self deactivation"), + ("moderator_suspension", "Moderator suspension"), + ("moderator_deletion", "Moderator deletion"), + ("domain_block", "Domain block"), + ], + max_length=255, + null=True, + ), + ), + migrations.AlterField( + model_name="user", + name="deactivation_reason", + field=models.CharField( + blank=True, + choices=[ + ("pending", "Pending"), + ("self_deletion", "Self deletion"), + ("self_deactivation", "Self deactivation"), + ("moderator_suspension", "Moderator suspension"), + ("moderator_deletion", "Moderator deletion"), + ("domain_block", "Domain block"), + ], + max_length=255, + null=True, + ), + ), + ] diff --git a/bookwyrm/migrations/0160_auto_20221105_2030.py b/bookwyrm/migrations/0160_auto_20221105_2030.py new file mode 100644 index 000000000..5bbedf55d --- /dev/null +++ b/bookwyrm/migrations/0160_auto_20221105_2030.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.15 on 2022-11-05 20:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0159_auto_20220924_0634"), + ] + + operations = [ + migrations.AddField( + model_name="importitem", + name="task_id", + field=models.CharField(blank=True, max_length=200, null=True), + ), + migrations.AddField( + model_name="importjob", + name="status", + field=models.CharField( + choices=[ + ("pending", "Pending"), + ("active", "Active"), + ("complete", "Complete"), + ("stopped", "Stopped"), + ], + max_length=50, + null=True, + ), + ), + ] diff --git a/bookwyrm/migrations/0161_alter_importjob_status.py b/bookwyrm/migrations/0161_alter_importjob_status.py new file mode 100644 index 000000000..44a1aea4c --- /dev/null +++ b/bookwyrm/migrations/0161_alter_importjob_status.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.15 on 2022-11-05 20:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0160_auto_20221105_2030"), + ] + + operations = [ + migrations.AlterField( + model_name="importjob", + name="status", + field=models.CharField( + choices=[ + ("pending", "Pending"), + ("active", "Active"), + ("complete", "Complete"), + ("stopped", "Stopped"), + ], + default="pending", + max_length=50, + null=True, + ), + ), + ] diff --git a/bookwyrm/migrations/0162_importjob_task_id.py b/bookwyrm/migrations/0162_importjob_task_id.py new file mode 100644 index 000000000..0bc7cc8de --- /dev/null +++ b/bookwyrm/migrations/0162_importjob_task_id.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.15 on 2022-11-05 22:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0161_alter_importjob_status"), + ] + + operations = [ + migrations.AddField( + model_name="importjob", + name="task_id", + field=models.CharField(blank=True, max_length=200, null=True), + ), + ] diff --git a/bookwyrm/migrations/0163_merge_0160_auto_20221101_2251_0162_importjob_task_id.py b/bookwyrm/migrations/0163_merge_0160_auto_20221101_2251_0162_importjob_task_id.py new file mode 100644 index 000000000..a76f19b00 --- /dev/null +++ b/bookwyrm/migrations/0163_merge_0160_auto_20221101_2251_0162_importjob_task_id.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.15 on 2022-11-10 20:34 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0160_auto_20221101_2251"), + ("bookwyrm", "0162_importjob_task_id"), + ] + + operations = [] diff --git a/bookwyrm/migrations/0164_status_ready.py b/bookwyrm/migrations/0164_status_ready.py new file mode 100644 index 000000000..fd8d49972 --- /dev/null +++ b/bookwyrm/migrations/0164_status_ready.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.16 on 2022-11-15 21:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0163_merge_0160_auto_20221101_2251_0162_importjob_task_id"), + ] + + operations = [ + migrations.AddField( + model_name="status", + name="ready", + field=models.BooleanField(default=True), + ), + ] diff --git a/bookwyrm/migrations/0165_alter_inviterequest_answer.py b/bookwyrm/migrations/0165_alter_inviterequest_answer.py new file mode 100644 index 000000000..2d2cc5e4d --- /dev/null +++ b/bookwyrm/migrations/0165_alter_inviterequest_answer.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.16 on 2022-11-15 22:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0164_status_ready"), + ] + + operations = [ + migrations.AlterField( + model_name="inviterequest", + name="answer", + field=models.TextField(blank=True, max_length=255, null=True), + ), + ] diff --git a/bookwyrm/migrations/0166_sitesettings_imports_enabled.py b/bookwyrm/migrations/0166_sitesettings_imports_enabled.py new file mode 100644 index 000000000..ccf4ef374 --- /dev/null +++ b/bookwyrm/migrations/0166_sitesettings_imports_enabled.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.16 on 2022-11-17 21:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0165_alter_inviterequest_answer"), + ] + + operations = [ + migrations.AddField( + model_name="sitesettings", + name="imports_enabled", + field=models.BooleanField(default=True), + ), + ] diff --git a/bookwyrm/models/__init__.py b/bookwyrm/models/__init__.py index a8a84f095..ae7000162 100644 --- a/bookwyrm/models/__init__.py +++ b/bookwyrm/models/__init__.py @@ -17,7 +17,8 @@ from .attachment import Image from .favorite import Favorite from .readthrough import ReadThrough, ProgressUpdate, ProgressMode -from .user import User, KeyPair, AnnualGoal +from .user import User, KeyPair +from .annual_goal import AnnualGoal from .relationship import UserFollows, UserFollowRequest, UserBlocks from .report import Report, ReportComment from .federated_server import FederatedServer diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py index 402cb040b..a9c6328fb 100644 --- a/bookwyrm/models/activitypub_mixin.py +++ b/bookwyrm/models/activitypub_mixin.py @@ -1,14 +1,15 @@ """ activitypub model functionality """ +import asyncio from base64 import b64encode from collections import namedtuple from functools import reduce import json import operator import logging +from typing import List from uuid import uuid4 -import requests -from requests.exceptions import RequestException +import aiohttp from Crypto.PublicKey import RSA from Crypto.Signature import pkcs1_15 from Crypto.Hash import SHA256 @@ -136,7 +137,7 @@ class ActivitypubMixin: queue=queue, ) - def get_recipients(self, software=None): + def get_recipients(self, software=None) -> List[str]: """figure out which inbox urls to post to""" # first we have to figure out who should receive this activity privacy = self.privacy if hasattr(self, "privacy") else "public" @@ -506,19 +507,31 @@ def unfurl_related_field(related_field, sort_field=None): @app.task(queue=MEDIUM) -def broadcast_task(sender_id, activity, recipients): +def broadcast_task(sender_id: int, activity: str, recipients: List[str]): """the celery task for broadcast""" user_model = apps.get_model("bookwyrm.User", require_ready=True) - sender = user_model.objects.get(id=sender_id) - for recipient in recipients: - try: - sign_and_send(sender, activity, recipient) - except RequestException: - pass + sender = user_model.objects.select_related("key_pair").get(id=sender_id) + asyncio.run(async_broadcast(recipients, sender, activity)) -def sign_and_send(sender, data, destination): - """crpyto whatever and http junk""" +async def async_broadcast(recipients: List[str], sender, data: str): + """Send all the broadcasts simultaneously""" + timeout = aiohttp.ClientTimeout(total=10) + async with aiohttp.ClientSession(timeout=timeout) as session: + tasks = [] + for recipient in recipients: + tasks.append( + asyncio.ensure_future(sign_and_send(session, sender, data, recipient)) + ) + + results = await asyncio.gather(*tasks) + return results + + +async def sign_and_send( + session: aiohttp.ClientSession, sender, data: str, destination: str +): + """Sign the messages and send them in an asynchronous bundle""" now = http_date() if not sender.key_pair.private_key: @@ -527,20 +540,25 @@ def sign_and_send(sender, data, destination): digest = make_digest(data) - response = requests.post( - destination, - data=data, - headers={ - "Date": now, - "Digest": digest, - "Signature": make_signature(sender, destination, now, digest), - "Content-Type": "application/activity+json; charset=utf-8", - "User-Agent": USER_AGENT, - }, - ) - if not response.ok: - response.raise_for_status() - return response + headers = { + "Date": now, + "Digest": digest, + "Signature": make_signature(sender, destination, now, digest), + "Content-Type": "application/activity+json; charset=utf-8", + "User-Agent": USER_AGENT, + } + + try: + async with session.post(destination, data=data, headers=headers) as response: + if not response.ok: + logger.exception( + "Failed to send broadcast to %s: %s", destination, response.reason + ) + return response + except asyncio.TimeoutError: + logger.info("Connection timed out for url: %s", destination) + except aiohttp.ClientError as err: + logger.exception(err) # pylint: disable=unused-argument diff --git a/bookwyrm/models/annual_goal.py b/bookwyrm/models/annual_goal.py new file mode 100644 index 000000000..53c041141 --- /dev/null +++ b/bookwyrm/models/annual_goal.py @@ -0,0 +1,67 @@ +""" How many books do you want to read this year """ +from django.core.validators import MinValueValidator +from django.db import models +from django.utils import timezone + +from bookwyrm.models.status import Review +from .base_model import BookWyrmModel +from . import fields, Review + + +def get_current_year(): + """sets default year for annual goal to this year""" + return timezone.now().year + + +class AnnualGoal(BookWyrmModel): + """set a goal for how many books you read in a year""" + + user = models.ForeignKey("User", on_delete=models.PROTECT) + goal = models.IntegerField(validators=[MinValueValidator(1)]) + year = models.IntegerField(default=get_current_year) + privacy = models.CharField( + max_length=255, default="public", choices=fields.PrivacyLevels + ) + + class Meta: + """unqiueness constraint""" + + unique_together = ("user", "year") + + def get_remote_id(self): + """put the year in the path""" + return f"{self.user.remote_id}/goal/{self.year}" + + @property + def books(self): + """the books you've read this year""" + return ( + self.user.readthrough_set.filter( + finish_date__year__gte=self.year, + finish_date__year__lt=self.year + 1, + ) + .order_by("-finish_date") + .all() + ) + + @property + def ratings(self): + """ratings for books read this year""" + book_ids = [r.book.id for r in self.books] + reviews = Review.objects.filter( + user=self.user, + book__in=book_ids, + ) + return {r.book.id: r.rating for r in reviews} + + @property + def progress(self): + """how many books you've read this year""" + count = self.user.readthrough_set.filter( + finish_date__year__gte=self.year, + finish_date__year__lt=self.year + 1, + ).count() + return { + "count": count, + "percent": int(float(count / self.goal) * 100), + } diff --git a/bookwyrm/models/antispam.py b/bookwyrm/models/antispam.py index f506b6f19..1e20df340 100644 --- a/bookwyrm/models/antispam.py +++ b/bookwyrm/models/antispam.py @@ -3,18 +3,33 @@ from functools import reduce import operator from django.apps import apps -from django.db import models +from django.core.exceptions import PermissionDenied +from django.db import models, transaction from django.db.models import Q from django.utils.translation import gettext_lazy as _ -from bookwyrm.tasks import app +from bookwyrm.tasks import app, LOW +from .base_model import BookWyrmModel from .user import User -class EmailBlocklist(models.Model): +class AdminModel(BookWyrmModel): + """Overrides the permissions methods""" + + class Meta: + """this is just here to provide default fields for other models""" + + abstract = True + + def raise_not_editable(self, viewer): + if viewer.has_perm("bookwyrm.moderate_user"): + return + raise PermissionDenied() + + +class EmailBlocklist(AdminModel): """blocked email addresses""" - created_date = models.DateTimeField(auto_now_add=True) domain = models.CharField(max_length=255, unique=True) is_active = models.BooleanField(default=True) @@ -29,10 +44,9 @@ class EmailBlocklist(models.Model): return User.objects.filter(email__endswith=f"@{self.domain}") -class IPBlocklist(models.Model): +class IPBlocklist(AdminModel): """blocked ip addresses""" - created_date = models.DateTimeField(auto_now_add=True) address = models.CharField(max_length=255, unique=True) is_active = models.BooleanField(default=True) @@ -42,7 +56,7 @@ class IPBlocklist(models.Model): ordering = ("-created_date",) -class AutoMod(models.Model): +class AutoMod(AdminModel): """rules to automatically flag suspicious activity""" string_match = models.CharField(max_length=200, unique=True) @@ -51,32 +65,24 @@ class AutoMod(models.Model): created_by = models.ForeignKey("User", on_delete=models.PROTECT) -@app.task(queue="low_priority") +@app.task(queue=LOW) def automod_task(): """Create reports""" if not AutoMod.objects.exists(): return reporter = AutoMod.objects.first().created_by reports = automod_users(reporter) + automod_statuses(reporter) - if reports: - admins = User.objects.filter( - models.Q(user_permissions__name__in=["moderate_user", "moderate_post"]) - | models.Q(is_superuser=True) - ).all() - notification_model = apps.get_model( - "bookwyrm", "Notification", require_ready=True - ) + if not reports: + return + + admins = User.admins() + notification_model = apps.get_model("bookwyrm", "Notification", require_ready=True) + with transaction.atomic(): for admin in admins: - notification_model.objects.bulk_create( - [ - notification_model( - user=admin, - related_report=r, - notification_type="REPORT", - ) - for r in reports - ] + notification, _ = notification_model.objects.get_or_create( + user=admin, notification_type=notification_model.REPORT, read=False ) + notification.related_reports.set(reports) def automod_users(reporter): diff --git a/bookwyrm/models/author.py b/bookwyrm/models/author.py index 78d153a21..7d2a0e62b 100644 --- a/bookwyrm/models/author.py +++ b/bookwyrm/models/author.py @@ -42,6 +42,11 @@ class Author(BookDataModel): for book in self.book_set.values_list("id", flat=True) ] cache.delete_many(cache_keys) + + # normalize isni format + if self.isni: + self.isni = re.sub(r"\s", "", self.isni) + return super().save(*args, **kwargs) @property diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py index eeb2e940d..2d39e2a6f 100644 --- a/bookwyrm/models/base_model.py +++ b/bookwyrm/models/base_model.py @@ -17,6 +17,7 @@ from .fields import RemoteIdField DeactivationReason = [ ("pending", _("Pending")), ("self_deletion", _("Self deletion")), + ("self_deactivation", _("Self deactivation")), ("moderator_suspension", _("Moderator suspension")), ("moderator_deletion", _("Moderator deletion")), ("domain_block", _("Domain block")), @@ -132,7 +133,7 @@ class BookWyrmModel(models.Model): return # but generally moderators can delete other people's stuff - if self.user == viewer or viewer.has_perm("moderate_post"): + if self.user == viewer or viewer.has_perm("bookwyrm.moderate_post"): return raise PermissionDenied() diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index 190046019..5bef5c1ee 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -241,6 +241,10 @@ class Work(OrderedCollectionPageMixin, Book): """in case the default edition is not set""" return self.editions.order_by("-edition_rank").first() + def author_edition(self, author): + """in case the default edition doesn't have the required author""" + return self.editions.filter(authors=author).order_by("-edition_rank").first() + def to_edition_list(self, **kwargs): """an ordered collection of editions""" return self.to_ordered_collection( diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index 62c61cc40..785f3397c 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -16,7 +16,7 @@ from django.utils.encoding import filepath_to_uri from bookwyrm import activitypub from bookwyrm.connectors import get_image -from bookwyrm.sanitize_html import InputHtmlParser +from bookwyrm.utils.sanitizer import clean from bookwyrm.settings import MEDIA_FULL_URL @@ -497,9 +497,7 @@ class HtmlField(ActivitypubFieldMixin, models.TextField): def field_from_activity(self, value): if not value or value == MISSING: return None - sanitizer = InputHtmlParser() - sanitizer.feed(value) - return sanitizer.get_output() + return clean(value) class ArrayField(ActivitypubFieldMixin, DjangoArrayField): diff --git a/bookwyrm/models/group.py b/bookwyrm/models/group.py index 05ed39a27..003b23d02 100644 --- a/bookwyrm/models/group.py +++ b/bookwyrm/models/group.py @@ -140,16 +140,6 @@ class GroupMemberInvitation(models.Model): # make an invitation super().save(*args, **kwargs) - # now send the invite - model = apps.get_model("bookwyrm.Notification", require_ready=True) - notification_type = "INVITE" - model.objects.create( - user=self.user, - related_user=self.group.user, - related_group=self.group, - notification_type=notification_type, - ) - @transaction.atomic def accept(self): """turn this request into the real deal""" @@ -157,25 +147,24 @@ class GroupMemberInvitation(models.Model): model = apps.get_model("bookwyrm.Notification", require_ready=True) # tell the group owner - model.objects.create( - user=self.group.user, - related_user=self.user, + model.notify( + self.group.user, + self.user, related_group=self.group, - notification_type="ACCEPT", + notification_type=model.ACCEPT, ) # let the other members know about it for membership in self.group.memberships.all(): member = membership.user if member not in (self.user, self.group.user): - model.objects.create( - user=member, - related_user=self.user, + model.notify( + member, + self.user, related_group=self.group, - notification_type="JOIN", + notification_type=model.JOIN, ) def reject(self): """generate a Reject for this membership request""" - self.delete() diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py index 556f133f9..d8cfad314 100644 --- a/bookwyrm/models/import_job.py +++ b/bookwyrm/models/import_job.py @@ -1,12 +1,25 @@ """ track progress of goodreads imports """ +import math import re import dateutil.parser from django.db import models from django.utils import timezone +from django.utils.translation import gettext_lazy as _ from bookwyrm.connectors import connector_manager -from bookwyrm.models import ReadThrough, User, Book, Edition +from bookwyrm.models import ( + User, + Book, + Edition, + Work, + ShelfBook, + Shelf, + ReadThrough, + Review, + ReviewRating, +) +from bookwyrm.tasks import app, LOW from .fields import PrivacyLevels @@ -30,6 +43,14 @@ def construct_search_term(title, author): return " ".join([title, author]) +ImportStatuses = [ + ("pending", _("Pending")), + ("active", _("Active")), + ("complete", _("Complete")), + ("stopped", _("Stopped")), +] + + class ImportJob(models.Model): """entry for a specific request for book data import""" @@ -38,16 +59,78 @@ class ImportJob(models.Model): updated_date = models.DateTimeField(default=timezone.now) include_reviews = models.BooleanField(default=True) mappings = models.JSONField() - complete = models.BooleanField(default=False) source = models.CharField(max_length=100) privacy = models.CharField(max_length=255, default="public", choices=PrivacyLevels) retry = models.BooleanField(default=False) + task_id = models.CharField(max_length=200, null=True, blank=True) + + complete = models.BooleanField(default=False) + status = models.CharField( + max_length=50, choices=ImportStatuses, default="pending", null=True + ) + + def start_job(self): + """Report that the job has started""" + task = start_import_task.delay(self.id) + self.task_id = task.id + + self.status = "active" + self.save(update_fields=["status", "task_id"]) + + def complete_job(self): + """Report that the job has completed""" + self.status = "complete" + self.complete = True + self.pending_items.update(fail_reason=_("Import stopped")) + self.save(update_fields=["status", "complete"]) + + def stop_job(self): + """Stop the job""" + self.status = "stopped" + self.complete = True + self.save(update_fields=["status", "complete"]) + self.pending_items.update(fail_reason=_("Import stopped")) + + # stop starting + app.control.revoke(self.task_id, terminate=True) + tasks = self.pending_items.filter(task_id__isnull=False).values_list( + "task_id", flat=True + ) + app.control.revoke(list(tasks)) @property def pending_items(self): """items that haven't been processed yet""" return self.items.filter(fail_reason__isnull=True, book__isnull=True) + @property + def item_count(self): + """How many books do you want to import???""" + return self.items.count() + + @property + def percent_complete(self): + """How far along?""" + item_count = self.item_count + if not item_count: + return 0 + return math.floor((item_count - self.pending_item_count) / item_count * 100) + + @property + def pending_item_count(self): + """And how many pending items??""" + return self.pending_items.count() + + @property + def successful_item_count(self): + """How many found a book?""" + return self.items.filter(book__isnull=False).count() + + @property + def failed_item_count(self): + """How many found a book?""" + return self.items.filter(fail_reason__isnull=False).count() + class ImportItem(models.Model): """a single line of a csv being imported""" @@ -68,15 +151,18 @@ class ImportItem(models.Model): linked_review = models.ForeignKey( "Review", on_delete=models.SET_NULL, null=True, blank=True ) + task_id = models.CharField(max_length=200, null=True, blank=True) def update_job(self): """let the job know when the items get work done""" job = self.job + if job.complete: + return + job.updated_date = timezone.now() job.save() if not job.pending_items.exists() and not job.complete: - job.complete = True - job.save(update_fields=["complete"]) + job.complete_job() def resolve(self): """try various ways to lookup a book""" @@ -240,3 +326,136 @@ class ImportItem(models.Model): return "{} by {}".format( self.normalized_data.get("title"), self.normalized_data.get("authors") ) + + +@app.task(queue=LOW) +def start_import_task(job_id): + """trigger the child tasks for each row""" + job = ImportJob.objects.get(id=job_id) + # don't start the job if it was stopped from the UI + if job.complete: + return + + # these are sub-tasks so that one big task doesn't use up all the memory in celery + for item in job.items.all(): + task = import_item_task.delay(item.id) + item.task_id = task.id + item.save() + job.status = "active" + job.save() + + +@app.task(queue=LOW) +def import_item_task(item_id): + """resolve a row into a book""" + item = ImportItem.objects.get(id=item_id) + # make sure the job has not been stopped + if item.job.complete: + return + + try: + item.resolve() + except Exception as err: # pylint: disable=broad-except + item.fail_reason = _("Error loading book") + item.save() + item.update_job() + raise err + + if item.book: + # shelves book and handles reviews + handle_imported_book(item) + else: + item.fail_reason = _("Could not find a match for book") + + item.save() + item.update_job() + + +def handle_imported_book(item): + """process a csv and then post about it""" + job = item.job + if job.complete: + return + + user = job.user + if isinstance(item.book, Work): + item.book = item.book.default_edition + if not item.book: + item.fail_reason = _("Error loading book") + item.save() + return + if not isinstance(item.book, Edition): + item.book = item.book.edition + + existing_shelf = ShelfBook.objects.filter(book=item.book, user=user).exists() + + # shelve the book if it hasn't been shelved already + if item.shelf and not existing_shelf: + desired_shelf = Shelf.objects.get(identifier=item.shelf, user=user) + shelved_date = item.date_added or timezone.now() + ShelfBook( + book=item.book, shelf=desired_shelf, user=user, shelved_date=shelved_date + ).save(priority=LOW) + + for read in item.reads: + # check for an existing readthrough with the same dates + if ReadThrough.objects.filter( + user=user, + book=item.book, + start_date=read.start_date, + finish_date=read.finish_date, + ).exists(): + continue + read.book = item.book + read.user = user + read.save() + + if job.include_reviews and (item.rating or item.review) and not item.linked_review: + # we don't know the publication date of the review, + # but "now" is a bad guess + published_date_guess = item.date_read or item.date_added + if item.review: + # pylint: disable=consider-using-f-string + review_title = "Review of {!r} on {!r}".format( + item.book.title, + job.source, + ) + review = Review.objects.filter( + user=user, + book=item.book, + name=review_title, + rating=item.rating, + published_date=published_date_guess, + ).first() + if not review: + review = Review( + user=user, + book=item.book, + name=review_title, + content=item.review, + rating=item.rating, + published_date=published_date_guess, + privacy=job.privacy, + ) + review.save(software="bookwyrm", priority=LOW) + else: + # just a rating + review = ReviewRating.objects.filter( + user=user, + book=item.book, + published_date=published_date_guess, + rating=item.rating, + ).first() + if not review: + review = ReviewRating( + user=user, + book=item.book, + rating=item.rating, + published_date=published_date_guess, + privacy=job.privacy, + ) + review.save(software="bookwyrm", priority=LOW) + + # only broadcast this review to other bookwyrm instances + item.linked_review = review + item.save() diff --git a/bookwyrm/models/link.py b/bookwyrm/models/link.py index 0e4148ddd..56b096bc2 100644 --- a/bookwyrm/models/link.py +++ b/bookwyrm/models/link.py @@ -84,7 +84,7 @@ class LinkDomain(BookWyrmModel): ) def raise_not_editable(self, viewer): - if viewer.has_perm("moderate_post"): + if viewer.has_perm("bookwyrm.moderate_post"): return raise PermissionDenied() diff --git a/bookwyrm/models/list.py b/bookwyrm/models/list.py index 0195020e0..63dd5b23f 100644 --- a/bookwyrm/models/list.py +++ b/bookwyrm/models/list.py @@ -1,7 +1,6 @@ """ make a list of books!! """ import uuid -from django.apps import apps from django.core.exceptions import PermissionDenied from django.db import models from django.db.models import Q @@ -151,34 +150,12 @@ class ListItem(CollectionItemMixin, BookWyrmModel): collection_field = "book_list" def save(self, *args, **kwargs): - """create a notification too""" - created = not bool(self.id) + """Update the list's date""" super().save(*args, **kwargs) # tick the updated date on the parent list self.book_list.updated_date = timezone.now() self.book_list.save(broadcast=False, update_fields=["updated_date"]) - list_owner = self.book_list.user - model = apps.get_model("bookwyrm.Notification", require_ready=True) - # create a notification if somoene ELSE added to a local user's list - if created and list_owner.local and list_owner != self.user: - model.objects.create( - user=list_owner, - related_user=self.user, - related_list_item=self, - notification_type="ADD", - ) - - if self.book_list.group: - for membership in self.book_list.group.memberships.all(): - if membership.user != self.user: - model.objects.create( - user=membership.user, - related_user=self.user, - related_list_item=self, - notification_type="ADD", - ) - def raise_not_deletable(self, viewer): """the associated user OR the list owner can delete""" if self.book_list.user == viewer: diff --git a/bookwyrm/models/notification.py b/bookwyrm/models/notification.py index 417bf7591..fa2ce54e2 100644 --- a/bookwyrm/models/notification.py +++ b/bookwyrm/models/notification.py @@ -1,77 +1,125 @@ """ alert a user to activity """ -from django.db import models +from django.db import models, transaction from django.dispatch import receiver from .base_model import BookWyrmModel -from . import Boost, Favorite, ImportJob, Report, Status, User - -# pylint: disable=line-too-long -NotificationType = models.TextChoices( - "NotificationType", - "FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD REPORT INVITE ACCEPT JOIN LEAVE REMOVE GROUP_PRIVACY GROUP_NAME GROUP_DESCRIPTION", -) +from . import Boost, Favorite, GroupMemberInvitation, ImportJob, ListItem, Report +from . import Status, User, UserFollowRequest class Notification(BookWyrmModel): """you've been tagged, liked, followed, etc""" + # Status interactions + FAVORITE = "FAVORITE" + BOOST = "BOOST" + REPLY = "REPLY" + MENTION = "MENTION" + TAG = "TAG" + + # Relationships + FOLLOW = "FOLLOW" + FOLLOW_REQUEST = "FOLLOW_REQUEST" + + # Imports + IMPORT = "IMPORT" + + # List activity + ADD = "ADD" + + # Admin + REPORT = "REPORT" + + # Groups + INVITE = "INVITE" + ACCEPT = "ACCEPT" + JOIN = "JOIN" + LEAVE = "LEAVE" + REMOVE = "REMOVE" + GROUP_PRIVACY = "GROUP_PRIVACY" + GROUP_NAME = "GROUP_NAME" + GROUP_DESCRIPTION = "GROUP_DESCRIPTION" + + # pylint: disable=line-too-long + NotificationType = models.TextChoices( + # there has got be a better way to do this + "NotificationType", + f"{FAVORITE} {REPLY} {MENTION} {TAG} {FOLLOW} {FOLLOW_REQUEST} {BOOST} {IMPORT} {ADD} {REPORT} {INVITE} {ACCEPT} {JOIN} {LEAVE} {REMOVE} {GROUP_PRIVACY} {GROUP_NAME} {GROUP_DESCRIPTION}", + ) + user = models.ForeignKey("User", on_delete=models.CASCADE) - related_book = models.ForeignKey("Edition", on_delete=models.CASCADE, null=True) - related_user = models.ForeignKey( - "User", on_delete=models.CASCADE, null=True, related_name="related_user" + read = models.BooleanField(default=False) + notification_type = models.CharField( + max_length=255, choices=NotificationType.choices + ) + + related_users = models.ManyToManyField( + "User", symmetrical=False, related_name="notifications" ) related_group = models.ForeignKey( "Group", on_delete=models.CASCADE, null=True, related_name="notifications" ) related_status = models.ForeignKey("Status", on_delete=models.CASCADE, null=True) related_import = models.ForeignKey("ImportJob", on_delete=models.CASCADE, null=True) - related_list_item = models.ForeignKey( - "ListItem", on_delete=models.CASCADE, null=True - ) - related_report = models.ForeignKey("Report", on_delete=models.CASCADE, null=True) - read = models.BooleanField(default=False) - notification_type = models.CharField( - max_length=255, choices=NotificationType.choices + related_list_items = models.ManyToManyField( + "ListItem", symmetrical=False, related_name="notifications" ) + related_reports = models.ManyToManyField("Report", symmetrical=False) - def save(self, *args, **kwargs): - """save, but don't make dupes""" - # there's probably a better way to do this - if self.__class__.objects.filter( - user=self.user, - related_book=self.related_book, - related_user=self.related_user, - related_group=self.related_group, - related_status=self.related_status, - related_import=self.related_import, - related_list_item=self.related_list_item, - related_report=self.related_report, - notification_type=self.notification_type, - ).exists(): + @classmethod + @transaction.atomic + def notify(cls, user, related_user, **kwargs): + """Create a notification""" + if related_user and (not user.local or user == related_user): return - super().save(*args, **kwargs) + notification = cls.objects.filter(user=user, **kwargs).first() + if not notification: + notification = cls.objects.create(user=user, **kwargs) + if related_user: + notification.related_users.add(related_user) + notification.read = False + notification.save() - class Meta: - """checks if notifcation is in enum list for valid types""" - - constraints = [ - models.CheckConstraint( - check=models.Q(notification_type__in=NotificationType.values), - name="notification_type_valid", + @classmethod + @transaction.atomic + def notify_list_item(cls, user, list_item): + """Group the notifications around the list items, not the user""" + related_user = list_item.user + notification = cls.objects.filter( + user=user, + related_users=related_user, + related_list_items__book_list=list_item.book_list, + notification_type=Notification.ADD, + ).first() + if not notification: + notification = cls.objects.create( + user=user, notification_type=Notification.ADD ) - ] + notification.related_users.add(related_user) + notification.related_list_items.add(list_item) + notification.read = False + notification.save() + + @classmethod + def unnotify(cls, user, related_user, **kwargs): + """Remove a user from a notification and delete it if that was the only user""" + try: + notification = cls.objects.filter(user=user, **kwargs).get() + except Notification.DoesNotExist: + return + notification.related_users.remove(related_user) + if not notification.related_users.count(): + notification.delete() @receiver(models.signals.post_save, sender=Favorite) # pylint: disable=unused-argument def notify_on_fav(sender, instance, *args, **kwargs): """someone liked your content, you ARE loved""" - if not instance.status.user.local or instance.status.user == instance.user: - return - Notification.objects.create( - user=instance.status.user, - notification_type="FAVORITE", - related_user=instance.user, + Notification.notify( + instance.status.user, + instance.user, related_status=instance.status, + notification_type=Notification.FAVORITE, ) @@ -81,15 +129,16 @@ def notify_on_unfav(sender, instance, *args, **kwargs): """oops, didn't like that after all""" if not instance.status.user.local: return - Notification.objects.filter( - user=instance.status.user, - related_user=instance.user, + Notification.unnotify( + instance.status.user, + instance.user, related_status=instance.status, - notification_type="FAVORITE", - ).delete() + notification_type=Notification.FAVORITE, + ) @receiver(models.signals.post_save) +@transaction.atomic # pylint: disable=unused-argument def notify_user_on_mention(sender, instance, *args, **kwargs): """creating and deleting statuses with @ mentions and replies""" @@ -105,22 +154,23 @@ def notify_user_on_mention(sender, instance, *args, **kwargs): and instance.reply_parent.user != instance.user and instance.reply_parent.user.local ): - Notification.objects.create( - user=instance.reply_parent.user, - notification_type="REPLY", - related_user=instance.user, + Notification.notify( + instance.reply_parent.user, + instance.user, related_status=instance, + notification_type=Notification.REPLY, ) + for mention_user in instance.mention_users.all(): # avoid double-notifying about this status if not mention_user.local or ( instance.reply_parent and mention_user == instance.reply_parent.user ): continue - Notification.objects.create( - user=mention_user, - notification_type="MENTION", - related_user=instance.user, + Notification.notify( + mention_user, + instance.user, + notification_type=Notification.MENTION, related_status=instance, ) @@ -135,11 +185,11 @@ def notify_user_on_boost(sender, instance, *args, **kwargs): ): return - Notification.objects.create( - user=instance.boosted_status.user, + Notification.notify( + instance.boosted_status.user, + instance.user, related_status=instance.boosted_status, - related_user=instance.user, - notification_type="BOOST", + notification_type=Notification.BOOST, ) @@ -147,12 +197,12 @@ def notify_user_on_boost(sender, instance, *args, **kwargs): # pylint: disable=unused-argument def notify_user_on_unboost(sender, instance, *args, **kwargs): """unboosting a status""" - Notification.objects.filter( - user=instance.boosted_status.user, + Notification.unnotify( + instance.boosted_status.user, + instance.user, related_status=instance.boosted_status, - related_user=instance.user, - notification_type="BOOST", - ).delete() + notification_type=Notification.BOOST, + ) @receiver(models.signals.post_save, sender=ImportJob) @@ -164,25 +214,93 @@ def notify_user_on_import_complete( update_fields = update_fields or [] if not instance.complete or "complete" not in update_fields: return - Notification.objects.create( + Notification.objects.get_or_create( user=instance.user, - notification_type="IMPORT", + notification_type=Notification.IMPORT, related_import=instance, ) @receiver(models.signals.post_save, sender=Report) +@transaction.atomic # pylint: disable=unused-argument -def notify_admins_on_report(sender, instance, *args, **kwargs): +def notify_admins_on_report(sender, instance, created, *args, **kwargs): """something is up, make sure the admins know""" + if not created: + # otherwise you'll get a notification when you resolve a report + return + # moderators and superusers should be notified - admins = User.objects.filter( - models.Q(user_permissions__name__in=["moderate_user", "moderate_post"]) - | models.Q(is_superuser=True) - ).all() + admins = User.admins() for admin in admins: - Notification.objects.create( + notification, _ = Notification.objects.get_or_create( user=admin, - related_report=instance, - notification_type="REPORT", + notification_type=Notification.REPORT, + read=False, + ) + notification.related_reports.add(instance) + + +@receiver(models.signals.post_save, sender=GroupMemberInvitation) +# pylint: disable=unused-argument +def notify_user_on_group_invite(sender, instance, *args, **kwargs): + """Cool kids club here we come""" + Notification.notify( + instance.user, + instance.group.user, + related_group=instance.group, + notification_type=Notification.INVITE, + ) + + +@receiver(models.signals.post_save, sender=ListItem) +@transaction.atomic +# pylint: disable=unused-argument +def notify_user_on_list_item_add(sender, instance, created, *args, **kwargs): + """Someone added to your list""" + if not created: + return + + list_owner = instance.book_list.user + # create a notification if somoene ELSE added to a local user's list + if list_owner.local and list_owner != instance.user: + # keep the related_user singular, group the items + Notification.notify_list_item(list_owner, instance) + + if instance.book_list.group: + for membership in instance.book_list.group.memberships.all(): + if membership.user != instance.user: + Notification.notify_list_item(membership.user, instance) + + +@receiver(models.signals.post_save, sender=UserFollowRequest) +@transaction.atomic +# pylint: disable=unused-argument +def notify_user_on_follow(sender, instance, created, *args, **kwargs): + """Someone added to your list""" + if not created or not instance.user_object.local: + return + + manually_approves = instance.user_object.manually_approves_followers + if manually_approves: + # don't group notifications + notification = Notification.objects.filter( + user=instance.user_object, + related_users=instance.user_subject, + notification_type=Notification.FOLLOW_REQUEST, + ).first() + if not notification: + notification = Notification.objects.create( + user=instance.user_object, notification_type=Notification.FOLLOW_REQUEST + ) + notification.related_users.set([instance.user_subject]) + notification.read = False + notification.save() + else: + # Only group unread follows + Notification.notify( + instance.user_object, + instance.user_subject, + notification_type=Notification.FOLLOW, + read=False, ) diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py index 313200514..082294c0e 100644 --- a/bookwyrm/models/relationship.py +++ b/bookwyrm/models/relationship.py @@ -1,5 +1,4 @@ """ defines relationships between users """ -from django.apps import apps from django.core.cache import cache from django.db import models, transaction, IntegrityError from django.db.models import Q @@ -148,14 +147,6 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship): if not manually_approves: self.accept() - model = apps.get_model("bookwyrm.Notification", require_ready=True) - notification_type = "FOLLOW_REQUEST" if manually_approves else "FOLLOW" - model.objects.create( - user=self.user_object, - related_user=self.user_subject, - notification_type=notification_type, - ) - def get_accept_reject_id(self, status): """get id for sending an accept or reject of a local user""" diff --git a/bookwyrm/models/report.py b/bookwyrm/models/report.py index bf3184f52..f6e665053 100644 --- a/bookwyrm/models/report.py +++ b/bookwyrm/models/report.py @@ -1,5 +1,7 @@ """ flagged for moderation """ +from django.core.exceptions import PermissionDenied from django.db import models + from bookwyrm.settings import DOMAIN from .base_model import BookWyrmModel @@ -11,7 +13,7 @@ class Report(BookWyrmModel): "User", related_name="reporter", on_delete=models.PROTECT ) note = models.TextField(null=True, blank=True) - user = models.ForeignKey("User", on_delete=models.PROTECT) + user = models.ForeignKey("User", on_delete=models.PROTECT, null=True, blank=True) status = models.ForeignKey( "Status", null=True, @@ -21,6 +23,12 @@ class Report(BookWyrmModel): links = models.ManyToManyField("Link", blank=True) resolved = models.BooleanField(default=False) + def raise_not_editable(self, viewer): + """instead of user being the owner field, it's reporter""" + if self.reporter == viewer or viewer.has_perm("bookwyrm.moderate_user"): + return + raise PermissionDenied() + def get_remote_id(self): return f"https://{DOMAIN}/settings/reports/{self.id}" diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py index 3291d5653..d955e8d07 100644 --- a/bookwyrm/models/shelf.py +++ b/bookwyrm/models/shelf.py @@ -103,12 +103,25 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel): if not self.user: self.user = self.shelf.user if self.id and self.user.local: - cache.delete(f"book-on-shelf-{self.book.id}-{self.shelf.id}") + # remove all caches related to all editions of this book + cache.delete_many( + [ + f"book-on-shelf-{book.id}-{self.shelf.id}" + for book in self.book.parent_work.editions.all() + ] + ) super().save(*args, **kwargs) def delete(self, *args, **kwargs): if self.id and self.user.local: - cache.delete(f"book-on-shelf-{self.book.id}-{self.shelf.id}") + cache.delete_many( + [ + f"book-on-shelf-{book}-{self.shelf.id}" + for book in self.book.parent_work.editions.values_list( + "id", flat=True + ) + ] + ) super().delete(*args, **kwargs) class Meta: diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py index 7730391f1..9e97ede9a 100644 --- a/bookwyrm/models/site.py +++ b/bookwyrm/models/site.py @@ -3,6 +3,7 @@ import datetime from urllib.parse import urljoin import uuid +from django.core.exceptions import PermissionDenied from django.db import models, IntegrityError from django.dispatch import receiver from django.utils import timezone @@ -15,7 +16,23 @@ from .user import User from .fields import get_absolute_url -class SiteSettings(models.Model): +class SiteModel(models.Model): + """we just need edit perms""" + + class Meta: + """this is just here to provide default fields for other models""" + + abstract = True + + # pylint: disable=no-self-use + def raise_not_editable(self, viewer): + """Check if the user has the right permissions""" + if viewer.has_perm("bookwyrm.edit_instance_settings"): + return + raise PermissionDenied() + + +class SiteSettings(SiteModel): """customized settings for this instance""" name = models.CharField(default="BookWyrm", max_length=100) @@ -69,6 +86,9 @@ class SiteSettings(models.Model): admin_email = models.EmailField(max_length=255, null=True, blank=True) footer_item = models.TextField(null=True, blank=True) + # controls + imports_enabled = models.BooleanField(default=True) + field_tracker = FieldTracker(fields=["name", "instance_tagline", "logo"]) @classmethod @@ -115,7 +135,7 @@ class SiteSettings(models.Model): super().save(*args, **kwargs) -class Theme(models.Model): +class Theme(SiteModel): """Theme files""" created_date = models.DateTimeField(auto_now_add=True) @@ -138,6 +158,13 @@ class SiteInvite(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) invitees = models.ManyToManyField(User, related_name="invitees") + # pylint: disable=no-self-use + def raise_not_editable(self, viewer): + """Admins only""" + if viewer.has_perm("bookwyrm.create_invites"): + return + raise PermissionDenied() + def valid(self): """make sure it hasn't expired or been used""" return (self.expiry is None or self.expiry > timezone.now()) and ( @@ -157,10 +184,16 @@ class InviteRequest(BookWyrmModel): invite = models.ForeignKey( SiteInvite, on_delete=models.SET_NULL, null=True, blank=True ) - answer = models.TextField(max_length=50, unique=False, null=True, blank=True) + answer = models.TextField(max_length=255, unique=False, null=True, blank=True) invite_sent = models.BooleanField(default=False) ignored = models.BooleanField(default=False) + def raise_not_editable(self, viewer): + """Only check perms on edit, not create""" + if not self.id or viewer.has_perm("bookwyrm.create_invites"): + return + raise PermissionDenied() + def save(self, *args, **kwargs): """don't create a request for a registered email""" if not self.id and User.objects.filter(email=self.email).exists(): diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index 3949e09a9..19eab584d 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -63,6 +63,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): activitypub_field="inReplyTo", ) thread_id = models.IntegerField(blank=True, null=True) + # statuses get saved a few times, this indicates if they're set + ready = models.BooleanField(default=True) + objects = InheritanceManager() activity_serializer = activitypub.Note @@ -83,8 +86,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): if not self.reply_parent: self.thread_id = self.id - - super().save(broadcast=False, update_fields=["thread_id"]) + super().save(broadcast=False, update_fields=["thread_id"]) def delete(self, *args, **kwargs): # pylint: disable=unused-argument """ "delete" a status""" @@ -218,7 +220,8 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): """certain types of status aren't editable""" # first, the standard raise super().raise_not_editable(viewer) - if isinstance(self, (GeneratedNote, ReviewRating)): + # if it's an edit (not a create) you can only edit content statuses + if self.id and isinstance(self, (GeneratedNote, ReviewRating)): raise PermissionDenied() @classmethod @@ -362,7 +365,7 @@ class Review(BookStatus): default=None, null=True, blank=True, - validators=[MinValueValidator(1), MaxValueValidator(5)], + validators=[MinValueValidator(0.5), MaxValueValidator(5)], decimal_places=2, max_digits=3, ) @@ -398,7 +401,7 @@ class ReviewRating(Review): def save(self, *args, **kwargs): if not self.rating: raise ValueError("ReviewRating object must include a numerical rating") - return super().save(*args, **kwargs) + super().save(*args, **kwargs) @property def pure_content(self): diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index dce74022c..e48d86572 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -5,7 +5,7 @@ from urllib.parse import urlparse from django.apps import apps from django.contrib.auth.models import AbstractUser, Group from django.contrib.postgres.fields import ArrayField, CICharField -from django.core.validators import MinValueValidator +from django.core.exceptions import PermissionDenied from django.dispatch import receiver from django.db import models, transaction from django.utils import timezone @@ -16,16 +16,16 @@ 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.models.status import Status from bookwyrm.preview_images import generate_user_preview_image_task from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, USE_HTTPS, LANGUAGES from bookwyrm.signatures import create_key_pair -from bookwyrm.tasks import app +from bookwyrm.tasks import app, LOW from bookwyrm.utils import regex from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin from .base_model import BookWyrmModel, DeactivationReason, new_access_code from .federated_server import FederatedServer -from . import fields, Review +from . import fields FeedFilterChoices = [ @@ -47,6 +47,7 @@ def site_link(): return f"{protocol}://{DOMAIN}" +# pylint: disable=too-many-public-methods class User(OrderedCollectionPageMixin, AbstractUser): """a user who wants to read books""" @@ -143,6 +144,7 @@ class User(OrderedCollectionPageMixin, AbstractUser): show_goal = models.BooleanField(default=True) show_suggested_users = models.BooleanField(default=True) discoverable = fields.BooleanField(default=False) + show_guided_tour = models.BooleanField(default=True) # feed options feed_status_types = ArrayField( @@ -168,12 +170,24 @@ class User(OrderedCollectionPageMixin, AbstractUser): max_length=255, choices=DeactivationReason, null=True, blank=True ) deactivation_date = models.DateTimeField(null=True, blank=True) + allow_reactivation = models.BooleanField(default=False) confirmation_code = models.CharField(max_length=32, default=new_access_code) name_field = "username" property_fields = [("following_link", "following")] field_tracker = FieldTracker(fields=["name", "avatar"]) + # two factor authentication + two_factor_auth = models.BooleanField(default=None, blank=True, null=True) + otp_secret = models.CharField(max_length=32, default=None, blank=True, null=True) + hotp_secret = models.CharField(max_length=32, default=None, blank=True, null=True) + hotp_count = models.IntegerField(default=0, blank=True, null=True) + + @property + def active_follower_requests(self): + """Follow requests from active users""" + return self.follower_requests.filter(is_active=True) + @property def confirmation_link(self): """helper for generating confirmation links""" @@ -226,6 +240,15 @@ class User(OrderedCollectionPageMixin, AbstractUser): queryset = queryset.exclude(blocks=viewer) return queryset + @classmethod + def admins(cls): + """Get a queryset of the admins for this instance""" + return cls.objects.filter( + models.Q(groups__name__in=["moderator", "admin"]) + | models.Q(is_superuser=True), + is_active=True, + ).distinct() + def update_active_date(self): """this user is here! they are doing things!""" self.last_active_date = timezone.now() @@ -347,12 +370,28 @@ class User(OrderedCollectionPageMixin, AbstractUser): self.create_shelves() def delete(self, *args, **kwargs): - """deactivate rather than delete a user""" + """We don't actually delete the database entry""" # pylint: disable=attribute-defined-outside-init self.is_active = False # skip the logic in this class's save() super().save(*args, **kwargs) + def deactivate(self): + """Disable the user but allow them to reactivate""" + # pylint: disable=attribute-defined-outside-init + self.is_active = False + self.deactivation_reason = "self_deactivation" + self.allow_reactivation = True + super().save(broadcast=False) + + def reactivate(self): + """Now you want to come back, huh?""" + # pylint: disable=attribute-defined-outside-init + self.is_active = True + self.deactivation_reason = None + self.allow_reactivation = False + super().save(broadcast=False) + @property def local_path(self): """this model doesn't inherit bookwyrm model, so here we are""" @@ -388,6 +427,12 @@ class User(OrderedCollectionPageMixin, AbstractUser): editable=False, ).save(broadcast=False) + def raise_not_editable(self, viewer): + """Who can edit the user object?""" + if self == viewer or viewer.has_perm("bookwyrm.moderate_user"): + return + raise PermissionDenied() + class KeyPair(ActivitypubMixin, BookWyrmModel): """public and private keys for a user""" @@ -414,66 +459,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel): return super().save(*args, **kwargs) -def get_current_year(): - """sets default year for annual goal to this year""" - return timezone.now().year - - -class AnnualGoal(BookWyrmModel): - """set a goal for how many books you read in a year""" - - user = models.ForeignKey("User", on_delete=models.PROTECT) - goal = models.IntegerField(validators=[MinValueValidator(1)]) - year = models.IntegerField(default=get_current_year) - privacy = models.CharField( - max_length=255, default="public", choices=fields.PrivacyLevels - ) - - class Meta: - """unqiueness constraint""" - - unique_together = ("user", "year") - - def get_remote_id(self): - """put the year in the path""" - return f"{self.user.remote_id}/goal/{self.year}" - - @property - def books(self): - """the books you've read this year""" - return ( - self.user.readthrough_set.filter( - finish_date__year__gte=self.year, - finish_date__year__lt=self.year + 1, - ) - .order_by("-finish_date") - .all() - ) - - @property - def ratings(self): - """ratings for books read this year""" - book_ids = [r.book.id for r in self.books] - reviews = Review.objects.filter( - user=self.user, - book__in=book_ids, - ) - return {r.book.id: r.rating for r in reviews} - - @property - def progress(self): - """how many books you've read this year""" - count = self.user.readthrough_set.filter( - finish_date__year__gte=self.year, - finish_date__year__lt=self.year + 1, - ).count() - return { - "count": count, - "percent": int(float(count / self.goal) * 100), - } - - -@app.task(queue="low_priority") +@app.task(queue=LOW) def set_remote_server(user_id): """figure out the user's remote server in the background""" user = User.objects.get(id=user_id) @@ -517,7 +503,7 @@ def get_or_create_remote_server(domain, refresh=False): return server -@app.task(queue="low_priority") +@app.task(queue=LOW) def get_remote_reviews(outbox): """ingest reviews by a new remote bookwyrm user""" outbox_page = outbox + "?page=true&type=Review" diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py index 891c8b6da..d20145cd3 100644 --- a/bookwyrm/preview_images.py +++ b/bookwyrm/preview_images.py @@ -16,7 +16,7 @@ from django.core.files.storage import default_storage from django.db.models import Avg from bookwyrm import models, settings -from bookwyrm.tasks import app +from bookwyrm.tasks import app, LOW logger = logging.getLogger(__name__) @@ -401,7 +401,7 @@ def save_and_cleanup(image, instance=None): # pylint: disable=invalid-name -@app.task(queue="low_priority") +@app.task(queue=LOW) def generate_site_preview_image_task(): """generate preview_image for the website""" if not settings.ENABLE_PREVIEW_IMAGES: @@ -426,7 +426,7 @@ def generate_site_preview_image_task(): # pylint: disable=invalid-name -@app.task(queue="low_priority") +@app.task(queue=LOW) def generate_edition_preview_image_task(book_id): """generate preview_image for a book""" if not settings.ENABLE_PREVIEW_IMAGES: @@ -451,7 +451,7 @@ def generate_edition_preview_image_task(book_id): save_and_cleanup(image, instance=book) -@app.task(queue="low_priority") +@app.task(queue=LOW) def generate_user_preview_image_task(user_id): """generate preview_image for a book""" if not settings.ENABLE_PREVIEW_IMAGES: diff --git a/bookwyrm/sanitize_html.py b/bookwyrm/sanitize_html.py deleted file mode 100644 index 4edd2818e..000000000 --- a/bookwyrm/sanitize_html.py +++ /dev/null @@ -1,71 +0,0 @@ -""" html parser to clean up incoming text from unknown sources """ -from html.parser import HTMLParser - - -class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method - """Removes any html that isn't allowed_tagsed from a block""" - - def __init__(self): - HTMLParser.__init__(self) - self.allowed_tags = [ - "p", - "blockquote", - "br", - "b", - "i", - "strong", - "em", - "pre", - "a", - "span", - "ul", - "ol", - "li", - ] - self.allowed_attrs = ["href", "rel", "src", "alt"] - self.tag_stack = [] - self.output = [] - # if the html appears invalid, we just won't allow any at all - self.allow_html = True - - def handle_starttag(self, tag, attrs): - """check if the tag is valid""" - if self.allow_html and tag in self.allowed_tags: - allowed_attrs = " ".join( - f'{a}="{v}"' for a, v in attrs if a in self.allowed_attrs - ) - reconstructed = f"<{tag}" - if allowed_attrs: - reconstructed += " " + allowed_attrs - reconstructed += ">" - self.output.append(("tag", reconstructed)) - self.tag_stack.append(tag) - else: - self.output.append(("data", "")) - - def handle_endtag(self, tag): - """keep the close tag""" - if not self.allow_html or tag not in self.allowed_tags: - self.output.append(("data", "")) - return - - if not self.tag_stack or self.tag_stack[-1] != tag: - # the end tag doesn't match the most recent start tag - self.allow_html = False - self.output.append(("data", "")) - return - - self.tag_stack = self.tag_stack[:-1] - self.output.append(("tag", f"")) - - def handle_data(self, data): - """extract the answer, if we're in an answer tag""" - self.output.append(("data", data)) - - def get_output(self): - """convert the output from a list of tuples to a string""" - if self.tag_stack: - self.allow_html = False - if not self.allow_html: - return "".join(v for (k, v) in self.output if k == "data") - return "".join(v for (k, v) in self.output) diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index dc0d71f30..0fcc00590 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _ env = Env() env.read_env() DOMAIN = env("DOMAIN") -VERSION = "0.4.0" +VERSION = "0.5.1" RELEASE_API = env( "RELEASE_API", @@ -21,7 +21,7 @@ RELEASE_API = env( PAGE_LENGTH = env("PAGE_LENGTH", 15) DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English") -JS_CACHE = "e678183b" +JS_CACHE = "e678183c" # email EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend") @@ -147,6 +147,9 @@ LOGGING = { "require_debug_true": { "()": "django.utils.log.RequireDebugTrue", }, + "ignore_missing_variable": { + "()": "bookwyrm.utils.log.IgnoreVariableDoesNotExist", + }, }, "handlers": { # Overrides the default handler to make it log to console @@ -154,6 +157,7 @@ LOGGING = { # console if DEBUG=False) "console": { "level": LOG_LEVEL, + "filters": ["ignore_missing_variable"], "class": "logging.StreamHandler", }, # This is copied as-is from the default logger, and is @@ -280,6 +284,7 @@ AUTH_PASSWORD_VALIDATORS = [ LANGUAGE_CODE = env("LANGUAGE_CODE", "en-us") LANGUAGES = [ ("en-us", _("English")), + ("ca-es", _("Català (Catalan)")), ("de-de", _("Deutsch (German)")), ("es-es", _("Español (Spanish)")), ("gl-es", _("Galego (Galician)")), @@ -288,6 +293,7 @@ LANGUAGES = [ ("fr-fr", _("Français (French)")), ("lt-lt", _("Lietuvių (Lithuanian)")), ("no-no", _("Norsk (Norwegian)")), + ("pl-pl", _("Polski (Polish)")), ("pt-br", _("Português do Brasil (Brazilian Portuguese)")), ("pt-pt", _("Português Europeu (European Portuguese)")), ("ro-ro", _("Română (Romanian)")), @@ -356,3 +362,5 @@ else: OTEL_EXPORTER_OTLP_ENDPOINT = env("OTEL_EXPORTER_OTLP_ENDPOINT", None) OTEL_EXPORTER_OTLP_HEADERS = env("OTEL_EXPORTER_OTLP_HEADERS", None) OTEL_SERVICE_NAME = env("OTEL_SERVICE_NAME", None) + +TWO_FACTOR_LOGIN_MAX_SECONDS = 60 diff --git a/bookwyrm/static/css/bookwyrm/components/_book_list.scss b/bookwyrm/static/css/bookwyrm/components/_book_list.scss index 0b1093489..3377de6b3 100644 --- a/bookwyrm/static/css/bookwyrm/components/_book_list.scss +++ b/bookwyrm/static/css/bookwyrm/components/_book_list.scss @@ -6,11 +6,11 @@ ol.ordered-list { counter-reset: list-counter; } -ol.ordered-list li { +ol.ordered-list > li { counter-increment: list-counter; } -ol.ordered-list li::before { +ol.ordered-list > li::before { content: counter(list-counter); position: absolute; left: -20px; diff --git a/bookwyrm/static/css/bookwyrm/components/_details.scss b/bookwyrm/static/css/bookwyrm/components/_details.scss index c9a0b33b8..de29629c8 100644 --- a/bookwyrm/static/css/bookwyrm/components/_details.scss +++ b/bookwyrm/static/css/bookwyrm/components/_details.scss @@ -67,7 +67,7 @@ details.dropdown .dropdown-menu a:focus-visible { align-items: center; justify-content: center; pointer-events: none; - z-index: 100; + z-index: 35; } details .dropdown-menu > * { diff --git a/bookwyrm/static/css/themes/bookwyrm-dark.scss b/bookwyrm/static/css/themes/bookwyrm-dark.scss index 88ee865bb..b98422688 100644 --- a/bookwyrm/static/css/themes/bookwyrm-dark.scss +++ b/bookwyrm/static/css/themes/bookwyrm-dark.scss @@ -92,5 +92,10 @@ $family-secondary: $family-sans-serif; color: $grey-light !important; } +#qrcode svg { + background-color: #a6a6a6; +} + @import "../bookwyrm.scss"; @import "../vendor/icons.css"; +@import "../vendor/shepherd.scss"; diff --git a/bookwyrm/static/css/themes/bookwyrm-light.scss b/bookwyrm/static/css/themes/bookwyrm-light.scss index 75f05164b..efb13c23e 100644 --- a/bookwyrm/static/css/themes/bookwyrm-light.scss +++ b/bookwyrm/static/css/themes/bookwyrm-light.scss @@ -67,3 +67,4 @@ $family-secondary: $family-sans-serif; @import "../bookwyrm.scss"; @import "../vendor/icons.css"; +@import "../vendor/shepherd.scss"; diff --git a/bookwyrm/static/css/vendor/shepherd.scss b/bookwyrm/static/css/vendor/shepherd.scss new file mode 100644 index 000000000..f8d39b782 --- /dev/null +++ b/bookwyrm/static/css/vendor/shepherd.scss @@ -0,0 +1,48 @@ +/* + Shepherd styles for guided tour. + Based on Shepherd v 10.0.0 styles. +*/ + +@use 'bulma/bulma.sass'; + +.shepherd-button { + @extend .button.mr-2; +} + +.shepherd-button.shepherd-button-secondary { + @extend .button.is-light; +} + +.shepherd-footer { + @extend .message-body; + @extend .is-info.is-light; + border-color: $info-light; + border-radius: 0 0 4px 4px; +} + +.shepherd-cancel-icon{background:transparent;border:none;color:hsla(0,0%,50%,.75);cursor:pointer;font-size:2em;font-weight:400;margin:0;padding:0;transition:color .5s ease}.shepherd-cancel-icon:hover{color:rgba(0,0,0,.75)}.shepherd-has-title .shepherd-content .shepherd-cancel-icon{color:hsla(0,0%,50%,.75)}.shepherd-has-title .shepherd-content .shepherd-cancel-icon:hover{color:rgba(0,0,0,.75)} + +.shepherd-header { + @extend .message-header; + @extend .is-info; +} + +.shepherd-text { + @extend .message-body; + @extend .is-info.is-light; + border-radius: 0; +} + +.shepherd-content { + @extend .message; +} + +.shepherd-element{background:$info-light;border-radius:5px;box-shadow:4px 4px 6px rgba(0,0,0,.2);max-width:400px;opacity:0;outline:none;transition:opacity .3s,visibility .3s;visibility:hidden;width:100%;z-index:9999}.shepherd-enabled.shepherd-element{opacity:1;visibility:visible}.shepherd-element[data-popper-reference-hidden]:not(.shepherd-centered){opacity:0;pointer-events:none;visibility:hidden}.shepherd-element,.shepherd-element *,.shepherd-element :after,.shepherd-element :before{box-sizing:border-box}.shepherd-arrow,.shepherd-arrow:before{height:16px;position:absolute;width:16px;z-index:-1}.shepherd-arrow:before{background:$info-light;box-shadow:0 2px 4px rgba(0,0,0,.2);content:"";transform:rotate(45deg)}.shepherd-element[data-popper-placement^=top]>.shepherd-arrow{bottom:-8px}.shepherd-element[data-popper-placement^=bottom]>.shepherd-arrow{top:-8px}.shepherd-element[data-popper-placement^=left]>.shepherd-arrow{right:-8px}.shepherd-element[data-popper-placement^=right]>.shepherd-arrow{left:-8px}.shepherd-element.shepherd-centered>.shepherd-arrow{opacity:0}.shepherd-element.shepherd-has-title[data-popper-placement^=bottom]>.shepherd-arrow:before{background-color:$info}.shepherd-target-click-disabled.shepherd-enabled.shepherd-target,.shepherd-target-click-disabled.shepherd-enabled.shepherd-target *{pointer-events:none} + +.shepherd-modal-overlay-container{height:0;left:0;opacity:0;overflow:hidden;pointer-events:none;position:fixed;top:0;transition:all .3s ease-out,height 0ms .3s,opacity .3s 0ms;width:100vw;z-index:9997}.shepherd-modal-overlay-container.shepherd-modal-is-visible{height:100vh;opacity:.5;transform:translateZ(0);transition:all .3s ease-out,height 0s 0s,opacity .3s 0s}.shepherd-modal-overlay-container.shepherd-modal-is-visible path{pointer-events:all} + +.tour-element-highlight { + border: 5px solid $info; + border-radius: 5px; + box-shadow:4px 4px 6px rgba(0,0,0,.2); +} diff --git a/bookwyrm/static/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js index 95271795d..aa06a8b0a 100644 --- a/bookwyrm/static/js/bookwyrm.js +++ b/bookwyrm/static/js/bookwyrm.js @@ -38,11 +38,12 @@ let BookWyrm = new (class { .querySelectorAll("[data-modal-open]") .forEach((node) => node.addEventListener("click", this.handleModalButton.bind(this))); - document - .querySelectorAll("details.dropdown") - .forEach((node) => - node.addEventListener("toggle", this.handleDetailsDropdown.bind(this)) + document.querySelectorAll("details.dropdown").forEach((node) => { + node.addEventListener("toggle", this.handleDetailsDropdown.bind(this)); + node.querySelectorAll("[data-modal-open]").forEach((modal_node) => + modal_node.addEventListener("click", () => (node.open = false)) ); + }); document .querySelector("#barcode-scanner-modal") diff --git a/bookwyrm/static/js/guided_tour.js b/bookwyrm/static/js/guided_tour.js new file mode 100644 index 000000000..56cdcdf57 --- /dev/null +++ b/bookwyrm/static/js/guided_tour.js @@ -0,0 +1,18 @@ +/** + * Set guided tour user value to False + * @param {csrf_token} string + * @return {undefined} + */ + +/* eslint-disable no-unused-vars */ +function disableGuidedTour(csrf_token) { + "use strict"; + fetch("/guided-tour/False", { + headers: { + "X-CSRFToken": csrf_token, + }, + method: "POST", + redirect: "follow", + mode: "same-origin", + }); +} diff --git a/bookwyrm/static/js/vendor/shepherd.min.js b/bookwyrm/static/js/vendor/shepherd.min.js new file mode 100644 index 000000000..eea679596 --- /dev/null +++ b/bookwyrm/static/js/vendor/shepherd.min.js @@ -0,0 +1,120 @@ +/*! shepherd.js 10.0.0 */ + +'use strict';(function(O,pa){"object"===typeof exports&&"undefined"!==typeof module?module.exports=pa():"function"===typeof define&&define.amd?define(pa):(O="undefined"!==typeof globalThis?globalThis:O||self,O.Shepherd=pa())})(this,function(){function O(a,b){return!1!==b.clone&&b.isMergeableObject(a)?ea(Array.isArray(a)?[]:{},a,b):a}function pa(a,b,c){return a.concat(b).map(function(d){return O(d,c)})}function Cb(a){return Object.getOwnPropertySymbols?Object.getOwnPropertySymbols(a).filter(function(b){return a.propertyIsEnumerable(b)}): +[]}function Sa(a){return Object.keys(a).concat(Cb(a))}function Ta(a,b){try{return b in a}catch(c){return!1}}function Db(a,b,c){var d={};c.isMergeableObject(a)&&Sa(a).forEach(function(e){d[e]=O(a[e],c)});Sa(b).forEach(function(e){if(!Ta(a,e)||Object.hasOwnProperty.call(a,e)&&Object.propertyIsEnumerable.call(a,e))if(Ta(a,e)&&c.isMergeableObject(b[e])){if(c.customMerge){var f=c.customMerge(e);f="function"===typeof f?f:ea}else f=ea;d[e]=f(a[e],b[e],c)}else d[e]=O(b[e],c)});return d}function ea(a,b,c){c= +c||{};c.arrayMerge=c.arrayMerge||pa;c.isMergeableObject=c.isMergeableObject||Eb;c.cloneUnlessOtherwiseSpecified=O;var d=Array.isArray(b),e=Array.isArray(a);return d!==e?O(b,c):d?c.arrayMerge(a,b,c):Db(a,b,c)}function Z(a){return"function"===typeof a}function qa(a){return"string"===typeof a}function Ua(a){let b=Object.getOwnPropertyNames(a.constructor.prototype);for(let c=0;c +{if(b.isOpen()){let d=b.el&&c.currentTarget===b.el;(void 0!==a&&c.currentTarget.matches(a)||d)&&b.tour.next()}}}function Gb(a){let {event:b,selector:c}=a.options.advanceOn||{};if(b){let d=Fb(c,a),e;try{e=document.querySelector(c)}catch(f){}if(void 0===c||e)e?(e.addEventListener(b,d),a.on("destroy",()=>e.removeEventListener(b,d))):(document.body.addEventListener(b,d,!0),a.on("destroy",()=>document.body.removeEventListener(b,d,!0)));else return console.error(`No element was found for the selector supplied to advanceOn: ${c}`)}else return console.error("advanceOn was defined, but no event name was passed.")} +function M(a){return a?(a.nodeName||"").toLowerCase():null}function K(a){return null==a?window:"[object Window]"!==a.toString()?(a=a.ownerDocument)?a.defaultView||window:window:a}function fa(a){var b=K(a).Element;return a instanceof b||a instanceof Element}function F(a){var b=K(a).HTMLElement;return a instanceof b||a instanceof HTMLElement}function Ea(a){if("undefined"===typeof ShadowRoot)return!1;var b=K(a).ShadowRoot;return a instanceof b||a instanceof ShadowRoot}function N(a){return a.split("-")[0]} +function ha(a,b){void 0===b&&(b=!1);var c=a.getBoundingClientRect(),d=1,e=1;F(a)&&b&&(b=a.offsetHeight,a=a.offsetWidth,0=Math.abs(b.width-c)&&(c=b.width);1>=Math.abs(b.height-d)&&(d=b.height);return{x:a.offsetLeft,y:a.offsetTop,width:c,height:d}}function Va(a,b){var c= +b.getRootNode&&b.getRootNode();if(a.contains(b))return!0;if(c&&Ea(c)){do{if(b&&a.isSameNode(b))return!0;b=b.parentNode||b.host}while(b)}return!1}function P(a){return K(a).getComputedStyle(a)}function U(a){return((fa(a)?a.ownerDocument:a.document)||window.document).documentElement}function wa(a){return"html"===M(a)?a:a.assignedSlot||a.parentNode||(Ea(a)?a.host:null)||U(a)}function Wa(a){return F(a)&&"fixed"!==P(a).position?a.offsetParent:null}function ra(a){for(var b=K(a),c=Wa(a);c&&0<=["table","td", +"th"].indexOf(M(c))&&"static"===P(c).position;)c=Wa(c);if(c&&("html"===M(c)||"body"===M(c)&&"static"===P(c).position))return b;if(!c)a:{c=-1!==navigator.userAgent.toLowerCase().indexOf("firefox");if(-1===navigator.userAgent.indexOf("Trident")||!F(a)||"fixed"!==P(a).position)for(a=wa(a),Ea(a)&&(a=a.host);F(a)&&0>["html","body"].indexOf(M(a));){var d=P(a);if("none"!==d.transform||"none"!==d.perspective||"paint"===d.contain||-1!==["transform","perspective"].indexOf(d.willChange)||c&&"filter"===d.willChange|| +c&&d.filter&&"none"!==d.filter){c=a;break a}else a=a.parentNode}c=null}return c||b}function Ga(a){return 0<=["top","bottom"].indexOf(a)?"x":"y"}function Xa(a){return Object.assign({},{top:0,right:0,bottom:0,left:0},a)}function Ya(a,b){return b.reduce(function(c,d){c[d]=a;return c},{})}function ja(a){return a.split("-")[1]}function Za(a){var b,c=a.popper,d=a.popperRect,e=a.placement,f=a.variation,g=a.offsets,l=a.position,m=a.gpuAcceleration,k=a.adaptive,p=a.roundOffsets,q=a.isFixed;a=g.x;a=void 0=== +a?0:a;var n=g.y,r=void 0===n?0:n;n="function"===typeof p?p({x:a,y:r}):{x:a,y:r};a=n.x;r=n.y;n=g.hasOwnProperty("x");g=g.hasOwnProperty("y");var x="left",h="top",t=window;if(k){var v=ra(c),A="clientHeight",u="clientWidth";v===K(c)&&(v=U(c),"static"!==P(v).position&&"absolute"===l&&(A="scrollHeight",u="scrollWidth"));if("top"===e||("left"===e||"right"===e)&&"end"===f)h="bottom",r-=(q&&v===t&&t.visualViewport?t.visualViewport.height:v[A])-d.height,r*=m?1:-1;if("left"===e||("top"===e||"bottom"===e)&& +"end"===f)x="right",a-=(q&&v===t&&t.visualViewport?t.visualViewport.width:v[u])-d.width,a*=m?1:-1}c=Object.assign({position:l},k&&Hb);!0===p?(p=r,d=window.devicePixelRatio||1,a={x:ia(a*d)/d||0,y:ia(p*d)/d||0}):a={x:a,y:r};p=a;a=p.x;r=p.y;if(m){var w;return Object.assign({},c,(w={},w[h]=g?"0":"",w[x]=n?"0":"",w.transform=1>=(t.devicePixelRatio||1)?"translate("+a+"px, "+r+"px)":"translate3d("+a+"px, "+r+"px, 0)",w))}return Object.assign({},c,(b={},b[h]=g?r+"px":"",b[x]=n?a+"px":"",b.transform="",b))} +function xa(a){return a.replace(/left|right|bottom|top/g,function(b){return Ib[b]})}function $a(a){return a.replace(/start|end/g,function(b){return Jb[b]})}function Ha(a){a=K(a);return{scrollLeft:a.pageXOffset,scrollTop:a.pageYOffset}}function Ia(a){return ha(U(a)).left+Ha(a).scrollLeft}function Ja(a){a=P(a);return/auto|scroll|overlay|hidden/.test(a.overflow+a.overflowY+a.overflowX)}function ab(a){return 0<=["html","body","#document"].indexOf(M(a))?a.ownerDocument.body:F(a)&&Ja(a)?a:ab(wa(a))}function sa(a, +b){var c;void 0===b&&(b=[]);var d=ab(a);a=d===(null==(c=a.ownerDocument)?void 0:c.body);c=K(d);d=a?[c].concat(c.visualViewport||[],Ja(d)?d:[]):d;b=b.concat(d);return a?b:b.concat(sa(wa(d)))}function Ka(a){return Object.assign({},a,{left:a.x,top:a.y,right:a.x+a.width,bottom:a.y+a.height})}function bb(a,b){if("viewport"===b){b=K(a);var c=U(a);b=b.visualViewport;var d=c.clientWidth;c=c.clientHeight;var e=0,f=0;b&&(d=b.width,c=b.height,/^((?!chrome|android).)*safari/i.test(navigator.userAgent)||(e=b.offsetLeft, +f=b.offsetTop));a={width:d,height:c,x:e+Ia(a),y:f};a=Ka(a)}else fa(b)?(a=ha(b),a.top+=b.clientTop,a.left+=b.clientLeft,a.bottom=a.top+b.clientHeight,a.right=a.left+b.clientWidth,a.width=b.clientWidth,a.height=b.clientHeight,a.x=a.left,a.y=a.top):(f=U(a),a=U(f),d=Ha(f),b=null==(c=f.ownerDocument)?void 0:c.body,c=L(a.scrollWidth,a.clientWidth,b?b.scrollWidth:0,b?b.clientWidth:0),e=L(a.scrollHeight,a.clientHeight,b?b.scrollHeight:0,b?b.clientHeight:0),f=-d.scrollLeft+Ia(f),d=-d.scrollTop,"rtl"===P(b|| +a).direction&&(f+=L(a.clientWidth,b?b.clientWidth:0)-c),a=Ka({width:c,height:e,x:f,y:d}));return a}function Kb(a){var b=sa(wa(a)),c=0<=["absolute","fixed"].indexOf(P(a).position)&&F(a)?ra(a):a;return fa(c)?b.filter(function(d){return fa(d)&&Va(d,c)&&"body"!==M(d)}):[]}function Lb(a,b,c){b="clippingParents"===b?Kb(a):[].concat(b);c=[].concat(b,[c]);c=c.reduce(function(d,e){e=bb(a,e);d.top=L(e.top,d.top);d.right=V(e.right,d.right);d.bottom=V(e.bottom,d.bottom);d.left=L(e.left,d.left);return d},bb(a, +c[0]));c.width=c.right-c.left;c.height=c.bottom-c.top;c.x=c.left;c.y=c.top;return c}function cb(a){var b=a.reference,c=a.element,d=(a=a.placement)?N(a):null;a=a?ja(a):null;var e=b.x+b.width/2-c.width/2,f=b.y+b.height/2-c.height/2;switch(d){case "top":e={x:e,y:b.y-c.height};break;case "bottom":e={x:e,y:b.y+b.height};break;case "right":e={x:b.x+b.width,y:f};break;case "left":e={x:b.x-c.width,y:f};break;default:e={x:b.x,y:b.y}}d=d?Ga(d):null;if(null!=d)switch(f="y"===d?"height":"width",a){case "start":e[d]-= +b[f]/2-c[f]/2;break;case "end":e[d]+=b[f]/2-c[f]/2}return e}function ta(a,b){void 0===b&&(b={});var c=b;b=c.placement;b=void 0===b?a.placement:b;var d=c.boundary,e=void 0===d?"clippingParents":d;d=c.rootBoundary;var f=void 0===d?"viewport":d;d=c.elementContext;d=void 0===d?"popper":d;var g=c.altBoundary,l=void 0===g?!1:g;c=c.padding;c=void 0===c?0:c;c=Xa("number"!==typeof c?c:Ya(c,ua));g=a.rects.popper;l=a.elements[l?"popper"===d?"reference":"popper":d];e=Lb(fa(l)?l:l.contextElement||U(a.elements.popper), +e,f);f=ha(a.elements.reference);l=cb({reference:f,element:g,strategy:"absolute",placement:b});g=Ka(Object.assign({},g,l));f="popper"===d?g:f;var m={top:e.top-f.top+c.top,bottom:f.bottom-e.bottom+c.bottom,left:e.left-f.left+c.left,right:f.right-e.right+c.right};a=a.modifiersData.offset;if("popper"===d&&a){var k=a[b];Object.keys(m).forEach(function(p){var q=0<=["right","bottom"].indexOf(p)?1:-1,n=0<=["top","bottom"].indexOf(p)?"y":"x";m[p]+=k[n]*q})}return m}function Mb(a,b){void 0===b&&(b={});var c= +b.boundary,d=b.rootBoundary,e=b.padding,f=b.flipVariations,g=b.allowedAutoPlacements,l=void 0===g?db:g,m=ja(b.placement);b=m?f?eb:eb.filter(function(p){return ja(p)===m}):ua;f=b.filter(function(p){return 0<=l.indexOf(p)});0===f.length&&(f=b);var k=f.reduce(function(p,q){p[q]=ta(a,{placement:q,boundary:c,rootBoundary:d,padding:e})[N(q)];return p},{});return Object.keys(k).sort(function(p,q){return k[p]-k[q]})}function Nb(a){if("auto"===N(a))return[];var b=xa(a);return[$a(a),b,$a(b)]}function fb(a, +b,c){void 0===c&&(c={x:0,y:0});return{top:a.top-b.height-c.y,right:a.right-b.width+c.x,bottom:a.bottom-b.height+c.y,left:a.left-b.width-c.x}}function gb(a){return["top","right","bottom","left"].some(function(b){return 0<=a[b]})}function Ob(a,b,c){void 0===c&&(c=!1);var d=F(b),e;if(e=F(b)){var f=b.getBoundingClientRect();e=ia(f.width)/b.offsetWidth||1;f=ia(f.height)/b.offsetHeight||1;e=1!==e||1!==f}f=e;e=U(b);a=ha(a,f);f={scrollLeft:0,scrollTop:0};var g={x:0,y:0};if(d||!d&&!c){if("body"!==M(b)||Ja(e))f= +b!==K(b)&&F(b)?{scrollLeft:b.scrollLeft,scrollTop:b.scrollTop}:Ha(b);F(b)?(g=ha(b,!0),g.x+=b.clientLeft,g.y+=b.clientTop):e&&(g.x=Ia(e))}return{x:a.left+f.scrollLeft-g.x,y:a.top+f.scrollTop-g.y,width:a.width,height:a.height}}function Pb(a){function b(f){d.add(f.name);[].concat(f.requires||[],f.requiresIfExists||[]).forEach(function(g){d.has(g)||(g=c.get(g))&&b(g)});e.push(f)}var c=new Map,d=new Set,e=[];a.forEach(function(f){c.set(f.name,f)});a.forEach(function(f){d.has(f.name)||b(f)});return e}function Qb(a){var b= +Pb(a);return Rb.reduce(function(c,d){return c.concat(b.filter(function(e){return e.phase===d}))},[])}function Sb(a){var b;return function(){b||(b=new Promise(function(c){Promise.resolve().then(function(){b=void 0;c(a())})}));return b}}function Tb(a){var b=a.reduce(function(c,d){var e=c[d.name];c[d.name]=e?Object.assign({},e,d,{options:Object.assign({},e.options,d.options),data:Object.assign({},e.data,d.data)}):d;return c},{});return Object.keys(b).map(function(c){return b[c]})}function hb(){for(var a= +arguments.length,b=Array(a),c=0;c{if("popper"===c){var d=b.attributes[c]|| +{},e=b.elements[c];Object.assign(e.style,{position:"fixed",left:"50%",top:"50%",transform:"translate(-50%, -50%)"});Object.keys(d).forEach(f=>{let g=d[f];!1===g?e.removeAttribute(f):e.setAttribute(f,!0===g?"":g)})}})}},{name:"computeStyles",options:{adaptive:!1}}]}function Vb(a){let b=Ub(),c={placement:"top",strategy:"fixed",modifiers:[{name:"focusAfterRender",enabled:!0,phase:"afterWrite",fn(){setTimeout(()=>{a.el&&a.el.focus()},300)}}]};return c=La({},c,{modifiers:Array.from(new Set([...c.modifiers, +...b]))})}function ib(a){return qa(a)&&""!==a?"-"!==a.charAt(a.length-1)?`${a}-`:a:""}function Ma(){let a=Date.now();return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,b=>{let c=(a+16*Math.random())%16|0;a=Math.floor(a/16);return("x"==b?c:c&3|8).toString(16)})}function Wb(a,b){let c={modifiers:[{name:"preventOverflow",options:{altAxis:!0,tether:!1}},{name:"focusAfterRender",enabled:!0,phase:"afterWrite",fn(){setTimeout(()=>{b.el&&b.el.focus()},300)}}],strategy:"absolute"};void 0!==a&&null!== +a&&a.element&&a.on?c.placement=a.on:c=Vb(b);(a=b.tour&&b.tour.options&&b.tour.options.defaultStepOptions)&&(c=jb(a,c));return c=jb(b.options,c)}function jb(a,b){if(a.popperOptions){let c=Object.assign({},b,a.popperOptions);if(a.popperOptions.modifiers&&0e.name);b=b.modifiers.filter(e=>!d.includes(e.name));c.modifiers=Array.from(new Set([...b,...a.popperOptions.modifiers]))}return c}return b}function G(){}function Xb(a,b){for(let c in b)a[c]= +b[c];return a}function ka(a){return a()}function kb(a){return"function"===typeof a}function Q(a,b){return a!=a?b==b:a!==b||a&&"object"===typeof a||"function"===typeof a}function H(a){a.parentNode.removeChild(a)}function lb(a){return document.createElementNS("http://www.w3.org/2000/svg",a)}function ya(a,b,c,d){a.addEventListener(b,c,d);return()=>a.removeEventListener(b,c,d)}function B(a,b,c){null==c?a.removeAttribute(b):a.getAttribute(b)!==c&&a.setAttribute(b,c)}function mb(a,b){let c=Object.getOwnPropertyDescriptors(a.__proto__); +for(let d in b)null==b[d]?a.removeAttribute(d):"style"===d?a.style.cssText=b[d]:"__value"===d?a.value=a[d]=b[d]:c[d]&&c[d].set?a[d]=b[d]:B(a,d,b[d])}function la(a,b,c){a.classList[c?"add":"remove"](b)}function za(){if(!R)throw Error("Function called outside component initialization");return R}function Na(a){Aa.push(a)}function nb(){let a=R;do{for(;Ba{Ca.delete(a);d&&(c&&a.d(1),d())}),a.o(b))}function da(a){a&&a.c()}function W(a,b,c,d){let {fragment:e, +on_mount:f,on_destroy:g,after_update:l}=a.$$;e&&e.m(b,c);d||Na(()=>{let m=f.map(ka).filter(kb);g?g.push(...m):m.forEach(ka);a.$$.on_mount=[]});l.forEach(Na)}function X(a,b){a=a.$$;null!==a.fragment&&(a.on_destroy.forEach(ka),a.fragment&&a.fragment.d(b),a.on_destroy=a.fragment=null,a.ctx=[])}function S(a,b,c,d,e,f,g,l){void 0===l&&(l=[-1]);let m=R;R=a;let k=a.$$={fragment:null,ctx:null,props:f,update:G,not_equal:e,bound:Object.create(null),on_mount:[],on_destroy:[],on_disconnect:[],before_update:[], +after_update:[],context:new Map(b.context||(m?m.$$.context:[])),callbacks:Object.create(null),dirty:l,skip_bound:!1,root:b.target||m.$$.root};g&&g(k.root);let p=!1;k.ctx=c?c(a,b.props||{},function(q,n){let r=(2>=arguments.length?0:arguments.length-2)?2>=arguments.length?void 0:arguments[2]:n;if(k.ctx&&e(k.ctx[q],k.ctx[q]=r)){if(!k.skip_bound&&k.bound[q])k.bound[q](r);p&&(-1===a.$$.dirty[0]&&(va.push(a),Pa||(Pa=!0,Yb.then(nb)),a.$$.dirty.fill(0)),a.$$.dirty[q/31|0]|=1<{"config"in n&&c(6,e=n.config);"step"in n&&c(7,f= +n.step)};a.$$.update=()=>{a.$$.dirty&192&&(c(0,g=e.action?e.action.bind(f.tour):null),c(1,l=e.classes),c(2,m=e.disabled?d(e.disabled):!1),c(3,k=e.label?d(e.label):null),c(4,p=e.secondary),c(5,q=e.text?d(e.text):null))};return[g,l,m,k,p,q,e,f]}function pb(a,b,c){a=a.slice();a[2]=b[c];return a}function qb(a){let b,c,d=a[1],e=[];for(let g=0;gC(e[g],1,1,()=>{e[g]=null});return{c(){for(let g=0;g{d=null}),ca())},i(e){c||(z(d), +c=!0)},o(e){C(d);c=!1},d(e){e&&H(b);d&&d.d()}}}function cc(a,b,c){let d,{step:e}=b;a.$$set=f=>{"step"in f&&c(0,e=f.step)};a.$$.update=()=>{a.$$.dirty&1&&c(1,d=e.options.buttons)};return[e,d]}function dc(a){let b,c,d,e,f;return{c(){b=document.createElement("button");c=document.createElement("span");c.textContent="\u00d7";B(c,"aria-hidden","true");B(b,"aria-label",d=a[0].label?a[0].label:"Close Tour");B(b,"class","shepherd-cancel-icon");B(b,"type","button")},m(g,l){g.insertBefore(b,l||null);b.appendChild(c); +e||(f=ya(b,"click",a[1]),e=!0)},p(g,l){[l]=l;l&1&&d!==(d=g[0].label?g[0].label:"Close Tour")&&B(b,"aria-label",d)},i:G,o:G,d(g){g&&H(b);e=!1;f()}}}function ec(a,b,c){let {cancelIcon:d,step:e}=b;a.$$set=f=>{"cancelIcon"in f&&c(0,d=f.cancelIcon);"step"in f&&c(2,e=f.step)};return[d,f=>{f.preventDefault();e.cancel()},e]}function fc(a){let b;return{c(){b=document.createElement("h3");B(b,"id",a[1]);B(b,"class","shepherd-title")},m(c,d){c.insertBefore(b,d||null);a[3](b)},p(c,d){[d]=d;d&2&&B(b,"id",c[1])}, +i:G,o:G,d(c){c&&H(b);a[3](null)}}}function gc(a,b,c){let {labelId:d,element:e,title:f}=b;za().$$.after_update.push(()=>{Z(f)&&c(2,f=f());c(0,e.innerHTML=f,e)});a.$$set=g=>{"labelId"in g&&c(1,d=g.labelId);"element"in g&&c(0,e=g.element);"title"in g&&c(2,f=g.title)};return[e,d,f,function(g){ma[g?"unshift":"push"](()=>{e=g;c(0,e)})}]}function sb(a){let b,c;b=new hc({props:{labelId:a[0],title:a[2]}});return{c(){da(b.$$.fragment)},m(d,e){W(b,d,e);c=!0},p(d,e){let f={};e&1&&(f.labelId=d[0]);e&4&&(f.title= +d[2]);b.$set(f)},i(d){c||(z(b.$$.fragment,d),c=!0)},o(d){C(b.$$.fragment,d);c=!1},d(d){X(b,d)}}}function tb(a){let b,c;b=new ic({props:{cancelIcon:a[3],step:a[1]}});return{c(){da(b.$$.fragment)},m(d,e){W(b,d,e);c=!0},p(d,e){let f={};e&8&&(f.cancelIcon=d[3]);e&2&&(f.step=d[1]);b.$set(f)},i(d){c||(z(b.$$.fragment,d),c=!0)},o(d){C(b.$$.fragment,d);c=!1},d(d){X(b,d)}}}function jc(a){let b,c,d,e=a[2]&&sb(a),f=a[3]&&a[3].enabled&&tb(a);return{c(){b=document.createElement("header");e&&e.c();c=document.createTextNode(" "); +f&&f.c();B(b,"class","shepherd-header")},m(g,l){g.insertBefore(b,l||null);e&&e.m(b,null);b.appendChild(c);f&&f.m(b,null);d=!0},p(g,l){[l]=l;g[2]?e?(e.p(g,l),l&4&&z(e,1)):(e=sb(g),e.c(),z(e,1),e.m(b,c)):e&&(aa(),C(e,1,1,()=>{e=null}),ca());g[3]&&g[3].enabled?f?(f.p(g,l),l&8&&z(f,1)):(f=tb(g),f.c(),z(f,1),f.m(b,null)):f&&(aa(),C(f,1,1,()=>{f=null}),ca())},i(g){d||(z(e),z(f),d=!0)},o(g){C(e);C(f);d=!1},d(g){g&&H(b);e&&e.d();f&&f.d()}}}function kc(a,b,c){let {labelId:d,step:e}=b,f,g;a.$$set=l=>{"labelId"in +l&&c(0,d=l.labelId);"step"in l&&c(1,e=l.step)};a.$$.update=()=>{a.$$.dirty&2&&(c(2,f=e.options.title),c(3,g=e.options.cancelIcon))};return[d,e,f,g]}function lc(a){let b;return{c(){b=document.createElement("div");B(b,"class","shepherd-text");B(b,"id",a[1])},m(c,d){c.insertBefore(b,d||null);a[3](b)},p(c,d){[d]=d;d&2&&B(b,"id",c[1])},i:G,o:G,d(c){c&&H(b);a[3](null)}}}function mc(a,b,c){let {descriptionId:d,element:e,step:f}=b;za().$$.after_update.push(()=>{let {text:g}=f.options;Z(g)&&(g=g.call(f)); +g instanceof HTMLElement?e.appendChild(g):c(0,e.innerHTML=g,e)});a.$$set=g=>{"descriptionId"in g&&c(1,d=g.descriptionId);"element"in g&&c(0,e=g.element);"step"in g&&c(2,f=g.step)};return[e,d,f,function(g){ma[g?"unshift":"push"](()=>{e=g;c(0,e)})}]}function ub(a){let b,c;b=new nc({props:{labelId:a[1],step:a[2]}});return{c(){da(b.$$.fragment)},m(d,e){W(b,d,e);c=!0},p(d,e){let f={};e&2&&(f.labelId=d[1]);e&4&&(f.step=d[2]);b.$set(f)},i(d){c||(z(b.$$.fragment,d),c=!0)},o(d){C(b.$$.fragment,d);c=!1},d(d){X(b, +d)}}}function vb(a){let b,c;b=new oc({props:{descriptionId:a[0],step:a[2]}});return{c(){da(b.$$.fragment)},m(d,e){W(b,d,e);c=!0},p(d,e){let f={};e&1&&(f.descriptionId=d[0]);e&4&&(f.step=d[2]);b.$set(f)},i(d){c||(z(b.$$.fragment,d),c=!0)},o(d){C(b.$$.fragment,d);c=!1},d(d){X(b,d)}}}function wb(a){let b,c;b=new pc({props:{step:a[2]}});return{c(){da(b.$$.fragment)},m(d,e){W(b,d,e);c=!0},p(d,e){let f={};e&4&&(f.step=d[2]);b.$set(f)},i(d){c||(z(b.$$.fragment,d),c=!0)},o(d){C(b.$$.fragment,d);c=!1},d(d){X(b, +d)}}}function qc(a){let b,c=void 0!==a[2].options.title||a[2].options.cancelIcon&&a[2].options.cancelIcon.enabled,d,e=void 0!==a[2].options.text,f,g=Array.isArray(a[2].options.buttons)&&a[2].options.buttons.length,l,m=c&&ub(a),k=e&&vb(a),p=g&&wb(a);return{c(){b=document.createElement("div");m&&m.c();d=document.createTextNode(" ");k&&k.c();f=document.createTextNode(" ");p&&p.c();B(b,"class","shepherd-content")},m(q,n){q.insertBefore(b,n||null);m&&m.m(b,null);b.appendChild(d);k&&k.m(b,null);b.appendChild(f); +p&&p.m(b,null);l=!0},p(q,n){[n]=n;n&4&&(c=void 0!==q[2].options.title||q[2].options.cancelIcon&&q[2].options.cancelIcon.enabled);c?m?(m.p(q,n),n&4&&z(m,1)):(m=ub(q),m.c(),z(m,1),m.m(b,d)):m&&(aa(),C(m,1,1,()=>{m=null}),ca());n&4&&(e=void 0!==q[2].options.text);e?k?(k.p(q,n),n&4&&z(k,1)):(k=vb(q),k.c(),z(k,1),k.m(b,f)):k&&(aa(),C(k,1,1,()=>{k=null}),ca());n&4&&(g=Array.isArray(q[2].options.buttons)&&q[2].options.buttons.length);g?p?(p.p(q,n),n&4&&z(p,1)):(p=wb(q),p.c(),z(p,1),p.m(b,null)):p&&(aa(), +C(p,1,1,()=>{p=null}),ca())},i(q){l||(z(m),z(k),z(p),l=!0)},o(q){C(m);C(k);C(p);l=!1},d(q){q&&H(b);m&&m.d();k&&k.d();p&&p.d()}}}function rc(a,b,c){let {descriptionId:d,labelId:e,step:f}=b;a.$$set=g=>{"descriptionId"in g&&c(0,d=g.descriptionId);"labelId"in g&&c(1,e=g.labelId);"step"in g&&c(2,f=g.step)};return[d,e,f]}function xb(a){let b;return{c(){b=document.createElement("div");B(b,"class","shepherd-arrow");B(b,"data-popper-arrow","")},m(c,d){c.insertBefore(b,d||null)},d(c){c&&H(b)}}}function sc(a){let b, +c,d,e,f,g,l,m,k=a[4].options.arrow&&a[4].options.attachTo&&a[4].options.attachTo.element&&a[4].options.attachTo.on&&xb();d=new tc({props:{descriptionId:a[2],labelId:a[3],step:a[4]}});let p=[{"aria-describedby":e=void 0!==a[4].options.text?a[2]:null},{"aria-labelledby":f=a[4].options.title?a[3]:null},a[1],{role:"dialog"},{tabindex:"0"}],q={};for(let n=0;n!!b.length)}function uc(a,b,c){let {classPrefix:d,element:e,descriptionId:f,firstFocusableElement:g,focusableElements:l,labelId:m,lastFocusableElement:k,step:p,dataStepId:q}=b,n,r,x;za().$$.on_mount.push(()=>{c(1,q={[`data-${d}shepherd-step-id`]:p.id});c(9,l=e.querySelectorAll('a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex="0"]')); +c(8,g=l[0]);c(10,k=l[l.length-1])});za().$$.after_update.push(()=>{if(x!==p.options.classes){var h=x;qa(h)&&(h=yb(h),h.length&&e.classList.remove(...h));h=x=p.options.classes;qa(h)&&(h=yb(h),h.length&&e.classList.add(...h))}});a.$$set=h=>{"classPrefix"in h&&c(11,d=h.classPrefix);"element"in h&&c(0,e=h.element);"descriptionId"in h&&c(2,f=h.descriptionId);"firstFocusableElement"in h&&c(8,g=h.firstFocusableElement);"focusableElements"in h&&c(9,l=h.focusableElements);"labelId"in h&&c(3,m=h.labelId);"lastFocusableElement"in +h&&c(10,k=h.lastFocusableElement);"step"in h&&c(4,p=h.step);"dataStepId"in h&&c(1,q=h.dataStepId)};a.$$.update=()=>{a.$$.dirty&16&&(c(5,n=p.options&&p.options.cancelIcon&&p.options.cancelIcon.enabled),c(6,r=p.options&&p.options.title))};return[e,q,f,m,p,n,r,h=>{const {tour:t}=p;switch(h.keyCode){case 9:if(0===l.length){h.preventDefault();break}if(h.shiftKey){if(document.activeElement===g||document.activeElement.classList.contains("shepherd-element"))h.preventDefault(),k.focus()}else document.activeElement=== +k&&(h.preventDefault(),g.focus());break;case 27:t.options.exitOnEsc&&p.cancel();break;case 37:t.options.keyboardNavigation&&t.back();break;case 39:t.options.keyboardNavigation&&t.next()}},g,l,k,d,()=>e,function(h){ma[h?"unshift":"push"](()=>{e=h;c(0,e)})}]}function vc(a){a&&({steps:a}=a,a.forEach(b=>{b.options&&!1===b.options.canClickTarget&&b.options.attachTo&&b.target instanceof HTMLElement&&b.target.classList.remove("shepherd-target-click-disabled")}))}function wc(a){let b,c,d,e,f;return{c(){b= +lb("svg");c=lb("path");B(c,"d",a[2]);B(b,"class",d=`${a[1]?"shepherd-modal-is-visible":""} shepherd-modal-overlay-container`)},m(g,l){g.insertBefore(b,l||null);b.appendChild(c);a[11](b);e||(f=ya(b,"touchmove",a[3]),e=!0)},p(g,l){[l]=l;l&4&&B(c,"d",g[2]);l&2&&d!==(d=`${g[1]?"shepherd-modal-is-visible":""} shepherd-modal-overlay-container`)&&B(b,"class",d)},i:G,o:G,d(g){g&&H(b);a[11](null);e=!1;f()}}}function zb(a){if(!a)return null;let b=a instanceof HTMLElement&&window.getComputedStyle(a).overflowY; +return"hidden"!==b&&"visible"!==b&&a.scrollHeight>=a.clientHeight?a:zb(a.parentElement)}function xc(a,b,c){function d(){c(4,p={width:0,height:0,x:0,y:0,r:0})}function e(){c(1,q=!1);l()}function f(h,t,v,A){void 0===h&&(h=0);void 0===t&&(t=0);if(A){var u=A.getBoundingClientRect();let y=u.y||u.top;u=u.bottom||y+u.height;if(v){var w=v.getBoundingClientRect();v=w.y||w.top;w=w.bottom||v+w.height;y=Math.max(y,v);u=Math.min(u,w)}let {y:Y,height:E}={y,height:Math.max(u-y,0)},{x:I,width:D,left:na}=A.getBoundingClientRect(); +c(4,p={width:D+2*h,height:E+2*h,x:(I||na)-h,y:Y-h,r:t})}else d()}function g(){c(1,q=!0)}function l(){n&&(cancelAnimationFrame(n),n=void 0);window.removeEventListener("touchmove",x,{passive:!1})}function m(h){let {modalOverlayOpeningPadding:t,modalOverlayOpeningRadius:v}=h.options,A=zb(h.target),u=()=>{n=void 0;f(t,v,A,h.target);n=requestAnimationFrame(u)};u();window.addEventListener("touchmove",x,{passive:!1})}let {element:k,openingProperties:p}=b;Ma();let q=!1,n=void 0,r;d();let x=h=>{h.preventDefault()}; +a.$$set=h=>{"element"in h&&c(0,k=h.element);"openingProperties"in h&&c(4,p=h.openingProperties)};a.$$.update=()=>{if(a.$$.dirty&16){let {width:h,height:t,x:v=0,y:A=0,r:u=0}=p,{innerWidth:w,innerHeight:y}=window;c(2,r=`M${w},${y}\ +H0\ +V0\ +H${w}\ +V${y}\ +Z\ +M${v+u},${A}\ +a${u},${u},0,0,0-${u},${u}\ +V${t+A-u}\ +a${u},${u},0,0,0,${u},${u}\ +H${h+v-u}\ +a${u},${u},0,0,0,${u}-${u}\ +V${A+u}\ +a${u},${u},0,0,0-${u}-${u}\ +Z`)}};return[k,q,r,h=>{h.stopPropagation()},p,()=>k,d,e,f,function(h){l();h.tour.options.useModalOverlay?(m(h),g()):e()},g,function(h){ma[h?"unshift":"push"](()=>{k=h;c(0,k)})}]}var Eb=function(a){var b;if(b=!!a&&"object"===typeof a)b=Object.prototype.toString.call(a),b=!("[object RegExp]"===b||"[object Date]"===b||a.$$typeof===yc);return b},yc="function"===typeof Symbol&&Symbol.for?Symbol.for("react.element"):60103;ea.all=function(a,b){if(!Array.isArray(a))throw Error("first argument should be an array"); +return a.reduce(function(c,d){return ea(c,d,b)},{})};var zc=ea;class Qa{on(a,b,c,d){void 0===d&&(d=!1);void 0===this.bindings&&(this.bindings={});void 0===this.bindings[a]&&(this.bindings[a]=[]);this.bindings[a].push({handler:b,ctx:c,once:d});return this}once(a,b,c){return this.on(a,b,c,!0)}off(a,b){if(void 0===this.bindings||void 0===this.bindings[a])return this;void 0===b?delete this.bindings[a]:this.bindings[a].forEach((c,d)=>{c.handler===b&&this.bindings[a].splice(d,1)});return this}trigger(a){for(var b= +arguments.length,c=Array(1{let {ctx:g,handler:l,once:m}=e;l.apply(g||this,c);m&&this.bindings[a].splice(f,1)});return this}}var ua=["top","bottom","right","left"],eb=ua.reduce(function(a,b){return a.concat([b+"-start",b+"-end"])},[]),db=[].concat(ua,["auto"]).reduce(function(a,b){return a.concat([b,b+"-start",b+"-end"])},[]),Rb="beforeRead read afterRead beforeMain main afterMain beforeWrite write afterWrite".split(" "), +L=Math.max,V=Math.min,ia=Math.round,Hb={top:"auto",right:"auto",bottom:"auto",left:"auto"},Da={passive:!0},Ib={left:"right",right:"left",bottom:"top",top:"bottom"},Jb={start:"end",end:"start"},Ab={placement:"bottom",modifiers:[],strategy:"absolute"},Ac=function(a){void 0===a&&(a={});var b=a.defaultModifiers,c=void 0===b?[]:b;a=a.defaultOptions;var d=void 0===a?Ab:a;return function(e,f,g){function l(){k.orderedModifiers.forEach(function(r){var x=r.name,h=r.options;h=void 0===h?{}:h;r=r.effect;"function"=== +typeof r&&(x=r({state:k,name:x,instance:n,options:h}),p.push(x||function(){}))})}function m(){p.forEach(function(r){return r()});p=[]}void 0===g&&(g=d);var k={placement:"bottom",orderedModifiers:[],options:Object.assign({},Ab,d),modifiersData:{},elements:{reference:e,popper:f},attributes:{},styles:{}},p=[],q=!1,n={state:k,setOptions:function(r){r="function"===typeof r?r(k.options):r;m();k.options=Object.assign({},d,k.options,r);k.scrollParents={reference:fa(e)?sa(e):e.contextElement?sa(e.contextElement): +[],popper:sa(f)};r=Qb(Tb([].concat(c,k.options.modifiers)));k.orderedModifiers=r.filter(function(x){return x.enabled});l();return n.update()},forceUpdate:function(){if(!q){var r=k.elements,x=r.reference;r=r.popper;if(hb(x,r))for(k.rects={reference:Ob(x,ra(r),"fixed"===k.options.strategy),popper:Fa(r)},k.reset=!1,k.placement=k.options.placement,k.orderedModifiers.forEach(function(v){return k.modifiersData[v.name]=Object.assign({},v.data)}),x=0;xf[y]&&(u=xa(u));y=xa(u);w=[];d&&w.push(0>=Y[A]);e&&w.push(0>=Y[u],0>=Y[y]);if(w.every(function(E){return E})){h=v;p=!1;break}x.set(v,w)}if(p)for(d=function(E){var I=r.find(function(D){if(D=x.get(D))return D.slice(0, +E).every(function(na){return na})});if(I)return h=I,"break"},e=q?3:1;0r?r:J):J=L(g?J:v,V(f,g?r:m));d[c]=J;l[c]=J-f}b.modifiersData[a]=l}},requiresIfExists:["offset"]},{name:"arrow",enabled:!0,phase:"main", +fn:function(a){var b,c=a.state,d=a.name,e=a.options,f=c.elements.arrow,g=c.modifiersData.popperOffsets,l=N(c.placement);a=Ga(l);l=0<=["left","right"].indexOf(l)?"height":"width";if(f&&g){e=e.padding;e="function"===typeof e?e(Object.assign({},c.rects,{placement:c.placement})):e;e=Xa("number"!==typeof e?e:Ya(e,ua));var m=Fa(f),k="y"===a?"top":"left",p="y"===a?"bottom":"right",q=c.rects.reference[l]+c.rects.reference[a]-g[a]-c.rects.popper[l];g=g[a]-c.rects.reference[a];f=(f=ra(f))?"y"===a?f.clientHeight|| +0:f.clientWidth||0:0;g=f/2-m[l]/2+(q/2-g/2);l=L(e[k],V(g,f-m[l]-e[p]));c.modifiersData[d]=(b={},b[a]=l,b.centerOffset=l-g,b)}},effect:function(a){var b=a.state;a=a.options.element;a=void 0===a?"[data-popper-arrow]":a;if(null!=a){if("string"===typeof a&&(a=b.elements.popper.querySelector(a),!a))return;Va(b.elements.popper,a)&&(b.elements.arrow=a)}},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]},{name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(a){var b= +a.state;a=a.name;var c=b.rects.reference,d=b.rects.popper,e=b.modifiersData.preventOverflow,f=ta(b,{elementContext:"reference"}),g=ta(b,{altBoundary:!0});c=fb(f,c);d=fb(g,d,e);e=gb(c);g=gb(d);b.modifiersData[a]={referenceClippingOffsets:c,popperEscapeOffsets:d,isReferenceHidden:e,hasPopperEscaped:g};b.attributes.popper=Object.assign({},b.attributes.popper,{"data-popper-reference-hidden":e,"data-popper-escaped":g})}}]});let R,va=[],ma=[],Aa=[],ob=[],Yb=Promise.resolve(),Pa=!1,Oa=new Set,Ba=0,Ca=new Set, +ba;class T{$destroy(){X(this,1);this.$destroy=G}$on(a,b){let c=this.$$.callbacks[a]||(this.$$.callbacks[a]=[]);c.push(b);return()=>{let d=c.indexOf(b);-1!==d&&c.splice(d,1)}}$set(a){this.$$set&&0!==Object.keys(a).length&&(this.$$.skip_bound=!0,this.$$set(a),this.$$.skip_bound=!1)}}class ac extends T{constructor(a){super();S(this,a,$b,Zb,Q,{config:6,step:7})}}class pc extends T{constructor(a){super();S(this,a,cc,bc,Q,{step:0})}}class ic extends T{constructor(a){super();S(this,a,ec,dc,Q,{cancelIcon:0, +step:2})}}class hc extends T{constructor(a){super();S(this,a,gc,fc,Q,{labelId:1,element:0,title:2})}}class nc extends T{constructor(a){super();S(this,a,kc,jc,Q,{labelId:0,step:1})}}class oc extends T{constructor(a){super();S(this,a,mc,lc,Q,{descriptionId:1,element:0,step:2})}}class tc extends T{constructor(a){super();S(this,a,rc,qc,Q,{descriptionId:0,labelId:1,step:2})}}class Bc extends T{constructor(a){super();S(this,a,uc,sc,Q,{classPrefix:11,element:0,descriptionId:2,firstFocusableElement:8,focusableElements:9, +labelId:3,lastFocusableElement:10,step:4,dataStepId:1,getElement:12})}get getElement(){return this.$$.ctx[12]}}var Bb=function(a,b){return b={exports:{}},a(b,b.exports),b.exports}(function(a,b){(function(){a.exports={polyfill:function(){function c(h,t){this.scrollLeft=h;this.scrollTop=t}function d(h){if(null===h||"object"!==typeof h||void 0===h.behavior||"auto"===h.behavior||"instant"===h.behavior)return!0;if("object"===typeof h&&"smooth"===h.behavior)return!1;throw new TypeError("behavior member of ScrollOptions "+ +h.behavior+" is not a valid value for enumeration ScrollBehavior.");}function e(h,t){if("Y"===t)return h.clientHeight+xthis._show())}this._show()}updateStepOptions(a){Object.assign(this.options,a);this.shepherdElementComponent&&this.shepherdElementComponent.$set({step:this})}getElement(){return this.el}getTarget(){return this.target}_createTooltipContent(){this.shepherdElementComponent=new Bc({target:this.tour.options.stepsContainer|| +document.body,props:{classPrefix:this.classPrefix,descriptionId:`${this.id}-description`,labelId:`${this.id}-label`,step:this,styles:this.styles}});return this.shepherdElementComponent.getElement()}_scrollTo(a){let {element:b}=this._getResolvedAttachToOptions();Z(this.options.scrollToHandler)?this.options.scrollToHandler(b):b instanceof Element&&"function"===typeof b.scrollIntoView&&b.scrollIntoView(a)}_getClassOptions(a){var b=this.tour&&this.tour.options&&this.tour.options.defaultStepOptions;b= +b&&b.classes?b.classes:"";a=[...(a.classes?a.classes:"").split(" "),...b.split(" ")];a=new Set(a);return Array.from(a).join(" ").trim()}_setOptions(a){void 0===a&&(a={});let b=this.tour&&this.tour.options&&this.tour.options.defaultStepOptions;b=zc({},b||{});this.options=Object.assign({arrow:!0},b,a);let {when:c}=this.options;this.options.classes=this._getClassOptions(a);this.destroy();this.id=this.options.id||`step-${Ma()}`;c&&Object.keys(c).forEach(d=>{this.on(d,c[d],this)})}_setupElements(){void 0!== +this.el&&this.destroy();this.el=this._createTooltipContent();this.options.advanceOn&&Gb(this);this.tooltip&&this.tooltip.destroy();let a=this._getResolvedAttachToOptions(),b=a.element,c=Wb(a,this);void 0!==a&&null!==a&&a.element&&a.on||(b=document.body,this.shepherdElementComponent.getElement().classList.add("shepherd-centered"));this.tooltip=Ac(b,this.el,c);this.target=a.element}_show(){this.trigger("before-show");this._resolveAttachToOptions();this._setupElements();this.tour.modal||this.tour._setupModal(); +this.tour.modal.setupForStep(this);this._styleTargetElementForStep(this);this.el.hidden=!1;this.options.scrollTo&&setTimeout(()=>{this._scrollTo(this.options.scrollTo)});this.el.hidden=!1;let a=this.shepherdElementComponent.getElement(),b=this.target||document.body;b.classList.add(`${this.classPrefix}shepherd-enabled`);b.classList.add(`${this.classPrefix}shepherd-target`);a.classList.add("shepherd-enabled");this.trigger("show")}_styleTargetElementForStep(a){let b=a.target;b&&(a.options.highlightClass&& +b.classList.add(a.options.highlightClass),b.classList.remove("shepherd-target-click-disabled"),!1===a.options.canClickTarget&&b.classList.add("shepherd-target-click-disabled"))}_updateStepTargetOnHide(){let a=this.target||document.body;this.options.highlightClass&&a.classList.remove(this.options.highlightClass);a.classList.remove("shepherd-target-click-disabled",`${this.classPrefix}shepherd-enabled`,`${this.classPrefix}shepherd-target`)}}class Cc extends T{constructor(a){super();S(this,a,xc,wc,Q, +{element:0,openingProperties:4,getElement:5,closeModalOpening:6,hide:7,positionModal:8,setupForStep:9,show:10})}get getElement(){return this.$$.ctx[5]}get closeModalOpening(){return this.$$.ctx[6]}get hide(){return this.$$.ctx[7]}get positionModal(){return this.$$.ctx[8]}get setupForStep(){return this.$$.ctx[9]}get show(){return this.$$.ctx[10]}}let oa=new Qa;class Dc extends Qa{constructor(a){void 0===a&&(a={});super(a);Ua(this);this.options=Object.assign({},{exitOnEsc:!0,keyboardNavigation:!0}, +a);this.classPrefix=ib(this.options.classPrefix);this.steps=[];this.addSteps(this.options.steps);"active cancel complete inactive show start".split(" ").map(b=>{(c=>{this.on(c,d=>{d=d||{};d.tour=this;oa.trigger(c,d)})})(b)});this._setTourID();return this}addStep(a,b){a instanceof Ra?a.tour=this:a=new Ra(this,a);void 0!==b?this.steps.splice(b,0,a):this.steps.push(a);return a}addSteps(a){Array.isArray(a)&&a.forEach(b=>{this.addStep(b)});return this}back(){let a=this.steps.indexOf(this.currentStep); +this.show(a-1,!1)}cancel(){this.options.confirmCancel?window.confirm(this.options.confirmCancelMessage||"Are you sure you want to stop the tour?")&&this._done("cancel"):this._done("cancel")}complete(){this._done("complete")}getById(a){return this.steps.find(b=>b.id===a)}getCurrentStep(){return this.currentStep}hide(){let a=this.getCurrentStep();if(a)return a.hide()}isActive(){return oa.activeTour===this}next(){let a=this.steps.indexOf(this.currentStep);a===this.steps.length-1?this.complete():this.show(a+ +1,!0)}removeStep(a){let b=this.getCurrentStep();this.steps.some((c,d)=>{if(c.id===a)return c.isOpen()&&c.hide(),c.destroy(),this.steps.splice(d,1),!0});b&&b.id===a&&(this.currentStep=void 0,this.steps.length?this.show(0):this.cancel())}show(a,b){void 0===a&&(a=0);void 0===b&&(b=!0);if(a=qa(a)?this.getById(a):this.steps[a])this._updateStateBeforeShow(),Z(a.options.showOn)&&!a.options.showOn()?this._skipStep(a,b):(this.trigger("show",{step:a,previous:this.currentStep}),this.currentStep=a,a.show())}start(){this.trigger("start"); +this.focusedElBeforeOpen=document.activeElement;this.currentStep=null;this._setupModal();this._setupActiveTour();this.next()}_done(a){let b=this.steps.indexOf(this.currentStep);Array.isArray(this.steps)&&this.steps.forEach(c=>c.destroy());vc(this);this.trigger(a,{index:b});oa.activeTour=null;this.trigger("inactive",{tour:this});this.modal&&this.modal.hide();"cancel"!==a&&"complete"!==a||!this.modal||(a=document.querySelector(".shepherd-modal-overlay-container"))&&a.remove();this.focusedElBeforeOpen instanceof +HTMLElement&&this.focusedElBeforeOpen.focus()}_setupActiveTour(){this.trigger("active",{tour:this});oa.activeTour=this}_setupModal(){this.modal=new Cc({target:this.options.modalContainer||document.body,props:{classPrefix:this.classPrefix,styles:this.styles}})}_skipStep(a,b){a=this.steps.indexOf(a);a===this.steps.length-1?this.complete():this.show(b?a+1:a-1,b)}_updateStateBeforeShow(){this.currentStep&&this.currentStep.hide();this.isActive()||this._setupActiveTour()}_setTourID(){this.id=`${this.options.tourName|| +"tour"}--${Ma()}`}}Object.assign(oa,{Tour:Dc,Step:Ra});return oa}) +//# sourceMappingURL=shepherd.min.js.map diff --git a/bookwyrm/status.py b/bookwyrm/status.py index 09fbdc06e..de7682ee7 100644 --- a/bookwyrm/status.py +++ b/bookwyrm/status.py @@ -2,15 +2,13 @@ from django.db import transaction from bookwyrm import models -from bookwyrm.sanitize_html import InputHtmlParser +from bookwyrm.utils import sanitizer def create_generated_note(user, content, mention_books=None, privacy="public"): """a note created by the app about user activity""" # sanitize input html - parser = InputHtmlParser() - parser.feed(content) - content = parser.get_output() + content = sanitizer.clean(content) with transaction.atomic(): # create but don't save diff --git a/bookwyrm/suggested_users.py b/bookwyrm/suggested_users.py index ea6bc886b..91f23dded 100644 --- a/bookwyrm/suggested_users.py +++ b/bookwyrm/suggested_users.py @@ -7,7 +7,7 @@ from django.db.models import signals, Count, Q, Case, When, IntegerField from bookwyrm import models from bookwyrm.redis_store import RedisStore, r -from bookwyrm.tasks import app +from bookwyrm.tasks import app, LOW, MEDIUM logger = logging.getLogger(__name__) @@ -237,41 +237,41 @@ def domain_level_update(sender, instance, created, update_fields=None, **kwargs) # ------------------- TASKS -@app.task(queue="low_priority") +@app.task(queue=LOW) def rerank_suggestions_task(user_id): """do the hard work in celery""" suggested_users.rerank_user_suggestions(user_id) -@app.task(queue="low_priority") +@app.task(queue=LOW) def rerank_user_task(user_id, update_only=False): """do the hard work in celery""" user = models.User.objects.get(id=user_id) suggested_users.rerank_obj(user, update_only=update_only) -@app.task(queue="low_priority") +@app.task(queue=LOW) def remove_user_task(user_id): """do the hard work in celery""" user = models.User.objects.get(id=user_id) suggested_users.remove_object_from_related_stores(user) -@app.task(queue="medium_priority") +@app.task(queue=MEDIUM) def remove_suggestion_task(user_id, suggested_user_id): """remove a specific user from a specific user's suggestions""" suggested_user = models.User.objects.get(id=suggested_user_id) suggested_users.remove_suggestion(user_id, suggested_user) -@app.task(queue="low_priority") +@app.task(queue=LOW) def bulk_remove_instance_task(instance_id): """remove a bunch of users from recs""" for user in models.User.objects.filter(federated_server__id=instance_id): suggested_users.remove_object_from_related_stores(user) -@app.task(queue="low_priority") +@app.task(queue=LOW) def bulk_add_instance_task(instance_id): """remove a bunch of users from recs""" for user in models.User.objects.filter(federated_server__id=instance_id): diff --git a/bookwyrm/templates/about/about.html b/bookwyrm/templates/about/about.html index b04e21b17..c446e0cf2 100644 --- a/bookwyrm/templates/about/about.html +++ b/bookwyrm/templates/about/about.html @@ -11,7 +11,7 @@ {% block about_content %} {# seven day cache #} -{% cache 604800 about_page %} +{% cache 604800 about_page_superlatives %} {% get_book_superlatives as superlatives %}
@@ -23,7 +23,9 @@

{% blocktrans trimmed with site_name=site.name %} {{ site_name }} is part of BookWyrm, a network of independent, self-directed communities for readers. - While you can interact seamlessly with users anywhere in the BookWyrm network, this community is unique. + While you can interact seamlessly with users anywhere in the + BookWyrm network, + this community is unique. {% endblocktrans %}

@@ -88,10 +90,14 @@

- {% trans "Track your reading, talk about books, write reviews, and discover what to read next. Always ad-free, anti-corporate, and community-oriented, BookWyrm is human-scale software, designed to stay small and personal. If you have feature requests, bug reports, or grand dreams, reach out and make yourself heard." %} + {% blocktrans trimmed %} + Track your reading, talk about books, write reviews, and discover what to read next. Always ad-free, anti-corporate, and community-oriented, BookWyrm is human-scale software, designed to stay small and personal. + If you have feature requests, bug reports, or grand dreams, reach out and make yourself heard. + {% endblocktrans %}

+{% endcache %}
@@ -140,5 +146,4 @@
-{% endcache %} {% endblock %} diff --git a/bookwyrm/templates/author/author.html b/bookwyrm/templates/author/author.html index afbf31784..f186c0f6e 100644 --- a/bookwyrm/templates/author/author.html +++ b/bookwyrm/templates/author/author.html @@ -3,6 +3,7 @@ {% load markdown %} {% load humanize %} {% load utilities %} +{% load book_display_tags %} {% block title %}{{ author.name }}{% endblock %} @@ -66,7 +67,7 @@
{% if author.wikipedia_link %} @@ -74,7 +75,7 @@ {% if author.isni %} @@ -83,7 +84,7 @@ {% trans "Load data" as button_text %} {% if author.openlibrary_key %}
- + {% trans "View on OpenLibrary" %} {% if request.user.is_authenticated and perms.bookwyrm.edit_book %} @@ -98,7 +99,7 @@ {% if author.inventaire_id %}
- + {% trans "View on Inventaire" %} @@ -114,7 +115,7 @@ {% if author.librarything_key %} @@ -122,7 +123,7 @@ {% if author.goodreads_key %} @@ -141,7 +142,7 @@

{% blocktrans with name=author.name %}Books by {{ name }}{% endblocktrans %}

{% for book in books %} - {% with book=book.default_edition %} + {% with book=book|author_edition:author %}
{% include 'landing/small-book.html' with book=book %} diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html index e7d10f4f3..95829ae9d 100644 --- a/bookwyrm/templates/book/book.html +++ b/bookwyrm/templates/book/book.html @@ -113,7 +113,7 @@ {% include 'snippets/rate_action.html' with user=request.user book=book %} -
+
{% include 'snippets/shelve_button/shelve_button.html' %}
@@ -131,7 +131,7 @@ {% trans "Load data" as button_text %} {% if book.openlibrary_key %}

- + {% trans "View on OpenLibrary" %} {% if request.user.is_authenticated and perms.bookwyrm.edit_book %} @@ -145,7 +145,7 @@ {% endif %} {% if book.inventaire_id %}

- + {% trans "View on Inventaire" %} @@ -210,7 +210,7 @@ {% with work=book.parent_work %}

- + {% blocktrans trimmed count counter=work.editions.count with count=work.editions.count|intcomma %} {{ count }} edition {% plural %} @@ -254,7 +254,7 @@

{% trans "Your reading activity" %}

-
@@ -405,4 +405,7 @@ {% block scripts %} +{% if request.user.show_guided_tour %} + {% include 'guided_tour/book.html' %} +{% endif %} {% endblock %} diff --git a/bookwyrm/templates/book/edit/edit_book.html b/bookwyrm/templates/book/edit/edit_book.html index e5b865b55..70ec827eb 100644 --- a/bookwyrm/templates/book/edit/edit_book.html +++ b/bookwyrm/templates/book/edit/edit_book.html @@ -78,9 +78,13 @@

{% with book_title=match.book_set.first.title alt_title=match.bio %} {% if book_title %} - {% trans "Author of " %}{{ book_title }} - {% else %} - {% if alt_title %}{% trans "Author of " %}{{ alt_title }}{% else %} {% trans "Find more information at isni.org" %}{% endif %} + {% blocktrans trimmed %} + Author of {{ book_title }} + {% endblocktrans %} + {% else %} + {% if alt_title %}{% blocktrans trimmed %} + Author of {{ alt_title }} + {% endblocktrans %}{% else %}{% trans "Find more information at isni.org" %}{% endif %} {% endif %} {% endwith %}

diff --git a/bookwyrm/templates/book/file_links/edit_links.html b/bookwyrm/templates/book/file_links/edit_links.html index 44206ef77..77431726b 100644 --- a/bookwyrm/templates/book/file_links/edit_links.html +++ b/bookwyrm/templates/book/file_links/edit_links.html @@ -39,10 +39,14 @@ {% for link in links %} - {{ link.url }} + {{ link.url }} + {% if link.added_by %} {{ link.added_by.display_name }} + {% else %} + {% trans "Unknown user" %} + {% endif %} {{ link.filelink.filetype }} @@ -50,7 +54,7 @@ {{ link.domain.name }}

- {% trans "Report spam" %} + {% trans "Report spam" %}

diff --git a/bookwyrm/templates/book/file_links/links.html b/bookwyrm/templates/book/file_links/links.html index 2147bf6e0..febc39e56 100644 --- a/bookwyrm/templates/book/file_links/links.html +++ b/bookwyrm/templates/book/file_links/links.html @@ -28,7 +28,7 @@ {% for link in links.all %} {% join "verify" link.id as verify_modal %}
  • - {{ link.name }} + {{ link.name }} ({{ link.filetype }}) {% if link.availability != "free" %} diff --git a/bookwyrm/templates/book/file_links/verification_modal.html b/bookwyrm/templates/book/file_links/verification_modal.html index 01f17f965..7a9e41ad2 100644 --- a/bookwyrm/templates/book/file_links/verification_modal.html +++ b/bookwyrm/templates/book/file_links/verification_modal.html @@ -19,11 +19,11 @@ Is that where you'd like to go? {% block modal-footer %} {% if request.user.is_authenticated %} -{% trans "Continue" %} +{% trans "Continue" %} {% endif %} {% endblock %} diff --git a/bookwyrm/templates/confirm_email/resend_modal.html b/bookwyrm/templates/confirm_email/resend_modal.html index beb9318a9..4d155cbb6 100644 --- a/bookwyrm/templates/confirm_email/resend_modal.html +++ b/bookwyrm/templates/confirm_email/resend_modal.html @@ -19,16 +19,8 @@ name="email" class="input" id="email" - aria-described-by="id_email_errors" required > - {% if error %} -
    -

    - {% trans "No user matching this email address found." %} -

    -
    - {% endif %}
  • {% endblock %} diff --git a/bookwyrm/templates/email/moderation_report/html_content.html b/bookwyrm/templates/email/moderation_report/html_content.html index 10df380f2..0e604ebf8 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 link_domain %} + + {% 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..351ab58ed 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 link_domain %} +{% 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 233ba387f..6a8d77016 100644 --- a/bookwyrm/templates/embed-layout.html +++ b/bookwyrm/templates/embed-layout.html @@ -1,14 +1,13 @@ {% load layout %} {% load i18n %} +{% load sass_tags %} {% load static %} {% block title %}BookWyrm{% endblock %} - {{ site.name }} - - - + diff --git a/bookwyrm/templates/feed/direct_messages.html b/bookwyrm/templates/feed/direct_messages.html index 77f9aac19..3d638bd33 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 %}
    @@ -30,3 +30,4 @@
    {% endblock %} + diff --git a/bookwyrm/templates/feed/feed.html b/bookwyrm/templates/feed/feed.html index 9e625313f..7ecf10b70 100644 --- a/bookwyrm/templates/feed/feed.html +++ b/bookwyrm/templates/feed/feed.html @@ -1,5 +1,6 @@ {% extends 'feed/layout.html' %} {% load i18n %} +{% load static %} {% block panel %} @@ -73,3 +74,12 @@ {% endfor %} {% endblock %} + +{% block scripts %} + + +{% if request.user.show_guided_tour %} + {% include 'guided_tour/home.html' %} +{% endif %} + +{% endblock %} diff --git a/bookwyrm/templates/feed/layout.html b/bookwyrm/templates/feed/layout.html index 5083c0ab8..16a868c2a 100644 --- a/bookwyrm/templates/feed/layout.html +++ b/bookwyrm/templates/feed/layout.html @@ -1,6 +1,5 @@ {% extends 'layout.html' %} {% load i18n %} -{% load static %} {% block title %}{% trans "Updates" %}{% endblock %} @@ -30,6 +29,4 @@
    {% endblock %} -{% block scripts %} - -{% endblock %} + diff --git a/bookwyrm/templates/feed/suggested_books.html b/bookwyrm/templates/feed/suggested_books.html index 12e478201..963e73786 100644 --- a/bookwyrm/templates/feed/suggested_books.html +++ b/bookwyrm/templates/feed/suggested_books.html @@ -2,7 +2,7 @@ {% load feed_page_tags %} {% suggested_books as suggested_books %} -
    +

    {% trans "Your Books" %}

    {% if not suggested_books %} diff --git a/bookwyrm/templates/groups/form.html b/bookwyrm/templates/groups/form.html index a0d057582..97870b2c3 100644 --- a/bookwyrm/templates/groups/form.html +++ b/bookwyrm/templates/groups/form.html @@ -5,7 +5,7 @@
    - + {{ group_form.name }}
    diff --git a/bookwyrm/templates/groups/group.html b/bookwyrm/templates/groups/group.html index 5f5b58601..26eb76977 100644 --- a/bookwyrm/templates/groups/group.html +++ b/bookwyrm/templates/groups/group.html @@ -22,7 +22,7 @@

    {% if request.user.is_authenticated and group|is_member:request.user %} -
    +
    {% trans "Create List" as button_text %} {% include 'snippets/toggle/open_button.html' with controls_text="create_list" icon_with_text="plus" text=button_text focus="create_list_header" %}
    @@ -80,3 +80,9 @@
    {% endblock %} + +{% block scripts %} +{% if request.user.show_guided_tour %} + {% include 'guided_tour/group.html' %} +{% endif %} +{% endblock %} diff --git a/bookwyrm/templates/groups/members.html b/bookwyrm/templates/groups/members.html index e9c9047c9..0fa4048fa 100644 --- a/bookwyrm/templates/groups/members.html +++ b/bookwyrm/templates/groups/members.html @@ -10,7 +10,7 @@
    -
    +

    {% trans "Recent Imports" %}

    - {% if not jobs %} -

    {% trans "No recent imports" %}

    - {% endif %} - +
    + + + + + + + + {% if not jobs %} + + + + {% endif %} + {% for job in jobs %} + + + + + + + {% endfor %} +
    + {% trans "Date Created" %} + + {% trans "Last Updated" %} + + {% trans "Items" %} + + {% trans "Status" %} +
    + {% trans "No recent imports" %} +
    + {{ job.created_date }} + {{ job.updated_date }}{{ job.item_count|intcomma }} + + {% if job.status %} + {{ job.status }} + {{ job.status_display }} + {% elif job.complete %} + {% trans "Complete" %} + {% else %} + {% trans "Active" %} + {% endif %} + +
    +
    + + {% include 'snippets/pagination.html' with page=jobs path=request.path %}
    {% endblock %} diff --git a/bookwyrm/templates/import/import_status.html b/bookwyrm/templates/import/import_status.html index 3a063954a..757ed49a9 100644 --- a/bookwyrm/templates/import/import_status.html +++ b/bookwyrm/templates/import/import_status.html @@ -41,7 +41,7 @@
    - {% if not job.complete %} + {% if not job.complete and show_progress %}
    @@ -66,6 +66,13 @@
    {% endif %} + {% if not job.complete %} +
    + {% csrf_token %} + +
    + {% endif %} + {% if manual_review_count and not legacy %}
    {% blocktrans trimmed count counter=manual_review_count with display_counter=manual_review_count|intcomma %} @@ -94,7 +101,7 @@
    {% block actions %}{% endblock %}
    - +
    {% else %} + {% if not items %} + + + + {% endif %} {% for item in items %} {% block index_col %} @@ -169,7 +183,7 @@

    {{ item.review|truncatechars:100 }}

    {% endif %} {% if item.linked_review %} - {% trans "View imported review" %} + {% trans "View imported review" %} {% endif %} {% block import_cols %} diff --git a/bookwyrm/templates/import/manual_review.html b/bookwyrm/templates/import/manual_review.html index 7e429a0fa..392eae639 100644 --- a/bookwyrm/templates/import/manual_review.html +++ b/bookwyrm/templates/import/manual_review.html @@ -42,7 +42,7 @@
    {% with guess=item.book_guess %} diff --git a/bookwyrm/templates/landing/login.html b/bookwyrm/templates/landing/login.html index c9ac25261..369a72bd2 100644 --- a/bookwyrm/templates/landing/login.html +++ b/bookwyrm/templates/landing/login.html @@ -14,7 +14,7 @@ {% if show_confirmed_email %}

    {% trans "Success! Email address confirmed." %}

    {% endif %} -
    + {% csrf_token %} {% if show_confirmed_email %}{% 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/landing/reactivate.html b/bookwyrm/templates/landing/reactivate.html new file mode 100644 index 000000000..da9e0b050 --- /dev/null +++ b/bookwyrm/templates/landing/reactivate.html @@ -0,0 +1,60 @@ +{% extends 'layout.html' %} +{% load i18n %} + +{% block title %}{% trans "Reactivate Account" %}{% endblock %} + +{% block content %} +

    {% trans "Reactivate Account" %}

    +
    +
    + {% if login_form.non_field_errors %} +

    {{ login_form.non_field_errors }}

    + {% endif %} + + + {% csrf_token %} +
    + +
    + +
    +
    +
    + +
    + +
    + + {% include 'snippets/form_errors.html' with errors_list=login_form.password.errors id="desc_password" %} +
    +
    +
    + +
    +
    + +
    + + {% if site.allow_registration %} +
    +
    +

    {% trans "Create an Account" %}

    +
    + {% include 'snippets/register_form.html' %} + +
    +
    + {% endif %} + +
    +
    + {% include 'snippets/about.html' %} + +

    + {% trans "More about this site" %} +

    +
    +
    +
    + +{% endblock %} diff --git a/bookwyrm/templates/layout.html b/bookwyrm/templates/layout.html index 6b9e4daa1..e58f65edd 100644 --- a/bookwyrm/templates/layout.html +++ b/bookwyrm/templates/layout.html @@ -47,7 +47,7 @@ {% else %} {% trans "Search for a book" as search_placeholder %} {% endif %} - +
    @@ -67,14 +67,32 @@ {% include "search/barcode_modal.html" with id="barcode-scanner-modal" %} -
    {% trans "Row" %} @@ -137,6 +144,13 @@
    + {% trans "No items currently need review" %} +