diff --git a/.env.example b/.env.example index af1d6430c..bbd825a9a 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 @@ -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 @@ -108,3 +108,10 @@ OTEL_EXPORTER_OTLP_ENDPOINT= OTEL_EXPORTER_OTLP_HEADERS= # Service name to identify your app OTEL_SERVICE_NAME= + +# Set HTTP_X_FORWARDED_PROTO ONLY to true if you know what you are doing. +# Only use it if your proxy is "swallowing" if the original request was made +# via https. Please refer to the Django-Documentation and assess the risks +# for your instance: +# https://docs.djangoproject.com/en/3.2/ref/settings/#secure-proxy-ssl-header +HTTP_X_FORWARDED_PROTO=false diff --git a/.github/workflows/django-tests.yml b/.github/workflows/django-tests.yml index 97a744813..4335a4605 100644 --- a/.github/workflows/django-tests.yml +++ b/.github/workflows/django-tests.yml @@ -56,5 +56,6 @@ jobs: EMAIL_USE_TLS: true ENABLE_PREVIEW_IMAGES: false ENABLE_THUMBNAIL_GENERATION: true + HTTP_X_FORWARDED_PROTO: false 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 5dd5e19a7..1b731dc2b 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Keep track of what books you've read, and what books you'd like to read in the f Federation allows you to interact with users on other instances and services, and also shares metadata about books and authors, which collaboratively builds a decentralized database of books. ### Privacy and moderation -Users and administrators can control who can see thier posts and what other instances to federate with. +Users and administrators can control who can see their posts and what other instances to federate with. ## Tech Stack Web backend diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index fa1535694..095ec0227 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__) @@ -194,6 +194,11 @@ class ActivityObject: try: if issubclass(type(v), ActivityObject): data[k] = v.serialize() + elif isinstance(v, list): + data[k] = [ + e.serialize() if issubclass(type(e), ActivityObject) else e + for e in v + ] except TypeError: pass data = {k: v for (k, v) in data.items() if v is not None and k not in omit} @@ -202,7 +207,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 +276,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 @@ -306,7 +311,9 @@ class Link(ActivityObject): def serialize(self, **kwargs): """remove fields""" - omit = ("id", "type", "@context") + omit = ("id", "@context") + if self.type == "Link": + omit += ("type",) return super().serialize(omit=omit) diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py index e6a01b359..745aa3aab 100644 --- a/bookwyrm/activitypub/book.py +++ b/bookwyrm/activitypub/book.py @@ -19,6 +19,8 @@ class BookData(ActivityObject): viaf: str = None wikidata: str = None asin: str = None + aasin: str = None + isfdb: str = None lastEditedBy: str = None links: List[str] = field(default_factory=lambda: []) fileLinks: List[str] = field(default_factory=lambda: []) 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 385880e5a..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__) @@ -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 df9b2e43a..a330b2c4a 100644 --- a/bookwyrm/connectors/inventaire.py +++ b/bookwyrm/connectors/inventaire.py @@ -165,8 +165,8 @@ class Connector(AbstractConnector): edition_data = self.get_book_data(edition_data) except ConnectorException: # who, indeed, knows - return - super().create_edition_from_data(work, edition_data, instance=instance) + return None + 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 9349b8ae2..2271077b1 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 @@ -18,12 +18,18 @@ def email_data(): } +def test_email(user): + """Just an admin checking if emails are sending""" + data = email_data() + send_email(user.email, *format_email("test", data)) + + def email_confirmation_email(user): """newly registered users confirm email address""" 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,7 +44,7 @@ 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): @@ -48,6 +54,7 @@ def moderation_report_email(report): 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"] @@ -68,7 +75,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/admin.py b/bookwyrm/forms/admin.py index 4141327d3..acff6cfaa 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 @@ -54,11 +55,45 @@ class CreateInviteForm(CustomForm): class SiteForm(CustomForm): class Meta: model = models.SiteSettings - exclude = ["admin_code", "install_mode"] + fields = [ + "name", + "instance_tagline", + "instance_description", + "instance_short_description", + "default_theme", + "code_of_conduct", + "privacy_policy", + "impressum", + "show_impressum", + "logo", + "logo_small", + "favicon", + "support_link", + "support_title", + "admin_email", + "footer_item", + ] widgets = { "instance_short_description": forms.TextInput( attrs={"aria-describedby": "desc_instance_short_description"} ), + } + + +class RegistrationForm(CustomForm): + class Meta: + model = models.SiteSettings + fields = [ + "allow_registration", + "allow_invite_requests", + "registration_closed_text", + "invite_request_text", + "invite_request_question", + "invite_question_text", + "require_confirm_email", + ] + + widgets = { "require_confirm_email": forms.CheckboxInput( attrs={"aria-describedby": "desc_require_confirm_email"} ), @@ -68,6 +103,23 @@ class SiteForm(CustomForm): } +class RegistrationLimitedForm(CustomForm): + class Meta: + model = models.SiteSettings + fields = [ + "registration_closed_text", + "invite_request_text", + "invite_request_question", + "invite_question_text", + ] + + widgets = { + "invite_request_text": forms.Textarea( + attrs={"aria-describedby": "desc_invite_request_text"} + ), + } + + class ThemeForm(CustomForm): class Meta: model = models.Theme @@ -130,7 +182,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 +191,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/author.py b/bookwyrm/forms/author.py index ca59426de..a7811180f 100644 --- a/bookwyrm/forms/author.py +++ b/bookwyrm/forms/author.py @@ -21,6 +21,7 @@ class AuthorForm(CustomForm): "inventaire_id", "librarything_key", "goodreads_key", + "isfdb", "isni", ] widgets = { diff --git a/bookwyrm/forms/books.py b/bookwyrm/forms/books.py index 9b3c84010..623beaa04 100644 --- a/bookwyrm/forms/books.py +++ b/bookwyrm/forms/books.py @@ -18,19 +18,30 @@ class CoverForm(CustomForm): class EditionForm(CustomForm): class Meta: model = models.Edition - exclude = [ - "remote_id", - "origin_id", - "created_date", - "updated_date", - "edition_rank", - "authors", - "parent_work", - "shelves", - "connector", - "search_vector", - "links", - "file_links", + fields = [ + "title", + "subtitle", + "description", + "series", + "series_number", + "languages", + "subjects", + "publishers", + "first_published_date", + "published_date", + "cover", + "physical_format", + "physical_format_detail", + "pages", + "isbn_13", + "isbn_10", + "openlibrary_key", + "inventaire_id", + "goodreads_key", + "oclc_number", + "asin", + "aasin", + "isfdb", ] widgets = { "title": forms.TextInput(attrs={"aria-describedby": "desc_title"}), @@ -73,10 +84,15 @@ class EditionForm(CustomForm): "inventaire_id": forms.TextInput( attrs={"aria-describedby": "desc_inventaire_id"} ), + "goodreads_key": forms.TextInput( + attrs={"aria-describedby": "desc_goodreads_key"} + ), "oclc_number": forms.TextInput( attrs={"aria-describedby": "desc_oclc_number"} ), "ASIN": forms.TextInput(attrs={"aria-describedby": "desc_ASIN"}), + "AASIN": forms.TextInput(attrs={"aria-describedby": "desc_AASIN"}), + "isfdb": forms.TextInput(attrs={"aria-describedby": "desc_isfdb"}), } 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 a291c6441..ce7bb6d07 100644 --- a/bookwyrm/forms/edit_user.py +++ b/bookwyrm/forms/edit_user.py @@ -8,7 +8,6 @@ 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: @@ -99,3 +98,21 @@ class ChangePasswordForm(CustomForm): 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 4aa1e5758..ea6093750 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: @@ -58,6 +58,21 @@ class ReadThroughForm(CustomForm): 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/landing.py b/bookwyrm/forms/landing.py index a31e8a7c4..bd9884bc3 100644 --- a/bookwyrm/forms/landing.py +++ b/bookwyrm/forms/landing.py @@ -4,7 +4,10 @@ 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 @@ -18,6 +21,21 @@ class LoginForm(CustomForm): "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: @@ -74,3 +92,40 @@ class PasswordResetForm(CustomForm): 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/links.py b/bookwyrm/forms/links.py index de229bc2d..d2fd5f116 100644 --- a/bookwyrm/forms/links.py +++ b/bookwyrm/forms/links.py @@ -36,13 +36,16 @@ class FileLinkForm(CustomForm): "This domain is blocked. Please contact your administrator if you think this is an error." ), ) - elif models.FileLink.objects.filter( + if ( + not self.instance + and models.FileLink.objects.filter( url=url, book=book, filetype=filetype - ).exists(): - # pylint: disable=line-too-long - self.add_error( - "url", - _( - "This link with file type has already been added for this book. If it is not visible, the domain is still pending." - ), - ) + ).exists() + ): + # pylint: disable=line-too-long + self.add_error( + "url", + _( + "This link with file type has already been added for this book. If it is not visible, the domain is still pending." + ), + ) diff --git a/bookwyrm/importers/importer.py b/bookwyrm/importers/importer.py index 32800e772..e4ee2c31a 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: @@ -24,8 +16,8 @@ class Importer: ("id", ["id", "book id"]), ("title", ["title"]), ("authors", ["author", "authors", "primary author"]), - ("isbn_10", ["isbn10", "isbn"]), - ("isbn_13", ["isbn13", "isbn", "isbns"]), + ("isbn_10", ["isbn10", "isbn", "isbn/uid"]), + ("isbn_13", ["isbn13", "isbn", "isbns", "isbn/uid"]), ("shelf", ["shelf", "exclusive shelf", "read status", "bookshelf"]), ("review_name", ["review name"]), ("review_body", ["my review", "review"]), @@ -44,7 +36,11 @@ class Importer: def create_job(self, user, csv_file, include_reviews, privacy): """check over a csv and creates a database entry for the job""" csv_reader = csv.DictReader(csv_file, delimiter=self.delimiter) - rows = enumerate(list(csv_reader)) + rows = list(csv_reader) + if len(rows) < 1: + raise ValueError("CSV file is empty") + rows = enumerate(rows) + job = ImportJob.objects.create( user=user, include_reviews=include_reviews, @@ -118,127 +114,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/confirm_email.py b/bookwyrm/management/commands/confirm_email.py new file mode 100644 index 000000000..450da7eec --- /dev/null +++ b/bookwyrm/management/commands/confirm_email.py @@ -0,0 +1,19 @@ +""" manually confirm e-mail of user """ +from django.core.management.base import BaseCommand + +from bookwyrm import models + + +class Command(BaseCommand): + """command-line options""" + + help = "Manually confirm email for 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.reactivate() + self.stdout.write(self.style.SUCCESS("User's email is now confirmed.")) diff --git a/bookwyrm/management/commands/initdb.py b/bookwyrm/management/commands/initdb.py index 23020a0a6..fda40bd07 100644 --- a/bookwyrm/management/commands/initdb.py +++ b/bookwyrm/management/commands/initdb.py @@ -8,54 +8,64 @@ from bookwyrm import models def init_groups(): """permission levels""" - groups = ["admin", "moderator", "editor"] + groups = ["admin", "owner", "moderator", "editor"] for group in groups: - Group.objects.create(name=group) + Group.objects.get_or_create(name=group) def init_permissions(): """permission types""" permissions = [ + { + "codename": "manage_registration", + "name": "allow or prevent user registration", + "groups": ["admin"], + }, + { + "codename": "system_administration", + "name": "technical controls", + "groups": ["admin"], + }, { "codename": "edit_instance_settings", "name": "change the instance info", - "groups": ["admin"], + "groups": ["admin", "owner"], }, { "codename": "set_user_group", "name": "change what group a user is in", - "groups": ["admin", "moderator"], + "groups": ["admin", "owner", "moderator"], }, { "codename": "control_federation", "name": "control who to federate with", - "groups": ["admin", "moderator"], + "groups": ["admin", "owner", "moderator"], }, { "codename": "create_invites", "name": "issue invitations to join", - "groups": ["admin", "moderator"], + "groups": ["admin", "owner", "moderator"], }, { "codename": "moderate_user", "name": "deactivate or silence a user", - "groups": ["admin", "moderator"], + "groups": ["admin", "owner", "moderator"], }, { "codename": "moderate_post", "name": "delete other users' posts", - "groups": ["admin", "moderator"], + "groups": ["admin", "owner", "moderator"], }, { "codename": "edit_book", "name": "edit book info", - "groups": ["admin", "moderator", "editor"], + "groups": ["admin", "owner", "moderator", "editor"], }, ] content_type = ContentType.objects.get_for_model(models.User) for permission in permissions: - permission_obj = Permission.objects.create( + permission_obj, _ = Permission.objects.get_or_create( codename=permission["codename"], name=permission["name"], content_type=content_type, 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/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/migrations/0167_auto_20221125_1900.py b/bookwyrm/migrations/0167_auto_20221125_1900.py new file mode 100644 index 000000000..db258b7c5 --- /dev/null +++ b/bookwyrm/migrations/0167_auto_20221125_1900.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.16 on 2022-11-25 19:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0166_sitesettings_imports_enabled"), + ] + + operations = [ + migrations.AddField( + model_name="sitesettings", + name="impressum", + field=models.TextField(default="Add a impressum here."), + ), + migrations.AddField( + model_name="sitesettings", + name="show_impressum", + field=models.BooleanField(default=False), + ), + ] diff --git a/bookwyrm/migrations/0168_auto_20221205_1701.py b/bookwyrm/migrations/0168_auto_20221205_1701.py new file mode 100644 index 000000000..45d6c30e7 --- /dev/null +++ b/bookwyrm/migrations/0168_auto_20221205_1701.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.16 on 2022-12-05 17:01 + +import bookwyrm.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0167_auto_20221125_1900"), + ] + + operations = [ + migrations.AddField( + model_name="author", + name="aasin", + field=bookwyrm.models.fields.CharField( + blank=True, max_length=255, null=True + ), + ), + migrations.AddField( + model_name="book", + name="aasin", + field=bookwyrm.models.fields.CharField( + blank=True, max_length=255, null=True + ), + ), + ] diff --git a/bookwyrm/migrations/0168_auto_20221205_2331.py b/bookwyrm/migrations/0168_auto_20221205_2331.py new file mode 100644 index 000000000..901ca56f0 --- /dev/null +++ b/bookwyrm/migrations/0168_auto_20221205_2331.py @@ -0,0 +1,63 @@ +""" I added two new permission types and a new group to the management command that +creates the database on install, this creates them for existing instances """ +# Generated by Django 3.2.16 on 2022-12-05 23:31 + +from django.db import migrations + + +def create_groups_and_perms(apps, schema_editor): + """create the new "owner" group and "system admin" permission""" + db_alias = schema_editor.connection.alias + group_model = apps.get_model("auth", "Group") + # Add the "owner" group, if needed + owner_group, group_created = group_model.objects.using(db_alias).get_or_create( + name="owner" + ) + + # Create perms, if needed + user_model = apps.get_model("bookwyrm", "User") + content_type_model = apps.get_model("contenttypes", "ContentType") + content_type = content_type_model.objects.get_for_model(user_model) + perms_model = apps.get_model("auth", "Permission") + reg_perm, perm_created = perms_model.objects.using(db_alias).get_or_create( + codename="manage_registration", + name="allow or prevent user registration", + content_type=content_type, + ) + admin_perm, admin_perm_created = perms_model.objects.using(db_alias).get_or_create( + codename="system_administration", + name="technical controls", + content_type=content_type, + ) + + # Add perms to the group if anything was created + if group_created or perm_created or admin_perm_created: + perms = [ + "edit_instance_settings", + "set_user_group", + "control_federation", + "create_invites", + "moderate_user", + "moderate_post", + "edit_book", + ] + owner_group.permissions.set( + perms_model.objects.using(db_alias).filter(codename__in=perms).all() + ) + + # also extend these perms to admins + # This is get or create so the tests don't fail -- it should already exist + admin_group, _ = group_model.objects.using(db_alias).get_or_create(name="admin") + admin_group.permissions.add(reg_perm) + admin_group.permissions.add(admin_perm) + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0167_auto_20221125_1900"), + ] + + operations = [ + migrations.RunPython(create_groups_and_perms, migrations.RunPython.noop) + ] diff --git a/bookwyrm/migrations/0169_auto_20221206_0902.py b/bookwyrm/migrations/0169_auto_20221206_0902.py new file mode 100644 index 000000000..7235490eb --- /dev/null +++ b/bookwyrm/migrations/0169_auto_20221206_0902.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.16 on 2022-12-06 09:02 + +import bookwyrm.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0168_auto_20221205_1701"), + ] + + operations = [ + migrations.AddField( + model_name="author", + name="isfdb", + field=bookwyrm.models.fields.CharField( + blank=True, max_length=255, null=True + ), + ), + migrations.AddField( + model_name="book", + name="isfdb", + field=bookwyrm.models.fields.CharField( + blank=True, max_length=255, null=True + ), + ), + ] diff --git a/bookwyrm/migrations/0170_merge_0168_auto_20221205_2331_0169_auto_20221206_0902.py b/bookwyrm/migrations/0170_merge_0168_auto_20221205_2331_0169_auto_20221206_0902.py new file mode 100644 index 000000000..3e199b014 --- /dev/null +++ b/bookwyrm/migrations/0170_merge_0168_auto_20221205_2331_0169_auto_20221206_0902.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.16 on 2022-12-11 20:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0168_auto_20221205_2331"), + ("bookwyrm", "0169_auto_20221206_0902"), + ] + + operations = [] 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/antispam.py b/bookwyrm/models/antispam.py index dd2a6df26..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.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,7 +65,7 @@ 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(): @@ -61,17 +75,14 @@ def automod_task(): if not reports: return - admins = User.objects.filter( - models.Q(user_permissions__name__in=["moderate_user", "moderate_post"]) - | models.Q(is_superuser=True) - ).all() + admins = User.admins() notification_model = apps.get_model("bookwyrm", "Notification", require_ready=True) with transaction.atomic(): for admin in admins: notification, _ = notification_model.objects.get_or_create( user=admin, notification_type=notification_model.REPORT, read=False ) - notification.related_repors.add(reports) + notification.related_reports.set(reports) def automod_users(reporter): diff --git a/bookwyrm/models/author.py b/bookwyrm/models/author.py index 78d153a21..de0c6483f 100644 --- a/bookwyrm/models/author.py +++ b/bookwyrm/models/author.py @@ -24,6 +24,9 @@ class Author(BookDataModel): gutenberg_id = fields.CharField( max_length=255, blank=True, null=True, deduplication_field=True ) + isfdb = fields.CharField( + max_length=255, blank=True, null=True, deduplication_field=True + ) # idk probably other keys would be useful here? born = fields.DateTimeField(blank=True, null=True) died = fields.DateTimeField(blank=True, null=True) @@ -42,6 +45,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 @@ -55,6 +63,11 @@ class Author(BookDataModel): """generate the url from the openlibrary id""" return f"https://openlibrary.org/authors/{self.openlibrary_key}" + @property + def isfdb_link(self): + """generate the url from the isni id""" + return f"https://www.isfdb.org/cgi-bin/ea.cgi?{self.isfdb}" + def get_remote_id(self): """editions and works both use "book" instead of model_name""" return f"https://{DOMAIN}/author/{self.id}" diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py index 3ac220bc4..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")), diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index 190046019..e990b6d64 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -55,6 +55,12 @@ class BookDataModel(ObjectMixin, BookWyrmModel): asin = fields.CharField( max_length=255, blank=True, null=True, deduplication_field=True ) + aasin = fields.CharField( + max_length=255, blank=True, null=True, deduplication_field=True + ) + isfdb = fields.CharField( + max_length=255, blank=True, null=True, deduplication_field=True + ) search_vector = SearchVectorField(null=True) last_edited_by = fields.ForeignKey( @@ -73,6 +79,11 @@ class BookDataModel(ObjectMixin, BookWyrmModel): """generate the url from the inventaire id""" return f"https://inventaire.io/entity/{self.inventaire_id}" + @property + def isfdb_link(self): + """generate the url from the isfdb id""" + return f"https://www.isfdb.org/cgi-bin/title.cgi?{self.isfdb}" + class Meta: """can't initialize this model, that wouldn't make sense""" @@ -241,6 +252,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 785f3397c..d11f5fb1d 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -13,6 +13,7 @@ from django.forms import ClearableFileInput, ImageField as DjangoImageField from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django.utils.encoding import filepath_to_uri +from markdown import markdown from bookwyrm import activitypub from bookwyrm.connectors import get_image @@ -499,6 +500,9 @@ class HtmlField(ActivitypubFieldMixin, models.TextField): return None return clean(value) + def field_to_activity(self, value): + return markdown(value) if value else value + class ArrayField(ActivitypubFieldMixin, DjangoArrayField): """activitypub-aware array field""" 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/notification.py b/bookwyrm/models/notification.py index b0b75a169..fa2ce54e2 100644 --- a/bookwyrm/models/notification.py +++ b/bookwyrm/models/notification.py @@ -214,7 +214,7 @@ 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=Notification.IMPORT, related_import=instance, @@ -231,10 +231,7 @@ def notify_admins_on_report(sender, instance, created, *args, **kwargs): 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, _ = Notification.objects.get_or_create( user=admin, diff --git a/bookwyrm/models/report.py b/bookwyrm/models/report.py index d161c0349..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 @@ -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/site.py b/bookwyrm/models/site.py index 7730391f1..533a37b30 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) @@ -45,6 +62,8 @@ class SiteSettings(models.Model): ) code_of_conduct = models.TextField(default="Add a code of conduct here.") privacy_policy = models.TextField(default="Add a privacy policy here.") + impressum = models.TextField(default="Add a impressum here.") + show_impressum = models.BooleanField(default=False) # registration allow_registration = models.BooleanField(default=False) @@ -69,6 +88,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 +137,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 +160,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 +186,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 fce69cae2..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""" @@ -363,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, ) @@ -399,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 f0c3cf04d..c885902f9 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -5,6 +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.exceptions import PermissionDenied from django.dispatch import receiver from django.db import models, transaction from django.utils import timezone @@ -19,7 +20,7 @@ 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 @@ -46,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""" @@ -168,12 +170,19 @@ 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""" @@ -231,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() @@ -352,12 +370,31 @@ 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, + update_fields=["deactivation_reason", "is_active", "allow_reactivation"], + ) + @property def local_path(self): """this model doesn't inherit bookwyrm model, so here we are""" @@ -393,6 +430,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""" @@ -419,7 +462,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel): return super().save(*args, **kwargs) -@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) @@ -463,7 +506,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/settings.py b/bookwyrm/settings.py index 5c2362771..d1d5a84d9 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.5" +VERSION = "0.5.3" 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 = "ad848b97" # 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 @@ -363,3 +367,9 @@ 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 + +HTTP_X_FORWARDED_PROTO = env.bool("SECURE_PROXY_SSL_HEADER", False) +if HTTP_X_FORWARDED_PROTO: + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") diff --git a/bookwyrm/static/css/bookwyrm/_all.scss b/bookwyrm/static/css/bookwyrm/_all.scss index 31e732ebe..1e8569827 100644 --- a/bookwyrm/static/css/bookwyrm/_all.scss +++ b/bookwyrm/static/css/bookwyrm/_all.scss @@ -140,6 +140,10 @@ button:focus-visible .button-invisible-overlay { opacity: 1; } +button.button-paragraph { + vertical-align: middle; +} + /** States ******************************************************************************/ diff --git a/bookwyrm/static/css/bookwyrm/components/_details.scss b/bookwyrm/static/css/bookwyrm/components/_details.scss index c9a0b33b8..4145554eb 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 > * { @@ -81,7 +81,19 @@ details.dropdown .dropdown-menu a:focus-visible { details.details-panel { box-shadow: 0 0 0 1px $border; transition: box-shadow 0.2s ease; - padding: 0.75rem; + padding: 0; + + > * { + padding: 0.75rem; + } + + summary { + position: relative; + + .details-close { + padding: 0.75rem; + } + } } details[open].details-panel, @@ -89,10 +101,6 @@ details.details-panel:hover { box-shadow: 0 0 0 1px $border; } -details.details-panel summary { - position: relative; -} - details summary .details-close { position: absolute; right: 0; diff --git a/bookwyrm/static/css/themes/bookwyrm-dark.scss b/bookwyrm/static/css/themes/bookwyrm-dark.scss index a2eb94efb..ae904b4a4 100644 --- a/bookwyrm/static/css/themes/bookwyrm-dark.scss +++ b/bookwyrm/static/css/themes/bookwyrm-dark.scss @@ -15,6 +15,8 @@ $danger: #872538; $danger-light: #481922; $light: #393939; $red: #ffa1b4; +$black: #000; +$white-ter: hsl(0, 0%, 90%); /* book cover standins */ $no-cover-color: #002549; @@ -56,9 +58,12 @@ $link-active: $white-bis; $link-light: #0d1c26; /* bulma overrides */ +$body-background-color: rgb(17, 18, 18); $background: $background-secondary; $menu-item-active-background-color: $link-background; $navbar-dropdown-item-hover-color: $white; +$info-light: $background-body; +$info-dark: #72b6ee; /* These element's colors are hardcoded, probably a bug in bulma? */ @media screen and (min-width: 769px) { @@ -74,7 +79,7 @@ $navbar-dropdown-item-hover-color: $white; } /* misc */ -$shadow: 0 0.5em 1em -0.125em rgba($black, 0.2), 0 0px 0 1px rgba($black, 0.02); +$shadow: 0 0.5em 0.5em -0.125em rgba($black, 0.2), 0 0px 0 1px rgba($black, 0.02); $card-header-shadow: 0 0.125em 0.25em rgba($black, 0.1); $invisible-overlay-background-color: rgba($black, 0.66); $progress-value-background-color: $border-light; @@ -92,6 +97,11 @@ $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/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js index 95271795d..5b3f13d4a 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") @@ -627,9 +628,9 @@ let BookWyrm = new (class { } function toggleStatus(status) { - for (const child of statusNode.children) { - BookWyrm.toggleContainer(child, !child.classList.contains(status)); - } + const template = document.querySelector(`#barcode-${status}`); + + statusNode.replaceChildren(template ? template.content.cloneNode(true) : null); } function initBarcodes(cameraId = null) { 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 481ecda99..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 %}
@@ -97,6 +97,7 @@

