diff --git a/.env.example b/.env.example index bb2d677ef..58c53b5bf 100644 --- a/.env.example +++ b/.env.example @@ -21,8 +21,8 @@ MEDIA_ROOT=images/ # Database configuration PGPORT=5432 POSTGRES_PASSWORD=securedbypassword123 -POSTGRES_USER=fedireads -POSTGRES_DB=fedireads +POSTGRES_USER=bookwyrm +POSTGRES_DB=bookwyrm POSTGRES_HOST=db # Redis activity stream manager @@ -56,7 +56,7 @@ EMAIL_SENDER_NAME=admin EMAIL_SENDER_DOMAIN= # Query timeouts -SEARCH_TIMEOUT=15 +SEARCH_TIMEOUT=5 QUERY_TIMEOUT=5 # Thumbnails Generation @@ -79,7 +79,7 @@ AWS_SECRET_ACCESS_KEY= # Preview image generation can be computing and storage intensive -# ENABLE_PREVIEW_IMAGES=True +ENABLE_PREVIEW_IMAGES=False # Specify RGB tuple or RGB hex strings, # or use_dominant_color_light / use_dominant_color_dark diff --git a/.github/workflows/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/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index fa1535694..e942c9aeb 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -7,7 +7,7 @@ from django.apps import apps from django.db import IntegrityError, transaction from bookwyrm.connectors import ConnectorException, get_data -from bookwyrm.tasks import app +from bookwyrm.tasks import app, MEDIUM logger = logging.getLogger(__name__) @@ -202,7 +202,7 @@ class ActivityObject: return data -@app.task(queue="medium_priority") +@app.task(queue=MEDIUM) @transaction.atomic def set_related_field( model_name, origin_model_name, related_field_name, related_remote_id, data 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..803641cb7 100644 --- a/bookwyrm/book_search.py +++ b/bookwyrm/book_search.py @@ -7,6 +7,7 @@ from django.contrib.postgres.search import SearchRank, SearchQuery from django.db.models import OuterRef, Subquery, F, Q from bookwyrm import models +from bookwyrm import connectors from bookwyrm.settings import MEDIA_FULL_URL @@ -30,7 +31,9 @@ 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( reduce(operator.or_, (Q(**f) for f in filters)) @@ -72,6 +75,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} 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..c1ee7fe78 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}" @@ -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/emailing.py b/bookwyrm/emailing.py index 9349b8ae2..80aacf7f4 100644 --- a/bookwyrm/emailing.py +++ b/bookwyrm/emailing.py @@ -3,7 +3,7 @@ from django.core.mail import EmailMultiAlternatives from django.template.loader import get_template from bookwyrm import models, settings -from bookwyrm.tasks import app +from bookwyrm.tasks import app, HIGH from bookwyrm.settings import DOMAIN @@ -23,7 +23,7 @@ def email_confirmation_email(user): data = email_data() data["confirmation_code"] = user.confirmation_code data["confirmation_link"] = user.confirmation_link - send_email.delay(user.email, *format_email("confirm", data)) + send_email(user.email, *format_email("confirm", data)) def invite_email(invite_request): @@ -48,6 +48,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 +69,7 @@ def format_email(email_name, data): return (subject, html_content, text_content) -@app.task(queue="high_priority") +@app.task(queue=HIGH) def send_email(recipient, subject, html_content, text_content): """use a task to send the email""" email = EmailMultiAlternatives( diff --git a/bookwyrm/forms/admin.py b/bookwyrm/forms/admin.py index 4141327d3..ae15e011b 100644 --- a/bookwyrm/forms/admin.py +++ b/bookwyrm/forms/admin.py @@ -2,13 +2,14 @@ import datetime from django import forms +from django.core.exceptions import PermissionDenied from django.forms import widgets from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django_celery_beat.models import IntervalSchedule from bookwyrm import models -from .custom_form import CustomForm +from .custom_form import CustomForm, StyledForm # pylint: disable=missing-class-docstring @@ -130,7 +131,7 @@ class AutoModRuleForm(CustomForm): fields = ["string_match", "flag_users", "flag_statuses", "created_by"] -class IntervalScheduleForm(CustomForm): +class IntervalScheduleForm(StyledForm): class Meta: model = IntervalSchedule fields = ["every", "period"] @@ -139,3 +140,10 @@ class IntervalScheduleForm(CustomForm): "every": forms.NumberInput(attrs={"aria-describedby": "desc_every"}), "period": forms.Select(attrs={"aria-describedby": "desc_period"}), } + + # pylint: disable=arguments-differ + def save(self, request, *args, **kwargs): + """This is an outside model so the perms check works differently""" + if not request.user.has_perm("bookwyrm.moderate_user"): + raise PermissionDenied() + return super().save(*args, **kwargs) diff --git a/bookwyrm/forms/custom_form.py b/bookwyrm/forms/custom_form.py index 74a3417a2..c604deea4 100644 --- a/bookwyrm/forms/custom_form.py +++ b/bookwyrm/forms/custom_form.py @@ -4,7 +4,7 @@ from django.forms import ModelForm from django.forms.widgets import Textarea -class CustomForm(ModelForm): +class StyledForm(ModelForm): """add css classes to the forms""" def __init__(self, *args, **kwargs): @@ -16,7 +16,7 @@ class CustomForm(ModelForm): css_classes["checkbox"] = "checkbox" css_classes["textarea"] = "textarea" # pylint: disable=super-with-arguments - super(CustomForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) for visible in self.visible_fields(): if hasattr(visible.field.widget, "input_type"): input_type = visible.field.widget.input_type @@ -24,3 +24,13 @@ class CustomForm(ModelForm): input_type = "textarea" visible.field.widget.attrs["rows"] = 5 visible.field.widget.attrs["class"] = css_classes[input_type] + + +class CustomForm(StyledForm): + """Check permissions on save""" + + # pylint: disable=arguments-differ + def save(self, request, *args, **kwargs): + """Save and check perms""" + self.instance.raise_not_editable(request.user) + return super().save(*args, **kwargs) diff --git a/bookwyrm/forms/edit_user.py b/bookwyrm/forms/edit_user.py index 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/importers/importer.py b/bookwyrm/importers/importer.py index 32800e772..a3cfba198 100644 --- a/bookwyrm/importers/importer.py +++ b/bookwyrm/importers/importer.py @@ -1,15 +1,7 @@ """ handle reading a csv from an external service, defaults are from Goodreads """ import csv -import logging - from django.utils import timezone -from django.utils.translation import gettext_lazy as _ - -from bookwyrm import models from bookwyrm.models import ImportJob, ImportItem -from bookwyrm.tasks import app, LOW - -logger = logging.getLogger(__name__) class Importer: @@ -118,127 +110,3 @@ class Importer: # this will re-normalize the raw data self.create_item(job, item.index, item.data) return job - - def start_import(self, job): # pylint: disable=no-self-use - """initalizes a csv import job""" - result = start_import_task.delay(job.id) - job.task_id = result.id - job.save() - - -@app.task(queue="low_priority") -def start_import_task(job_id): - """trigger the child tasks for each row""" - job = ImportJob.objects.get(id=job_id) - # these are sub-tasks so that one big task doesn't use up all the memory in celery - for item in job.items.values_list("id", flat=True).all(): - import_item_task.delay(item) - - -@app.task(queue="low_priority") -def import_item_task(item_id): - """resolve a row into a book""" - item = models.ImportItem.objects.get(id=item_id) - try: - item.resolve() - except Exception as err: # pylint: disable=broad-except - item.fail_reason = _("Error loading book") - item.save() - item.update_job() - raise err - - if item.book: - # shelves book and handles reviews - handle_imported_book(item) - else: - item.fail_reason = _("Could not find a match for book") - - item.save() - item.update_job() - - -def handle_imported_book(item): - """process a csv and then post about it""" - job = item.job - user = job.user - if isinstance(item.book, models.Work): - item.book = item.book.default_edition - if not item.book: - item.fail_reason = _("Error loading book") - item.save() - return - if not isinstance(item.book, models.Edition): - item.book = item.book.edition - - existing_shelf = models.ShelfBook.objects.filter(book=item.book, user=user).exists() - - # shelve the book if it hasn't been shelved already - if item.shelf and not existing_shelf: - desired_shelf = models.Shelf.objects.get(identifier=item.shelf, user=user) - shelved_date = item.date_added or timezone.now() - models.ShelfBook( - book=item.book, shelf=desired_shelf, user=user, shelved_date=shelved_date - ).save(priority=LOW) - - for read in item.reads: - # check for an existing readthrough with the same dates - if models.ReadThrough.objects.filter( - user=user, - book=item.book, - start_date=read.start_date, - finish_date=read.finish_date, - ).exists(): - continue - read.book = item.book - read.user = user - read.save() - - if job.include_reviews and (item.rating or item.review) and not item.linked_review: - # we don't know the publication date of the review, - # but "now" is a bad guess - published_date_guess = item.date_read or item.date_added - if item.review: - # pylint: disable=consider-using-f-string - review_title = "Review of {!r} on {!r}".format( - item.book.title, - job.source, - ) - review = models.Review.objects.filter( - user=user, - book=item.book, - name=review_title, - rating=item.rating, - published_date=published_date_guess, - ).first() - if not review: - review = models.Review( - user=user, - book=item.book, - name=review_title, - content=item.review, - rating=item.rating, - published_date=published_date_guess, - privacy=job.privacy, - ) - review.save(software="bookwyrm", priority=LOW) - else: - # just a rating - review = models.ReviewRating.objects.filter( - user=user, - book=item.book, - published_date=published_date_guess, - rating=item.rating, - ).first() - if not review: - review = models.ReviewRating( - user=user, - book=item.book, - rating=item.rating, - published_date=published_date_guess, - privacy=job.privacy, - ) - review.save(software="bookwyrm", priority=LOW) - - # only broadcast this review to other bookwyrm instances - item.linked_review = review - item.save() diff --git a/bookwyrm/management/commands/remove_2fa.py b/bookwyrm/management/commands/remove_2fa.py new file mode 100644 index 000000000..1c9d5f71a --- /dev/null +++ b/bookwyrm/management/commands/remove_2fa.py @@ -0,0 +1,22 @@ +"""deactivate two factor auth""" + +from django.core.management.base import BaseCommand, CommandError +from bookwyrm import models + + +class Command(BaseCommand): + """command-line options""" + + help = "Remove Two Factor Authorisation from user" + + def add_arguments(self, parser): + parser.add_argument("username") + + def handle(self, *args, **options): + name = options["username"] + user = models.User.objects.get(localname=name) + user.two_factor_auth = False + user.save(broadcast=False, update_fields=["two_factor_auth"]) + self.stdout.write( + self.style.SUCCESS("Two Factor Authorisation was removed from user") + ) diff --git a/bookwyrm/management/commands/revoke_preview_image_tasks.py b/bookwyrm/management/commands/revoke_preview_image_tasks.py new file mode 100644 index 000000000..6d6e59e8f --- /dev/null +++ b/bookwyrm/management/commands/revoke_preview_image_tasks.py @@ -0,0 +1,31 @@ +""" Actually let's not generate those preview images """ +import json +from django.core.management.base import BaseCommand +from bookwyrm.tasks import app + + +class Command(BaseCommand): + """Find and revoke image tasks""" + + # pylint: disable=unused-argument + def handle(self, *args, **options): + """reveoke nonessential low priority tasks""" + types = [ + "bookwyrm.preview_images.generate_edition_preview_image_task", + "bookwyrm.preview_images.generate_user_preview_image_task", + ] + self.stdout.write(" | Finding tasks of types:") + self.stdout.write("\n".join(types)) + with app.pool.acquire(block=True) as conn: + tasks = conn.default_channel.client.lrange("low_priority", 0, -1) + self.stdout.write(f" | Found {len(tasks)} task(s) in low priority queue") + + revoke_ids = [] + for task in tasks: + task_json = json.loads(task) + task_type = task_json.get("headers", {}).get("task") + if task_type in types: + revoke_ids.append(task_json.get("headers", {}).get("id")) + self.stdout.write(".", ending="") + self.stdout.write(f"\n | Revoking {len(revoke_ids)} task(s)") + app.control.revoke(revoke_ids) diff --git a/bookwyrm/migrations/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/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..7d2a0e62b 100644 --- a/bookwyrm/models/author.py +++ b/bookwyrm/models/author.py @@ -42,6 +42,11 @@ class Author(BookDataModel): for book in self.book_set.values_list("id", flat=True) ] cache.delete_many(cache_keys) + + # normalize isni format + if self.isni: + self.isni = re.sub(r"\s", "", self.isni) + return super().save(*args, **kwargs) @property diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py index 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..5bef5c1ee 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -241,6 +241,10 @@ class Work(OrderedCollectionPageMixin, Book): """in case the default edition is not set""" return self.editions.order_by("-edition_rank").first() + def author_edition(self, author): + """in case the default edition doesn't have the required author""" + return self.editions.filter(authors=author).order_by("-edition_rank").first() + def to_edition_list(self, **kwargs): """an ordered collection of editions""" return self.to_ordered_collection( diff --git a/bookwyrm/models/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..3c1494204 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) @@ -115,7 +132,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 +155,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 +181,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..5f7b00d87 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,14 @@ 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(user_permissions__name__in=["moderate_user", "moderate_post"]) + | models.Q(is_superuser=True) + ) + def update_active_date(self): """this user is here! they are doing things!""" self.last_active_date = timezone.now() @@ -352,12 +369,28 @@ class User(OrderedCollectionPageMixin, AbstractUser): self.create_shelves() def delete(self, *args, **kwargs): - """deactivate rather than delete a user""" + """We don't actually delete the database entry""" # pylint: disable=attribute-defined-outside-init self.is_active = False # skip the logic in this class's save() super().save(*args, **kwargs) + def deactivate(self): + """Disable the user but allow them to reactivate""" + # pylint: disable=attribute-defined-outside-init + self.is_active = False + self.deactivation_reason = "self_deactivation" + self.allow_reactivation = True + super().save(broadcast=False) + + def reactivate(self): + """Now you want to come back, huh?""" + # pylint: disable=attribute-defined-outside-init + self.is_active = True + self.deactivation_reason = None + self.allow_reactivation = False + super().save(broadcast=False) + @property def local_path(self): """this model doesn't inherit bookwyrm model, so here we are""" @@ -393,6 +426,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 +458,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 +502,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 44e5de530..447a81413 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.4" +VERSION = "0.4.6" RELEASE_API = env( "RELEASE_API", @@ -21,7 +21,7 @@ RELEASE_API = env( PAGE_LENGTH = env("PAGE_LENGTH", 15) DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English") -JS_CACHE = "e678183b" +JS_CACHE = "e678183c" # email EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend") @@ -149,6 +149,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 @@ -156,6 +159,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 @@ -361,6 +365,9 @@ 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 + + def show_toolbar(_): """workaround for docker""" return True diff --git a/bookwyrm/static/css/bookwyrm/components/_details.scss b/bookwyrm/static/css/bookwyrm/components/_details.scss index c9a0b33b8..de29629c8 100644 --- a/bookwyrm/static/css/bookwyrm/components/_details.scss +++ b/bookwyrm/static/css/bookwyrm/components/_details.scss @@ -67,7 +67,7 @@ details.dropdown .dropdown-menu a:focus-visible { align-items: center; justify-content: center; pointer-events: none; - z-index: 100; + z-index: 35; } details .dropdown-menu > * { diff --git a/bookwyrm/static/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js index 95271795d..aa06a8b0a 100644 --- a/bookwyrm/static/js/bookwyrm.js +++ b/bookwyrm/static/js/bookwyrm.js @@ -38,11 +38,12 @@ let BookWyrm = new (class { .querySelectorAll("[data-modal-open]") .forEach((node) => node.addEventListener("click", this.handleModalButton.bind(this))); - document - .querySelectorAll("details.dropdown") - .forEach((node) => - node.addEventListener("toggle", this.handleDetailsDropdown.bind(this)) + document.querySelectorAll("details.dropdown").forEach((node) => { + node.addEventListener("toggle", this.handleDetailsDropdown.bind(this)); + node.querySelectorAll("[data-modal-open]").forEach((modal_node) => + modal_node.addEventListener("click", () => (node.open = false)) ); + }); document .querySelector("#barcode-scanner-modal") diff --git a/bookwyrm/suggested_users.py b/bookwyrm/suggested_users.py index ea6bc886b..91f23dded 100644 --- a/bookwyrm/suggested_users.py +++ b/bookwyrm/suggested_users.py @@ -7,7 +7,7 @@ from django.db.models import signals, Count, Q, Case, When, IntegerField from bookwyrm import models from bookwyrm.redis_store import RedisStore, r -from bookwyrm.tasks import app +from bookwyrm.tasks import app, LOW, MEDIUM logger = logging.getLogger(__name__) @@ -237,41 +237,41 @@ def domain_level_update(sender, instance, created, update_fields=None, **kwargs) # ------------------- TASKS -@app.task(queue="low_priority") +@app.task(queue=LOW) def rerank_suggestions_task(user_id): """do the hard work in celery""" suggested_users.rerank_user_suggestions(user_id) -@app.task(queue="low_priority") +@app.task(queue=LOW) def rerank_user_task(user_id, update_only=False): """do the hard work in celery""" user = models.User.objects.get(id=user_id) suggested_users.rerank_obj(user, update_only=update_only) -@app.task(queue="low_priority") +@app.task(queue=LOW) def remove_user_task(user_id): """do the hard work in celery""" user = models.User.objects.get(id=user_id) suggested_users.remove_object_from_related_stores(user) -@app.task(queue="medium_priority") +@app.task(queue=MEDIUM) def remove_suggestion_task(user_id, suggested_user_id): """remove a specific user from a specific user's suggestions""" suggested_user = models.User.objects.get(id=suggested_user_id) suggested_users.remove_suggestion(user_id, suggested_user) -@app.task(queue="low_priority") +@app.task(queue=LOW) def bulk_remove_instance_task(instance_id): """remove a bunch of users from recs""" for user in models.User.objects.filter(federated_server__id=instance_id): suggested_users.remove_object_from_related_stores(user) -@app.task(queue="low_priority") +@app.task(queue=LOW) def bulk_add_instance_task(instance_id): """remove a bunch of users from recs""" for user in models.User.objects.filter(federated_server__id=instance_id): diff --git a/bookwyrm/templates/about/about.html b/bookwyrm/templates/about/about.html index b04e21b17..481ecda99 100644 --- a/bookwyrm/templates/about/about.html +++ b/bookwyrm/templates/about/about.html @@ -23,7 +23,9 @@

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

