diff --git a/.env.example b/.env.example
index af1d6430c..bbd825a9a 100644
--- a/.env.example
+++ b/.env.example
@@ -21,8 +21,8 @@ MEDIA_ROOT=images/
# Database configuration
PGPORT=5432
POSTGRES_PASSWORD=securedbypassword123
-POSTGRES_USER=fedireads
-POSTGRES_DB=fedireads
+POSTGRES_USER=bookwyrm
+POSTGRES_DB=bookwyrm
POSTGRES_HOST=db
# Redis activity stream manager
@@ -79,7 +79,7 @@ AWS_SECRET_ACCESS_KEY=
# Preview image generation can be computing and storage intensive
-# ENABLE_PREVIEW_IMAGES=True
+ENABLE_PREVIEW_IMAGES=False
# Specify RGB tuple or RGB hex strings,
# or use_dominant_color_light / use_dominant_color_dark
@@ -108,3 +108,10 @@ OTEL_EXPORTER_OTLP_ENDPOINT=
OTEL_EXPORTER_OTLP_HEADERS=
# Service name to identify your app
OTEL_SERVICE_NAME=
+
+# Set HTTP_X_FORWARDED_PROTO ONLY to true if you know what you are doing.
+# Only use it if your proxy is "swallowing" if the original request was made
+# via https. Please refer to the Django-Documentation and assess the risks
+# for your instance:
+# https://docs.djangoproject.com/en/3.2/ref/settings/#secure-proxy-ssl-header
+HTTP_X_FORWARDED_PROTO=false
diff --git a/.github/workflows/django-tests.yml b/.github/workflows/django-tests.yml
index 97a744813..4335a4605 100644
--- a/.github/workflows/django-tests.yml
+++ b/.github/workflows/django-tests.yml
@@ -56,5 +56,6 @@ jobs:
EMAIL_USE_TLS: true
ENABLE_PREVIEW_IMAGES: false
ENABLE_THUMBNAIL_GENERATION: true
+ HTTP_X_FORWARDED_PROTO: false
run: |
pytest -n 3
diff --git a/.github/workflows/lint-frontend.yaml b/.github/workflows/lint-frontend.yaml
index ed106d6a8..c97ee02ad 100644
--- a/.github/workflows/lint-frontend.yaml
+++ b/.github/workflows/lint-frontend.yaml
@@ -25,10 +25,10 @@ jobs:
run: npm install stylelint stylelint-config-recommended stylelint-config-standard stylelint-order eslint
# See .stylelintignore for files that are not linted.
- - name: Run stylelint
- run: >
- npx stylelint bookwyrm/static/css/*.scss bookwyrm/static/css/bookwyrm/**/*.scss \
- --config dev-tools/.stylelintrc.js
+ # - name: Run stylelint
+ # run: >
+ # npx stylelint bookwyrm/static/css/*.scss bookwyrm/static/css/bookwyrm/**/*.scss \
+ # --config dev-tools/.stylelintrc.js
# See .eslintignore for files that are not linted.
- name: Run ESLint
diff --git a/README.md b/README.md
index 5dd5e19a7..1b731dc2b 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,7 @@ Keep track of what books you've read, and what books you'd like to read in the f
Federation allows you to interact with users on other instances and services, and also shares metadata about books and authors, which collaboratively builds a decentralized database of books.
### Privacy and moderation
-Users and administrators can control who can see thier posts and what other instances to federate with.
+Users and administrators can control who can see their posts and what other instances to federate with.
## Tech Stack
Web backend
diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py
index fa1535694..095ec0227 100644
--- a/bookwyrm/activitypub/base_activity.py
+++ b/bookwyrm/activitypub/base_activity.py
@@ -7,7 +7,7 @@ from django.apps import apps
from django.db import IntegrityError, transaction
from bookwyrm.connectors import ConnectorException, get_data
-from bookwyrm.tasks import app
+from bookwyrm.tasks import app, MEDIUM
logger = logging.getLogger(__name__)
@@ -194,6 +194,11 @@ class ActivityObject:
try:
if issubclass(type(v), ActivityObject):
data[k] = v.serialize()
+ elif isinstance(v, list):
+ data[k] = [
+ e.serialize() if issubclass(type(e), ActivityObject) else e
+ for e in v
+ ]
except TypeError:
pass
data = {k: v for (k, v) in data.items() if v is not None and k not in omit}
@@ -202,7 +207,7 @@ class ActivityObject:
return data
-@app.task(queue="medium_priority")
+@app.task(queue=MEDIUM)
@transaction.atomic
def set_related_field(
model_name, origin_model_name, related_field_name, related_remote_id, data
@@ -271,7 +276,7 @@ def resolve_remote_id(
try:
data = get_data(remote_id)
except ConnectorException:
- logger.exception("Could not connect to host for remote_id: %s", remote_id)
+ logger.info("Could not connect to host for remote_id: %s", remote_id)
return None
# determine the model implicitly, if not provided
@@ -306,7 +311,9 @@ class Link(ActivityObject):
def serialize(self, **kwargs):
"""remove fields"""
- omit = ("id", "type", "@context")
+ omit = ("id", "@context")
+ if self.type == "Link":
+ omit += ("type",)
return super().serialize(omit=omit)
diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py
index e6a01b359..745aa3aab 100644
--- a/bookwyrm/activitypub/book.py
+++ b/bookwyrm/activitypub/book.py
@@ -19,6 +19,8 @@ class BookData(ActivityObject):
viaf: str = None
wikidata: str = None
asin: str = None
+ aasin: str = None
+ isfdb: str = None
lastEditedBy: str = None
links: List[str] = field(default_factory=lambda: [])
fileLinks: List[str] = field(default_factory=lambda: [])
diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py
index a90d7943b..1765f7e34 100644
--- a/bookwyrm/activitystreams.py
+++ b/bookwyrm/activitystreams.py
@@ -117,6 +117,17 @@ class ActivityStream(RedisStore):
Q(id=status.user.id) # if the user is the post's author
| Q(id__in=status.mention_users.all()) # if the user is mentioned
)
+
+ # don't show replies to statuses the user can't see
+ elif status.reply_parent and status.reply_parent.privacy == "followers":
+ audience = audience.filter(
+ Q(id=status.user.id) # if the user is the post's author
+ | Q(id=status.reply_parent.user.id) # if the user is the OG author
+ | (
+ Q(following=status.user) & Q(following=status.reply_parent.user)
+ ) # if the user is following both authors
+ ).distinct()
+
# only visible to the poster's followers and tagged users
elif status.privacy == "followers":
audience = audience.filter(
@@ -287,6 +298,12 @@ def add_status_on_create(sender, instance, created, *args, **kwargs):
remove_status_task.delay(instance.id)
return
+ # To avoid creating a zillion unnecessary tasks caused by re-saving the model,
+ # check if it's actually ready to send before we go. We're trusting this was
+ # set correctly by the inbox or view
+ if not instance.ready:
+ return
+
# when creating new things, gotta wait on the transaction
transaction.on_commit(
lambda: add_status_on_create_command(sender, instance, created)
diff --git a/bookwyrm/book_search.py b/bookwyrm/book_search.py
index 4b0a6eab9..f9bb57dfc 100644
--- a/bookwyrm/book_search.py
+++ b/bookwyrm/book_search.py
@@ -4,9 +4,10 @@ from functools import reduce
import operator
from django.contrib.postgres.search import SearchRank, SearchQuery
-from django.db.models import OuterRef, Subquery, F, Q
+from django.db.models import F, Q
from bookwyrm import models
+from bookwyrm import connectors
from bookwyrm.settings import MEDIA_FULL_URL
@@ -16,8 +17,15 @@ def search(query, min_confidence=0, filters=None, return_first=False):
filters = filters or []
if not query:
return []
+ query = query.strip()
+
+ results = None
# first, try searching unqiue identifiers
- results = search_identifiers(query, *filters, return_first=return_first)
+ # unique identifiers never have spaces, title/author usually do
+ if not " " in query:
+ results = search_identifiers(query, *filters, return_first=return_first)
+
+ # if there were no identifier results...
if not results:
# then try searching title/author
results = search_title_author(
@@ -30,26 +38,14 @@ def isbn_search(query):
"""search your local database"""
if not query:
return []
-
+ # Up-case the ISBN string to ensure any 'X' check-digit is correct
+ # If the ISBN has only 9 characters, prepend missing zero
+ query = query.strip().upper().rjust(10, "0")
filters = [{f: query} for f in ["isbn_10", "isbn_13"]]
- results = models.Edition.objects.filter(
+ return models.Edition.objects.filter(
reduce(operator.or_, (Q(**f) for f in filters))
).distinct()
- # when there are multiple editions of the same work, pick the default.
- # it would be odd for this to happen.
-
- default_editions = models.Edition.objects.filter(
- parent_work=OuterRef("parent_work")
- ).order_by("-edition_rank")
- results = (
- results.annotate(default_id=Subquery(default_editions.values("id")[:1])).filter(
- default_id=F("id")
- )
- or results
- )
- return results
-
def format_search_result(search_result):
"""convert a book object into a search result object"""
@@ -72,6 +68,10 @@ def format_search_result(search_result):
def search_identifiers(query, *filters, return_first=False):
"""tries remote_id, isbn; defined as dedupe fields on the model"""
+ if connectors.maybe_isbn(query):
+ # Oh did you think the 'S' in ISBN stood for 'standard'?
+ normalized_isbn = query.strip().upper().rjust(10, "0")
+ query = normalized_isbn
# pylint: disable=W0212
or_filters = [
{f.name: query}
@@ -81,22 +81,7 @@ def search_identifiers(query, *filters, return_first=False):
results = models.Edition.objects.filter(
*filters, reduce(operator.or_, (Q(**f) for f in or_filters))
).distinct()
- if results.count() <= 1:
- if return_first:
- return results.first()
- return results
- # when there are multiple editions of the same work, pick the default.
- # it would be odd for this to happen.
- default_editions = models.Edition.objects.filter(
- parent_work=OuterRef("parent_work")
- ).order_by("-edition_rank")
- results = (
- results.annotate(default_id=Subquery(default_editions.values("id")[:1])).filter(
- default_id=F("id")
- )
- or results
- )
if return_first:
return results.first()
return results
@@ -113,19 +98,16 @@ def search_title_author(query, min_confidence, *filters, return_first=False):
)
# when there are multiple editions of the same work, pick the closest
- editions_of_work = results.values("parent_work__id").values_list("parent_work__id")
+ editions_of_work = results.values_list("parent_work__id", flat=True).distinct()
# filter out multiple editions of the same work
list_results = []
- for work_id in set(editions_of_work):
- editions = results.filter(parent_work=work_id)
- default = editions.order_by("-edition_rank").first()
- default_rank = default.rank if default else 0
- # if mutliple books have the top rank, pick the default edition
- if default_rank == editions.first().rank:
- result = default
- else:
- result = editions.first()
+ for work_id in set(editions_of_work[:30]):
+ result = (
+ results.filter(parent_work=work_id)
+ .order_by("-rank", "-edition_rank")
+ .first()
+ )
if return_first:
return result
diff --git a/bookwyrm/connectors/__init__.py b/bookwyrm/connectors/__init__.py
index efbdb1666..3a4f5f3e0 100644
--- a/bookwyrm/connectors/__init__.py
+++ b/bookwyrm/connectors/__init__.py
@@ -1,6 +1,6 @@
""" bring connectors into the namespace """
from .settings import CONNECTORS
from .abstract_connector import ConnectorException
-from .abstract_connector import get_data, get_image
+from .abstract_connector import get_data, get_image, maybe_isbn
from .connector_manager import search, first_search_result
diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py
index dc4be4b3d..8ae93926a 100644
--- a/bookwyrm/connectors/abstract_connector.py
+++ b/bookwyrm/connectors/abstract_connector.py
@@ -42,8 +42,10 @@ class AbstractMinimalConnector(ABC):
"""format the query url"""
# Check if the query resembles an ISBN
if maybe_isbn(query) and self.isbn_search_url and self.isbn_search_url != "":
- return f"{self.isbn_search_url}{query}"
-
+ # Up-case the ISBN string to ensure any 'X' check-digit is correct
+ # If the ISBN has only 9 characters, prepend missing zero
+ normalized_query = query.strip().upper().rjust(10, "0")
+ return f"{self.isbn_search_url}{normalized_query}"
# NOTE: previously, we tried searching isbn and if that produces no results,
# searched as free text. This, instead, only searches isbn if it's isbn-y
return f"{self.search_url}{query}"
@@ -220,7 +222,7 @@ def dict_from_mappings(data, mappings):
return result
-def get_data(url, params=None, timeout=10):
+def get_data(url, params=None, timeout=settings.QUERY_TIMEOUT):
"""wrapper for request.get"""
# check if the url is blocked
raise_not_valid_url(url)
@@ -325,4 +327,11 @@ def unique_physical_format(format_text):
def maybe_isbn(query):
"""check if a query looks like an isbn"""
isbn = re.sub(r"[\W_]", "", query) # removes filler characters
- return len(isbn) in [10, 13] # ISBN10 or ISBN13
+ # ISBNs must be numeric except an ISBN10 checkdigit can be 'X'
+ if not isbn.upper().rstrip("X").isnumeric():
+ return False
+ return len(isbn) in [
+ 9,
+ 10,
+ 13,
+ ] # ISBN10 or ISBN13, or maybe ISBN10 missing a leading zero
diff --git a/bookwyrm/connectors/connector_manager.py b/bookwyrm/connectors/connector_manager.py
index 385880e5a..9a6f834af 100644
--- a/bookwyrm/connectors/connector_manager.py
+++ b/bookwyrm/connectors/connector_manager.py
@@ -13,7 +13,7 @@ from requests import HTTPError
from bookwyrm import book_search, models
from bookwyrm.settings import SEARCH_TIMEOUT, USER_AGENT
-from bookwyrm.tasks import app
+from bookwyrm.tasks import app, LOW
logger = logging.getLogger(__name__)
@@ -143,7 +143,7 @@ def get_or_create_connector(remote_id):
return load_connector(connector_info)
-@app.task(queue="low_priority")
+@app.task(queue=LOW)
def load_more_data(connector_id, book_id):
"""background the work of getting all 10,000 editions of LoTR"""
connector_info = models.Connector.objects.get(id=connector_id)
@@ -152,7 +152,7 @@ def load_more_data(connector_id, book_id):
connector.expand_book_data(book)
-@app.task(queue="low_priority")
+@app.task(queue=LOW)
def create_edition_task(connector_id, work_id, data):
"""separate task for each of the 10,000 editions of LoTR"""
connector_info = models.Connector.objects.get(id=connector_id)
diff --git a/bookwyrm/connectors/inventaire.py b/bookwyrm/connectors/inventaire.py
index df9b2e43a..a330b2c4a 100644
--- a/bookwyrm/connectors/inventaire.py
+++ b/bookwyrm/connectors/inventaire.py
@@ -165,8 +165,8 @@ class Connector(AbstractConnector):
edition_data = self.get_book_data(edition_data)
except ConnectorException:
# who, indeed, knows
- return
- super().create_edition_from_data(work, edition_data, instance=instance)
+ return None
+ return super().create_edition_from_data(work, edition_data, instance=instance)
def get_cover_url(self, cover_blob, *_):
"""format the relative cover url into an absolute one:
diff --git a/bookwyrm/emailing.py b/bookwyrm/emailing.py
index 9349b8ae2..2271077b1 100644
--- a/bookwyrm/emailing.py
+++ b/bookwyrm/emailing.py
@@ -3,7 +3,7 @@ from django.core.mail import EmailMultiAlternatives
from django.template.loader import get_template
from bookwyrm import models, settings
-from bookwyrm.tasks import app
+from bookwyrm.tasks import app, HIGH
from bookwyrm.settings import DOMAIN
@@ -18,12 +18,18 @@ def email_data():
}
+def test_email(user):
+ """Just an admin checking if emails are sending"""
+ data = email_data()
+ send_email(user.email, *format_email("test", data))
+
+
def email_confirmation_email(user):
"""newly registered users confirm email address"""
data = email_data()
data["confirmation_code"] = user.confirmation_code
data["confirmation_link"] = user.confirmation_link
- send_email.delay(user.email, *format_email("confirm", data))
+ send_email(user.email, *format_email("confirm", data))
def invite_email(invite_request):
@@ -38,7 +44,7 @@ def password_reset_email(reset_code):
data = email_data()
data["reset_link"] = reset_code.link
data["user"] = reset_code.user.display_name
- send_email.delay(reset_code.user.email, *format_email("password_reset", data))
+ send_email(reset_code.user.email, *format_email("password_reset", data))
def moderation_report_email(report):
@@ -48,6 +54,7 @@ def moderation_report_email(report):
if report.user:
data["reportee"] = report.user.localname or report.user.username
data["report_link"] = report.remote_id
+ data["link_domain"] = report.links.exists()
for admin in models.User.objects.filter(
groups__name__in=["admin", "moderator"]
@@ -68,7 +75,7 @@ def format_email(email_name, data):
return (subject, html_content, text_content)
-@app.task(queue="high_priority")
+@app.task(queue=HIGH)
def send_email(recipient, subject, html_content, text_content):
"""use a task to send the email"""
email = EmailMultiAlternatives(
diff --git a/bookwyrm/forms/admin.py b/bookwyrm/forms/admin.py
index 4141327d3..acff6cfaa 100644
--- a/bookwyrm/forms/admin.py
+++ b/bookwyrm/forms/admin.py
@@ -2,13 +2,14 @@
import datetime
from django import forms
+from django.core.exceptions import PermissionDenied
from django.forms import widgets
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django_celery_beat.models import IntervalSchedule
from bookwyrm import models
-from .custom_form import CustomForm
+from .custom_form import CustomForm, StyledForm
# pylint: disable=missing-class-docstring
@@ -54,11 +55,45 @@ class CreateInviteForm(CustomForm):
class SiteForm(CustomForm):
class Meta:
model = models.SiteSettings
- exclude = ["admin_code", "install_mode"]
+ fields = [
+ "name",
+ "instance_tagline",
+ "instance_description",
+ "instance_short_description",
+ "default_theme",
+ "code_of_conduct",
+ "privacy_policy",
+ "impressum",
+ "show_impressum",
+ "logo",
+ "logo_small",
+ "favicon",
+ "support_link",
+ "support_title",
+ "admin_email",
+ "footer_item",
+ ]
widgets = {
"instance_short_description": forms.TextInput(
attrs={"aria-describedby": "desc_instance_short_description"}
),
+ }
+
+
+class RegistrationForm(CustomForm):
+ class Meta:
+ model = models.SiteSettings
+ fields = [
+ "allow_registration",
+ "allow_invite_requests",
+ "registration_closed_text",
+ "invite_request_text",
+ "invite_request_question",
+ "invite_question_text",
+ "require_confirm_email",
+ ]
+
+ widgets = {
"require_confirm_email": forms.CheckboxInput(
attrs={"aria-describedby": "desc_require_confirm_email"}
),
@@ -68,6 +103,23 @@ class SiteForm(CustomForm):
}
+class RegistrationLimitedForm(CustomForm):
+ class Meta:
+ model = models.SiteSettings
+ fields = [
+ "registration_closed_text",
+ "invite_request_text",
+ "invite_request_question",
+ "invite_question_text",
+ ]
+
+ widgets = {
+ "invite_request_text": forms.Textarea(
+ attrs={"aria-describedby": "desc_invite_request_text"}
+ ),
+ }
+
+
class ThemeForm(CustomForm):
class Meta:
model = models.Theme
@@ -130,7 +182,7 @@ class AutoModRuleForm(CustomForm):
fields = ["string_match", "flag_users", "flag_statuses", "created_by"]
-class IntervalScheduleForm(CustomForm):
+class IntervalScheduleForm(StyledForm):
class Meta:
model = IntervalSchedule
fields = ["every", "period"]
@@ -139,3 +191,10 @@ class IntervalScheduleForm(CustomForm):
"every": forms.NumberInput(attrs={"aria-describedby": "desc_every"}),
"period": forms.Select(attrs={"aria-describedby": "desc_period"}),
}
+
+ # pylint: disable=arguments-differ
+ def save(self, request, *args, **kwargs):
+ """This is an outside model so the perms check works differently"""
+ if not request.user.has_perm("bookwyrm.moderate_user"):
+ raise PermissionDenied()
+ return super().save(*args, **kwargs)
diff --git a/bookwyrm/forms/author.py b/bookwyrm/forms/author.py
index ca59426de..a7811180f 100644
--- a/bookwyrm/forms/author.py
+++ b/bookwyrm/forms/author.py
@@ -21,6 +21,7 @@ class AuthorForm(CustomForm):
"inventaire_id",
"librarything_key",
"goodreads_key",
+ "isfdb",
"isni",
]
widgets = {
diff --git a/bookwyrm/forms/books.py b/bookwyrm/forms/books.py
index 9b3c84010..623beaa04 100644
--- a/bookwyrm/forms/books.py
+++ b/bookwyrm/forms/books.py
@@ -18,19 +18,30 @@ class CoverForm(CustomForm):
class EditionForm(CustomForm):
class Meta:
model = models.Edition
- exclude = [
- "remote_id",
- "origin_id",
- "created_date",
- "updated_date",
- "edition_rank",
- "authors",
- "parent_work",
- "shelves",
- "connector",
- "search_vector",
- "links",
- "file_links",
+ fields = [
+ "title",
+ "subtitle",
+ "description",
+ "series",
+ "series_number",
+ "languages",
+ "subjects",
+ "publishers",
+ "first_published_date",
+ "published_date",
+ "cover",
+ "physical_format",
+ "physical_format_detail",
+ "pages",
+ "isbn_13",
+ "isbn_10",
+ "openlibrary_key",
+ "inventaire_id",
+ "goodreads_key",
+ "oclc_number",
+ "asin",
+ "aasin",
+ "isfdb",
]
widgets = {
"title": forms.TextInput(attrs={"aria-describedby": "desc_title"}),
@@ -73,10 +84,15 @@ class EditionForm(CustomForm):
"inventaire_id": forms.TextInput(
attrs={"aria-describedby": "desc_inventaire_id"}
),
+ "goodreads_key": forms.TextInput(
+ attrs={"aria-describedby": "desc_goodreads_key"}
+ ),
"oclc_number": forms.TextInput(
attrs={"aria-describedby": "desc_oclc_number"}
),
"ASIN": forms.TextInput(attrs={"aria-describedby": "desc_ASIN"}),
+ "AASIN": forms.TextInput(attrs={"aria-describedby": "desc_AASIN"}),
+ "isfdb": forms.TextInput(attrs={"aria-describedby": "desc_isfdb"}),
}
diff --git a/bookwyrm/forms/custom_form.py b/bookwyrm/forms/custom_form.py
index 74a3417a2..c604deea4 100644
--- a/bookwyrm/forms/custom_form.py
+++ b/bookwyrm/forms/custom_form.py
@@ -4,7 +4,7 @@ from django.forms import ModelForm
from django.forms.widgets import Textarea
-class CustomForm(ModelForm):
+class StyledForm(ModelForm):
"""add css classes to the forms"""
def __init__(self, *args, **kwargs):
@@ -16,7 +16,7 @@ class CustomForm(ModelForm):
css_classes["checkbox"] = "checkbox"
css_classes["textarea"] = "textarea"
# pylint: disable=super-with-arguments
- super(CustomForm, self).__init__(*args, **kwargs)
+ super().__init__(*args, **kwargs)
for visible in self.visible_fields():
if hasattr(visible.field.widget, "input_type"):
input_type = visible.field.widget.input_type
@@ -24,3 +24,13 @@ class CustomForm(ModelForm):
input_type = "textarea"
visible.field.widget.attrs["rows"] = 5
visible.field.widget.attrs["class"] = css_classes[input_type]
+
+
+class CustomForm(StyledForm):
+ """Check permissions on save"""
+
+ # pylint: disable=arguments-differ
+ def save(self, request, *args, **kwargs):
+ """Save and check perms"""
+ self.instance.raise_not_editable(request.user)
+ return super().save(*args, **kwargs)
diff --git a/bookwyrm/forms/edit_user.py b/bookwyrm/forms/edit_user.py
index a291c6441..ce7bb6d07 100644
--- a/bookwyrm/forms/edit_user.py
+++ b/bookwyrm/forms/edit_user.py
@@ -8,7 +8,6 @@ from bookwyrm import models
from bookwyrm.models.fields import ClearableFileInputWithWarning
from .custom_form import CustomForm
-
# pylint: disable=missing-class-docstring
class EditUserForm(CustomForm):
class Meta:
@@ -99,3 +98,21 @@ class ChangePasswordForm(CustomForm):
validate_password(new_password)
except ValidationError as err:
self.add_error("password", err)
+
+
+class ConfirmPasswordForm(CustomForm):
+ password = forms.CharField(widget=forms.PasswordInput)
+
+ class Meta:
+ model = models.User
+ fields = ["password"]
+ widgets = {
+ "password": forms.PasswordInput(),
+ }
+
+ def clean(self):
+ """Make sure password is correct"""
+ password = self.data.get("password")
+
+ if not self.instance.check_password(password):
+ self.add_error("password", _("Incorrect Password"))
diff --git a/bookwyrm/forms/forms.py b/bookwyrm/forms/forms.py
index 4aa1e5758..ea6093750 100644
--- a/bookwyrm/forms/forms.py
+++ b/bookwyrm/forms/forms.py
@@ -1,4 +1,5 @@
""" using django model forms """
+import datetime
from django import forms
from django.forms import widgets
from django.utils.translation import gettext_lazy as _
@@ -7,7 +8,6 @@ from bookwyrm import models
from bookwyrm.models.user import FeedFilterChoices
from .custom_form import CustomForm
-
# pylint: disable=missing-class-docstring
class FeedStatusTypesForm(CustomForm):
class Meta:
@@ -58,6 +58,21 @@ class ReadThroughForm(CustomForm):
self.add_error(
"stopped_date", _("Reading stopped date cannot be before start date.")
)
+ current_time = datetime.datetime.now()
+ if (
+ stopped_date is not None
+ and current_time.timestamp() < stopped_date.timestamp()
+ ):
+ self.add_error(
+ "stopped_date", _("Reading stopped date cannot be in the future.")
+ )
+ if (
+ finish_date is not None
+ and current_time.timestamp() < finish_date.timestamp()
+ ):
+ self.add_error(
+ "finish_date", _("Reading finished date cannot be in the future.")
+ )
class Meta:
model = models.ReadThrough
diff --git a/bookwyrm/forms/landing.py b/bookwyrm/forms/landing.py
index a31e8a7c4..bd9884bc3 100644
--- a/bookwyrm/forms/landing.py
+++ b/bookwyrm/forms/landing.py
@@ -4,7 +4,10 @@ from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
+import pyotp
+
from bookwyrm import models
+from bookwyrm.settings import DOMAIN
from .custom_form import CustomForm
@@ -18,6 +21,21 @@ class LoginForm(CustomForm):
"password": forms.PasswordInput(),
}
+ def infer_username(self):
+ """Users may enter their localname, username, or email"""
+ localname = self.data.get("localname")
+ if "@" in localname: # looks like an email address to me
+ try:
+ return models.User.objects.get(email=localname).username
+ except models.User.DoesNotExist: # maybe it's a full username?
+ return localname
+ return f"{localname}@{DOMAIN}"
+
+ def add_invalid_password_error(self):
+ """We don't want to be too specific about this"""
+ # pylint: disable=attribute-defined-outside-init
+ self.non_field_errors = _("Username or password are incorrect")
+
class RegisterForm(CustomForm):
class Meta:
@@ -74,3 +92,40 @@ class PasswordResetForm(CustomForm):
validate_password(new_password)
except ValidationError as err:
self.add_error("password", err)
+
+
+class Confirm2FAForm(CustomForm):
+ otp = forms.CharField(
+ max_length=6, min_length=6, widget=forms.TextInput(attrs={"autofocus": True})
+ )
+
+ class Meta:
+ model = models.User
+ fields = ["otp_secret", "hotp_count"]
+
+ def clean_otp(self):
+ """Check otp matches"""
+ otp = self.data.get("otp")
+ totp = pyotp.TOTP(self.instance.otp_secret)
+
+ if not totp.verify(otp):
+
+ if self.instance.hotp_secret:
+ # maybe it's a backup code?
+ hotp = pyotp.HOTP(self.instance.hotp_secret)
+ hotp_count = (
+ self.instance.hotp_count
+ if self.instance.hotp_count is not None
+ else 0
+ )
+
+ if not hotp.verify(otp, hotp_count):
+ self.add_error("otp", _("Incorrect code"))
+
+ # increment the user hotp_count
+ else:
+ self.instance.hotp_count = hotp_count + 1
+ self.instance.save(broadcast=False, update_fields=["hotp_count"])
+
+ else:
+ self.add_error("otp", _("Incorrect code"))
diff --git a/bookwyrm/forms/links.py b/bookwyrm/forms/links.py
index de229bc2d..d2fd5f116 100644
--- a/bookwyrm/forms/links.py
+++ b/bookwyrm/forms/links.py
@@ -36,13 +36,16 @@ class FileLinkForm(CustomForm):
"This domain is blocked. Please contact your administrator if you think this is an error."
),
)
- elif models.FileLink.objects.filter(
+ if (
+ not self.instance
+ and models.FileLink.objects.filter(
url=url, book=book, filetype=filetype
- ).exists():
- # pylint: disable=line-too-long
- self.add_error(
- "url",
- _(
- "This link with file type has already been added for this book. If it is not visible, the domain is still pending."
- ),
- )
+ ).exists()
+ ):
+ # pylint: disable=line-too-long
+ self.add_error(
+ "url",
+ _(
+ "This link with file type has already been added for this book. If it is not visible, the domain is still pending."
+ ),
+ )
diff --git a/bookwyrm/importers/importer.py b/bookwyrm/importers/importer.py
index 32800e772..e4ee2c31a 100644
--- a/bookwyrm/importers/importer.py
+++ b/bookwyrm/importers/importer.py
@@ -1,15 +1,7 @@
""" handle reading a csv from an external service, defaults are from Goodreads """
import csv
-import logging
-
from django.utils import timezone
-from django.utils.translation import gettext_lazy as _
-
-from bookwyrm import models
from bookwyrm.models import ImportJob, ImportItem
-from bookwyrm.tasks import app, LOW
-
-logger = logging.getLogger(__name__)
class Importer:
@@ -24,8 +16,8 @@ class Importer:
("id", ["id", "book id"]),
("title", ["title"]),
("authors", ["author", "authors", "primary author"]),
- ("isbn_10", ["isbn10", "isbn"]),
- ("isbn_13", ["isbn13", "isbn", "isbns"]),
+ ("isbn_10", ["isbn10", "isbn", "isbn/uid"]),
+ ("isbn_13", ["isbn13", "isbn", "isbns", "isbn/uid"]),
("shelf", ["shelf", "exclusive shelf", "read status", "bookshelf"]),
("review_name", ["review name"]),
("review_body", ["my review", "review"]),
@@ -44,7 +36,11 @@ class Importer:
def create_job(self, user, csv_file, include_reviews, privacy):
"""check over a csv and creates a database entry for the job"""
csv_reader = csv.DictReader(csv_file, delimiter=self.delimiter)
- rows = enumerate(list(csv_reader))
+ rows = list(csv_reader)
+ if len(rows) < 1:
+ raise ValueError("CSV file is empty")
+ rows = enumerate(rows)
+
job = ImportJob.objects.create(
user=user,
include_reviews=include_reviews,
@@ -118,127 +114,3 @@ class Importer:
# this will re-normalize the raw data
self.create_item(job, item.index, item.data)
return job
-
- def start_import(self, job): # pylint: disable=no-self-use
- """initalizes a csv import job"""
- result = start_import_task.delay(job.id)
- job.task_id = result.id
- job.save()
-
-
-@app.task(queue="low_priority")
-def start_import_task(job_id):
- """trigger the child tasks for each row"""
- job = ImportJob.objects.get(id=job_id)
- # these are sub-tasks so that one big task doesn't use up all the memory in celery
- for item in job.items.values_list("id", flat=True).all():
- import_item_task.delay(item)
-
-
-@app.task(queue="low_priority")
-def import_item_task(item_id):
- """resolve a row into a book"""
- item = models.ImportItem.objects.get(id=item_id)
- try:
- item.resolve()
- except Exception as err: # pylint: disable=broad-except
- item.fail_reason = _("Error loading book")
- item.save()
- item.update_job()
- raise err
-
- if item.book:
- # shelves book and handles reviews
- handle_imported_book(item)
- else:
- item.fail_reason = _("Could not find a match for book")
-
- item.save()
- item.update_job()
-
-
-def handle_imported_book(item):
- """process a csv and then post about it"""
- job = item.job
- user = job.user
- if isinstance(item.book, models.Work):
- item.book = item.book.default_edition
- if not item.book:
- item.fail_reason = _("Error loading book")
- item.save()
- return
- if not isinstance(item.book, models.Edition):
- item.book = item.book.edition
-
- existing_shelf = models.ShelfBook.objects.filter(book=item.book, user=user).exists()
-
- # shelve the book if it hasn't been shelved already
- if item.shelf and not existing_shelf:
- desired_shelf = models.Shelf.objects.get(identifier=item.shelf, user=user)
- shelved_date = item.date_added or timezone.now()
- models.ShelfBook(
- book=item.book, shelf=desired_shelf, user=user, shelved_date=shelved_date
- ).save(priority=LOW)
-
- for read in item.reads:
- # check for an existing readthrough with the same dates
- if models.ReadThrough.objects.filter(
- user=user,
- book=item.book,
- start_date=read.start_date,
- finish_date=read.finish_date,
- ).exists():
- continue
- read.book = item.book
- read.user = user
- read.save()
-
- if job.include_reviews and (item.rating or item.review) and not item.linked_review:
- # we don't know the publication date of the review,
- # but "now" is a bad guess
- published_date_guess = item.date_read or item.date_added
- if item.review:
- # pylint: disable=consider-using-f-string
- review_title = "Review of {!r} on {!r}".format(
- item.book.title,
- job.source,
- )
- review = models.Review.objects.filter(
- user=user,
- book=item.book,
- name=review_title,
- rating=item.rating,
- published_date=published_date_guess,
- ).first()
- if not review:
- review = models.Review(
- user=user,
- book=item.book,
- name=review_title,
- content=item.review,
- rating=item.rating,
- published_date=published_date_guess,
- privacy=job.privacy,
- )
- review.save(software="bookwyrm", priority=LOW)
- else:
- # just a rating
- review = models.ReviewRating.objects.filter(
- user=user,
- book=item.book,
- published_date=published_date_guess,
- rating=item.rating,
- ).first()
- if not review:
- review = models.ReviewRating(
- user=user,
- book=item.book,
- rating=item.rating,
- published_date=published_date_guess,
- privacy=job.privacy,
- )
- review.save(software="bookwyrm", priority=LOW)
-
- # only broadcast this review to other bookwyrm instances
- item.linked_review = review
- item.save()
diff --git a/bookwyrm/management/commands/confirm_email.py b/bookwyrm/management/commands/confirm_email.py
new file mode 100644
index 000000000..450da7eec
--- /dev/null
+++ b/bookwyrm/management/commands/confirm_email.py
@@ -0,0 +1,19 @@
+""" manually confirm e-mail of user """
+from django.core.management.base import BaseCommand
+
+from bookwyrm import models
+
+
+class Command(BaseCommand):
+ """command-line options"""
+
+ help = "Manually confirm email for user"
+
+ def add_arguments(self, parser):
+ parser.add_argument("username")
+
+ def handle(self, *args, **options):
+ name = options["username"]
+ user = models.User.objects.get(localname=name)
+ user.reactivate()
+ self.stdout.write(self.style.SUCCESS("User's email is now confirmed."))
diff --git a/bookwyrm/management/commands/initdb.py b/bookwyrm/management/commands/initdb.py
index 23020a0a6..fda40bd07 100644
--- a/bookwyrm/management/commands/initdb.py
+++ b/bookwyrm/management/commands/initdb.py
@@ -8,54 +8,64 @@ from bookwyrm import models
def init_groups():
"""permission levels"""
- groups = ["admin", "moderator", "editor"]
+ groups = ["admin", "owner", "moderator", "editor"]
for group in groups:
- Group.objects.create(name=group)
+ Group.objects.get_or_create(name=group)
def init_permissions():
"""permission types"""
permissions = [
+ {
+ "codename": "manage_registration",
+ "name": "allow or prevent user registration",
+ "groups": ["admin"],
+ },
+ {
+ "codename": "system_administration",
+ "name": "technical controls",
+ "groups": ["admin"],
+ },
{
"codename": "edit_instance_settings",
"name": "change the instance info",
- "groups": ["admin"],
+ "groups": ["admin", "owner"],
},
{
"codename": "set_user_group",
"name": "change what group a user is in",
- "groups": ["admin", "moderator"],
+ "groups": ["admin", "owner", "moderator"],
},
{
"codename": "control_federation",
"name": "control who to federate with",
- "groups": ["admin", "moderator"],
+ "groups": ["admin", "owner", "moderator"],
},
{
"codename": "create_invites",
"name": "issue invitations to join",
- "groups": ["admin", "moderator"],
+ "groups": ["admin", "owner", "moderator"],
},
{
"codename": "moderate_user",
"name": "deactivate or silence a user",
- "groups": ["admin", "moderator"],
+ "groups": ["admin", "owner", "moderator"],
},
{
"codename": "moderate_post",
"name": "delete other users' posts",
- "groups": ["admin", "moderator"],
+ "groups": ["admin", "owner", "moderator"],
},
{
"codename": "edit_book",
"name": "edit book info",
- "groups": ["admin", "moderator", "editor"],
+ "groups": ["admin", "owner", "moderator", "editor"],
},
]
content_type = ContentType.objects.get_for_model(models.User)
for permission in permissions:
- permission_obj = Permission.objects.create(
+ permission_obj, _ = Permission.objects.get_or_create(
codename=permission["codename"],
name=permission["name"],
content_type=content_type,
diff --git a/bookwyrm/management/commands/remove_2fa.py b/bookwyrm/management/commands/remove_2fa.py
new file mode 100644
index 000000000..1c9d5f71a
--- /dev/null
+++ b/bookwyrm/management/commands/remove_2fa.py
@@ -0,0 +1,22 @@
+"""deactivate two factor auth"""
+
+from django.core.management.base import BaseCommand, CommandError
+from bookwyrm import models
+
+
+class Command(BaseCommand):
+ """command-line options"""
+
+ help = "Remove Two Factor Authorisation from user"
+
+ def add_arguments(self, parser):
+ parser.add_argument("username")
+
+ def handle(self, *args, **options):
+ name = options["username"]
+ user = models.User.objects.get(localname=name)
+ user.two_factor_auth = False
+ user.save(broadcast=False, update_fields=["two_factor_auth"])
+ self.stdout.write(
+ self.style.SUCCESS("Two Factor Authorisation was removed from user")
+ )
diff --git a/bookwyrm/management/commands/revoke_preview_image_tasks.py b/bookwyrm/management/commands/revoke_preview_image_tasks.py
new file mode 100644
index 000000000..6d6e59e8f
--- /dev/null
+++ b/bookwyrm/management/commands/revoke_preview_image_tasks.py
@@ -0,0 +1,31 @@
+""" Actually let's not generate those preview images """
+import json
+from django.core.management.base import BaseCommand
+from bookwyrm.tasks import app
+
+
+class Command(BaseCommand):
+ """Find and revoke image tasks"""
+
+ # pylint: disable=unused-argument
+ def handle(self, *args, **options):
+ """reveoke nonessential low priority tasks"""
+ types = [
+ "bookwyrm.preview_images.generate_edition_preview_image_task",
+ "bookwyrm.preview_images.generate_user_preview_image_task",
+ ]
+ self.stdout.write(" | Finding tasks of types:")
+ self.stdout.write("\n".join(types))
+ with app.pool.acquire(block=True) as conn:
+ tasks = conn.default_channel.client.lrange("low_priority", 0, -1)
+ self.stdout.write(f" | Found {len(tasks)} task(s) in low priority queue")
+
+ revoke_ids = []
+ for task in tasks:
+ task_json = json.loads(task)
+ task_type = task_json.get("headers", {}).get("task")
+ if task_type in types:
+ revoke_ids.append(task_json.get("headers", {}).get("id"))
+ self.stdout.write(".", ending="")
+ self.stdout.write(f"\n | Revoking {len(revoke_ids)} task(s)")
+ app.control.revoke(revoke_ids)
diff --git a/bookwyrm/migrations/0157_auto_20220909_2338.py b/bookwyrm/migrations/0157_auto_20220909_2338.py
new file mode 100644
index 000000000..86ea8fab3
--- /dev/null
+++ b/bookwyrm/migrations/0157_auto_20220909_2338.py
@@ -0,0 +1,647 @@
+# Generated by Django 3.2.15 on 2022-09-09 23:38
+
+import bookwyrm.models.fields
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0156_alter_user_preferred_language"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="review",
+ name="rating",
+ field=bookwyrm.models.fields.DecimalField(
+ blank=True,
+ decimal_places=2,
+ default=None,
+ max_digits=3,
+ null=True,
+ validators=[
+ django.core.validators.MinValueValidator(0.5),
+ django.core.validators.MaxValueValidator(5),
+ ],
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="preferred_timezone",
+ field=models.CharField(
+ choices=[
+ ("Africa/Abidjan", "Africa/Abidjan"),
+ ("Africa/Accra", "Africa/Accra"),
+ ("Africa/Addis_Ababa", "Africa/Addis_Ababa"),
+ ("Africa/Algiers", "Africa/Algiers"),
+ ("Africa/Asmara", "Africa/Asmara"),
+ ("Africa/Asmera", "Africa/Asmera"),
+ ("Africa/Bamako", "Africa/Bamako"),
+ ("Africa/Bangui", "Africa/Bangui"),
+ ("Africa/Banjul", "Africa/Banjul"),
+ ("Africa/Bissau", "Africa/Bissau"),
+ ("Africa/Blantyre", "Africa/Blantyre"),
+ ("Africa/Brazzaville", "Africa/Brazzaville"),
+ ("Africa/Bujumbura", "Africa/Bujumbura"),
+ ("Africa/Cairo", "Africa/Cairo"),
+ ("Africa/Casablanca", "Africa/Casablanca"),
+ ("Africa/Ceuta", "Africa/Ceuta"),
+ ("Africa/Conakry", "Africa/Conakry"),
+ ("Africa/Dakar", "Africa/Dakar"),
+ ("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"),
+ ("Africa/Djibouti", "Africa/Djibouti"),
+ ("Africa/Douala", "Africa/Douala"),
+ ("Africa/El_Aaiun", "Africa/El_Aaiun"),
+ ("Africa/Freetown", "Africa/Freetown"),
+ ("Africa/Gaborone", "Africa/Gaborone"),
+ ("Africa/Harare", "Africa/Harare"),
+ ("Africa/Johannesburg", "Africa/Johannesburg"),
+ ("Africa/Juba", "Africa/Juba"),
+ ("Africa/Kampala", "Africa/Kampala"),
+ ("Africa/Khartoum", "Africa/Khartoum"),
+ ("Africa/Kigali", "Africa/Kigali"),
+ ("Africa/Kinshasa", "Africa/Kinshasa"),
+ ("Africa/Lagos", "Africa/Lagos"),
+ ("Africa/Libreville", "Africa/Libreville"),
+ ("Africa/Lome", "Africa/Lome"),
+ ("Africa/Luanda", "Africa/Luanda"),
+ ("Africa/Lubumbashi", "Africa/Lubumbashi"),
+ ("Africa/Lusaka", "Africa/Lusaka"),
+ ("Africa/Malabo", "Africa/Malabo"),
+ ("Africa/Maputo", "Africa/Maputo"),
+ ("Africa/Maseru", "Africa/Maseru"),
+ ("Africa/Mbabane", "Africa/Mbabane"),
+ ("Africa/Mogadishu", "Africa/Mogadishu"),
+ ("Africa/Monrovia", "Africa/Monrovia"),
+ ("Africa/Nairobi", "Africa/Nairobi"),
+ ("Africa/Ndjamena", "Africa/Ndjamena"),
+ ("Africa/Niamey", "Africa/Niamey"),
+ ("Africa/Nouakchott", "Africa/Nouakchott"),
+ ("Africa/Ouagadougou", "Africa/Ouagadougou"),
+ ("Africa/Porto-Novo", "Africa/Porto-Novo"),
+ ("Africa/Sao_Tome", "Africa/Sao_Tome"),
+ ("Africa/Timbuktu", "Africa/Timbuktu"),
+ ("Africa/Tripoli", "Africa/Tripoli"),
+ ("Africa/Tunis", "Africa/Tunis"),
+ ("Africa/Windhoek", "Africa/Windhoek"),
+ ("America/Adak", "America/Adak"),
+ ("America/Anchorage", "America/Anchorage"),
+ ("America/Anguilla", "America/Anguilla"),
+ ("America/Antigua", "America/Antigua"),
+ ("America/Araguaina", "America/Araguaina"),
+ (
+ "America/Argentina/Buenos_Aires",
+ "America/Argentina/Buenos_Aires",
+ ),
+ ("America/Argentina/Catamarca", "America/Argentina/Catamarca"),
+ (
+ "America/Argentina/ComodRivadavia",
+ "America/Argentina/ComodRivadavia",
+ ),
+ ("America/Argentina/Cordoba", "America/Argentina/Cordoba"),
+ ("America/Argentina/Jujuy", "America/Argentina/Jujuy"),
+ ("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"),
+ ("America/Argentina/Mendoza", "America/Argentina/Mendoza"),
+ (
+ "America/Argentina/Rio_Gallegos",
+ "America/Argentina/Rio_Gallegos",
+ ),
+ ("America/Argentina/Salta", "America/Argentina/Salta"),
+ ("America/Argentina/San_Juan", "America/Argentina/San_Juan"),
+ ("America/Argentina/San_Luis", "America/Argentina/San_Luis"),
+ ("America/Argentina/Tucuman", "America/Argentina/Tucuman"),
+ ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"),
+ ("America/Aruba", "America/Aruba"),
+ ("America/Asuncion", "America/Asuncion"),
+ ("America/Atikokan", "America/Atikokan"),
+ ("America/Atka", "America/Atka"),
+ ("America/Bahia", "America/Bahia"),
+ ("America/Bahia_Banderas", "America/Bahia_Banderas"),
+ ("America/Barbados", "America/Barbados"),
+ ("America/Belem", "America/Belem"),
+ ("America/Belize", "America/Belize"),
+ ("America/Blanc-Sablon", "America/Blanc-Sablon"),
+ ("America/Boa_Vista", "America/Boa_Vista"),
+ ("America/Bogota", "America/Bogota"),
+ ("America/Boise", "America/Boise"),
+ ("America/Buenos_Aires", "America/Buenos_Aires"),
+ ("America/Cambridge_Bay", "America/Cambridge_Bay"),
+ ("America/Campo_Grande", "America/Campo_Grande"),
+ ("America/Cancun", "America/Cancun"),
+ ("America/Caracas", "America/Caracas"),
+ ("America/Catamarca", "America/Catamarca"),
+ ("America/Cayenne", "America/Cayenne"),
+ ("America/Cayman", "America/Cayman"),
+ ("America/Chicago", "America/Chicago"),
+ ("America/Chihuahua", "America/Chihuahua"),
+ ("America/Coral_Harbour", "America/Coral_Harbour"),
+ ("America/Cordoba", "America/Cordoba"),
+ ("America/Costa_Rica", "America/Costa_Rica"),
+ ("America/Creston", "America/Creston"),
+ ("America/Cuiaba", "America/Cuiaba"),
+ ("America/Curacao", "America/Curacao"),
+ ("America/Danmarkshavn", "America/Danmarkshavn"),
+ ("America/Dawson", "America/Dawson"),
+ ("America/Dawson_Creek", "America/Dawson_Creek"),
+ ("America/Denver", "America/Denver"),
+ ("America/Detroit", "America/Detroit"),
+ ("America/Dominica", "America/Dominica"),
+ ("America/Edmonton", "America/Edmonton"),
+ ("America/Eirunepe", "America/Eirunepe"),
+ ("America/El_Salvador", "America/El_Salvador"),
+ ("America/Ensenada", "America/Ensenada"),
+ ("America/Fort_Nelson", "America/Fort_Nelson"),
+ ("America/Fort_Wayne", "America/Fort_Wayne"),
+ ("America/Fortaleza", "America/Fortaleza"),
+ ("America/Glace_Bay", "America/Glace_Bay"),
+ ("America/Godthab", "America/Godthab"),
+ ("America/Goose_Bay", "America/Goose_Bay"),
+ ("America/Grand_Turk", "America/Grand_Turk"),
+ ("America/Grenada", "America/Grenada"),
+ ("America/Guadeloupe", "America/Guadeloupe"),
+ ("America/Guatemala", "America/Guatemala"),
+ ("America/Guayaquil", "America/Guayaquil"),
+ ("America/Guyana", "America/Guyana"),
+ ("America/Halifax", "America/Halifax"),
+ ("America/Havana", "America/Havana"),
+ ("America/Hermosillo", "America/Hermosillo"),
+ ("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"),
+ ("America/Indiana/Knox", "America/Indiana/Knox"),
+ ("America/Indiana/Marengo", "America/Indiana/Marengo"),
+ ("America/Indiana/Petersburg", "America/Indiana/Petersburg"),
+ ("America/Indiana/Tell_City", "America/Indiana/Tell_City"),
+ ("America/Indiana/Vevay", "America/Indiana/Vevay"),
+ ("America/Indiana/Vincennes", "America/Indiana/Vincennes"),
+ ("America/Indiana/Winamac", "America/Indiana/Winamac"),
+ ("America/Indianapolis", "America/Indianapolis"),
+ ("America/Inuvik", "America/Inuvik"),
+ ("America/Iqaluit", "America/Iqaluit"),
+ ("America/Jamaica", "America/Jamaica"),
+ ("America/Jujuy", "America/Jujuy"),
+ ("America/Juneau", "America/Juneau"),
+ ("America/Kentucky/Louisville", "America/Kentucky/Louisville"),
+ ("America/Kentucky/Monticello", "America/Kentucky/Monticello"),
+ ("America/Knox_IN", "America/Knox_IN"),
+ ("America/Kralendijk", "America/Kralendijk"),
+ ("America/La_Paz", "America/La_Paz"),
+ ("America/Lima", "America/Lima"),
+ ("America/Los_Angeles", "America/Los_Angeles"),
+ ("America/Louisville", "America/Louisville"),
+ ("America/Lower_Princes", "America/Lower_Princes"),
+ ("America/Maceio", "America/Maceio"),
+ ("America/Managua", "America/Managua"),
+ ("America/Manaus", "America/Manaus"),
+ ("America/Marigot", "America/Marigot"),
+ ("America/Martinique", "America/Martinique"),
+ ("America/Matamoros", "America/Matamoros"),
+ ("America/Mazatlan", "America/Mazatlan"),
+ ("America/Mendoza", "America/Mendoza"),
+ ("America/Menominee", "America/Menominee"),
+ ("America/Merida", "America/Merida"),
+ ("America/Metlakatla", "America/Metlakatla"),
+ ("America/Mexico_City", "America/Mexico_City"),
+ ("America/Miquelon", "America/Miquelon"),
+ ("America/Moncton", "America/Moncton"),
+ ("America/Monterrey", "America/Monterrey"),
+ ("America/Montevideo", "America/Montevideo"),
+ ("America/Montreal", "America/Montreal"),
+ ("America/Montserrat", "America/Montserrat"),
+ ("America/Nassau", "America/Nassau"),
+ ("America/New_York", "America/New_York"),
+ ("America/Nipigon", "America/Nipigon"),
+ ("America/Nome", "America/Nome"),
+ ("America/Noronha", "America/Noronha"),
+ ("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"),
+ ("America/North_Dakota/Center", "America/North_Dakota/Center"),
+ (
+ "America/North_Dakota/New_Salem",
+ "America/North_Dakota/New_Salem",
+ ),
+ ("America/Nuuk", "America/Nuuk"),
+ ("America/Ojinaga", "America/Ojinaga"),
+ ("America/Panama", "America/Panama"),
+ ("America/Pangnirtung", "America/Pangnirtung"),
+ ("America/Paramaribo", "America/Paramaribo"),
+ ("America/Phoenix", "America/Phoenix"),
+ ("America/Port-au-Prince", "America/Port-au-Prince"),
+ ("America/Port_of_Spain", "America/Port_of_Spain"),
+ ("America/Porto_Acre", "America/Porto_Acre"),
+ ("America/Porto_Velho", "America/Porto_Velho"),
+ ("America/Puerto_Rico", "America/Puerto_Rico"),
+ ("America/Punta_Arenas", "America/Punta_Arenas"),
+ ("America/Rainy_River", "America/Rainy_River"),
+ ("America/Rankin_Inlet", "America/Rankin_Inlet"),
+ ("America/Recife", "America/Recife"),
+ ("America/Regina", "America/Regina"),
+ ("America/Resolute", "America/Resolute"),
+ ("America/Rio_Branco", "America/Rio_Branco"),
+ ("America/Rosario", "America/Rosario"),
+ ("America/Santa_Isabel", "America/Santa_Isabel"),
+ ("America/Santarem", "America/Santarem"),
+ ("America/Santiago", "America/Santiago"),
+ ("America/Santo_Domingo", "America/Santo_Domingo"),
+ ("America/Sao_Paulo", "America/Sao_Paulo"),
+ ("America/Scoresbysund", "America/Scoresbysund"),
+ ("America/Shiprock", "America/Shiprock"),
+ ("America/Sitka", "America/Sitka"),
+ ("America/St_Barthelemy", "America/St_Barthelemy"),
+ ("America/St_Johns", "America/St_Johns"),
+ ("America/St_Kitts", "America/St_Kitts"),
+ ("America/St_Lucia", "America/St_Lucia"),
+ ("America/St_Thomas", "America/St_Thomas"),
+ ("America/St_Vincent", "America/St_Vincent"),
+ ("America/Swift_Current", "America/Swift_Current"),
+ ("America/Tegucigalpa", "America/Tegucigalpa"),
+ ("America/Thule", "America/Thule"),
+ ("America/Thunder_Bay", "America/Thunder_Bay"),
+ ("America/Tijuana", "America/Tijuana"),
+ ("America/Toronto", "America/Toronto"),
+ ("America/Tortola", "America/Tortola"),
+ ("America/Vancouver", "America/Vancouver"),
+ ("America/Virgin", "America/Virgin"),
+ ("America/Whitehorse", "America/Whitehorse"),
+ ("America/Winnipeg", "America/Winnipeg"),
+ ("America/Yakutat", "America/Yakutat"),
+ ("America/Yellowknife", "America/Yellowknife"),
+ ("Antarctica/Casey", "Antarctica/Casey"),
+ ("Antarctica/Davis", "Antarctica/Davis"),
+ ("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"),
+ ("Antarctica/Macquarie", "Antarctica/Macquarie"),
+ ("Antarctica/Mawson", "Antarctica/Mawson"),
+ ("Antarctica/McMurdo", "Antarctica/McMurdo"),
+ ("Antarctica/Palmer", "Antarctica/Palmer"),
+ ("Antarctica/Rothera", "Antarctica/Rothera"),
+ ("Antarctica/South_Pole", "Antarctica/South_Pole"),
+ ("Antarctica/Syowa", "Antarctica/Syowa"),
+ ("Antarctica/Troll", "Antarctica/Troll"),
+ ("Antarctica/Vostok", "Antarctica/Vostok"),
+ ("Arctic/Longyearbyen", "Arctic/Longyearbyen"),
+ ("Asia/Aden", "Asia/Aden"),
+ ("Asia/Almaty", "Asia/Almaty"),
+ ("Asia/Amman", "Asia/Amman"),
+ ("Asia/Anadyr", "Asia/Anadyr"),
+ ("Asia/Aqtau", "Asia/Aqtau"),
+ ("Asia/Aqtobe", "Asia/Aqtobe"),
+ ("Asia/Ashgabat", "Asia/Ashgabat"),
+ ("Asia/Ashkhabad", "Asia/Ashkhabad"),
+ ("Asia/Atyrau", "Asia/Atyrau"),
+ ("Asia/Baghdad", "Asia/Baghdad"),
+ ("Asia/Bahrain", "Asia/Bahrain"),
+ ("Asia/Baku", "Asia/Baku"),
+ ("Asia/Bangkok", "Asia/Bangkok"),
+ ("Asia/Barnaul", "Asia/Barnaul"),
+ ("Asia/Beirut", "Asia/Beirut"),
+ ("Asia/Bishkek", "Asia/Bishkek"),
+ ("Asia/Brunei", "Asia/Brunei"),
+ ("Asia/Calcutta", "Asia/Calcutta"),
+ ("Asia/Chita", "Asia/Chita"),
+ ("Asia/Choibalsan", "Asia/Choibalsan"),
+ ("Asia/Chongqing", "Asia/Chongqing"),
+ ("Asia/Chungking", "Asia/Chungking"),
+ ("Asia/Colombo", "Asia/Colombo"),
+ ("Asia/Dacca", "Asia/Dacca"),
+ ("Asia/Damascus", "Asia/Damascus"),
+ ("Asia/Dhaka", "Asia/Dhaka"),
+ ("Asia/Dili", "Asia/Dili"),
+ ("Asia/Dubai", "Asia/Dubai"),
+ ("Asia/Dushanbe", "Asia/Dushanbe"),
+ ("Asia/Famagusta", "Asia/Famagusta"),
+ ("Asia/Gaza", "Asia/Gaza"),
+ ("Asia/Harbin", "Asia/Harbin"),
+ ("Asia/Hebron", "Asia/Hebron"),
+ ("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"),
+ ("Asia/Hong_Kong", "Asia/Hong_Kong"),
+ ("Asia/Hovd", "Asia/Hovd"),
+ ("Asia/Irkutsk", "Asia/Irkutsk"),
+ ("Asia/Istanbul", "Asia/Istanbul"),
+ ("Asia/Jakarta", "Asia/Jakarta"),
+ ("Asia/Jayapura", "Asia/Jayapura"),
+ ("Asia/Jerusalem", "Asia/Jerusalem"),
+ ("Asia/Kabul", "Asia/Kabul"),
+ ("Asia/Kamchatka", "Asia/Kamchatka"),
+ ("Asia/Karachi", "Asia/Karachi"),
+ ("Asia/Kashgar", "Asia/Kashgar"),
+ ("Asia/Kathmandu", "Asia/Kathmandu"),
+ ("Asia/Katmandu", "Asia/Katmandu"),
+ ("Asia/Khandyga", "Asia/Khandyga"),
+ ("Asia/Kolkata", "Asia/Kolkata"),
+ ("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"),
+ ("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"),
+ ("Asia/Kuching", "Asia/Kuching"),
+ ("Asia/Kuwait", "Asia/Kuwait"),
+ ("Asia/Macao", "Asia/Macao"),
+ ("Asia/Macau", "Asia/Macau"),
+ ("Asia/Magadan", "Asia/Magadan"),
+ ("Asia/Makassar", "Asia/Makassar"),
+ ("Asia/Manila", "Asia/Manila"),
+ ("Asia/Muscat", "Asia/Muscat"),
+ ("Asia/Nicosia", "Asia/Nicosia"),
+ ("Asia/Novokuznetsk", "Asia/Novokuznetsk"),
+ ("Asia/Novosibirsk", "Asia/Novosibirsk"),
+ ("Asia/Omsk", "Asia/Omsk"),
+ ("Asia/Oral", "Asia/Oral"),
+ ("Asia/Phnom_Penh", "Asia/Phnom_Penh"),
+ ("Asia/Pontianak", "Asia/Pontianak"),
+ ("Asia/Pyongyang", "Asia/Pyongyang"),
+ ("Asia/Qatar", "Asia/Qatar"),
+ ("Asia/Qostanay", "Asia/Qostanay"),
+ ("Asia/Qyzylorda", "Asia/Qyzylorda"),
+ ("Asia/Rangoon", "Asia/Rangoon"),
+ ("Asia/Riyadh", "Asia/Riyadh"),
+ ("Asia/Saigon", "Asia/Saigon"),
+ ("Asia/Sakhalin", "Asia/Sakhalin"),
+ ("Asia/Samarkand", "Asia/Samarkand"),
+ ("Asia/Seoul", "Asia/Seoul"),
+ ("Asia/Shanghai", "Asia/Shanghai"),
+ ("Asia/Singapore", "Asia/Singapore"),
+ ("Asia/Srednekolymsk", "Asia/Srednekolymsk"),
+ ("Asia/Taipei", "Asia/Taipei"),
+ ("Asia/Tashkent", "Asia/Tashkent"),
+ ("Asia/Tbilisi", "Asia/Tbilisi"),
+ ("Asia/Tehran", "Asia/Tehran"),
+ ("Asia/Tel_Aviv", "Asia/Tel_Aviv"),
+ ("Asia/Thimbu", "Asia/Thimbu"),
+ ("Asia/Thimphu", "Asia/Thimphu"),
+ ("Asia/Tokyo", "Asia/Tokyo"),
+ ("Asia/Tomsk", "Asia/Tomsk"),
+ ("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"),
+ ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"),
+ ("Asia/Ulan_Bator", "Asia/Ulan_Bator"),
+ ("Asia/Urumqi", "Asia/Urumqi"),
+ ("Asia/Ust-Nera", "Asia/Ust-Nera"),
+ ("Asia/Vientiane", "Asia/Vientiane"),
+ ("Asia/Vladivostok", "Asia/Vladivostok"),
+ ("Asia/Yakutsk", "Asia/Yakutsk"),
+ ("Asia/Yangon", "Asia/Yangon"),
+ ("Asia/Yekaterinburg", "Asia/Yekaterinburg"),
+ ("Asia/Yerevan", "Asia/Yerevan"),
+ ("Atlantic/Azores", "Atlantic/Azores"),
+ ("Atlantic/Bermuda", "Atlantic/Bermuda"),
+ ("Atlantic/Canary", "Atlantic/Canary"),
+ ("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"),
+ ("Atlantic/Faeroe", "Atlantic/Faeroe"),
+ ("Atlantic/Faroe", "Atlantic/Faroe"),
+ ("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"),
+ ("Atlantic/Madeira", "Atlantic/Madeira"),
+ ("Atlantic/Reykjavik", "Atlantic/Reykjavik"),
+ ("Atlantic/South_Georgia", "Atlantic/South_Georgia"),
+ ("Atlantic/St_Helena", "Atlantic/St_Helena"),
+ ("Atlantic/Stanley", "Atlantic/Stanley"),
+ ("Australia/ACT", "Australia/ACT"),
+ ("Australia/Adelaide", "Australia/Adelaide"),
+ ("Australia/Brisbane", "Australia/Brisbane"),
+ ("Australia/Broken_Hill", "Australia/Broken_Hill"),
+ ("Australia/Canberra", "Australia/Canberra"),
+ ("Australia/Currie", "Australia/Currie"),
+ ("Australia/Darwin", "Australia/Darwin"),
+ ("Australia/Eucla", "Australia/Eucla"),
+ ("Australia/Hobart", "Australia/Hobart"),
+ ("Australia/LHI", "Australia/LHI"),
+ ("Australia/Lindeman", "Australia/Lindeman"),
+ ("Australia/Lord_Howe", "Australia/Lord_Howe"),
+ ("Australia/Melbourne", "Australia/Melbourne"),
+ ("Australia/NSW", "Australia/NSW"),
+ ("Australia/North", "Australia/North"),
+ ("Australia/Perth", "Australia/Perth"),
+ ("Australia/Queensland", "Australia/Queensland"),
+ ("Australia/South", "Australia/South"),
+ ("Australia/Sydney", "Australia/Sydney"),
+ ("Australia/Tasmania", "Australia/Tasmania"),
+ ("Australia/Victoria", "Australia/Victoria"),
+ ("Australia/West", "Australia/West"),
+ ("Australia/Yancowinna", "Australia/Yancowinna"),
+ ("Brazil/Acre", "Brazil/Acre"),
+ ("Brazil/DeNoronha", "Brazil/DeNoronha"),
+ ("Brazil/East", "Brazil/East"),
+ ("Brazil/West", "Brazil/West"),
+ ("CET", "CET"),
+ ("CST6CDT", "CST6CDT"),
+ ("Canada/Atlantic", "Canada/Atlantic"),
+ ("Canada/Central", "Canada/Central"),
+ ("Canada/Eastern", "Canada/Eastern"),
+ ("Canada/Mountain", "Canada/Mountain"),
+ ("Canada/Newfoundland", "Canada/Newfoundland"),
+ ("Canada/Pacific", "Canada/Pacific"),
+ ("Canada/Saskatchewan", "Canada/Saskatchewan"),
+ ("Canada/Yukon", "Canada/Yukon"),
+ ("Chile/Continental", "Chile/Continental"),
+ ("Chile/EasterIsland", "Chile/EasterIsland"),
+ ("Cuba", "Cuba"),
+ ("EET", "EET"),
+ ("EST", "EST"),
+ ("EST5EDT", "EST5EDT"),
+ ("Egypt", "Egypt"),
+ ("Eire", "Eire"),
+ ("Etc/GMT", "Etc/GMT"),
+ ("Etc/GMT+0", "Etc/GMT+0"),
+ ("Etc/GMT+1", "Etc/GMT+1"),
+ ("Etc/GMT+10", "Etc/GMT+10"),
+ ("Etc/GMT+11", "Etc/GMT+11"),
+ ("Etc/GMT+12", "Etc/GMT+12"),
+ ("Etc/GMT+2", "Etc/GMT+2"),
+ ("Etc/GMT+3", "Etc/GMT+3"),
+ ("Etc/GMT+4", "Etc/GMT+4"),
+ ("Etc/GMT+5", "Etc/GMT+5"),
+ ("Etc/GMT+6", "Etc/GMT+6"),
+ ("Etc/GMT+7", "Etc/GMT+7"),
+ ("Etc/GMT+8", "Etc/GMT+8"),
+ ("Etc/GMT+9", "Etc/GMT+9"),
+ ("Etc/GMT-0", "Etc/GMT-0"),
+ ("Etc/GMT-1", "Etc/GMT-1"),
+ ("Etc/GMT-10", "Etc/GMT-10"),
+ ("Etc/GMT-11", "Etc/GMT-11"),
+ ("Etc/GMT-12", "Etc/GMT-12"),
+ ("Etc/GMT-13", "Etc/GMT-13"),
+ ("Etc/GMT-14", "Etc/GMT-14"),
+ ("Etc/GMT-2", "Etc/GMT-2"),
+ ("Etc/GMT-3", "Etc/GMT-3"),
+ ("Etc/GMT-4", "Etc/GMT-4"),
+ ("Etc/GMT-5", "Etc/GMT-5"),
+ ("Etc/GMT-6", "Etc/GMT-6"),
+ ("Etc/GMT-7", "Etc/GMT-7"),
+ ("Etc/GMT-8", "Etc/GMT-8"),
+ ("Etc/GMT-9", "Etc/GMT-9"),
+ ("Etc/GMT0", "Etc/GMT0"),
+ ("Etc/Greenwich", "Etc/Greenwich"),
+ ("Etc/UCT", "Etc/UCT"),
+ ("Etc/UTC", "Etc/UTC"),
+ ("Etc/Universal", "Etc/Universal"),
+ ("Etc/Zulu", "Etc/Zulu"),
+ ("Europe/Amsterdam", "Europe/Amsterdam"),
+ ("Europe/Andorra", "Europe/Andorra"),
+ ("Europe/Astrakhan", "Europe/Astrakhan"),
+ ("Europe/Athens", "Europe/Athens"),
+ ("Europe/Belfast", "Europe/Belfast"),
+ ("Europe/Belgrade", "Europe/Belgrade"),
+ ("Europe/Berlin", "Europe/Berlin"),
+ ("Europe/Bratislava", "Europe/Bratislava"),
+ ("Europe/Brussels", "Europe/Brussels"),
+ ("Europe/Bucharest", "Europe/Bucharest"),
+ ("Europe/Budapest", "Europe/Budapest"),
+ ("Europe/Busingen", "Europe/Busingen"),
+ ("Europe/Chisinau", "Europe/Chisinau"),
+ ("Europe/Copenhagen", "Europe/Copenhagen"),
+ ("Europe/Dublin", "Europe/Dublin"),
+ ("Europe/Gibraltar", "Europe/Gibraltar"),
+ ("Europe/Guernsey", "Europe/Guernsey"),
+ ("Europe/Helsinki", "Europe/Helsinki"),
+ ("Europe/Isle_of_Man", "Europe/Isle_of_Man"),
+ ("Europe/Istanbul", "Europe/Istanbul"),
+ ("Europe/Jersey", "Europe/Jersey"),
+ ("Europe/Kaliningrad", "Europe/Kaliningrad"),
+ ("Europe/Kiev", "Europe/Kiev"),
+ ("Europe/Kirov", "Europe/Kirov"),
+ ("Europe/Kyiv", "Europe/Kyiv"),
+ ("Europe/Lisbon", "Europe/Lisbon"),
+ ("Europe/Ljubljana", "Europe/Ljubljana"),
+ ("Europe/London", "Europe/London"),
+ ("Europe/Luxembourg", "Europe/Luxembourg"),
+ ("Europe/Madrid", "Europe/Madrid"),
+ ("Europe/Malta", "Europe/Malta"),
+ ("Europe/Mariehamn", "Europe/Mariehamn"),
+ ("Europe/Minsk", "Europe/Minsk"),
+ ("Europe/Monaco", "Europe/Monaco"),
+ ("Europe/Moscow", "Europe/Moscow"),
+ ("Europe/Nicosia", "Europe/Nicosia"),
+ ("Europe/Oslo", "Europe/Oslo"),
+ ("Europe/Paris", "Europe/Paris"),
+ ("Europe/Podgorica", "Europe/Podgorica"),
+ ("Europe/Prague", "Europe/Prague"),
+ ("Europe/Riga", "Europe/Riga"),
+ ("Europe/Rome", "Europe/Rome"),
+ ("Europe/Samara", "Europe/Samara"),
+ ("Europe/San_Marino", "Europe/San_Marino"),
+ ("Europe/Sarajevo", "Europe/Sarajevo"),
+ ("Europe/Saratov", "Europe/Saratov"),
+ ("Europe/Simferopol", "Europe/Simferopol"),
+ ("Europe/Skopje", "Europe/Skopje"),
+ ("Europe/Sofia", "Europe/Sofia"),
+ ("Europe/Stockholm", "Europe/Stockholm"),
+ ("Europe/Tallinn", "Europe/Tallinn"),
+ ("Europe/Tirane", "Europe/Tirane"),
+ ("Europe/Tiraspol", "Europe/Tiraspol"),
+ ("Europe/Ulyanovsk", "Europe/Ulyanovsk"),
+ ("Europe/Uzhgorod", "Europe/Uzhgorod"),
+ ("Europe/Vaduz", "Europe/Vaduz"),
+ ("Europe/Vatican", "Europe/Vatican"),
+ ("Europe/Vienna", "Europe/Vienna"),
+ ("Europe/Vilnius", "Europe/Vilnius"),
+ ("Europe/Volgograd", "Europe/Volgograd"),
+ ("Europe/Warsaw", "Europe/Warsaw"),
+ ("Europe/Zagreb", "Europe/Zagreb"),
+ ("Europe/Zaporozhye", "Europe/Zaporozhye"),
+ ("Europe/Zurich", "Europe/Zurich"),
+ ("GB", "GB"),
+ ("GB-Eire", "GB-Eire"),
+ ("GMT", "GMT"),
+ ("GMT+0", "GMT+0"),
+ ("GMT-0", "GMT-0"),
+ ("GMT0", "GMT0"),
+ ("Greenwich", "Greenwich"),
+ ("HST", "HST"),
+ ("Hongkong", "Hongkong"),
+ ("Iceland", "Iceland"),
+ ("Indian/Antananarivo", "Indian/Antananarivo"),
+ ("Indian/Chagos", "Indian/Chagos"),
+ ("Indian/Christmas", "Indian/Christmas"),
+ ("Indian/Cocos", "Indian/Cocos"),
+ ("Indian/Comoro", "Indian/Comoro"),
+ ("Indian/Kerguelen", "Indian/Kerguelen"),
+ ("Indian/Mahe", "Indian/Mahe"),
+ ("Indian/Maldives", "Indian/Maldives"),
+ ("Indian/Mauritius", "Indian/Mauritius"),
+ ("Indian/Mayotte", "Indian/Mayotte"),
+ ("Indian/Reunion", "Indian/Reunion"),
+ ("Iran", "Iran"),
+ ("Israel", "Israel"),
+ ("Jamaica", "Jamaica"),
+ ("Japan", "Japan"),
+ ("Kwajalein", "Kwajalein"),
+ ("Libya", "Libya"),
+ ("MET", "MET"),
+ ("MST", "MST"),
+ ("MST7MDT", "MST7MDT"),
+ ("Mexico/BajaNorte", "Mexico/BajaNorte"),
+ ("Mexico/BajaSur", "Mexico/BajaSur"),
+ ("Mexico/General", "Mexico/General"),
+ ("NZ", "NZ"),
+ ("NZ-CHAT", "NZ-CHAT"),
+ ("Navajo", "Navajo"),
+ ("PRC", "PRC"),
+ ("PST8PDT", "PST8PDT"),
+ ("Pacific/Apia", "Pacific/Apia"),
+ ("Pacific/Auckland", "Pacific/Auckland"),
+ ("Pacific/Bougainville", "Pacific/Bougainville"),
+ ("Pacific/Chatham", "Pacific/Chatham"),
+ ("Pacific/Chuuk", "Pacific/Chuuk"),
+ ("Pacific/Easter", "Pacific/Easter"),
+ ("Pacific/Efate", "Pacific/Efate"),
+ ("Pacific/Enderbury", "Pacific/Enderbury"),
+ ("Pacific/Fakaofo", "Pacific/Fakaofo"),
+ ("Pacific/Fiji", "Pacific/Fiji"),
+ ("Pacific/Funafuti", "Pacific/Funafuti"),
+ ("Pacific/Galapagos", "Pacific/Galapagos"),
+ ("Pacific/Gambier", "Pacific/Gambier"),
+ ("Pacific/Guadalcanal", "Pacific/Guadalcanal"),
+ ("Pacific/Guam", "Pacific/Guam"),
+ ("Pacific/Honolulu", "Pacific/Honolulu"),
+ ("Pacific/Johnston", "Pacific/Johnston"),
+ ("Pacific/Kanton", "Pacific/Kanton"),
+ ("Pacific/Kiritimati", "Pacific/Kiritimati"),
+ ("Pacific/Kosrae", "Pacific/Kosrae"),
+ ("Pacific/Kwajalein", "Pacific/Kwajalein"),
+ ("Pacific/Majuro", "Pacific/Majuro"),
+ ("Pacific/Marquesas", "Pacific/Marquesas"),
+ ("Pacific/Midway", "Pacific/Midway"),
+ ("Pacific/Nauru", "Pacific/Nauru"),
+ ("Pacific/Niue", "Pacific/Niue"),
+ ("Pacific/Norfolk", "Pacific/Norfolk"),
+ ("Pacific/Noumea", "Pacific/Noumea"),
+ ("Pacific/Pago_Pago", "Pacific/Pago_Pago"),
+ ("Pacific/Palau", "Pacific/Palau"),
+ ("Pacific/Pitcairn", "Pacific/Pitcairn"),
+ ("Pacific/Pohnpei", "Pacific/Pohnpei"),
+ ("Pacific/Ponape", "Pacific/Ponape"),
+ ("Pacific/Port_Moresby", "Pacific/Port_Moresby"),
+ ("Pacific/Rarotonga", "Pacific/Rarotonga"),
+ ("Pacific/Saipan", "Pacific/Saipan"),
+ ("Pacific/Samoa", "Pacific/Samoa"),
+ ("Pacific/Tahiti", "Pacific/Tahiti"),
+ ("Pacific/Tarawa", "Pacific/Tarawa"),
+ ("Pacific/Tongatapu", "Pacific/Tongatapu"),
+ ("Pacific/Truk", "Pacific/Truk"),
+ ("Pacific/Wake", "Pacific/Wake"),
+ ("Pacific/Wallis", "Pacific/Wallis"),
+ ("Pacific/Yap", "Pacific/Yap"),
+ ("Poland", "Poland"),
+ ("Portugal", "Portugal"),
+ ("ROC", "ROC"),
+ ("ROK", "ROK"),
+ ("Singapore", "Singapore"),
+ ("Turkey", "Turkey"),
+ ("UCT", "UCT"),
+ ("US/Alaska", "US/Alaska"),
+ ("US/Aleutian", "US/Aleutian"),
+ ("US/Arizona", "US/Arizona"),
+ ("US/Central", "US/Central"),
+ ("US/East-Indiana", "US/East-Indiana"),
+ ("US/Eastern", "US/Eastern"),
+ ("US/Hawaii", "US/Hawaii"),
+ ("US/Indiana-Starke", "US/Indiana-Starke"),
+ ("US/Michigan", "US/Michigan"),
+ ("US/Mountain", "US/Mountain"),
+ ("US/Pacific", "US/Pacific"),
+ ("US/Samoa", "US/Samoa"),
+ ("UTC", "UTC"),
+ ("Universal", "Universal"),
+ ("W-SU", "W-SU"),
+ ("WET", "WET"),
+ ("Zulu", "Zulu"),
+ ],
+ default="UTC",
+ max_length=255,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0158_auto_20220919_1634.py b/bookwyrm/migrations/0158_auto_20220919_1634.py
new file mode 100644
index 000000000..c7cce19fd
--- /dev/null
+++ b/bookwyrm/migrations/0158_auto_20220919_1634.py
@@ -0,0 +1,65 @@
+# Generated by Django 3.2.15 on 2022-09-19 16:34
+
+import bookwyrm.models.fields
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0157_auto_20220909_2338"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="automod",
+ name="created_date",
+ field=models.DateTimeField(
+ auto_now_add=True, default=django.utils.timezone.now
+ ),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name="automod",
+ name="remote_id",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
+ ),
+ migrations.AddField(
+ model_name="automod",
+ name="updated_date",
+ field=models.DateTimeField(auto_now=True),
+ ),
+ migrations.AddField(
+ model_name="emailblocklist",
+ name="remote_id",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
+ ),
+ migrations.AddField(
+ model_name="emailblocklist",
+ name="updated_date",
+ field=models.DateTimeField(auto_now=True),
+ ),
+ migrations.AddField(
+ model_name="ipblocklist",
+ name="remote_id",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
+ ),
+ migrations.AddField(
+ model_name="ipblocklist",
+ name="updated_date",
+ field=models.DateTimeField(auto_now=True),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0159_auto_20220924_0634.py b/bookwyrm/migrations/0159_auto_20220924_0634.py
new file mode 100644
index 000000000..c223d9061
--- /dev/null
+++ b/bookwyrm/migrations/0159_auto_20220924_0634.py
@@ -0,0 +1,33 @@
+# Generated by Django 3.2.15 on 2022-09-24 06:34
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0158_auto_20220919_1634"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="hotp_count",
+ field=models.IntegerField(blank=True, default=0, null=True),
+ ),
+ migrations.AddField(
+ model_name="user",
+ name="hotp_secret",
+ field=models.CharField(blank=True, default=None, max_length=32, null=True),
+ ),
+ migrations.AddField(
+ model_name="user",
+ name="otp_secret",
+ field=models.CharField(blank=True, default=None, max_length=32, null=True),
+ ),
+ migrations.AddField(
+ model_name="user",
+ name="two_factor_auth",
+ field=models.BooleanField(blank=True, default=None, null=True),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0160_auto_20221101_2251.py b/bookwyrm/migrations/0160_auto_20221101_2251.py
new file mode 100644
index 000000000..5c3c1d09e
--- /dev/null
+++ b/bookwyrm/migrations/0160_auto_20221101_2251.py
@@ -0,0 +1,52 @@
+# Generated by Django 3.2.15 on 2022-11-01 22:51
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0159_auto_20220924_0634"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="allow_reactivation",
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AlterField(
+ model_name="connector",
+ name="deactivation_reason",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("pending", "Pending"),
+ ("self_deletion", "Self deletion"),
+ ("self_deactivation", "Self deactivation"),
+ ("moderator_suspension", "Moderator suspension"),
+ ("moderator_deletion", "Moderator deletion"),
+ ("domain_block", "Domain block"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="deactivation_reason",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("pending", "Pending"),
+ ("self_deletion", "Self deletion"),
+ ("self_deactivation", "Self deactivation"),
+ ("moderator_suspension", "Moderator suspension"),
+ ("moderator_deletion", "Moderator deletion"),
+ ("domain_block", "Domain block"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0160_auto_20221105_2030.py b/bookwyrm/migrations/0160_auto_20221105_2030.py
new file mode 100644
index 000000000..5bbedf55d
--- /dev/null
+++ b/bookwyrm/migrations/0160_auto_20221105_2030.py
@@ -0,0 +1,32 @@
+# Generated by Django 3.2.15 on 2022-11-05 20:30
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0159_auto_20220924_0634"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="importitem",
+ name="task_id",
+ field=models.CharField(blank=True, max_length=200, null=True),
+ ),
+ migrations.AddField(
+ model_name="importjob",
+ name="status",
+ field=models.CharField(
+ choices=[
+ ("pending", "Pending"),
+ ("active", "Active"),
+ ("complete", "Complete"),
+ ("stopped", "Stopped"),
+ ],
+ max_length=50,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0161_alter_importjob_status.py b/bookwyrm/migrations/0161_alter_importjob_status.py
new file mode 100644
index 000000000..44a1aea4c
--- /dev/null
+++ b/bookwyrm/migrations/0161_alter_importjob_status.py
@@ -0,0 +1,28 @@
+# Generated by Django 3.2.15 on 2022-11-05 20:40
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0160_auto_20221105_2030"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="importjob",
+ name="status",
+ field=models.CharField(
+ choices=[
+ ("pending", "Pending"),
+ ("active", "Active"),
+ ("complete", "Complete"),
+ ("stopped", "Stopped"),
+ ],
+ default="pending",
+ max_length=50,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0162_importjob_task_id.py b/bookwyrm/migrations/0162_importjob_task_id.py
new file mode 100644
index 000000000..0bc7cc8de
--- /dev/null
+++ b/bookwyrm/migrations/0162_importjob_task_id.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.15 on 2022-11-05 22:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0161_alter_importjob_status"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="importjob",
+ name="task_id",
+ field=models.CharField(blank=True, max_length=200, null=True),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0163_merge_0160_auto_20221101_2251_0162_importjob_task_id.py b/bookwyrm/migrations/0163_merge_0160_auto_20221101_2251_0162_importjob_task_id.py
new file mode 100644
index 000000000..a76f19b00
--- /dev/null
+++ b/bookwyrm/migrations/0163_merge_0160_auto_20221101_2251_0162_importjob_task_id.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.15 on 2022-11-10 20:34
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0160_auto_20221101_2251"),
+ ("bookwyrm", "0162_importjob_task_id"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0164_status_ready.py b/bookwyrm/migrations/0164_status_ready.py
new file mode 100644
index 000000000..fd8d49972
--- /dev/null
+++ b/bookwyrm/migrations/0164_status_ready.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.16 on 2022-11-15 21:40
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0163_merge_0160_auto_20221101_2251_0162_importjob_task_id"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="status",
+ name="ready",
+ field=models.BooleanField(default=True),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0165_alter_inviterequest_answer.py b/bookwyrm/migrations/0165_alter_inviterequest_answer.py
new file mode 100644
index 000000000..2d2cc5e4d
--- /dev/null
+++ b/bookwyrm/migrations/0165_alter_inviterequest_answer.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.16 on 2022-11-15 22:43
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0164_status_ready"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="inviterequest",
+ name="answer",
+ field=models.TextField(blank=True, max_length=255, null=True),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0166_sitesettings_imports_enabled.py b/bookwyrm/migrations/0166_sitesettings_imports_enabled.py
new file mode 100644
index 000000000..ccf4ef374
--- /dev/null
+++ b/bookwyrm/migrations/0166_sitesettings_imports_enabled.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.16 on 2022-11-17 21:50
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0165_alter_inviterequest_answer"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="sitesettings",
+ name="imports_enabled",
+ field=models.BooleanField(default=True),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0167_auto_20221125_1900.py b/bookwyrm/migrations/0167_auto_20221125_1900.py
new file mode 100644
index 000000000..db258b7c5
--- /dev/null
+++ b/bookwyrm/migrations/0167_auto_20221125_1900.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.2.16 on 2022-11-25 19:00
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0166_sitesettings_imports_enabled"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="sitesettings",
+ name="impressum",
+ field=models.TextField(default="Add a impressum here."),
+ ),
+ migrations.AddField(
+ model_name="sitesettings",
+ name="show_impressum",
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0168_auto_20221205_1701.py b/bookwyrm/migrations/0168_auto_20221205_1701.py
new file mode 100644
index 000000000..45d6c30e7
--- /dev/null
+++ b/bookwyrm/migrations/0168_auto_20221205_1701.py
@@ -0,0 +1,28 @@
+# Generated by Django 3.2.16 on 2022-12-05 17:01
+
+import bookwyrm.models.fields
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0167_auto_20221125_1900"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="author",
+ name="aasin",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
+ ),
+ migrations.AddField(
+ model_name="book",
+ name="aasin",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0168_auto_20221205_2331.py b/bookwyrm/migrations/0168_auto_20221205_2331.py
new file mode 100644
index 000000000..901ca56f0
--- /dev/null
+++ b/bookwyrm/migrations/0168_auto_20221205_2331.py
@@ -0,0 +1,63 @@
+""" I added two new permission types and a new group to the management command that
+creates the database on install, this creates them for existing instances """
+# Generated by Django 3.2.16 on 2022-12-05 23:31
+
+from django.db import migrations
+
+
+def create_groups_and_perms(apps, schema_editor):
+ """create the new "owner" group and "system admin" permission"""
+ db_alias = schema_editor.connection.alias
+ group_model = apps.get_model("auth", "Group")
+ # Add the "owner" group, if needed
+ owner_group, group_created = group_model.objects.using(db_alias).get_or_create(
+ name="owner"
+ )
+
+ # Create perms, if needed
+ user_model = apps.get_model("bookwyrm", "User")
+ content_type_model = apps.get_model("contenttypes", "ContentType")
+ content_type = content_type_model.objects.get_for_model(user_model)
+ perms_model = apps.get_model("auth", "Permission")
+ reg_perm, perm_created = perms_model.objects.using(db_alias).get_or_create(
+ codename="manage_registration",
+ name="allow or prevent user registration",
+ content_type=content_type,
+ )
+ admin_perm, admin_perm_created = perms_model.objects.using(db_alias).get_or_create(
+ codename="system_administration",
+ name="technical controls",
+ content_type=content_type,
+ )
+
+ # Add perms to the group if anything was created
+ if group_created or perm_created or admin_perm_created:
+ perms = [
+ "edit_instance_settings",
+ "set_user_group",
+ "control_federation",
+ "create_invites",
+ "moderate_user",
+ "moderate_post",
+ "edit_book",
+ ]
+ owner_group.permissions.set(
+ perms_model.objects.using(db_alias).filter(codename__in=perms).all()
+ )
+
+ # also extend these perms to admins
+ # This is get or create so the tests don't fail -- it should already exist
+ admin_group, _ = group_model.objects.using(db_alias).get_or_create(name="admin")
+ admin_group.permissions.add(reg_perm)
+ admin_group.permissions.add(admin_perm)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0167_auto_20221125_1900"),
+ ]
+
+ operations = [
+ migrations.RunPython(create_groups_and_perms, migrations.RunPython.noop)
+ ]
diff --git a/bookwyrm/migrations/0169_auto_20221206_0902.py b/bookwyrm/migrations/0169_auto_20221206_0902.py
new file mode 100644
index 000000000..7235490eb
--- /dev/null
+++ b/bookwyrm/migrations/0169_auto_20221206_0902.py
@@ -0,0 +1,28 @@
+# Generated by Django 3.2.16 on 2022-12-06 09:02
+
+import bookwyrm.models.fields
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0168_auto_20221205_1701"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="author",
+ name="isfdb",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
+ ),
+ migrations.AddField(
+ model_name="book",
+ name="isfdb",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0170_merge_0168_auto_20221205_2331_0169_auto_20221206_0902.py b/bookwyrm/migrations/0170_merge_0168_auto_20221205_2331_0169_auto_20221206_0902.py
new file mode 100644
index 000000000..3e199b014
--- /dev/null
+++ b/bookwyrm/migrations/0170_merge_0168_auto_20221205_2331_0169_auto_20221206_0902.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.16 on 2022-12-11 20:00
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0168_auto_20221205_2331"),
+ ("bookwyrm", "0169_auto_20221206_0902"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py
index 402cb040b..a9c6328fb 100644
--- a/bookwyrm/models/activitypub_mixin.py
+++ b/bookwyrm/models/activitypub_mixin.py
@@ -1,14 +1,15 @@
""" activitypub model functionality """
+import asyncio
from base64 import b64encode
from collections import namedtuple
from functools import reduce
import json
import operator
import logging
+from typing import List
from uuid import uuid4
-import requests
-from requests.exceptions import RequestException
+import aiohttp
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
@@ -136,7 +137,7 @@ class ActivitypubMixin:
queue=queue,
)
- def get_recipients(self, software=None):
+ def get_recipients(self, software=None) -> List[str]:
"""figure out which inbox urls to post to"""
# first we have to figure out who should receive this activity
privacy = self.privacy if hasattr(self, "privacy") else "public"
@@ -506,19 +507,31 @@ def unfurl_related_field(related_field, sort_field=None):
@app.task(queue=MEDIUM)
-def broadcast_task(sender_id, activity, recipients):
+def broadcast_task(sender_id: int, activity: str, recipients: List[str]):
"""the celery task for broadcast"""
user_model = apps.get_model("bookwyrm.User", require_ready=True)
- sender = user_model.objects.get(id=sender_id)
- for recipient in recipients:
- try:
- sign_and_send(sender, activity, recipient)
- except RequestException:
- pass
+ sender = user_model.objects.select_related("key_pair").get(id=sender_id)
+ asyncio.run(async_broadcast(recipients, sender, activity))
-def sign_and_send(sender, data, destination):
- """crpyto whatever and http junk"""
+async def async_broadcast(recipients: List[str], sender, data: str):
+ """Send all the broadcasts simultaneously"""
+ timeout = aiohttp.ClientTimeout(total=10)
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ tasks = []
+ for recipient in recipients:
+ tasks.append(
+ asyncio.ensure_future(sign_and_send(session, sender, data, recipient))
+ )
+
+ results = await asyncio.gather(*tasks)
+ return results
+
+
+async def sign_and_send(
+ session: aiohttp.ClientSession, sender, data: str, destination: str
+):
+ """Sign the messages and send them in an asynchronous bundle"""
now = http_date()
if not sender.key_pair.private_key:
@@ -527,20 +540,25 @@ def sign_and_send(sender, data, destination):
digest = make_digest(data)
- response = requests.post(
- destination,
- data=data,
- headers={
- "Date": now,
- "Digest": digest,
- "Signature": make_signature(sender, destination, now, digest),
- "Content-Type": "application/activity+json; charset=utf-8",
- "User-Agent": USER_AGENT,
- },
- )
- if not response.ok:
- response.raise_for_status()
- return response
+ headers = {
+ "Date": now,
+ "Digest": digest,
+ "Signature": make_signature(sender, destination, now, digest),
+ "Content-Type": "application/activity+json; charset=utf-8",
+ "User-Agent": USER_AGENT,
+ }
+
+ try:
+ async with session.post(destination, data=data, headers=headers) as response:
+ if not response.ok:
+ logger.exception(
+ "Failed to send broadcast to %s: %s", destination, response.reason
+ )
+ return response
+ except asyncio.TimeoutError:
+ logger.info("Connection timed out for url: %s", destination)
+ except aiohttp.ClientError as err:
+ logger.exception(err)
# pylint: disable=unused-argument
diff --git a/bookwyrm/models/antispam.py b/bookwyrm/models/antispam.py
index dd2a6df26..1e20df340 100644
--- a/bookwyrm/models/antispam.py
+++ b/bookwyrm/models/antispam.py
@@ -3,18 +3,33 @@ from functools import reduce
import operator
from django.apps import apps
+from django.core.exceptions import PermissionDenied
from django.db import models, transaction
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
-from bookwyrm.tasks import app
+from bookwyrm.tasks import app, LOW
+from .base_model import BookWyrmModel
from .user import User
-class EmailBlocklist(models.Model):
+class AdminModel(BookWyrmModel):
+ """Overrides the permissions methods"""
+
+ class Meta:
+ """this is just here to provide default fields for other models"""
+
+ abstract = True
+
+ def raise_not_editable(self, viewer):
+ if viewer.has_perm("bookwyrm.moderate_user"):
+ return
+ raise PermissionDenied()
+
+
+class EmailBlocklist(AdminModel):
"""blocked email addresses"""
- created_date = models.DateTimeField(auto_now_add=True)
domain = models.CharField(max_length=255, unique=True)
is_active = models.BooleanField(default=True)
@@ -29,10 +44,9 @@ class EmailBlocklist(models.Model):
return User.objects.filter(email__endswith=f"@{self.domain}")
-class IPBlocklist(models.Model):
+class IPBlocklist(AdminModel):
"""blocked ip addresses"""
- created_date = models.DateTimeField(auto_now_add=True)
address = models.CharField(max_length=255, unique=True)
is_active = models.BooleanField(default=True)
@@ -42,7 +56,7 @@ class IPBlocklist(models.Model):
ordering = ("-created_date",)
-class AutoMod(models.Model):
+class AutoMod(AdminModel):
"""rules to automatically flag suspicious activity"""
string_match = models.CharField(max_length=200, unique=True)
@@ -51,7 +65,7 @@ class AutoMod(models.Model):
created_by = models.ForeignKey("User", on_delete=models.PROTECT)
-@app.task(queue="low_priority")
+@app.task(queue=LOW)
def automod_task():
"""Create reports"""
if not AutoMod.objects.exists():
@@ -61,17 +75,14 @@ def automod_task():
if not reports:
return
- admins = User.objects.filter(
- models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
- | models.Q(is_superuser=True)
- ).all()
+ admins = User.admins()
notification_model = apps.get_model("bookwyrm", "Notification", require_ready=True)
with transaction.atomic():
for admin in admins:
notification, _ = notification_model.objects.get_or_create(
user=admin, notification_type=notification_model.REPORT, read=False
)
- notification.related_repors.add(reports)
+ notification.related_reports.set(reports)
def automod_users(reporter):
diff --git a/bookwyrm/models/author.py b/bookwyrm/models/author.py
index 78d153a21..de0c6483f 100644
--- a/bookwyrm/models/author.py
+++ b/bookwyrm/models/author.py
@@ -24,6 +24,9 @@ class Author(BookDataModel):
gutenberg_id = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True
)
+ isfdb = fields.CharField(
+ max_length=255, blank=True, null=True, deduplication_field=True
+ )
# idk probably other keys would be useful here?
born = fields.DateTimeField(blank=True, null=True)
died = fields.DateTimeField(blank=True, null=True)
@@ -42,6 +45,11 @@ class Author(BookDataModel):
for book in self.book_set.values_list("id", flat=True)
]
cache.delete_many(cache_keys)
+
+ # normalize isni format
+ if self.isni:
+ self.isni = re.sub(r"\s", "", self.isni)
+
return super().save(*args, **kwargs)
@property
@@ -55,6 +63,11 @@ class Author(BookDataModel):
"""generate the url from the openlibrary id"""
return f"https://openlibrary.org/authors/{self.openlibrary_key}"
+ @property
+ def isfdb_link(self):
+ """generate the url from the isni id"""
+ return f"https://www.isfdb.org/cgi-bin/ea.cgi?{self.isfdb}"
+
def get_remote_id(self):
"""editions and works both use "book" instead of model_name"""
return f"https://{DOMAIN}/author/{self.id}"
diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py
index 3ac220bc4..2d39e2a6f 100644
--- a/bookwyrm/models/base_model.py
+++ b/bookwyrm/models/base_model.py
@@ -17,6 +17,7 @@ from .fields import RemoteIdField
DeactivationReason = [
("pending", _("Pending")),
("self_deletion", _("Self deletion")),
+ ("self_deactivation", _("Self deactivation")),
("moderator_suspension", _("Moderator suspension")),
("moderator_deletion", _("Moderator deletion")),
("domain_block", _("Domain block")),
diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py
index 190046019..e990b6d64 100644
--- a/bookwyrm/models/book.py
+++ b/bookwyrm/models/book.py
@@ -55,6 +55,12 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
asin = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True
)
+ aasin = fields.CharField(
+ max_length=255, blank=True, null=True, deduplication_field=True
+ )
+ isfdb = fields.CharField(
+ max_length=255, blank=True, null=True, deduplication_field=True
+ )
search_vector = SearchVectorField(null=True)
last_edited_by = fields.ForeignKey(
@@ -73,6 +79,11 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
"""generate the url from the inventaire id"""
return f"https://inventaire.io/entity/{self.inventaire_id}"
+ @property
+ def isfdb_link(self):
+ """generate the url from the isfdb id"""
+ return f"https://www.isfdb.org/cgi-bin/title.cgi?{self.isfdb}"
+
class Meta:
"""can't initialize this model, that wouldn't make sense"""
@@ -241,6 +252,10 @@ class Work(OrderedCollectionPageMixin, Book):
"""in case the default edition is not set"""
return self.editions.order_by("-edition_rank").first()
+ def author_edition(self, author):
+ """in case the default edition doesn't have the required author"""
+ return self.editions.filter(authors=author).order_by("-edition_rank").first()
+
def to_edition_list(self, **kwargs):
"""an ordered collection of editions"""
return self.to_ordered_collection(
diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py
index 785f3397c..d11f5fb1d 100644
--- a/bookwyrm/models/fields.py
+++ b/bookwyrm/models/fields.py
@@ -13,6 +13,7 @@ from django.forms import ClearableFileInput, ImageField as DjangoImageField
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.utils.encoding import filepath_to_uri
+from markdown import markdown
from bookwyrm import activitypub
from bookwyrm.connectors import get_image
@@ -499,6 +500,9 @@ class HtmlField(ActivitypubFieldMixin, models.TextField):
return None
return clean(value)
+ def field_to_activity(self, value):
+ return markdown(value) if value else value
+
class ArrayField(ActivitypubFieldMixin, DjangoArrayField):
"""activitypub-aware array field"""
diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py
index 556f133f9..d8cfad314 100644
--- a/bookwyrm/models/import_job.py
+++ b/bookwyrm/models/import_job.py
@@ -1,12 +1,25 @@
""" track progress of goodreads imports """
+import math
import re
import dateutil.parser
from django.db import models
from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
from bookwyrm.connectors import connector_manager
-from bookwyrm.models import ReadThrough, User, Book, Edition
+from bookwyrm.models import (
+ User,
+ Book,
+ Edition,
+ Work,
+ ShelfBook,
+ Shelf,
+ ReadThrough,
+ Review,
+ ReviewRating,
+)
+from bookwyrm.tasks import app, LOW
from .fields import PrivacyLevels
@@ -30,6 +43,14 @@ def construct_search_term(title, author):
return " ".join([title, author])
+ImportStatuses = [
+ ("pending", _("Pending")),
+ ("active", _("Active")),
+ ("complete", _("Complete")),
+ ("stopped", _("Stopped")),
+]
+
+
class ImportJob(models.Model):
"""entry for a specific request for book data import"""
@@ -38,16 +59,78 @@ class ImportJob(models.Model):
updated_date = models.DateTimeField(default=timezone.now)
include_reviews = models.BooleanField(default=True)
mappings = models.JSONField()
- complete = models.BooleanField(default=False)
source = models.CharField(max_length=100)
privacy = models.CharField(max_length=255, default="public", choices=PrivacyLevels)
retry = models.BooleanField(default=False)
+ task_id = models.CharField(max_length=200, null=True, blank=True)
+
+ complete = models.BooleanField(default=False)
+ status = models.CharField(
+ max_length=50, choices=ImportStatuses, default="pending", null=True
+ )
+
+ def start_job(self):
+ """Report that the job has started"""
+ task = start_import_task.delay(self.id)
+ self.task_id = task.id
+
+ self.status = "active"
+ self.save(update_fields=["status", "task_id"])
+
+ def complete_job(self):
+ """Report that the job has completed"""
+ self.status = "complete"
+ self.complete = True
+ self.pending_items.update(fail_reason=_("Import stopped"))
+ self.save(update_fields=["status", "complete"])
+
+ def stop_job(self):
+ """Stop the job"""
+ self.status = "stopped"
+ self.complete = True
+ self.save(update_fields=["status", "complete"])
+ self.pending_items.update(fail_reason=_("Import stopped"))
+
+ # stop starting
+ app.control.revoke(self.task_id, terminate=True)
+ tasks = self.pending_items.filter(task_id__isnull=False).values_list(
+ "task_id", flat=True
+ )
+ app.control.revoke(list(tasks))
@property
def pending_items(self):
"""items that haven't been processed yet"""
return self.items.filter(fail_reason__isnull=True, book__isnull=True)
+ @property
+ def item_count(self):
+ """How many books do you want to import???"""
+ return self.items.count()
+
+ @property
+ def percent_complete(self):
+ """How far along?"""
+ item_count = self.item_count
+ if not item_count:
+ return 0
+ return math.floor((item_count - self.pending_item_count) / item_count * 100)
+
+ @property
+ def pending_item_count(self):
+ """And how many pending items??"""
+ return self.pending_items.count()
+
+ @property
+ def successful_item_count(self):
+ """How many found a book?"""
+ return self.items.filter(book__isnull=False).count()
+
+ @property
+ def failed_item_count(self):
+ """How many found a book?"""
+ return self.items.filter(fail_reason__isnull=False).count()
+
class ImportItem(models.Model):
"""a single line of a csv being imported"""
@@ -68,15 +151,18 @@ class ImportItem(models.Model):
linked_review = models.ForeignKey(
"Review", on_delete=models.SET_NULL, null=True, blank=True
)
+ task_id = models.CharField(max_length=200, null=True, blank=True)
def update_job(self):
"""let the job know when the items get work done"""
job = self.job
+ if job.complete:
+ return
+
job.updated_date = timezone.now()
job.save()
if not job.pending_items.exists() and not job.complete:
- job.complete = True
- job.save(update_fields=["complete"])
+ job.complete_job()
def resolve(self):
"""try various ways to lookup a book"""
@@ -240,3 +326,136 @@ class ImportItem(models.Model):
return "{} by {}".format(
self.normalized_data.get("title"), self.normalized_data.get("authors")
)
+
+
+@app.task(queue=LOW)
+def start_import_task(job_id):
+ """trigger the child tasks for each row"""
+ job = ImportJob.objects.get(id=job_id)
+ # don't start the job if it was stopped from the UI
+ if job.complete:
+ return
+
+ # these are sub-tasks so that one big task doesn't use up all the memory in celery
+ for item in job.items.all():
+ task = import_item_task.delay(item.id)
+ item.task_id = task.id
+ item.save()
+ job.status = "active"
+ job.save()
+
+
+@app.task(queue=LOW)
+def import_item_task(item_id):
+ """resolve a row into a book"""
+ item = ImportItem.objects.get(id=item_id)
+ # make sure the job has not been stopped
+ if item.job.complete:
+ return
+
+ try:
+ item.resolve()
+ except Exception as err: # pylint: disable=broad-except
+ item.fail_reason = _("Error loading book")
+ item.save()
+ item.update_job()
+ raise err
+
+ if item.book:
+ # shelves book and handles reviews
+ handle_imported_book(item)
+ else:
+ item.fail_reason = _("Could not find a match for book")
+
+ item.save()
+ item.update_job()
+
+
+def handle_imported_book(item):
+ """process a csv and then post about it"""
+ job = item.job
+ if job.complete:
+ return
+
+ user = job.user
+ if isinstance(item.book, Work):
+ item.book = item.book.default_edition
+ if not item.book:
+ item.fail_reason = _("Error loading book")
+ item.save()
+ return
+ if not isinstance(item.book, Edition):
+ item.book = item.book.edition
+
+ existing_shelf = ShelfBook.objects.filter(book=item.book, user=user).exists()
+
+ # shelve the book if it hasn't been shelved already
+ if item.shelf and not existing_shelf:
+ desired_shelf = Shelf.objects.get(identifier=item.shelf, user=user)
+ shelved_date = item.date_added or timezone.now()
+ ShelfBook(
+ book=item.book, shelf=desired_shelf, user=user, shelved_date=shelved_date
+ ).save(priority=LOW)
+
+ for read in item.reads:
+ # check for an existing readthrough with the same dates
+ if ReadThrough.objects.filter(
+ user=user,
+ book=item.book,
+ start_date=read.start_date,
+ finish_date=read.finish_date,
+ ).exists():
+ continue
+ read.book = item.book
+ read.user = user
+ read.save()
+
+ if job.include_reviews and (item.rating or item.review) and not item.linked_review:
+ # we don't know the publication date of the review,
+ # but "now" is a bad guess
+ published_date_guess = item.date_read or item.date_added
+ if item.review:
+ # pylint: disable=consider-using-f-string
+ review_title = "Review of {!r} on {!r}".format(
+ item.book.title,
+ job.source,
+ )
+ review = Review.objects.filter(
+ user=user,
+ book=item.book,
+ name=review_title,
+ rating=item.rating,
+ published_date=published_date_guess,
+ ).first()
+ if not review:
+ review = Review(
+ user=user,
+ book=item.book,
+ name=review_title,
+ content=item.review,
+ rating=item.rating,
+ published_date=published_date_guess,
+ privacy=job.privacy,
+ )
+ review.save(software="bookwyrm", priority=LOW)
+ else:
+ # just a rating
+ review = ReviewRating.objects.filter(
+ user=user,
+ book=item.book,
+ published_date=published_date_guess,
+ rating=item.rating,
+ ).first()
+ if not review:
+ review = ReviewRating(
+ user=user,
+ book=item.book,
+ rating=item.rating,
+ published_date=published_date_guess,
+ privacy=job.privacy,
+ )
+ review.save(software="bookwyrm", priority=LOW)
+
+ # only broadcast this review to other bookwyrm instances
+ item.linked_review = review
+ item.save()
diff --git a/bookwyrm/models/notification.py b/bookwyrm/models/notification.py
index b0b75a169..fa2ce54e2 100644
--- a/bookwyrm/models/notification.py
+++ b/bookwyrm/models/notification.py
@@ -214,7 +214,7 @@ def notify_user_on_import_complete(
update_fields = update_fields or []
if not instance.complete or "complete" not in update_fields:
return
- Notification.objects.create(
+ Notification.objects.get_or_create(
user=instance.user,
notification_type=Notification.IMPORT,
related_import=instance,
@@ -231,10 +231,7 @@ def notify_admins_on_report(sender, instance, created, *args, **kwargs):
return
# moderators and superusers should be notified
- admins = User.objects.filter(
- models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
- | models.Q(is_superuser=True)
- ).all()
+ admins = User.admins()
for admin in admins:
notification, _ = Notification.objects.get_or_create(
user=admin,
diff --git a/bookwyrm/models/report.py b/bookwyrm/models/report.py
index d161c0349..f6e665053 100644
--- a/bookwyrm/models/report.py
+++ b/bookwyrm/models/report.py
@@ -1,5 +1,7 @@
""" flagged for moderation """
+from django.core.exceptions import PermissionDenied
from django.db import models
+
from bookwyrm.settings import DOMAIN
from .base_model import BookWyrmModel
@@ -21,6 +23,12 @@ class Report(BookWyrmModel):
links = models.ManyToManyField("Link", blank=True)
resolved = models.BooleanField(default=False)
+ def raise_not_editable(self, viewer):
+ """instead of user being the owner field, it's reporter"""
+ if self.reporter == viewer or viewer.has_perm("bookwyrm.moderate_user"):
+ return
+ raise PermissionDenied()
+
def get_remote_id(self):
return f"https://{DOMAIN}/settings/reports/{self.id}"
diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py
index 7730391f1..533a37b30 100644
--- a/bookwyrm/models/site.py
+++ b/bookwyrm/models/site.py
@@ -3,6 +3,7 @@ import datetime
from urllib.parse import urljoin
import uuid
+from django.core.exceptions import PermissionDenied
from django.db import models, IntegrityError
from django.dispatch import receiver
from django.utils import timezone
@@ -15,7 +16,23 @@ from .user import User
from .fields import get_absolute_url
-class SiteSettings(models.Model):
+class SiteModel(models.Model):
+ """we just need edit perms"""
+
+ class Meta:
+ """this is just here to provide default fields for other models"""
+
+ abstract = True
+
+ # pylint: disable=no-self-use
+ def raise_not_editable(self, viewer):
+ """Check if the user has the right permissions"""
+ if viewer.has_perm("bookwyrm.edit_instance_settings"):
+ return
+ raise PermissionDenied()
+
+
+class SiteSettings(SiteModel):
"""customized settings for this instance"""
name = models.CharField(default="BookWyrm", max_length=100)
@@ -45,6 +62,8 @@ class SiteSettings(models.Model):
)
code_of_conduct = models.TextField(default="Add a code of conduct here.")
privacy_policy = models.TextField(default="Add a privacy policy here.")
+ impressum = models.TextField(default="Add a impressum here.")
+ show_impressum = models.BooleanField(default=False)
# registration
allow_registration = models.BooleanField(default=False)
@@ -69,6 +88,9 @@ class SiteSettings(models.Model):
admin_email = models.EmailField(max_length=255, null=True, blank=True)
footer_item = models.TextField(null=True, blank=True)
+ # controls
+ imports_enabled = models.BooleanField(default=True)
+
field_tracker = FieldTracker(fields=["name", "instance_tagline", "logo"])
@classmethod
@@ -115,7 +137,7 @@ class SiteSettings(models.Model):
super().save(*args, **kwargs)
-class Theme(models.Model):
+class Theme(SiteModel):
"""Theme files"""
created_date = models.DateTimeField(auto_now_add=True)
@@ -138,6 +160,13 @@ class SiteInvite(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
invitees = models.ManyToManyField(User, related_name="invitees")
+ # pylint: disable=no-self-use
+ def raise_not_editable(self, viewer):
+ """Admins only"""
+ if viewer.has_perm("bookwyrm.create_invites"):
+ return
+ raise PermissionDenied()
+
def valid(self):
"""make sure it hasn't expired or been used"""
return (self.expiry is None or self.expiry > timezone.now()) and (
@@ -157,10 +186,16 @@ class InviteRequest(BookWyrmModel):
invite = models.ForeignKey(
SiteInvite, on_delete=models.SET_NULL, null=True, blank=True
)
- answer = models.TextField(max_length=50, unique=False, null=True, blank=True)
+ answer = models.TextField(max_length=255, unique=False, null=True, blank=True)
invite_sent = models.BooleanField(default=False)
ignored = models.BooleanField(default=False)
+ def raise_not_editable(self, viewer):
+ """Only check perms on edit, not create"""
+ if not self.id or viewer.has_perm("bookwyrm.create_invites"):
+ return
+ raise PermissionDenied()
+
def save(self, *args, **kwargs):
"""don't create a request for a registered email"""
if not self.id and User.objects.filter(email=self.email).exists():
diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py
index fce69cae2..19eab584d 100644
--- a/bookwyrm/models/status.py
+++ b/bookwyrm/models/status.py
@@ -63,6 +63,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
activitypub_field="inReplyTo",
)
thread_id = models.IntegerField(blank=True, null=True)
+ # statuses get saved a few times, this indicates if they're set
+ ready = models.BooleanField(default=True)
+
objects = InheritanceManager()
activity_serializer = activitypub.Note
@@ -83,8 +86,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
if not self.reply_parent:
self.thread_id = self.id
-
- super().save(broadcast=False, update_fields=["thread_id"])
+ super().save(broadcast=False, update_fields=["thread_id"])
def delete(self, *args, **kwargs): # pylint: disable=unused-argument
""" "delete" a status"""
@@ -363,7 +365,7 @@ class Review(BookStatus):
default=None,
null=True,
blank=True,
- validators=[MinValueValidator(1), MaxValueValidator(5)],
+ validators=[MinValueValidator(0.5), MaxValueValidator(5)],
decimal_places=2,
max_digits=3,
)
@@ -399,7 +401,7 @@ class ReviewRating(Review):
def save(self, *args, **kwargs):
if not self.rating:
raise ValueError("ReviewRating object must include a numerical rating")
- return super().save(*args, **kwargs)
+ super().save(*args, **kwargs)
@property
def pure_content(self):
diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py
index f0c3cf04d..c885902f9 100644
--- a/bookwyrm/models/user.py
+++ b/bookwyrm/models/user.py
@@ -5,6 +5,7 @@ from urllib.parse import urlparse
from django.apps import apps
from django.contrib.auth.models import AbstractUser, Group
from django.contrib.postgres.fields import ArrayField, CICharField
+from django.core.exceptions import PermissionDenied
from django.dispatch import receiver
from django.db import models, transaction
from django.utils import timezone
@@ -19,7 +20,7 @@ from bookwyrm.models.status import Status
from bookwyrm.preview_images import generate_user_preview_image_task
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, USE_HTTPS, LANGUAGES
from bookwyrm.signatures import create_key_pair
-from bookwyrm.tasks import app
+from bookwyrm.tasks import app, LOW
from bookwyrm.utils import regex
from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin
from .base_model import BookWyrmModel, DeactivationReason, new_access_code
@@ -46,6 +47,7 @@ def site_link():
return f"{protocol}://{DOMAIN}"
+# pylint: disable=too-many-public-methods
class User(OrderedCollectionPageMixin, AbstractUser):
"""a user who wants to read books"""
@@ -168,12 +170,19 @@ class User(OrderedCollectionPageMixin, AbstractUser):
max_length=255, choices=DeactivationReason, null=True, blank=True
)
deactivation_date = models.DateTimeField(null=True, blank=True)
+ allow_reactivation = models.BooleanField(default=False)
confirmation_code = models.CharField(max_length=32, default=new_access_code)
name_field = "username"
property_fields = [("following_link", "following")]
field_tracker = FieldTracker(fields=["name", "avatar"])
+ # two factor authentication
+ two_factor_auth = models.BooleanField(default=None, blank=True, null=True)
+ otp_secret = models.CharField(max_length=32, default=None, blank=True, null=True)
+ hotp_secret = models.CharField(max_length=32, default=None, blank=True, null=True)
+ hotp_count = models.IntegerField(default=0, blank=True, null=True)
+
@property
def active_follower_requests(self):
"""Follow requests from active users"""
@@ -231,6 +240,15 @@ class User(OrderedCollectionPageMixin, AbstractUser):
queryset = queryset.exclude(blocks=viewer)
return queryset
+ @classmethod
+ def admins(cls):
+ """Get a queryset of the admins for this instance"""
+ return cls.objects.filter(
+ models.Q(groups__name__in=["moderator", "admin"])
+ | models.Q(is_superuser=True),
+ is_active=True,
+ ).distinct()
+
def update_active_date(self):
"""this user is here! they are doing things!"""
self.last_active_date = timezone.now()
@@ -352,12 +370,31 @@ class User(OrderedCollectionPageMixin, AbstractUser):
self.create_shelves()
def delete(self, *args, **kwargs):
- """deactivate rather than delete a user"""
+ """We don't actually delete the database entry"""
# pylint: disable=attribute-defined-outside-init
self.is_active = False
# skip the logic in this class's save()
super().save(*args, **kwargs)
+ def deactivate(self):
+ """Disable the user but allow them to reactivate"""
+ # pylint: disable=attribute-defined-outside-init
+ self.is_active = False
+ self.deactivation_reason = "self_deactivation"
+ self.allow_reactivation = True
+ super().save(broadcast=False)
+
+ def reactivate(self):
+ """Now you want to come back, huh?"""
+ # pylint: disable=attribute-defined-outside-init
+ self.is_active = True
+ self.deactivation_reason = None
+ self.allow_reactivation = False
+ super().save(
+ broadcast=False,
+ update_fields=["deactivation_reason", "is_active", "allow_reactivation"],
+ )
+
@property
def local_path(self):
"""this model doesn't inherit bookwyrm model, so here we are"""
@@ -393,6 +430,12 @@ class User(OrderedCollectionPageMixin, AbstractUser):
editable=False,
).save(broadcast=False)
+ def raise_not_editable(self, viewer):
+ """Who can edit the user object?"""
+ if self == viewer or viewer.has_perm("bookwyrm.moderate_user"):
+ return
+ raise PermissionDenied()
+
class KeyPair(ActivitypubMixin, BookWyrmModel):
"""public and private keys for a user"""
@@ -419,7 +462,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
return super().save(*args, **kwargs)
-@app.task(queue="low_priority")
+@app.task(queue=LOW)
def set_remote_server(user_id):
"""figure out the user's remote server in the background"""
user = User.objects.get(id=user_id)
@@ -463,7 +506,7 @@ def get_or_create_remote_server(domain, refresh=False):
return server
-@app.task(queue="low_priority")
+@app.task(queue=LOW)
def get_remote_reviews(outbox):
"""ingest reviews by a new remote bookwyrm user"""
outbox_page = outbox + "?page=true&type=Review"
diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py
index 891c8b6da..d20145cd3 100644
--- a/bookwyrm/preview_images.py
+++ b/bookwyrm/preview_images.py
@@ -16,7 +16,7 @@ from django.core.files.storage import default_storage
from django.db.models import Avg
from bookwyrm import models, settings
-from bookwyrm.tasks import app
+from bookwyrm.tasks import app, LOW
logger = logging.getLogger(__name__)
@@ -401,7 +401,7 @@ def save_and_cleanup(image, instance=None):
# pylint: disable=invalid-name
-@app.task(queue="low_priority")
+@app.task(queue=LOW)
def generate_site_preview_image_task():
"""generate preview_image for the website"""
if not settings.ENABLE_PREVIEW_IMAGES:
@@ -426,7 +426,7 @@ def generate_site_preview_image_task():
# pylint: disable=invalid-name
-@app.task(queue="low_priority")
+@app.task(queue=LOW)
def generate_edition_preview_image_task(book_id):
"""generate preview_image for a book"""
if not settings.ENABLE_PREVIEW_IMAGES:
@@ -451,7 +451,7 @@ def generate_edition_preview_image_task(book_id):
save_and_cleanup(image, instance=book)
-@app.task(queue="low_priority")
+@app.task(queue=LOW)
def generate_user_preview_image_task(user_id):
"""generate preview_image for a book"""
if not settings.ENABLE_PREVIEW_IMAGES:
diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py
index 5c2362771..d1d5a84d9 100644
--- a/bookwyrm/settings.py
+++ b/bookwyrm/settings.py
@@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _
env = Env()
env.read_env()
DOMAIN = env("DOMAIN")
-VERSION = "0.4.5"
+VERSION = "0.5.3"
RELEASE_API = env(
"RELEASE_API",
@@ -21,7 +21,7 @@ RELEASE_API = env(
PAGE_LENGTH = env("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
-JS_CACHE = "e678183b"
+JS_CACHE = "ad848b97"
# email
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
@@ -147,6 +147,9 @@ LOGGING = {
"require_debug_true": {
"()": "django.utils.log.RequireDebugTrue",
},
+ "ignore_missing_variable": {
+ "()": "bookwyrm.utils.log.IgnoreVariableDoesNotExist",
+ },
},
"handlers": {
# Overrides the default handler to make it log to console
@@ -154,6 +157,7 @@ LOGGING = {
# console if DEBUG=False)
"console": {
"level": LOG_LEVEL,
+ "filters": ["ignore_missing_variable"],
"class": "logging.StreamHandler",
},
# This is copied as-is from the default logger, and is
@@ -363,3 +367,9 @@ else:
OTEL_EXPORTER_OTLP_ENDPOINT = env("OTEL_EXPORTER_OTLP_ENDPOINT", None)
OTEL_EXPORTER_OTLP_HEADERS = env("OTEL_EXPORTER_OTLP_HEADERS", None)
OTEL_SERVICE_NAME = env("OTEL_SERVICE_NAME", None)
+
+TWO_FACTOR_LOGIN_MAX_SECONDS = 60
+
+HTTP_X_FORWARDED_PROTO = env.bool("SECURE_PROXY_SSL_HEADER", False)
+if HTTP_X_FORWARDED_PROTO:
+ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
diff --git a/bookwyrm/static/css/bookwyrm/_all.scss b/bookwyrm/static/css/bookwyrm/_all.scss
index 31e732ebe..1e8569827 100644
--- a/bookwyrm/static/css/bookwyrm/_all.scss
+++ b/bookwyrm/static/css/bookwyrm/_all.scss
@@ -140,6 +140,10 @@ button:focus-visible .button-invisible-overlay {
opacity: 1;
}
+button.button-paragraph {
+ vertical-align: middle;
+}
+
/** States
******************************************************************************/
diff --git a/bookwyrm/static/css/bookwyrm/components/_details.scss b/bookwyrm/static/css/bookwyrm/components/_details.scss
index c9a0b33b8..4145554eb 100644
--- a/bookwyrm/static/css/bookwyrm/components/_details.scss
+++ b/bookwyrm/static/css/bookwyrm/components/_details.scss
@@ -67,7 +67,7 @@ details.dropdown .dropdown-menu a:focus-visible {
align-items: center;
justify-content: center;
pointer-events: none;
- z-index: 100;
+ z-index: 35;
}
details .dropdown-menu > * {
@@ -81,7 +81,19 @@ details.dropdown .dropdown-menu a:focus-visible {
details.details-panel {
box-shadow: 0 0 0 1px $border;
transition: box-shadow 0.2s ease;
- padding: 0.75rem;
+ padding: 0;
+
+ > * {
+ padding: 0.75rem;
+ }
+
+ summary {
+ position: relative;
+
+ .details-close {
+ padding: 0.75rem;
+ }
+ }
}
details[open].details-panel,
@@ -89,10 +101,6 @@ details.details-panel:hover {
box-shadow: 0 0 0 1px $border;
}
-details.details-panel summary {
- position: relative;
-}
-
details summary .details-close {
position: absolute;
right: 0;
diff --git a/bookwyrm/static/css/themes/bookwyrm-dark.scss b/bookwyrm/static/css/themes/bookwyrm-dark.scss
index a2eb94efb..ae904b4a4 100644
--- a/bookwyrm/static/css/themes/bookwyrm-dark.scss
+++ b/bookwyrm/static/css/themes/bookwyrm-dark.scss
@@ -15,6 +15,8 @@ $danger: #872538;
$danger-light: #481922;
$light: #393939;
$red: #ffa1b4;
+$black: #000;
+$white-ter: hsl(0, 0%, 90%);
/* book cover standins */
$no-cover-color: #002549;
@@ -56,9 +58,12 @@ $link-active: $white-bis;
$link-light: #0d1c26;
/* bulma overrides */
+$body-background-color: rgb(17, 18, 18);
$background: $background-secondary;
$menu-item-active-background-color: $link-background;
$navbar-dropdown-item-hover-color: $white;
+$info-light: $background-body;
+$info-dark: #72b6ee;
/* These element's colors are hardcoded, probably a bug in bulma? */
@media screen and (min-width: 769px) {
@@ -74,7 +79,7 @@ $navbar-dropdown-item-hover-color: $white;
}
/* misc */
-$shadow: 0 0.5em 1em -0.125em rgba($black, 0.2), 0 0px 0 1px rgba($black, 0.02);
+$shadow: 0 0.5em 0.5em -0.125em rgba($black, 0.2), 0 0px 0 1px rgba($black, 0.02);
$card-header-shadow: 0 0.125em 0.25em rgba($black, 0.1);
$invisible-overlay-background-color: rgba($black, 0.66);
$progress-value-background-color: $border-light;
@@ -92,6 +97,11 @@ $family-secondary: $family-sans-serif;
color: $grey-light !important;
}
+
+#qrcode svg {
+ background-color: #a6a6a6;
+}
+
@import "../bookwyrm.scss";
@import "../vendor/icons.css";
@import "../vendor/shepherd.scss";
diff --git a/bookwyrm/static/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js
index 95271795d..5b3f13d4a 100644
--- a/bookwyrm/static/js/bookwyrm.js
+++ b/bookwyrm/static/js/bookwyrm.js
@@ -38,11 +38,12 @@ let BookWyrm = new (class {
.querySelectorAll("[data-modal-open]")
.forEach((node) => node.addEventListener("click", this.handleModalButton.bind(this)));
- document
- .querySelectorAll("details.dropdown")
- .forEach((node) =>
- node.addEventListener("toggle", this.handleDetailsDropdown.bind(this))
+ document.querySelectorAll("details.dropdown").forEach((node) => {
+ node.addEventListener("toggle", this.handleDetailsDropdown.bind(this));
+ node.querySelectorAll("[data-modal-open]").forEach((modal_node) =>
+ modal_node.addEventListener("click", () => (node.open = false))
);
+ });
document
.querySelector("#barcode-scanner-modal")
@@ -627,9 +628,9 @@ let BookWyrm = new (class {
}
function toggleStatus(status) {
- for (const child of statusNode.children) {
- BookWyrm.toggleContainer(child, !child.classList.contains(status));
- }
+ const template = document.querySelector(`#barcode-${status}`);
+
+ statusNode.replaceChildren(template ? template.content.cloneNode(true) : null);
}
function initBarcodes(cameraId = null) {
diff --git a/bookwyrm/suggested_users.py b/bookwyrm/suggested_users.py
index ea6bc886b..91f23dded 100644
--- a/bookwyrm/suggested_users.py
+++ b/bookwyrm/suggested_users.py
@@ -7,7 +7,7 @@ from django.db.models import signals, Count, Q, Case, When, IntegerField
from bookwyrm import models
from bookwyrm.redis_store import RedisStore, r
-from bookwyrm.tasks import app
+from bookwyrm.tasks import app, LOW, MEDIUM
logger = logging.getLogger(__name__)
@@ -237,41 +237,41 @@ def domain_level_update(sender, instance, created, update_fields=None, **kwargs)
# ------------------- TASKS
-@app.task(queue="low_priority")
+@app.task(queue=LOW)
def rerank_suggestions_task(user_id):
"""do the hard work in celery"""
suggested_users.rerank_user_suggestions(user_id)
-@app.task(queue="low_priority")
+@app.task(queue=LOW)
def rerank_user_task(user_id, update_only=False):
"""do the hard work in celery"""
user = models.User.objects.get(id=user_id)
suggested_users.rerank_obj(user, update_only=update_only)
-@app.task(queue="low_priority")
+@app.task(queue=LOW)
def remove_user_task(user_id):
"""do the hard work in celery"""
user = models.User.objects.get(id=user_id)
suggested_users.remove_object_from_related_stores(user)
-@app.task(queue="medium_priority")
+@app.task(queue=MEDIUM)
def remove_suggestion_task(user_id, suggested_user_id):
"""remove a specific user from a specific user's suggestions"""
suggested_user = models.User.objects.get(id=suggested_user_id)
suggested_users.remove_suggestion(user_id, suggested_user)
-@app.task(queue="low_priority")
+@app.task(queue=LOW)
def bulk_remove_instance_task(instance_id):
"""remove a bunch of users from recs"""
for user in models.User.objects.filter(federated_server__id=instance_id):
suggested_users.remove_object_from_related_stores(user)
-@app.task(queue="low_priority")
+@app.task(queue=LOW)
def bulk_add_instance_task(instance_id):
"""remove a bunch of users from recs"""
for user in models.User.objects.filter(federated_server__id=instance_id):
diff --git a/bookwyrm/templates/about/about.html b/bookwyrm/templates/about/about.html
index 481ecda99..c446e0cf2 100644
--- a/bookwyrm/templates/about/about.html
+++ b/bookwyrm/templates/about/about.html
@@ -11,7 +11,7 @@
{% block about_content %}
{# seven day cache #}
-{% cache 604800 about_page %}
+{% cache 604800 about_page_superlatives %}
{% get_book_superlatives as superlatives %}
-{% if report_link %} +{% if link_domain %} {% blocktrans trimmed %} @{{ reporter }} has flagged a link domain for moderation. diff --git a/bookwyrm/templates/email/moderation_report/text_content.html b/bookwyrm/templates/email/moderation_report/text_content.html index 764a3c72a..351ab58ed 100644 --- a/bookwyrm/templates/email/moderation_report/text_content.html +++ b/bookwyrm/templates/email/moderation_report/text_content.html @@ -2,7 +2,7 @@ {% load i18n %} {% block content %} -{% if report_link %} +{% if link_domain %} {% blocktrans trimmed %} @{{ reporter }} has flagged a link domain for moderation. {% endblocktrans %} diff --git a/bookwyrm/templates/email/test/html_content.html b/bookwyrm/templates/email/test/html_content.html new file mode 100644 index 000000000..7cf577f45 --- /dev/null +++ b/bookwyrm/templates/email/test/html_content.html @@ -0,0 +1,12 @@ +{% extends 'email/html_layout.html' %} +{% load i18n %} + +{% block content %} +
+{% blocktrans trimmed %} +This is a test email. +{% endblocktrans %} +
+ + +{% endblock %} diff --git a/bookwyrm/templates/email/test/subject.html b/bookwyrm/templates/email/test/subject.html new file mode 100644 index 000000000..6ddada523 --- /dev/null +++ b/bookwyrm/templates/email/test/subject.html @@ -0,0 +1,4 @@ +{% load i18n %} +{% blocktrans trimmed %} +Test email +{% endblocktrans %} diff --git a/bookwyrm/templates/email/test/text_content.html b/bookwyrm/templates/email/test/text_content.html new file mode 100644 index 000000000..9d8a8f685 --- /dev/null +++ b/bookwyrm/templates/email/test/text_content.html @@ -0,0 +1,9 @@ +{% extends 'email/text_layout.html' %} +{% load i18n %} +{% block content %} +{% blocktrans trimmed %} +This is a test email. +{% endblocktrans %} + + +{% endblock %} diff --git a/bookwyrm/templates/embed-layout.html b/bookwyrm/templates/embed-layout.html index 233ba387f..6a8d77016 100644 --- a/bookwyrm/templates/embed-layout.html +++ b/bookwyrm/templates/embed-layout.html @@ -1,14 +1,13 @@ {% load layout %} {% load i18n %} +{% load sass_tags %} {% load static %}