+{% endcache %}
@@ -145,5 +146,4 @@
-{% endcache %} {% endblock %} diff --git a/bookwyrm/templates/about/impressum.html b/bookwyrm/templates/about/impressum.html new file mode 100644 index 000000000..3f892c7a7 --- /dev/null +++ b/bookwyrm/templates/about/impressum.html @@ -0,0 +1,15 @@ +{% extends 'about/layout.html' %} +{% load i18n %} + +{% block title %}{% trans "Impressum" %}{% endblock %} + + +{% block about_content %} +
+

{% trans "Impressum" %}

+
+ {{ site.impressum | safe }} +
+
+ +{% endblock %} diff --git a/bookwyrm/templates/about/layout.html b/bookwyrm/templates/about/layout.html index e921fcd29..22237508c 100644 --- a/bookwyrm/templates/about/layout.html +++ b/bookwyrm/templates/about/layout.html @@ -47,6 +47,14 @@ {% trans "Privacy Policy" %} + {% if site.show_impressum %} +
  • + {% url 'impressum' as path %} + + {% trans "Impressum" %} + +
  • + {% endif %} diff --git a/bookwyrm/templates/annual_summary/layout.html b/bookwyrm/templates/annual_summary/layout.html index 3d1796250..597604504 100644 --- a/bookwyrm/templates/annual_summary/layout.html +++ b/bookwyrm/templates/annual_summary/layout.html @@ -53,7 +53,7 @@ {% trans "Share this page" %} -
    +
    {% if year_key %} diff --git a/bookwyrm/templates/author/author.html b/bookwyrm/templates/author/author.html index 1d87dee96..ade654568 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 %} @@ -27,7 +28,7 @@ {% firstof author.aliases author.born author.died as details %} - {% firstof author.wikipedia_link author.openlibrary_key author.inventaire_id author.isni as links %} + {% firstof author.wikipedia_link author.openlibrary_key author.inventaire_id author.isni author.isfdb as links %} {% if details or links %}
    {% if details %} @@ -80,6 +81,14 @@
    {% endif %} + {% if author.isfdb %} + + {% endif %} + {% trans "Load data" as button_text %} {% if author.openlibrary_key %}
    @@ -127,6 +136,14 @@
    {% endif %} + + {% if author.isfdb %} +
    + + {% trans "View ISFDB entry" %} + +
    + {% endif %}
    {% endif %} @@ -141,9 +158,9 @@

    {% 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 %}
    {% include 'snippets/shelve_button/shelve_button.html' with book=book %} diff --git a/bookwyrm/templates/author/edit_author.html b/bookwyrm/templates/author/edit_author.html index b0727c43b..1916df6be 100644 --- a/bookwyrm/templates/author/edit_author.html +++ b/bookwyrm/templates/author/edit_author.html @@ -101,6 +101,13 @@ {% include 'snippets/form_errors.html' with errors_list=form.goodreads_key.errors id="desc_goodreads_key" %}
    +
    + + {{ form.isfdb }} + + {% include 'snippets/form_errors.html' with errors_list=form.isfdb.errors id="desc_isfdb" %} +
    +
    {{ form.isni }} diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html index 95829ae9d..6a8d4d794 100644 --- a/bookwyrm/templates/book/book.html +++ b/bookwyrm/templates/book/book.html @@ -25,7 +25,7 @@
    -

    +

    {{ book.title }}

    @@ -37,7 +37,7 @@ content="{{ book.subtitle | escape }}" > - + {{ book.subtitle }} {% endif %} @@ -52,7 +52,7 @@ {% endif %} {% if book.authors.exists %} -
    +
    {% trans "by" %} {% include 'snippets/authors.html' with book=book %}
    {% endif %} @@ -135,7 +135,7 @@ {% trans "View on OpenLibrary" %} {% if request.user.is_authenticated and perms.bookwyrm.edit_book %} - @@ -150,7 +150,7 @@ {% if request.user.is_authenticated and perms.bookwyrm.edit_book %} - @@ -158,6 +158,13 @@ {% endif %}

    {% endif %} + {% if book.isfdb %} +

    + + {% trans "View on ISFDB" %} + +

    + {% endif %}
    @@ -189,15 +196,15 @@ {% if user_authenticated and can_edit_book and not book|book_description %} {% trans 'Add Description' as button_text %} - {% include 'snippets/toggle/open_button.html' with text=button_text controls_text="add_description" controls_uid=book.id focus="id_description" hide_active=True id="hide_description" %} + {% include 'snippets/toggle/open_button.html' with class="mb-2" text=button_text controls_text="add_description" controls_uid=book.id focus="id_description" hide_active=True id="hide_description" %} diff --git a/bookwyrm/templates/book/file_links/edit_links.html b/bookwyrm/templates/book/file_links/edit_links.html index 77431726b..e6819f6bb 100644 --- a/bookwyrm/templates/book/file_links/edit_links.html +++ b/bookwyrm/templates/book/file_links/edit_links.html @@ -86,6 +86,7 @@
    + {% include 'snippets/form_errors.html' with errors_list=link.form.availability.errors id="desc_availability" %} diff --git a/bookwyrm/templates/email/moderation_report/html_content.html b/bookwyrm/templates/email/moderation_report/html_content.html index 3828ff70c..0e604ebf8 100644 --- a/bookwyrm/templates/email/moderation_report/html_content.html +++ b/bookwyrm/templates/email/moderation_report/html_content.html @@ -3,7 +3,7 @@ {% block content %}

    -{% if report_link %} +{% if link_domain %} {% blocktrans trimmed %} @{{ reporter }} has flagged a link domain for moderation. diff --git a/bookwyrm/templates/email/moderation_report/text_content.html b/bookwyrm/templates/email/moderation_report/text_content.html index 764a3c72a..351ab58ed 100644 --- a/bookwyrm/templates/email/moderation_report/text_content.html +++ b/bookwyrm/templates/email/moderation_report/text_content.html @@ -2,7 +2,7 @@ {% load i18n %} {% block content %} -{% if report_link %} +{% if link_domain %} {% blocktrans trimmed %} @{{ reporter }} has flagged a link domain for moderation. {% endblocktrans %} diff --git a/bookwyrm/templates/email/test/html_content.html b/bookwyrm/templates/email/test/html_content.html new file mode 100644 index 000000000..7cf577f45 --- /dev/null +++ b/bookwyrm/templates/email/test/html_content.html @@ -0,0 +1,12 @@ +{% extends 'email/html_layout.html' %} +{% load i18n %} + +{% block content %} +

    +{% blocktrans trimmed %} +This is a test email. +{% endblocktrans %} +

    + + +{% endblock %} diff --git a/bookwyrm/templates/email/test/subject.html b/bookwyrm/templates/email/test/subject.html new file mode 100644 index 000000000..6ddada523 --- /dev/null +++ b/bookwyrm/templates/email/test/subject.html @@ -0,0 +1,4 @@ +{% load i18n %} +{% blocktrans trimmed %} +Test email +{% endblocktrans %} diff --git a/bookwyrm/templates/email/test/text_content.html b/bookwyrm/templates/email/test/text_content.html new file mode 100644 index 000000000..9d8a8f685 --- /dev/null +++ b/bookwyrm/templates/email/test/text_content.html @@ -0,0 +1,9 @@ +{% extends 'email/text_layout.html' %} +{% load i18n %} +{% block content %} +{% blocktrans trimmed %} +This is a test email. +{% endblocktrans %} + + +{% endblock %} 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/status_types_filter.html b/bookwyrm/templates/feed/status_types_filter.html index 1a6255b6e..ff1e800f8 100644 --- a/bookwyrm/templates/feed/status_types_filter.html +++ b/bookwyrm/templates/feed/status_types_filter.html @@ -2,7 +2,7 @@ {% load i18n %} {% block filter %} - +
    {% for name, value in feed_status_types_options %} diff --git a/bookwyrm/templates/guided_tour/search.html b/bookwyrm/templates/guided_tour/search.html index aa8cf7538..3e726aeb8 100644 --- a/bookwyrm/templates/guided_tour/search.html +++ b/bookwyrm/templates/guided_tour/search.html @@ -119,7 +119,7 @@ }, { text: `{% trans "If you still can't find your book, you can add a record manually." %}`, - title: "{% trans 'Add a record manally' %}", + title: "{% trans 'Add a record manually' %}", attachTo: { element: "#tour-manually-add-book", on: "right", diff --git a/bookwyrm/templates/import/import.html b/bookwyrm/templates/import/import.html index 9657773d6..325caa92b 100644 --- a/bookwyrm/templates/import/import.html +++ b/bookwyrm/templates/import/import.html @@ -7,79 +7,162 @@ {% block content %}

    {% trans "Import Books" %}

    -
    - {% csrf_token %} -
    -
    + {% if invalid %} +
    + {% trans "Not a valid CSV file" %} +
    + {% endif %} -
    - + {% if site.imports_enabled %} + {% if recent_avg_hours or recent_avg_minutes %} +
    +

    + {% if recent_avg_hours %} + {% blocktrans trimmed with hours=recent_avg_hours|floatformat:0|intcomma %} + On average, recent imports have taken {{ hours }} hours. + {% endblocktrans %} + {% else %} + {% blocktrans trimmed with minutes=recent_avg_minutes|floatformat:0|intcomma %} + On average, recent imports have taken {{ minutes }} minutes. + {% endblocktrans %} + {% endif %} +

    +
    + {% endif %} -
    - + + {% csrf_token %} + +
    + -
    - - {{ import_form.csv_file }} +
    +
    + +
    +
    + + {% include 'snippets/privacy_select.html' with no_label=True privacy_uuid="import" %} +
    -
    - -
    -
    -
    -
    - - {% include 'snippets/privacy_select.html' with no_label=True privacy_uuid="import" %} -
    -
    -
    - - + + + {% else %} +
    +

    + +

    +

    + {% trans "Imports are temporarily disabled; thank you for your patience." %} +

    +
    + {% endif %}

    {% 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 d0ad7b7e7..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 %} diff --git a/bookwyrm/templates/landing/layout.html b/bookwyrm/templates/landing/layout.html index bf0a6b2a1..e3cdf1bdf 100644 --- a/bookwyrm/templates/landing/layout.html +++ b/bookwyrm/templates/landing/layout.html @@ -73,7 +73,7 @@ {% if site.invite_request_question %}
    - + {% include 'snippets/form_errors.html' with errors_list=request_form.answer.errors id="desc_answer_register" %}
    {% endif %} 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/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 205c4178e..81aaee575 100644 --- a/bookwyrm/templates/layout.html +++ b/bookwyrm/templates/layout.html @@ -13,6 +13,7 @@ + {% if preview_images_enabled is True %} @@ -168,7 +169,7 @@ {% if request.user.is_authenticated and active_announcements.exists %} -
    +
    {% for announcement in active_announcements %} {% include 'snippets/announcement.html' with announcement=announcement %} @@ -192,53 +193,7 @@
    - +{% include 'snippets/footer.html' %} {% endblock %}
    {% trans "Row" %} @@ -137,6 +144,13 @@
    + {% trans "No items currently need review" %} +