@@ -88,7 +90,10 @@

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

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

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

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

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

- + {% trans "View on Inventaire" %} diff --git a/bookwyrm/templates/book/edit/edit_book.html b/bookwyrm/templates/book/edit/edit_book.html index e5b865b55..70ec827eb 100644 --- a/bookwyrm/templates/book/edit/edit_book.html +++ b/bookwyrm/templates/book/edit/edit_book.html @@ -78,9 +78,13 @@

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

diff --git a/bookwyrm/templates/book/file_links/edit_links.html b/bookwyrm/templates/book/file_links/edit_links.html index fb722753f..77431726b 100644 --- a/bookwyrm/templates/book/file_links/edit_links.html +++ b/bookwyrm/templates/book/file_links/edit_links.html @@ -39,7 +39,7 @@ {% for link in links %} - {{ link.url }} + {{ link.url }} {% if link.added_by %} diff --git a/bookwyrm/templates/book/file_links/links.html b/bookwyrm/templates/book/file_links/links.html index 2147bf6e0..febc39e56 100644 --- a/bookwyrm/templates/book/file_links/links.html +++ b/bookwyrm/templates/book/file_links/links.html @@ -28,7 +28,7 @@ {% for link in links.all %} {% join "verify" link.id as verify_modal %}
  • - {{ link.name }} + {{ link.name }} ({{ link.filetype }}) {% if link.availability != "free" %} diff --git a/bookwyrm/templates/book/file_links/verification_modal.html b/bookwyrm/templates/book/file_links/verification_modal.html index 75678763f..7a9e41ad2 100644 --- a/bookwyrm/templates/book/file_links/verification_modal.html +++ b/bookwyrm/templates/book/file_links/verification_modal.html @@ -23,7 +23,7 @@ Is that where you'd like to go?
  • -{% trans "Continue" %} +{% trans "Continue" %} {% endif %} {% endblock %} 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/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 fc00389c5..a2924703c 100644 --- a/bookwyrm/templates/import/import.html +++ b/bookwyrm/templates/import/import.html @@ -7,12 +7,28 @@ {% block content %}

    {% trans "Import Books" %}

    + + {% 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 %}
    -
    @@ -69,13 +89,63 @@

    {% trans "Recent Imports" %}

    - {% if not jobs %} -

    {% trans "No recent imports" %}

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

    {{ item.review|truncatechars:100 }}

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

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

    {% endif %} -
    + {% csrf_token %} {% if show_confirmed_email %}{% endif %}
    diff --git a/bookwyrm/templates/landing/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 a7d1b0d0a..e58f65edd 100644 --- a/bookwyrm/templates/layout.html +++ b/bookwyrm/templates/layout.html @@ -67,9 +67,27 @@ {% include "search/barcode_modal.html" with id="barcode-scanner-modal" %} -
    @@ -150,7 +168,7 @@ {% if request.user.is_authenticated and active_announcements.exists %} -
    +
    {% for announcement in active_announcements %} {% include 'snippets/announcement.html' with announcement=announcement %} @@ -174,47 +192,7 @@
    - +{% include 'snippets/footer.html' %} {% endblock %}
    {% trans "Row" %} @@ -137,6 +144,13 @@
    + {% trans "No items currently need review" %} +