diff --git a/.env.example b/.env.example
index 1bf6d5406..c61ceba1e 100644
--- a/.env.example
+++ b/.env.example
@@ -16,6 +16,11 @@ DEFAULT_LANGUAGE="English"
## Leave unset to allow all hosts
# ALLOWED_HOSTS="localhost,127.0.0.1,[::1]"
+# Specify when the site is served from a port that is not the default
+# for the protocol (80 for HTTP or 443 for HTTPS).
+# Probably only necessary in development.
+# PORT=1333
+
MEDIA_ROOT=images/
# Database configuration
@@ -71,14 +76,20 @@ ENABLE_THUMBNAIL_GENERATION=true
USE_S3=false
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
+# seconds for signed S3 urls to expire
+# this is currently only used for user export files
+S3_SIGNED_URL_EXPIRY=900
# Commented are example values if you use a non-AWS, S3-compatible service
# AWS S3 should work with only AWS_STORAGE_BUCKET_NAME and AWS_S3_REGION_NAME
# non-AWS S3-compatible services will need AWS_STORAGE_BUCKET_NAME,
-# along with both AWS_S3_CUSTOM_DOMAIN and AWS_S3_ENDPOINT_URL
+# along with both AWS_S3_CUSTOM_DOMAIN and AWS_S3_ENDPOINT_URL.
+# AWS_S3_URL_PROTOCOL must end in ":" and defaults to the same protocol as
+# the BookWyrm instance ("http:" or "https:", based on USE_SSL).
# AWS_STORAGE_BUCKET_NAME= # "example-bucket-name"
# AWS_S3_CUSTOM_DOMAIN=None # "example-bucket-name.s3.fr-par.scw.cloud"
+# AWS_S3_URL_PROTOCOL=None # "http:"
# AWS_S3_REGION_NAME=None # "fr-par"
# AWS_S3_ENDPOINT_URL=None # "https://s3.fr-par.scw.cloud"
@@ -133,9 +144,9 @@ HTTP_X_FORWARDED_PROTO=false
TWO_FACTOR_LOGIN_VALIDITY_WINDOW=2
TWO_FACTOR_LOGIN_MAX_SECONDS=60
-# Additional hosts to allow in the Content-Security-Policy, "self" (should be DOMAIN)
-# and AWS_S3_CUSTOM_DOMAIN (if used) are added by default.
-# Value should be a comma-separated list of host names.
+# Additional hosts to allow in the Content-Security-Policy, "self" (should be
+# DOMAIN with optionally ":" + PORT) and AWS_S3_CUSTOM_DOMAIN (if used) are
+# added by default. Value should be a comma-separated list of host names.
CSP_ADDITIONAL_HOSTS=
# Time before being logged out (in seconds)
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 000000000..570174248
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,68 @@
+
+## Description
+
+
+
+
+
+- Related Issue #
+- Closes #
+
+## What type of Pull Request is this?
+
+
+- [ ] Bug Fix
+- [ ] Enhancement
+- [ ] Plumbing / Internals / Dependencies
+- [ ] Refactor
+
+## Does this PR change settings or dependencies, or break something?
+
+
+- [ ] This PR changes or adds default settings, configuration, or .env values
+- [ ] This PR changes or adds dependencies
+- [ ] This PR introduces other breaking changes
+
+### Details of breaking or configuration changes (if any of above checked)
+
+
+## Documentation
+
+
+
+
+- [ ] New or amended documentation will be required if this PR is merged
+- [ ] I have created a matching pull request in the Documentation repository
+- [ ] I intend to create a matching pull request in the Documentation repository after this PR is merged
+
+
+
+### Tests
+
+
+- [ ] My changes do not need new tests
+- [ ] All tests I have added are passing
+- [ ] I have written tests but need help to make them pass
+- [ ] I have not written tests and need help to write them
diff --git a/.github/release.yml b/.github/release.yml
new file mode 100644
index 000000000..3a347bf51
--- /dev/null
+++ b/.github/release.yml
@@ -0,0 +1,26 @@
+changelog:
+ exclude:
+ labels:
+ - ignore-for-release
+ categories:
+ - title: ‼️ Breaking Changes & New Settings ⚙️
+ labels:
+ - breaking-change
+ - config-change
+ - title: Updated Dependencies 🧸
+ labels:
+ - dependencies
+ - title: New Features 🎉
+ labels:
+ - enhancement
+ - title: Bug Fixes 🐛
+ labels:
+ - fix
+ - bug
+ - title: Internals/Plumbing 👩🔧
+ - plumbing
+ - tests
+ - deployment
+ - title: Other Changes
+ labels:
+ - "*"
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 51316ef62..014745a52 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -40,7 +40,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@v2
+ uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -51,7 +51,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
- uses: github/codeql-action/autobuild@v2
+ uses: github/codeql-action/autobuild@v3
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -65,4 +65,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v2
+ uses: github/codeql-action/analyze@v3
diff --git a/.github/workflows/lint-frontend.yaml b/.github/workflows/lint-frontend.yaml
index 21f11ebf3..b0322f371 100644
--- a/.github/workflows/lint-frontend.yaml
+++ b/.github/workflows/lint-frontend.yaml
@@ -22,7 +22,8 @@ jobs:
- uses: actions/checkout@v4
- name: Install modules
- run: npm install stylelint stylelint-config-recommended stylelint-config-standard stylelint-order eslint
+ # run: npm install stylelint stylelint-config-recommended stylelint-config-standard stylelint-order eslint
+ run: npm install eslint@^8.9.0
# See .stylelintignore for files that are not linted.
# - name: Run stylelint
diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml
index 01241b467..68e3b7b65 100644
--- a/.github/workflows/python.yml
+++ b/.github/workflows/python.yml
@@ -48,7 +48,7 @@ jobs:
- name: Set up .env
run: cp .env.example .env
- name: Check migrations up-to-date
- run: python ./manage.py makemigrations --check
+ run: python ./manage.py makemigrations --check -v 3
- name: Run Tests
run: pytest -n 3
diff --git a/.gitignore b/.gitignore
index 755375b34..fd6cc7547 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,7 @@
# BookWyrm
.env
/images/
+/exports/
/static/
bookwyrm/static/css/bookwyrm.css
bookwyrm/static/css/themes/
@@ -37,3 +38,6 @@ nginx/default.conf
#macOS
**/.DS_Store
+
+# Docker
+docker-compose.override.yml
diff --git a/.pylintrc b/.pylintrc
index 464638853..e89f7d536 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -3,7 +3,19 @@ ignore=migrations
load-plugins=pylint.extensions.no_self_use
[MESSAGES CONTROL]
-disable=E1101,E1135,E1136,R0903,R0901,R0902,W0707,W0511,W0406,R0401,R0801,C3001,import-error
+disable =
+ cyclic-import,
+ duplicate-code,
+ fixme,
+ no-member,
+ raise-missing-from,
+ too-few-public-methods,
+ too-many-ancestors,
+ too-many-instance-attributes,
+ unnecessary-lambda-assignment,
+ unsubscriptable-object,
+enable =
+ useless-suppression
[FORMAT]
max-line-length=88
diff --git a/README.md b/README.md
index f8b2eb1f6..7e27d44e6 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,6 @@ BookWyrm is a social network for tracking your reading, talking about books, wri
## Links
[![Mastodon Follow](https://img.shields.io/mastodon/follow/000146121?domain=https%3A%2F%2Ftech.lgbt&style=social)](https://tech.lgbt/@bookwyrm)
-[![Twitter Follow](https://img.shields.io/twitter/follow/BookWyrmSocial?style=social)](https://twitter.com/BookWyrmSocial)
- [Project homepage](https://joinbookwyrm.com/)
- [Support](https://patreon.com/bookwyrm)
diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py
index efc9d8da2..dc4b8f6ae 100644
--- a/bookwyrm/activitypub/base_activity.py
+++ b/bookwyrm/activitypub/base_activity.py
@@ -250,7 +250,10 @@ class ActivityObject:
pass
data = {k: v for (k, v) in data.items() if v is not None and k not in omit}
if "@context" not in omit:
- data["@context"] = "https://www.w3.org/ns/activitystreams"
+ data["@context"] = [
+ "https://www.w3.org/ns/activitystreams",
+ {"Hashtag": "as:Hashtag"},
+ ]
return data
@@ -400,11 +403,11 @@ def get_representative():
to sign outgoing HTTP GET requests"""
return models.User.objects.get_or_create(
username=f"{INSTANCE_ACTOR_USERNAME}@{DOMAIN}",
- defaults=dict(
- email="bookwyrm@localhost",
- local=True,
- localname=INSTANCE_ACTOR_USERNAME,
- ),
+ defaults={
+ "email": "bookwyrm@localhost",
+ "local": True,
+ "localname": INSTANCE_ACTOR_USERNAME,
+ },
)[0]
diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py
index a53222053..9a268f905 100644
--- a/bookwyrm/activitypub/book.py
+++ b/bookwyrm/activitypub/book.py
@@ -67,7 +67,6 @@ class Edition(Book):
type: str = "Edition"
-# pylint: disable=invalid-name
@dataclass(init=False)
class Work(Book):
"""work instance of a book object"""
diff --git a/bookwyrm/activitypub/ordered_collection.py b/bookwyrm/activitypub/ordered_collection.py
index 32e37c996..250490041 100644
--- a/bookwyrm/activitypub/ordered_collection.py
+++ b/bookwyrm/activitypub/ordered_collection.py
@@ -18,7 +18,6 @@ class OrderedCollection(ActivityObject):
type: str = "OrderedCollection"
-# pylint: disable=invalid-name
@dataclass(init=False)
class OrderedCollectionPrivate(OrderedCollection):
"""an ordered collection with privacy settings"""
diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py
index a365f4cc0..549f14c9c 100644
--- a/bookwyrm/activitypub/verbs.py
+++ b/bookwyrm/activitypub/verbs.py
@@ -22,7 +22,6 @@ class Verb(ActivityObject):
self.object.to_model(allow_external_connections=allow_external_connections)
-# pylint: disable=invalid-name
@dataclass(init=False)
class Create(Verb):
"""Create activity"""
@@ -33,7 +32,6 @@ class Create(Verb):
type: str = "Create"
-# pylint: disable=invalid-name
@dataclass(init=False)
class Delete(Verb):
"""Create activity"""
@@ -63,7 +61,6 @@ class Delete(Verb):
# if we can't find it, we don't need to delete it because we don't have it
-# pylint: disable=invalid-name
@dataclass(init=False)
class Update(Verb):
"""Update activity"""
@@ -227,7 +224,6 @@ class Like(Verb):
self.to_model(allow_external_connections=allow_external_connections)
-# pylint: disable=invalid-name
@dataclass(init=False)
class Announce(Verb):
"""boosting a status"""
diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py
index 42f99e209..08fb757d5 100644
--- a/bookwyrm/activitystreams.py
+++ b/bookwyrm/activitystreams.py
@@ -32,7 +32,7 @@ class ActivityStream(RedisStore):
stream_id = self.stream_id(user_id)
return f"{stream_id}-unread-by-type"
- def get_rank(self, obj): # pylint: disable=no-self-use
+ def get_rank(self, obj):
"""statuses are sorted by date published"""
return obj.published_date.timestamp()
@@ -139,14 +139,14 @@ class ActivityStream(RedisStore):
| (
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(
Q(following=status.user) # if the user is following the author
)
- return audience.distinct()
+ return audience.distinct("id")
@tracer.start_as_current_span("ActivityStream.get_audience")
def get_audience(self, status):
@@ -156,7 +156,7 @@ class ActivityStream(RedisStore):
status_author = models.User.objects.filter(
is_active=True, local=True, id=status.user.id
).values_list("id", flat=True)
- return list(set(list(audience) + list(status_author)))
+ return list(set(audience) | set(status_author))
def get_stores_for_users(self, user_ids):
"""convert a list of user ids into redis store ids"""
@@ -183,15 +183,13 @@ class HomeStream(ActivityStream):
def get_audience(self, status):
trace.get_current_span().set_attribute("stream_id", self.key)
audience = super()._get_audience(status)
- if not audience:
- return []
# if the user is following the author
audience = audience.filter(following=status.user).values_list("id", flat=True)
# if the user is the post's author
status_author = models.User.objects.filter(
is_active=True, local=True, id=status.user.id
).values_list("id", flat=True)
- return list(set(list(audience) + list(status_author)))
+ return list(set(audience) | set(status_author))
def get_statuses_for_user(self, user):
return models.Status.privacy_filter(
@@ -239,9 +237,7 @@ class BooksStream(ActivityStream):
)
audience = super()._get_audience(status)
- if not audience:
- return models.User.objects.none()
- return audience.filter(shelfbook__book__parent_work=work).distinct()
+ return audience.filter(shelfbook__book__parent_work=work)
def get_audience(self, status):
# only show public statuses on the books feed,
diff --git a/bookwyrm/apps.py b/bookwyrm/apps.py
index 41b1a17a2..d5384bb7b 100644
--- a/bookwyrm/apps.py
+++ b/bookwyrm/apps.py
@@ -33,7 +33,6 @@ class BookwyrmConfig(AppConfig):
name = "bookwyrm"
verbose_name = "BookWyrm"
- # pylint: disable=no-self-use
def ready(self):
"""set up OTLP and preview image files, if desired"""
if settings.OTEL_EXPORTER_OTLP_ENDPOINT or settings.OTEL_EXPORTER_CONSOLE:
diff --git a/bookwyrm/book_search.py b/bookwyrm/book_search.py
index cf48f4832..c11cdb8f1 100644
--- a/bookwyrm/book_search.py
+++ b/bookwyrm/book_search.py
@@ -36,7 +36,6 @@ def search(
...
-# pylint: disable=arguments-differ
def search(
query: str,
*,
diff --git a/bookwyrm/connectors/connector_manager.py b/bookwyrm/connectors/connector_manager.py
index 444a626ba..ad68af1dc 100644
--- a/bookwyrm/connectors/connector_manager.py
+++ b/bookwyrm/connectors/connector_manager.py
@@ -118,9 +118,11 @@ def get_connectors() -> Iterator[abstract_connector.AbstractConnector]:
def get_or_create_connector(remote_id: str) -> abstract_connector.AbstractConnector:
"""get the connector related to the object's server"""
url = urlparse(remote_id)
- identifier = url.netloc
+ identifier = url.hostname
if not identifier:
- raise ValueError("Invalid remote id")
+ raise ValueError(f"Invalid remote id: {remote_id}")
+
+ base_url = f"{url.scheme}://{url.netloc}"
try:
connector_info = models.Connector.objects.get(identifier=identifier)
@@ -128,10 +130,10 @@ def get_or_create_connector(remote_id: str) -> abstract_connector.AbstractConnec
connector_info = models.Connector.objects.create(
identifier=identifier,
connector_file="bookwyrm_connector",
- base_url=f"https://{identifier}",
- books_url=f"https://{identifier}/book",
- covers_url=f"https://{identifier}/images/covers",
- search_url=f"https://{identifier}/search?q=",
+ base_url=base_url,
+ books_url=f"{base_url}/book",
+ covers_url=f"{base_url}/images/covers",
+ search_url=f"{base_url}/search?q=",
priority=2,
)
@@ -143,7 +145,9 @@ def load_more_data(connector_id: str, book_id: str) -> None:
"""background the work of getting all 10,000 editions of LoTR"""
connector_info = models.Connector.objects.get(id=connector_id)
connector = load_connector(connector_info)
- book = models.Book.objects.select_subclasses().get(id=book_id)
+ book = models.Book.objects.select_subclasses().get( # type: ignore[no-untyped-call]
+ id=book_id
+ )
connector.expand_book_data(book)
@@ -154,7 +158,9 @@ def create_edition_task(
"""separate task for each of the 10,000 editions of LoTR"""
connector_info = models.Connector.objects.get(id=connector_id)
connector = load_connector(connector_info)
- work = models.Work.objects.select_subclasses().get(id=work_id)
+ work = models.Work.objects.select_subclasses().get( # type: ignore[no-untyped-call]
+ id=work_id
+ )
connector.create_edition_from_data(work, data)
@@ -188,8 +194,11 @@ def raise_not_valid_url(url: str) -> None:
if not parsed.scheme in ["http", "https"]:
raise ConnectorException("Invalid scheme: ", url)
+ if not parsed.hostname:
+ raise ConnectorException("Hostname missing: ", url)
+
try:
- ipaddress.ip_address(parsed.netloc)
+ ipaddress.ip_address(parsed.hostname)
raise ConnectorException("Provided url is an IP address: ", url)
except ValueError:
# it's not an IP address, which is good
diff --git a/bookwyrm/connectors/inventaire.py b/bookwyrm/connectors/inventaire.py
index c08bcdee1..249f6b9ca 100644
--- a/bookwyrm/connectors/inventaire.py
+++ b/bookwyrm/connectors/inventaire.py
@@ -229,7 +229,7 @@ class Connector(AbstractConnector):
data = get_data(url)
except ConnectorException:
return ""
- return data.get("extract", "")
+ return str(data.get("extract", ""))
def get_remote_id_from_model(self, obj: models.BookDataModel) -> str:
"""use get_remote_id to figure out the link from a model obj"""
diff --git a/bookwyrm/context_processors.py b/bookwyrm/context_processors.py
index 0047bfce1..bec704a2c 100644
--- a/bookwyrm/context_processors.py
+++ b/bookwyrm/context_processors.py
@@ -2,7 +2,7 @@
from bookwyrm import models, settings
-def site_settings(request): # pylint: disable=unused-argument
+def site_settings(request):
"""include the custom info about the site"""
request_protocol = "https://"
if not request.is_secure():
diff --git a/bookwyrm/emailing.py b/bookwyrm/emailing.py
index 5e08ebba1..ccc0aea61 100644
--- a/bookwyrm/emailing.py
+++ b/bookwyrm/emailing.py
@@ -4,7 +4,7 @@ from django.template.loader import get_template
from bookwyrm import models, settings
from bookwyrm.tasks import app, EMAIL
-from bookwyrm.settings import DOMAIN
+from bookwyrm.settings import DOMAIN, BASE_URL
def email_data():
@@ -14,6 +14,7 @@ def email_data():
"site_name": site.name,
"logo": site.logo_small_url,
"domain": DOMAIN,
+ "base_url": BASE_URL,
"user": None,
}
diff --git a/bookwyrm/forms/custom_form.py b/bookwyrm/forms/custom_form.py
index c604deea4..6b425d216 100644
--- a/bookwyrm/forms/custom_form.py
+++ b/bookwyrm/forms/custom_form.py
@@ -15,9 +15,9 @@ class StyledForm(ModelForm):
css_classes["number"] = "input"
css_classes["checkbox"] = "checkbox"
css_classes["textarea"] = "textarea"
- # pylint: disable=super-with-arguments
super().__init__(*args, **kwargs)
for visible in self.visible_fields():
+ input_type = ""
if hasattr(visible.field.widget, "input_type"):
input_type = visible.field.widget.input_type
if isinstance(visible.field.widget, Textarea):
diff --git a/bookwyrm/forms/edit_user.py b/bookwyrm/forms/edit_user.py
index 9024972c3..13ac285d8 100644
--- a/bookwyrm/forms/edit_user.py
+++ b/bookwyrm/forms/edit_user.py
@@ -18,6 +18,7 @@ class EditUserForm(CustomForm):
"email",
"summary",
"show_goal",
+ "show_ratings",
"show_suggested_users",
"manually_approves_followers",
"default_post_privacy",
diff --git a/bookwyrm/forms/landing.py b/bookwyrm/forms/landing.py
index 1da4fc4f1..831d1d539 100644
--- a/bookwyrm/forms/landing.py
+++ b/bookwyrm/forms/landing.py
@@ -34,7 +34,6 @@ class LoginForm(CustomForm):
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")
diff --git a/bookwyrm/forms/links.py b/bookwyrm/forms/links.py
index 345c5c1d4..5156d2578 100644
--- a/bookwyrm/forms/links.py
+++ b/bookwyrm/forms/links.py
@@ -26,7 +26,7 @@ class FileLinkForm(CustomForm):
url = cleaned_data.get("url")
filetype = cleaned_data.get("filetype")
book = cleaned_data.get("book")
- domain = urlparse(url).netloc
+ domain = urlparse(url).hostname
if models.LinkDomain.objects.filter(domain=domain).exists():
status = models.LinkDomain.objects.get(domain=domain).status
if status == "blocked":
diff --git a/bookwyrm/forms/widgets.py b/bookwyrm/forms/widgets.py
index ee9345aa0..001fdbec4 100644
--- a/bookwyrm/forms/widgets.py
+++ b/bookwyrm/forms/widgets.py
@@ -5,8 +5,6 @@ from django import forms
class ArrayWidget(forms.widgets.TextInput):
"""Inputs for postgres array fields"""
- # pylint: disable=unused-argument
- # pylint: disable=no-self-use
def value_from_datadict(self, data, files, name):
"""get all values for this name"""
return [i for i in data.getlist(name) if i]
diff --git a/bookwyrm/importers/__init__.py b/bookwyrm/importers/__init__.py
index 8e92872f2..3c895741b 100644
--- a/bookwyrm/importers/__init__.py
+++ b/bookwyrm/importers/__init__.py
@@ -1,7 +1,7 @@
""" import classes """
from .importer import Importer
-from .bookwyrm_import import BookwyrmImporter
+from .bookwyrm_import import BookwyrmImporter, BookwyrmBooksImporter
from .calibre_import import CalibreImporter
from .goodreads_import import GoodreadsImporter
from .librarything_import import LibrarythingImporter
diff --git a/bookwyrm/importers/bookwyrm_import.py b/bookwyrm/importers/bookwyrm_import.py
index 206cd6219..8afc1abf7 100644
--- a/bookwyrm/importers/bookwyrm_import.py
+++ b/bookwyrm/importers/bookwyrm_import.py
@@ -3,6 +3,7 @@ from django.http import QueryDict
from bookwyrm.models import User
from bookwyrm.models.bookwyrm_import_job import BookwyrmImportJob
+from . import Importer
class BookwyrmImporter:
@@ -22,3 +23,17 @@ class BookwyrmImporter:
user=user, archive_file=archive_file, required=required
)
return job
+
+
+class BookwyrmBooksImporter(Importer):
+ """
+ Handle reading a csv from BookWyrm.
+ Goodreads is the default importer, we basically just use the same structure
+ But BookWyrm has additional attributes in the csv
+ """
+
+ service = "BookWyrm"
+ row_mappings_guesses = Importer.row_mappings_guesses + [
+ ("shelf_name", ["shelf_name"]),
+ ("review_published", ["review_published"]),
+ ]
diff --git a/bookwyrm/importers/calibre_import.py b/bookwyrm/importers/calibre_import.py
index 5c22a539d..542175dd7 100644
--- a/bookwyrm/importers/calibre_import.py
+++ b/bookwyrm/importers/calibre_import.py
@@ -14,15 +14,10 @@ class CalibreImporter(Importer):
def __init__(self, *args: Any, **kwargs: Any):
# Add timestamp to row_mappings_guesses for date_added to avoid
# integrity error
- row_mappings_guesses = []
-
- for field, mapping in self.row_mappings_guesses:
- if field in ("date_added",):
- row_mappings_guesses.append((field, mapping + ["timestamp"]))
- else:
- row_mappings_guesses.append((field, mapping))
-
- self.row_mappings_guesses = row_mappings_guesses
+ self.row_mappings_guesses = [
+ (field, mapping + (["timestamp"] if field == "date_added" else []))
+ for field, mapping in self.row_mappings_guesses
+ ]
super().__init__(*args, **kwargs)
def get_shelf(self, normalized_row: dict[str, Optional[str]]) -> Optional[str]:
diff --git a/bookwyrm/importers/importer.py b/bookwyrm/importers/importer.py
index 5b3192fa5..d2a11d7f2 100644
--- a/bookwyrm/importers/importer.py
+++ b/bookwyrm/importers/importer.py
@@ -18,17 +18,26 @@ class Importer:
row_mappings_guesses = [
("id", ["id", "book id"]),
("title", ["title"]),
- ("authors", ["author", "authors", "primary author"]),
- ("isbn_10", ["isbn10", "isbn", "isbn/uid"]),
- ("isbn_13", ["isbn13", "isbn", "isbns", "isbn/uid"]),
+ ("authors", ["author_text", "author", "authors", "primary author"]),
+ ("isbn_10", ["isbn_10", "isbn10", "isbn", "isbn/uid"]),
+ ("isbn_13", ["isbn_13", "isbn13", "isbn", "isbns", "isbn/uid"]),
("shelf", ["shelf", "exclusive shelf", "read status", "bookshelf"]),
- ("review_name", ["review name"]),
- ("review_body", ["my review", "review"]),
+ ("review_name", ["review_name", "review name"]),
+ ("review_body", ["review_content", "my review", "review"]),
("rating", ["my rating", "rating", "star rating"]),
- ("date_added", ["date added", "entry date", "added"]),
- ("date_started", ["date started", "started"]),
- ("date_finished", ["date finished", "last date read", "date read", "finished"]),
+ (
+ "date_added",
+ ["shelf_date", "date_added", "date added", "entry date", "added"],
+ ),
+ ("date_started", ["start_date", "date started", "started"]),
+ (
+ "date_finished",
+ ["finish_date", "date finished", "last date read", "date read", "finished"],
+ ),
]
+
+ # TODO: stopped
+
date_fields = ["date_added", "date_started", "date_finished"]
shelf_mapping_guesses = {
"to-read": ["to-read", "want to read"],
@@ -36,9 +45,14 @@ class Importer:
"reading": ["currently-reading", "reading", "currently reading"],
}
- # pylint: disable=too-many-locals
+ # pylint: disable=too-many-arguments
def create_job(
- self, user: User, csv_file: Iterable[str], include_reviews: bool, privacy: str
+ self,
+ user: User,
+ csv_file: Iterable[str],
+ include_reviews: bool,
+ privacy: str,
+ create_shelves: bool = True,
) -> ImportJob:
"""check over a csv and creates a database entry for the job"""
csv_reader = csv.DictReader(csv_file, delimiter=self.delimiter)
@@ -55,6 +69,7 @@ class Importer:
job = ImportJob.objects.create(
user=user,
include_reviews=include_reviews,
+ create_shelves=create_shelves,
privacy=privacy,
mappings=mappings,
source=self.service,
@@ -114,7 +129,7 @@ class Importer:
shelf = [
s for (s, gs) in self.shelf_mapping_guesses.items() if shelf_name in gs
]
- return shelf[0] if shelf else None
+ return shelf[0] if shelf else normalized_row.get("shelf") or None
# pylint: disable=no-self-use
def normalize_row(
@@ -149,6 +164,7 @@ class Importer:
job = ImportJob.objects.create(
user=user,
include_reviews=original_job.include_reviews,
+ create_shelves=original_job.create_shelves,
privacy=original_job.privacy,
source=original_job.source,
# TODO: allow users to adjust mappings
diff --git a/bookwyrm/importers/librarything_import.py b/bookwyrm/importers/librarything_import.py
index 145657ba0..24a2626bf 100644
--- a/bookwyrm/importers/librarything_import.py
+++ b/bookwyrm/importers/librarything_import.py
@@ -20,7 +20,7 @@ class LibrarythingImporter(Importer):
def normalize_row(
self, entry: dict[str, str], mappings: dict[str, Optional[str]]
- ) -> dict[str, Optional[str]]: # pylint: disable=no-self-use
+ ) -> dict[str, Optional[str]]:
"""use the dataclass to create the formatted row of data"""
normalized = {
k: _remove_brackets(entry.get(v) if v else None)
diff --git a/bookwyrm/lists_stream.py b/bookwyrm/lists_stream.py
index 148b81a78..479eacb19 100644
--- a/bookwyrm/lists_stream.py
+++ b/bookwyrm/lists_stream.py
@@ -18,7 +18,7 @@ class ListsStream(RedisStore):
return f"{user}-lists"
return f"{user.id}-lists"
- def get_rank(self, obj): # pylint: disable=no-self-use
+ def get_rank(self, obj):
"""lists are sorted by updated date"""
return obj.updated_date.timestamp()
diff --git a/bookwyrm/management/commands/deduplicate_book_data.py b/bookwyrm/management/commands/deduplicate_book_data.py
index dde7d133c..c2d897ce3 100644
--- a/bookwyrm/management/commands/deduplicate_book_data.py
+++ b/bookwyrm/management/commands/deduplicate_book_data.py
@@ -1,13 +1,14 @@
""" PROCEED WITH CAUTION: uses deduplication fields to permanently
merge book data objects """
+
from django.core.management.base import BaseCommand
from django.db.models import Count
from bookwyrm import models
-from bookwyrm.management.merge import merge_objects
-def dedupe_model(model):
+def dedupe_model(model, dry_run=False):
"""combine duplicate editions and update related models"""
+ print(f"deduplicating {model.__name__}:")
fields = model._meta.get_fields()
dedupe_fields = [
f for f in fields if hasattr(f, "deduplication_field") and f.deduplication_field
@@ -16,30 +17,42 @@ def dedupe_model(model):
dupes = (
model.objects.values(field.name)
.annotate(Count(field.name))
- .filter(**{"%s__count__gt" % field.name: 1})
+ .filter(**{f"{field.name}__count__gt": 1})
+ .exclude(**{field.name: ""})
+ .exclude(**{f"{field.name}__isnull": True})
)
for dupe in dupes:
value = dupe[field.name]
- if not value or value == "":
- continue
print("----------")
- print(dupe)
objs = model.objects.filter(**{field.name: value}).order_by("id")
canonical = objs.first()
- print("keeping", canonical.remote_id)
+ action = "would merge" if dry_run else "merging"
+ print(
+ f"{action} into {model.__name__} {canonical.remote_id} based on {field.name} {value}:"
+ )
for obj in objs[1:]:
- print(obj.remote_id)
- merge_objects(canonical, obj)
+ print(f"- {obj.remote_id}")
+ absorbed_fields = obj.merge_into(canonical, dry_run=dry_run)
+ print(f" absorbed fields: {absorbed_fields}")
class Command(BaseCommand):
"""deduplicate allllll the book data models"""
help = "merges duplicate book data"
+
+ def add_arguments(self, parser):
+ """add the arguments for this command"""
+ parser.add_argument(
+ "--dry_run",
+ action="store_true",
+ help="don't actually merge, only print what would happen",
+ )
+
# pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options):
"""run deduplications"""
- dedupe_model(models.Edition)
- dedupe_model(models.Work)
- dedupe_model(models.Author)
+ dedupe_model(models.Edition, dry_run=options["dry_run"])
+ dedupe_model(models.Work, dry_run=options["dry_run"])
+ dedupe_model(models.Author, dry_run=options["dry_run"])
diff --git a/bookwyrm/management/merge.py b/bookwyrm/management/merge.py
deleted file mode 100644
index f55229f18..000000000
--- a/bookwyrm/management/merge.py
+++ /dev/null
@@ -1,50 +0,0 @@
-from django.db.models import ManyToManyField
-
-
-def update_related(canonical, obj):
- """update all the models with fk to the object being removed"""
- # move related models to canonical
- related_models = [
- (r.remote_field.name, r.related_model) for r in canonical._meta.related_objects
- ]
- for (related_field, related_model) in related_models:
- # Skip the ManyToMany fields that aren’t auto-created. These
- # should have a corresponding OneToMany field in the model for
- # the linking table anyway. If we update it through that model
- # instead then we won’t lose the extra fields in the linking
- # table.
- related_field_obj = related_model._meta.get_field(related_field)
- if isinstance(related_field_obj, ManyToManyField):
- through = related_field_obj.remote_field.through
- if not through._meta.auto_created:
- continue
- related_objs = related_model.objects.filter(**{related_field: obj})
- for related_obj in related_objs:
- print("replacing in", related_model.__name__, related_field, related_obj.id)
- try:
- setattr(related_obj, related_field, canonical)
- related_obj.save()
- except TypeError:
- getattr(related_obj, related_field).add(canonical)
- getattr(related_obj, related_field).remove(obj)
-
-
-def copy_data(canonical, obj):
- """try to get the most data possible"""
- for data_field in obj._meta.get_fields():
- if not hasattr(data_field, "activitypub_field"):
- continue
- data_value = getattr(obj, data_field.name)
- if not data_value:
- continue
- if not getattr(canonical, data_field.name):
- print("setting data field", data_field.name, data_value)
- setattr(canonical, data_field.name, data_value)
- canonical.save()
-
-
-def merge_objects(canonical, obj):
- copy_data(canonical, obj)
- update_related(canonical, obj)
- # remove the outdated entry
- obj.delete()
diff --git a/bookwyrm/management/merge_command.py b/bookwyrm/management/merge_command.py
index 805dc73fa..66e60814a 100644
--- a/bookwyrm/management/merge_command.py
+++ b/bookwyrm/management/merge_command.py
@@ -1,4 +1,3 @@
-from bookwyrm.management.merge import merge_objects
from django.core.management.base import BaseCommand
@@ -9,6 +8,11 @@ class MergeCommand(BaseCommand):
"""add the arguments for this command"""
parser.add_argument("--canonical", type=int, required=True)
parser.add_argument("--other", type=int, required=True)
+ parser.add_argument(
+ "--dry_run",
+ action="store_true",
+ help="don't actually merge, only print what would happen",
+ )
# pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options):
@@ -26,4 +30,8 @@ class MergeCommand(BaseCommand):
print("other book doesn’t exist!")
return
- merge_objects(canonical, other)
+ absorbed_fields = other.merge_into(canonical, dry_run=options["dry_run"])
+
+ action = "would be" if options["dry_run"] else "has been"
+ print(f"{other.remote_id} {action} merged into {canonical.remote_id}")
+ print(f"absorbed fields: {absorbed_fields}")
diff --git a/bookwyrm/middleware/timezone_middleware.py b/bookwyrm/middleware/timezone_middleware.py
index 5033397a5..3cf084154 100644
--- a/bookwyrm/middleware/timezone_middleware.py
+++ b/bookwyrm/middleware/timezone_middleware.py
@@ -1,5 +1,5 @@
""" Makes the app aware of the users timezone """
-import pytz
+import zoneinfo
from django.utils import timezone
@@ -12,9 +12,7 @@ class TimezoneMiddleware:
def __call__(self, request):
if request.user.is_authenticated:
- timezone.activate(pytz.timezone(request.user.preferred_timezone))
+ timezone.activate(zoneinfo.ZoneInfo(request.user.preferred_timezone))
else:
- timezone.activate(pytz.utc)
- response = self.get_response(request)
- timezone.deactivate()
- return response
+ timezone.deactivate()
+ return self.get_response(request)
diff --git a/bookwyrm/migrations/0171_alter_user_preferred_timezone.py b/bookwyrm/migrations/0171_alter_user_preferred_timezone.py
index 7dcd9546c..8d1dff553 100644
--- a/bookwyrm/migrations/0171_alter_user_preferred_timezone.py
+++ b/bookwyrm/migrations/0171_alter_user_preferred_timezone.py
@@ -10,6 +10,7 @@ class Migration(migrations.Migration):
]
operations = [
+ # The new timezones are "Factory" and "localtime"
migrations.AlterField(
model_name="user",
name="preferred_timezone",
diff --git a/bookwyrm/migrations/0189_importjob_create_shelves.py b/bookwyrm/migrations/0189_importjob_create_shelves.py
new file mode 100644
index 000000000..a1b1fc512
--- /dev/null
+++ b/bookwyrm/migrations/0189_importjob_create_shelves.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.23 on 2023-11-25 05:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0188_theme_loads"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="importjob",
+ name="create_shelves",
+ field=models.BooleanField(default=True),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0193_auto_20240128_0249.py b/bookwyrm/migrations/0193_auto_20240128_0249.py
new file mode 100644
index 000000000..82e32ee48
--- /dev/null
+++ b/bookwyrm/migrations/0193_auto_20240128_0249.py
@@ -0,0 +1,92 @@
+# Generated by Django 3.2.23 on 2024-01-28 02:49
+
+import django.core.serializers.json
+from django.db import migrations, models
+import django.db.models.deletion
+from django.core.files.storage import storages
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0192_sitesettings_user_exports_enabled"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="bookwyrmexportjob",
+ name="export_json",
+ field=models.JSONField(
+ encoder=django.core.serializers.json.DjangoJSONEncoder, null=True
+ ),
+ ),
+ migrations.AddField(
+ model_name="bookwyrmexportjob",
+ name="json_completed",
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AlterField(
+ model_name="bookwyrmexportjob",
+ name="export_data",
+ field=models.FileField(
+ null=True,
+ storage=storages["exports"],
+ upload_to="",
+ ),
+ ),
+ migrations.CreateModel(
+ name="AddFileToTar",
+ fields=[
+ (
+ "childjob_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="bookwyrm.childjob",
+ ),
+ ),
+ (
+ "parent_export_job",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="child_edition_export_jobs",
+ to="bookwyrm.bookwyrmexportjob",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ bases=("bookwyrm.childjob",),
+ ),
+ migrations.CreateModel(
+ name="AddBookToUserExportJob",
+ fields=[
+ (
+ "childjob_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="bookwyrm.childjob",
+ ),
+ ),
+ (
+ "edition",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="bookwyrm.edition",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ bases=("bookwyrm.childjob",),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0196_merge_20240318_1737.py b/bookwyrm/migrations/0196_merge_20240318_1737.py
new file mode 100644
index 000000000..2d80b2e58
--- /dev/null
+++ b/bookwyrm/migrations/0196_merge_20240318_1737.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.23 on 2024-03-18 17:37
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0193_auto_20240128_0249"),
+ ("bookwyrm", "0195_alter_user_preferred_language"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0197_merge_20240324_0235.py b/bookwyrm/migrations/0197_merge_20240324_0235.py
new file mode 100644
index 000000000..a7c01a955
--- /dev/null
+++ b/bookwyrm/migrations/0197_merge_20240324_0235.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.25 on 2024-03-24 02:35
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0196_merge_20240318_1737"),
+ ("bookwyrm", "0196_merge_pr3134_into_main"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0197_mergedauthor_mergedbook.py b/bookwyrm/migrations/0197_mergedauthor_mergedbook.py
new file mode 100644
index 000000000..23ca38ab2
--- /dev/null
+++ b/bookwyrm/migrations/0197_mergedauthor_mergedbook.py
@@ -0,0 +1,48 @@
+# Generated by Django 3.2.24 on 2024-02-28 21:30
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0196_merge_pr3134_into_main"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="MergedBook",
+ fields=[
+ ("deleted_id", models.IntegerField(primary_key=True, serialize=False)),
+ (
+ "merged_into",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="absorbed",
+ to="bookwyrm.book",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ migrations.CreateModel(
+ name="MergedAuthor",
+ fields=[
+ ("deleted_id", models.IntegerField(primary_key=True, serialize=False)),
+ (
+ "merged_into",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="absorbed",
+ to="bookwyrm.author",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ ]
diff --git a/bookwyrm/migrations/0198_alter_bookwyrmexportjob_export_data.py b/bookwyrm/migrations/0198_alter_bookwyrmexportjob_export_data.py
new file mode 100644
index 000000000..552584d2b
--- /dev/null
+++ b/bookwyrm/migrations/0198_alter_bookwyrmexportjob_export_data.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.2.25 on 2024-03-26 11:37
+
+import bookwyrm.models.bookwyrm_export_job
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0197_merge_20240324_0235"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="bookwyrmexportjob",
+ name="export_data",
+ field=models.FileField(
+ null=True,
+ storage=bookwyrm.models.bookwyrm_export_job.select_exports_storage,
+ upload_to="",
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0199_alter_userblocks_user_object_and_more.py b/bookwyrm/migrations/0199_alter_userblocks_user_object_and_more.py
new file mode 100644
index 000000000..bde1f25c1
--- /dev/null
+++ b/bookwyrm/migrations/0199_alter_userblocks_user_object_and_more.py
@@ -0,0 +1,70 @@
+# Generated by Django 4.2.11 on 2024-03-29 19:25
+
+import bookwyrm.models.fields
+from django.conf import settings
+from django.db import migrations
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0198_book_search_vector_author_aliases"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="userblocks",
+ name="user_object",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="%(class)s_user_object",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userblocks",
+ name="user_subject",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="%(class)s_user_subject",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userfollowrequest",
+ name="user_object",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="%(class)s_user_object",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userfollowrequest",
+ name="user_subject",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="%(class)s_user_subject",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userfollows",
+ name="user_object",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="%(class)s_user_object",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userfollows",
+ name="user_subject",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="%(class)s_user_subject",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0199_merge_20240326_1217.py b/bookwyrm/migrations/0199_merge_20240326_1217.py
new file mode 100644
index 000000000..7794af54a
--- /dev/null
+++ b/bookwyrm/migrations/0199_merge_20240326_1217.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.25 on 2024-03-26 12:17
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0198_alter_bookwyrmexportjob_export_data"),
+ ("bookwyrm", "0198_book_search_vector_author_aliases"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0199_status_bookwyrm_st_remote__06aeba_idx.py b/bookwyrm/migrations/0199_status_bookwyrm_st_remote__06aeba_idx.py
new file mode 100644
index 000000000..5d2513698
--- /dev/null
+++ b/bookwyrm/migrations/0199_status_bookwyrm_st_remote__06aeba_idx.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.25 on 2024-04-02 19:53
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0198_book_search_vector_author_aliases"),
+ ]
+
+ operations = [
+ migrations.AddIndex(
+ model_name="status",
+ index=models.Index(
+ fields=["remote_id"], name="bookwyrm_st_remote__06aeba_idx"
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0200_alter_user_preferred_timezone.py b/bookwyrm/migrations/0200_alter_user_preferred_timezone.py
new file mode 100644
index 000000000..1b21c0f94
--- /dev/null
+++ b/bookwyrm/migrations/0200_alter_user_preferred_timezone.py
@@ -0,0 +1,633 @@
+# Generated by Django 4.2.11 on 2024-04-01 20:19
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0199_alter_userblocks_user_object_and_more"),
+ ]
+
+ operations = [
+ 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/Ciudad_Juarez", "America/Ciudad_Juarez"),
+ ("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"),
+ ("Factory", "Factory"),
+ ("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"),
+ ("localtime", "localtime"),
+ ],
+ default="UTC",
+ max_length=255,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0200_auto_20240327_1914.py b/bookwyrm/migrations/0200_auto_20240327_1914.py
new file mode 100644
index 000000000..38180b3f9
--- /dev/null
+++ b/bookwyrm/migrations/0200_auto_20240327_1914.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.2.25 on 2024-03-27 19:14
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0199_merge_20240326_1217"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="addfiletotar",
+ name="childjob_ptr",
+ ),
+ migrations.RemoveField(
+ model_name="addfiletotar",
+ name="parent_export_job",
+ ),
+ migrations.DeleteModel(
+ name="AddBookToUserExportJob",
+ ),
+ migrations.DeleteModel(
+ name="AddFileToTar",
+ ),
+ ]
diff --git a/bookwyrm/migrations/0200_status_bookwyrm_st_thread__cf064f_idx.py b/bookwyrm/migrations/0200_status_bookwyrm_st_thread__cf064f_idx.py
new file mode 100644
index 000000000..daca654c7
--- /dev/null
+++ b/bookwyrm/migrations/0200_status_bookwyrm_st_thread__cf064f_idx.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.25 on 2024-04-03 19:05
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0199_status_bookwyrm_st_remote__06aeba_idx"),
+ ]
+
+ operations = [
+ migrations.AddIndex(
+ model_name="status",
+ index=models.Index(
+ fields=["thread_id"], name="bookwyrm_st_thread__cf064f_idx"
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0201_alter_hashtag_name_alter_user_localname.py b/bookwyrm/migrations/0201_alter_hashtag_name_alter_user_localname.py
new file mode 100644
index 000000000..4fe41ec35
--- /dev/null
+++ b/bookwyrm/migrations/0201_alter_hashtag_name_alter_user_localname.py
@@ -0,0 +1,39 @@
+# Generated by Django 4.2.11 on 2024-04-01 21:09
+
+import bookwyrm.models.fields
+from django.db import migrations, models
+from django.contrib.postgres.operations import CreateCollation
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0200_alter_user_preferred_timezone"),
+ ]
+
+ operations = [
+ CreateCollation(
+ "case_insensitive",
+ provider="icu",
+ locale="und-u-ks-level2",
+ deterministic=False,
+ ),
+ migrations.AlterField(
+ model_name="hashtag",
+ name="name",
+ field=bookwyrm.models.fields.CharField(
+ db_collation="case_insensitive", max_length=256
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="localname",
+ field=models.CharField(
+ db_collation="case_insensitive",
+ max_length=255,
+ null=True,
+ unique=True,
+ validators=[bookwyrm.models.fields.validate_localname],
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0201_keypair_bookwyrm_ke_remote__472927_idx.py b/bookwyrm/migrations/0201_keypair_bookwyrm_ke_remote__472927_idx.py
new file mode 100644
index 000000000..e3d27a11b
--- /dev/null
+++ b/bookwyrm/migrations/0201_keypair_bookwyrm_ke_remote__472927_idx.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.25 on 2024-04-03 19:10
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0200_status_bookwyrm_st_thread__cf064f_idx"),
+ ]
+
+ operations = [
+ migrations.AddIndex(
+ model_name="keypair",
+ index=models.Index(
+ fields=["remote_id"], name="bookwyrm_ke_remote__472927_idx"
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0202_user_bookwyrm_us_usernam_b2546d_idx.py b/bookwyrm/migrations/0202_user_bookwyrm_us_usernam_b2546d_idx.py
new file mode 100644
index 000000000..d8666fe3f
--- /dev/null
+++ b/bookwyrm/migrations/0202_user_bookwyrm_us_usernam_b2546d_idx.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.25 on 2024-04-03 19:14
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0201_keypair_bookwyrm_ke_remote__472927_idx"),
+ ]
+
+ operations = [
+ migrations.AddIndex(
+ model_name="user",
+ index=models.Index(
+ fields=["username"], name="bookwyrm_us_usernam_b2546d_idx"
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0203_user_bookwyrm_us_is_acti_972dc4_idx.py b/bookwyrm/migrations/0203_user_bookwyrm_us_is_acti_972dc4_idx.py
new file mode 100644
index 000000000..b07f1c8a9
--- /dev/null
+++ b/bookwyrm/migrations/0203_user_bookwyrm_us_is_acti_972dc4_idx.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.25 on 2024-04-03 19:22
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0202_user_bookwyrm_us_usernam_b2546d_idx"),
+ ]
+
+ operations = [
+ migrations.AddIndex(
+ model_name="user",
+ index=models.Index(
+ fields=["is_active", "local"], name="bookwyrm_us_is_acti_972dc4_idx"
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0204_merge_20240409_1042.py b/bookwyrm/migrations/0204_merge_20240409_1042.py
new file mode 100644
index 000000000..5656ac586
--- /dev/null
+++ b/bookwyrm/migrations/0204_merge_20240409_1042.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.25 on 2024-04-09 10:42
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0197_mergedauthor_mergedbook"),
+ ("bookwyrm", "0203_user_bookwyrm_us_is_acti_972dc4_idx"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0205_merge_20240410_2022.py b/bookwyrm/migrations/0205_merge_20240410_2022.py
new file mode 100644
index 000000000..294f48487
--- /dev/null
+++ b/bookwyrm/migrations/0205_merge_20240410_2022.py
@@ -0,0 +1,13 @@
+# Generated by Django 4.2.11 on 2024-04-10 20:22
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0201_alter_hashtag_name_alter_user_localname"),
+ ("bookwyrm", "0204_merge_20240409_1042"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0205_merge_20240413_0232.py b/bookwyrm/migrations/0205_merge_20240413_0232.py
new file mode 100644
index 000000000..9cca29c45
--- /dev/null
+++ b/bookwyrm/migrations/0205_merge_20240413_0232.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.25 on 2024-04-13 02:32
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0200_auto_20240327_1914"),
+ ("bookwyrm", "0204_merge_20240409_1042"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0206_merge_20240415_1537.py b/bookwyrm/migrations/0206_merge_20240415_1537.py
new file mode 100644
index 000000000..454e69880
--- /dev/null
+++ b/bookwyrm/migrations/0206_merge_20240415_1537.py
@@ -0,0 +1,13 @@
+# Generated by Django 4.2.11 on 2024-04-15 15:37
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0205_merge_20240410_2022"),
+ ("bookwyrm", "0205_merge_20240413_0232"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0207_merge_20240629_0626.py b/bookwyrm/migrations/0207_merge_20240629_0626.py
new file mode 100644
index 000000000..b5a1a4556
--- /dev/null
+++ b/bookwyrm/migrations/0207_merge_20240629_0626.py
@@ -0,0 +1,13 @@
+# Generated by Django 4.2.11 on 2024-06-29 06:26
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0189_importjob_create_shelves"),
+ ("bookwyrm", "0206_merge_20240415_1537"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0207_sqlparse_update.py b/bookwyrm/migrations/0207_sqlparse_update.py
new file mode 100644
index 000000000..95c46eba2
--- /dev/null
+++ b/bookwyrm/migrations/0207_sqlparse_update.py
@@ -0,0 +1,51 @@
+# Generated by Django 4.2.11 on 2024-07-27 18:18
+
+from django.db import migrations, models
+import pgtrigger.compiler
+import pgtrigger.migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0206_merge_20240415_1537"),
+ ]
+
+ operations = [
+ pgtrigger.migrations.RemoveTrigger(
+ model_name="author",
+ name="reset_book_search_vector_on_author_edit",
+ ),
+ pgtrigger.migrations.RemoveTrigger(
+ model_name="book",
+ name="update_search_vector_on_book_edit",
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="author",
+ trigger=pgtrigger.compiler.Trigger(
+ name="reset_book_search_vector_on_author_edit",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func="WITH updated_books AS (SELECT book_id FROM bookwyrm_book_authors WHERE author_id = new.id) UPDATE bookwyrm_book SET search_vector = '' FROM updated_books WHERE id = updated_books.book_id;RETURN NEW;",
+ hash="4eeb17d1c9c53f543615bcae1234bd0260adefcc",
+ operation='UPDATE OF "name", "aliases"',
+ pgid="pgtrigger_reset_book_search_vector_on_author_edit_a50c7",
+ table="bookwyrm_author",
+ when="AFTER",
+ ),
+ ),
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="book",
+ trigger=pgtrigger.compiler.Trigger(
+ name="update_search_vector_on_book_edit",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func="WITH author_names AS (SELECT array_to_string(bookwyrm_author.name || bookwyrm_author.aliases, ' ') AS name_and_aliases FROM bookwyrm_author LEFT JOIN bookwyrm_book_authors ON bookwyrm_author.id = bookwyrm_book_authors.author_id WHERE bookwyrm_book_authors.book_id = new.id) SELECT setweight(coalesce(nullif(to_tsvector('english', new.title), ''), to_tsvector('simple', new.title)), 'A') || setweight(to_tsvector('english', coalesce(new.subtitle, '')), 'B') || (SELECT setweight(to_tsvector('simple', coalesce(array_to_string(array_agg(name_and_aliases), ' '), '')), 'C') FROM author_names) || setweight(to_tsvector('english', coalesce(new.series, '')), 'D') INTO new.search_vector;RETURN NEW;",
+ hash="676d929ce95beff671544b6add09cf9360b6f299",
+ operation='INSERT OR UPDATE OF "title", "subtitle", "series", "search_vector"',
+ pgid="pgtrigger_update_search_vector_on_book_edit_bec58",
+ table="bookwyrm_book",
+ when="BEFORE",
+ ),
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0208_merge_0207_merge_20240629_0626_0207_sqlparse_update.py b/bookwyrm/migrations/0208_merge_0207_merge_20240629_0626_0207_sqlparse_update.py
new file mode 100644
index 000000000..24ef28e04
--- /dev/null
+++ b/bookwyrm/migrations/0208_merge_0207_merge_20240629_0626_0207_sqlparse_update.py
@@ -0,0 +1,13 @@
+# Generated by Django 4.2.11 on 2024-07-28 11:07
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0207_merge_20240629_0626"),
+ ("bookwyrm", "0207_sqlparse_update"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0209_user_show_ratings.py b/bookwyrm/migrations/0209_user_show_ratings.py
new file mode 100644
index 000000000..94e074407
--- /dev/null
+++ b/bookwyrm/migrations/0209_user_show_ratings.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.15 on 2024-08-24 01:16
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0208_merge_0207_merge_20240629_0626_0207_sqlparse_update"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="show_ratings",
+ field=models.BooleanField(default=True),
+ ),
+ ]
diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py
index db737b8bc..54ad11511 100644
--- a/bookwyrm/models/activitypub_mixin.py
+++ b/bookwyrm/models/activitypub_mixin.py
@@ -31,7 +31,7 @@ logger = logging.getLogger(__name__)
PropertyField = namedtuple("PropertyField", ("set_activity_from_field"))
-# pylint: disable=invalid-name
+
def set_activity_from_property_field(activity, obj, field):
"""assign a model property value to the activity json"""
activity[field[1]] = getattr(obj, field[0])
@@ -169,7 +169,7 @@ class ActivitypubMixin:
# filter users first by whether they're using the desired software
# this lets us send book updates only to other bw servers
if software:
- queryset = queryset.filter(bookwyrm_user=(software == "bookwyrm"))
+ queryset = queryset.filter(bookwyrm_user=software == "bookwyrm")
# if there's a user, we only want to send to the user's followers
if user:
queryset = queryset.filter(following=user)
@@ -206,14 +206,10 @@ class ObjectMixin(ActivitypubMixin):
created: Optional[bool] = None,
software: Any = None,
priority: str = BROADCAST,
+ broadcast: bool = True,
**kwargs: Any,
) -> None:
"""broadcast created/updated/deleted objects as appropriate"""
- broadcast = kwargs.get("broadcast", True)
- # this bonus kwarg would cause an error in the base save method
- if "broadcast" in kwargs:
- del kwargs["broadcast"]
-
created = created or not bool(self.id)
# first off, we want to save normally no matter what
super().save(*args, **kwargs)
diff --git a/bookwyrm/models/author.py b/bookwyrm/models/author.py
index 9dc3962ad..20c4e9e00 100644
--- a/bookwyrm/models/author.py
+++ b/bookwyrm/models/author.py
@@ -1,22 +1,25 @@
""" database schema for info about authors """
+
import re
-from typing import Tuple, Any
+from typing import Any
from django.db import models
from django.contrib.postgres.indexes import GinIndex
import pgtrigger
from bookwyrm import activitypub
-from bookwyrm.settings import DOMAIN
+from bookwyrm.settings import BASE_URL
from bookwyrm.utils.db import format_trigger
-from .book import BookDataModel
+from .book import BookDataModel, MergedAuthor
from . import fields
class Author(BookDataModel):
"""basic biographic info"""
+ merged_model = MergedAuthor
+
wikipedia_link = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True
)
@@ -42,12 +45,12 @@ class Author(BookDataModel):
)
bio = fields.HtmlField(null=True, blank=True)
- def save(self, *args: Tuple[Any, ...], **kwargs: dict[str, Any]) -> None:
+ def save(self, *args: Any, **kwargs: Any) -> None:
"""normalize isni format"""
- if self.isni:
+ if self.isni is not None:
self.isni = re.sub(r"\s", "", self.isni)
- return super().save(*args, **kwargs)
+ super().save(*args, **kwargs)
@property
def isni_link(self):
@@ -67,7 +70,7 @@ class Author(BookDataModel):
def get_remote_id(self):
"""editions and works both use "book" instead of model_name"""
- return f"https://{DOMAIN}/author/{self.id}"
+ return f"{BASE_URL}/author/{self.id}"
class Meta:
"""sets up indexes and triggers"""
diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py
index 2d39e2a6f..ca13d9553 100644
--- a/bookwyrm/models/base_model.py
+++ b/bookwyrm/models/base_model.py
@@ -10,7 +10,7 @@ from django.http import Http404
from django.utils.translation import gettext_lazy as _
from django.utils.text import slugify
-from bookwyrm.settings import DOMAIN
+from bookwyrm.settings import BASE_URL
from .fields import RemoteIdField
@@ -38,7 +38,7 @@ class BookWyrmModel(models.Model):
def get_remote_id(self):
"""generate the url that resolves to the local object, without a slug"""
- base_path = f"https://{DOMAIN}"
+ base_path = BASE_URL
if hasattr(self, "user"):
base_path = f"{base_path}{self.user.local_path}"
@@ -53,7 +53,7 @@ class BookWyrmModel(models.Model):
@property
def local_path(self):
"""how to link to this object in the local app, with a slug"""
- local = self.get_remote_id().replace(f"https://{DOMAIN}", "")
+ local = self.get_remote_id().replace(BASE_URL, "")
name = None
if hasattr(self, "name_field"):
diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py
index 5dba6532f..368276523 100644
--- a/bookwyrm/models/book.py
+++ b/bookwyrm/models/book.py
@@ -1,13 +1,15 @@
""" database schema for books and shelves """
+
from itertools import chain
import re
-from typing import Any
+from typing import Any, Dict, Optional, Iterable
+from typing_extensions import Self
from django.contrib.postgres.search import SearchVectorField
from django.contrib.postgres.indexes import GinIndex
from django.core.cache import cache
from django.db import models, transaction
-from django.db.models import Prefetch
+from django.db.models import Prefetch, ManyToManyField
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
from model_utils import FieldTracker
@@ -19,13 +21,13 @@ from bookwyrm import activitypub
from bookwyrm.isbn.isbn import hyphenator_singleton as hyphenator
from bookwyrm.preview_images import generate_edition_preview_image_task
from bookwyrm.settings import (
- DOMAIN,
+ BASE_URL,
DEFAULT_LANGUAGE,
LANGUAGE_ARTICLES,
ENABLE_PREVIEW_IMAGES,
ENABLE_THUMBNAIL_GENERATION,
)
-from bookwyrm.utils.db import format_trigger
+from bookwyrm.utils.db import format_trigger, add_update_fields
from .activitypub_mixin import OrderedCollectionPageMixin, ObjectMixin
from .base_model import BookWyrmModel
@@ -94,24 +96,133 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
abstract = True
- def save(self, *args: Any, **kwargs: Any) -> None:
+ def save(
+ self, *args: Any, update_fields: Optional[Iterable[str]] = None, **kwargs: Any
+ ) -> None:
"""ensure that the remote_id is within this instance"""
if self.id:
self.remote_id = self.get_remote_id()
+ update_fields = add_update_fields(update_fields, "remote_id")
else:
self.origin_id = self.remote_id
self.remote_id = None
- return super().save(*args, **kwargs)
+ update_fields = add_update_fields(update_fields, "origin_id", "remote_id")
+
+ super().save(*args, update_fields=update_fields, **kwargs)
# pylint: disable=arguments-differ
def broadcast(self, activity, sender, software="bookwyrm", **kwargs):
"""only send book data updates to other bookwyrm instances"""
super().broadcast(activity, sender, software=software, **kwargs)
+ def merge_into(self, canonical: Self, dry_run=False) -> Dict[str, Any]:
+ """merge this entity into another entity"""
+ if canonical.id == self.id:
+ raise ValueError(f"Cannot merge {self} into itself")
+
+ absorbed_fields = canonical.absorb_data_from(self, dry_run=dry_run)
+
+ if dry_run:
+ return absorbed_fields
+
+ canonical.save()
+
+ self.merged_model.objects.create(deleted_id=self.id, merged_into=canonical)
+
+ # move related models to canonical
+ related_models = [
+ (r.remote_field.name, r.related_model) for r in self._meta.related_objects
+ ]
+ for related_field, related_model in related_models:
+ # Skip the ManyToMany fields that aren’t auto-created. These
+ # should have a corresponding OneToMany field in the model for
+ # the linking table anyway. If we update it through that model
+ # instead then we won’t lose the extra fields in the linking
+ # table.
+ # pylint: disable=protected-access
+ related_field_obj = related_model._meta.get_field(related_field)
+ if isinstance(related_field_obj, ManyToManyField):
+ through = related_field_obj.remote_field.through
+ if not through._meta.auto_created:
+ continue
+ related_objs = related_model.objects.filter(**{related_field: self})
+ for related_obj in related_objs:
+ try:
+ setattr(related_obj, related_field, canonical)
+ related_obj.save()
+ except TypeError:
+ getattr(related_obj, related_field).add(canonical)
+ getattr(related_obj, related_field).remove(self)
+
+ self.delete()
+ return absorbed_fields
+
+ def absorb_data_from(self, other: Self, dry_run=False) -> Dict[str, Any]:
+ """fill empty fields with values from another entity"""
+ absorbed_fields = {}
+ for data_field in self._meta.get_fields():
+ if not hasattr(data_field, "activitypub_field"):
+ continue
+ canonical_value = getattr(self, data_field.name)
+ other_value = getattr(other, data_field.name)
+ if not other_value:
+ continue
+ if isinstance(data_field, fields.ArrayField):
+ if new_values := list(set(other_value) - set(canonical_value)):
+ # append at the end (in no particular order)
+ if not dry_run:
+ setattr(self, data_field.name, canonical_value + new_values)
+ absorbed_fields[data_field.name] = new_values
+ elif isinstance(data_field, fields.PartialDateField):
+ if (
+ (not canonical_value)
+ or (other_value.has_day and not canonical_value.has_day)
+ or (other_value.has_month and not canonical_value.has_month)
+ ):
+ if not dry_run:
+ setattr(self, data_field.name, other_value)
+ absorbed_fields[data_field.name] = other_value
+ else:
+ if not canonical_value:
+ if not dry_run:
+ setattr(self, data_field.name, other_value)
+ absorbed_fields[data_field.name] = other_value
+ return absorbed_fields
+
+
+class MergedBookDataModel(models.Model):
+ """a BookDataModel instance that has been merged into another instance. kept
+ to be able to redirect old URLs"""
+
+ deleted_id = models.IntegerField(primary_key=True)
+
+ class Meta:
+ """abstract just like BookDataModel"""
+
+ abstract = True
+
+
+class MergedBook(MergedBookDataModel):
+ """an Book that has been merged into another one"""
+
+ merged_into = models.ForeignKey(
+ "Book", on_delete=models.PROTECT, related_name="absorbed"
+ )
+
+
+class MergedAuthor(MergedBookDataModel):
+ """an Author that has been merged into another one"""
+
+ merged_into = models.ForeignKey(
+ "Author", on_delete=models.PROTECT, related_name="absorbed"
+ )
+
class Book(BookDataModel):
"""a generic book, which can mean either an edition or a work"""
+ merged_model = MergedBook
+
connector = models.ForeignKey("Connector", on_delete=models.PROTECT, null=True)
# book/work metadata
@@ -192,9 +303,13 @@ class Book(BookDataModel):
"""properties of this edition, as a string"""
items = [
self.physical_format if hasattr(self, "physical_format") else None,
- f"{self.languages[0]} language"
- if self.languages and self.languages[0] and self.languages[0] != "English"
- else None,
+ (
+ f"{self.languages[0]} language"
+ if self.languages
+ and self.languages[0]
+ and self.languages[0] != "English"
+ else None
+ ),
str(self.published_date.year) if self.published_date else None,
", ".join(self.publishers) if hasattr(self, "publishers") else None,
]
@@ -212,11 +327,11 @@ class Book(BookDataModel):
if not isinstance(self, (Edition, Work)):
raise ValueError("Books should be added as Editions or Works")
- return super().save(*args, **kwargs)
+ super().save(*args, **kwargs)
def get_remote_id(self):
"""editions and works both use "book" instead of model_name"""
- return f"https://{DOMAIN}/book/{self.id}"
+ return f"{BASE_URL}/book/{self.id}"
def guess_sort_title(self):
"""Get a best-guess sort title for the current book"""
@@ -289,10 +404,11 @@ class Work(OrderedCollectionPageMixin, Book):
def save(self, *args, **kwargs):
"""set some fields on the edition object"""
+ super().save(*args, **kwargs)
+
# set rank
for edition in self.editions.all():
edition.save()
- return super().save(*args, **kwargs)
@property
def default_edition(self):
@@ -398,33 +514,48 @@ class Edition(Book):
# max rank is 9
return rank
- def save(self, *args: Any, **kwargs: Any) -> None:
+ def save(
+ self, *args: Any, update_fields: Optional[Iterable[str]] = None, **kwargs: Any
+ ) -> None:
"""set some fields on the edition object"""
# calculate isbn 10/13
- if self.isbn_13 and self.isbn_13[:3] == "978" and not self.isbn_10:
+ if (
+ self.isbn_10 is None
+ and self.isbn_13 is not None
+ and self.isbn_13[:3] == "978"
+ ):
self.isbn_10 = isbn_13_to_10(self.isbn_13)
- if self.isbn_10 and not self.isbn_13:
+ update_fields = add_update_fields(update_fields, "isbn_10")
+ if self.isbn_13 is None and self.isbn_10 is not None:
self.isbn_13 = isbn_10_to_13(self.isbn_10)
+ update_fields = add_update_fields(update_fields, "isbn_13")
# normalize isbn format
- if self.isbn_10:
+ if self.isbn_10 is not None:
self.isbn_10 = normalize_isbn(self.isbn_10)
- if self.isbn_13:
+ if self.isbn_13 is not None:
self.isbn_13 = normalize_isbn(self.isbn_13)
# set rank
- self.edition_rank = self.get_rank()
-
- # clear author cache
- if self.id:
- for author_id in self.authors.values_list("id", flat=True):
- cache.delete(f"author-books-{author_id}")
+ if (new := self.get_rank()) != self.edition_rank:
+ self.edition_rank = new
+ update_fields = add_update_fields(update_fields, "edition_rank")
# Create sort title by removing articles from title
if self.sort_title in [None, ""]:
self.sort_title = self.guess_sort_title()
+ update_fields = add_update_fields(update_fields, "sort_title")
- return super().save(*args, **kwargs)
+ super().save(*args, update_fields=update_fields, **kwargs)
+
+ # clear author cache
+ if self.id:
+ cache.delete_many(
+ [
+ f"author-books-{author_id}"
+ for author_id in self.authors.values_list("id", flat=True)
+ ]
+ )
@transaction.atomic
def repair(self):
diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py
index 2f32cbd29..a42562f30 100644
--- a/bookwyrm/models/bookwyrm_export_job.py
+++ b/bookwyrm/models/bookwyrm_export_job.py
@@ -1,229 +1,341 @@
"""Export user account to tar.gz file for import into another Bookwyrm instance"""
-import dataclasses
import logging
-from uuid import uuid4
+import os
-from django.db.models import FileField
-from django.db.models import Q
+from boto3.session import Session as BotoSession
+from s3_tar import S3Tar
+
+from django.db.models import BooleanField, FileField, JSONField
from django.core.serializers.json import DjangoJSONEncoder
from django.core.files.base import ContentFile
+from django.core.files.storage import storages
-from bookwyrm.models import AnnualGoal, ReadThrough, ShelfBook, List, ListItem
+from bookwyrm import settings
+
+from bookwyrm.models import AnnualGoal, ReadThrough, ShelfBook, ListItem
from bookwyrm.models import Review, Comment, Quotation
from bookwyrm.models import Edition
from bookwyrm.models import UserFollows, User, UserBlocks
-from bookwyrm.models.job import ParentJob, ParentTask
+from bookwyrm.models.job import ParentJob
from bookwyrm.tasks import app, IMPORTS
from bookwyrm.utils.tar import BookwyrmTarFile
logger = logging.getLogger(__name__)
+class BookwyrmAwsSession(BotoSession):
+ """a boto session that always uses settings.AWS_S3_ENDPOINT_URL"""
+
+ def client(self, *args, **kwargs):
+ kwargs["endpoint_url"] = settings.AWS_S3_ENDPOINT_URL
+ return super().client("s3", *args, **kwargs)
+
+
+def select_exports_storage():
+ """callable to allow for dependency on runtime configuration"""
+ return storages["exports"]
+
+
class BookwyrmExportJob(ParentJob):
"""entry for a specific request to export a bookwyrm user"""
- export_data = FileField(null=True)
+ export_data = FileField(null=True, storage=select_exports_storage)
+ export_json = JSONField(null=True, encoder=DjangoJSONEncoder)
+ json_completed = BooleanField(default=False)
def start_job(self):
- """Start the job"""
- start_export_task.delay(job_id=self.id, no_children=True)
+ """schedule the first task"""
- return self
+ task = create_export_json_task.delay(job_id=self.id)
+ self.task_id = task.id
+ self.save(update_fields=["task_id"])
-@app.task(queue=IMPORTS, base=ParentTask)
-def start_export_task(**kwargs):
- """trigger the child tasks for each row"""
- job = BookwyrmExportJob.objects.get(id=kwargs["job_id"])
+@app.task(queue=IMPORTS)
+def create_export_json_task(job_id):
+ """create the JSON data for the export"""
+
+ job = BookwyrmExportJob.objects.get(id=job_id)
# don't start the job if it was stopped from the UI
if job.complete:
return
+
try:
- # This is where ChildJobs get made
- job.export_data = ContentFile(b"", str(uuid4()))
- json_data = json_export(job.user)
- tar_export(json_data, job.user, job.export_data)
- job.save(update_fields=["export_data"])
+ job.set_status("active")
+
+ # generate JSON structure
+ job.export_json = export_json(job.user)
+ job.save(update_fields=["export_json"])
+
+ # create archive in separate task
+ create_archive_task.delay(job_id=job.id)
except Exception as err: # pylint: disable=broad-except
- logger.exception("User Export Job %s Failed with error: %s", job.id, err)
+ logger.exception(
+ "create_export_json_task for %s failed with error: %s", job, err
+ )
job.set_status("failed")
- job.set_status("complete")
+
+def archive_file_location(file, directory="") -> str:
+ """get the relative location of a file inside the archive"""
+ return os.path.join(directory, file.name)
-def tar_export(json_data: str, user, file):
- """wrap the export information in a tar file"""
- file.open("wb")
- with BookwyrmTarFile.open(mode="w:gz", fileobj=file) as tar:
- tar.write_bytes(json_data.encode("utf-8"))
+def add_file_to_s3_tar(s3_tar: S3Tar, storage, file, directory=""):
+ """
+ add file to S3Tar inside directory, keeping any directories under its
+ storage location
+ """
+ s3_tar.add_file(
+ os.path.join(storage.location, file.name),
+ folder=os.path.dirname(archive_file_location(file, directory=directory)),
+ )
- # Add avatar image if present
- if getattr(user, "avatar", False):
- tar.add_image(user.avatar, filename="avatar")
+@app.task(queue=IMPORTS)
+def create_archive_task(job_id):
+ """create the archive containing the JSON file and additional files"""
+
+ job = BookwyrmExportJob.objects.get(id=job_id)
+
+ # don't start the job if it was stopped from the UI
+ if job.complete:
+ return
+
+ try:
+ export_task_id = str(job.task_id)
+ archive_filename = f"{export_task_id}.tar.gz"
+ export_json_bytes = DjangoJSONEncoder().encode(job.export_json).encode("utf-8")
+
+ user = job.user
editions = get_books_for_user(user)
- for book in editions:
- if getattr(book, "cover", False):
- tar.add_image(book.cover)
- file.close()
+ if settings.USE_S3:
+ # Storage for writing temporary files
+ exports_storage = storages["exports"]
+
+ # Handle for creating the final archive
+ s3_tar = S3Tar(
+ exports_storage.bucket_name,
+ os.path.join(exports_storage.location, archive_filename),
+ session=BookwyrmAwsSession(),
+ )
+
+ # Save JSON file to a temporary location
+ export_json_tmp_file = os.path.join(export_task_id, "archive.json")
+ exports_storage.save(
+ export_json_tmp_file,
+ ContentFile(export_json_bytes),
+ )
+ s3_tar.add_file(
+ os.path.join(exports_storage.location, export_json_tmp_file)
+ )
+
+ # Add images to TAR
+ images_storage = storages["default"]
+
+ if user.avatar:
+ add_file_to_s3_tar(s3_tar, images_storage, user.avatar)
+
+ for edition in editions:
+ if edition.cover:
+ add_file_to_s3_tar(
+ s3_tar, images_storage, edition.cover, directory="images"
+ )
+
+ # Create archive and store file name
+ s3_tar.tar()
+ job.export_data = archive_filename
+ job.save(update_fields=["export_data"])
+
+ # Delete temporary files
+ exports_storage.delete(export_json_tmp_file)
+
+ else:
+ job.export_data = archive_filename
+ with job.export_data.open("wb") as tar_file:
+ with BookwyrmTarFile.open(mode="w:gz", fileobj=tar_file) as tar:
+ # save json file
+ tar.write_bytes(export_json_bytes)
+
+ # Add avatar image if present
+ if user.avatar:
+ tar.add_image(user.avatar)
+
+ for edition in editions:
+ if edition.cover:
+ tar.add_image(edition.cover, directory="images")
+ job.save(update_fields=["export_data"])
+
+ job.set_status("completed")
+
+ except Exception as err: # pylint: disable=broad-except
+ logger.exception("create_archive_task for %s failed with error: %s", job, err)
+ job.set_status("failed")
-def json_export(
- user,
-): # pylint: disable=too-many-locals, too-many-statements, too-many-branches
- """Generate an export for a user"""
+def export_json(user: User):
+ """create export JSON"""
+ data = export_user(user) # in the root of the JSON structure
+ data["settings"] = export_settings(user)
+ data["goals"] = export_goals(user)
+ data["books"] = export_books(user)
+ data["saved_lists"] = export_saved_lists(user)
+ data["follows"] = export_follows(user)
+ data["blocks"] = export_blocks(user)
+ return data
- # User as AP object
- exported_user = user.to_activity()
- # I don't love this but it prevents a JSON encoding error
- # when there is no user image
- if exported_user.get("icon") in (None, dataclasses.MISSING):
- exported_user["icon"] = {}
+
+def export_user(user: User):
+ """export user data"""
+ data = user.to_activity()
+ if user.avatar:
+ data["icon"]["url"] = archive_file_location(user.avatar)
else:
- # change the URL to be relative to the JSON file
- file_type = exported_user["icon"]["url"].rsplit(".", maxsplit=1)[-1]
- filename = f"avatar.{file_type}"
- exported_user["icon"]["url"] = filename
+ data["icon"] = {}
+ return data
- # Additional settings - can't be serialized as AP
+
+def export_settings(user: User):
+ """Additional settings - can't be serialized as AP"""
vals = [
"show_goal",
"preferred_timezone",
"default_post_privacy",
"show_suggested_users",
]
- exported_user["settings"] = {}
- for k in vals:
- exported_user["settings"][k] = getattr(user, k)
+ return {k: getattr(user, k) for k in vals}
- # Reading goals - can't be serialized as AP
- reading_goals = AnnualGoal.objects.filter(user=user).distinct()
- exported_user["goals"] = []
- for goal in reading_goals:
- exported_user["goals"].append(
- {"goal": goal.goal, "year": goal.year, "privacy": goal.privacy}
- )
- # Reading history - can't be serialized as AP
- readthroughs = ReadThrough.objects.filter(user=user).distinct().values()
- readthroughs = list(readthroughs)
+def export_saved_lists(user: User):
+ """add user saved lists to export JSON"""
+ return [l.remote_id for l in user.saved_lists.all()]
- # Books
- editions = get_books_for_user(user)
- exported_user["books"] = []
- for edition in editions:
- book = {}
- book["work"] = edition.parent_work.to_activity()
- book["edition"] = edition.to_activity()
-
- if book["edition"].get("cover"):
- # change the URL to be relative to the JSON file
- filename = book["edition"]["cover"]["url"].rsplit("/", maxsplit=1)[-1]
- book["edition"]["cover"]["url"] = f"covers/{filename}"
-
- # authors
- book["authors"] = []
- for author in edition.authors.all():
- book["authors"].append(author.to_activity())
-
- # Shelves this book is on
- # Every ShelfItem is this book so we don't other serializing
- book["shelves"] = []
- shelf_books = (
- ShelfBook.objects.select_related("shelf")
- .filter(user=user, book=edition)
- .distinct()
- )
-
- for shelfbook in shelf_books:
- book["shelves"].append(shelfbook.shelf.to_activity())
-
- # Lists and ListItems
- # ListItems include "notes" and "approved" so we need them
- # even though we know it's this book
- book["lists"] = []
- list_items = ListItem.objects.filter(book=edition, user=user).distinct()
-
- for item in list_items:
- list_info = item.book_list.to_activity()
- list_info[
- "privacy"
- ] = item.book_list.privacy # this isn't serialized so we add it
- list_info["list_item"] = item.to_activity()
- book["lists"].append(list_info)
-
- # Statuses
- # Can't use select_subclasses here because
- # we need to filter on the "book" value,
- # which is not available on an ordinary Status
- for status in ["comments", "quotations", "reviews"]:
- book[status] = []
-
- comments = Comment.objects.filter(user=user, book=edition).all()
- for status in comments:
- obj = status.to_activity()
- obj["progress"] = status.progress
- obj["progress_mode"] = status.progress_mode
- book["comments"].append(obj)
-
- quotes = Quotation.objects.filter(user=user, book=edition).all()
- for status in quotes:
- obj = status.to_activity()
- obj["position"] = status.position
- obj["endposition"] = status.endposition
- obj["position_mode"] = status.position_mode
- book["quotations"].append(obj)
-
- reviews = Review.objects.filter(user=user, book=edition).all()
- for status in reviews:
- obj = status.to_activity()
- book["reviews"].append(obj)
-
- # readthroughs can't be serialized to activity
- book_readthroughs = (
- ReadThrough.objects.filter(user=user, book=edition).distinct().values()
- )
- book["readthroughs"] = list(book_readthroughs)
-
- # append everything
- exported_user["books"].append(book)
-
- # saved book lists - just the remote id
- saved_lists = List.objects.filter(id__in=user.saved_lists.all()).distinct()
- exported_user["saved_lists"] = [l.remote_id for l in saved_lists]
-
- # follows - just the remote id
+def export_follows(user: User):
+ """add user follows to export JSON"""
follows = UserFollows.objects.filter(user_subject=user).distinct()
following = User.objects.filter(userfollows_user_object__in=follows).distinct()
- exported_user["follows"] = [f.remote_id for f in following]
+ return [f.remote_id for f in following]
- # blocks - just the remote id
+
+def export_blocks(user: User):
+ """add user blocks to export JSON"""
blocks = UserBlocks.objects.filter(user_subject=user).distinct()
blocking = User.objects.filter(userblocks_user_object__in=blocks).distinct()
+ return [b.remote_id for b in blocking]
- exported_user["blocks"] = [b.remote_id for b in blocking]
- return DjangoJSONEncoder().encode(exported_user)
+def export_goals(user: User):
+ """add user reading goals to export JSON"""
+ reading_goals = AnnualGoal.objects.filter(user=user).distinct()
+ return [
+ {"goal": goal.goal, "year": goal.year, "privacy": goal.privacy}
+ for goal in reading_goals
+ ]
+
+
+def export_books(user: User):
+ """add books to export JSON"""
+ editions = get_books_for_user(user)
+ return [export_book(user, edition) for edition in editions]
+
+
+def export_book(user: User, edition: Edition):
+ """add book to export JSON"""
+ data = {}
+ data["work"] = edition.parent_work.to_activity()
+ data["edition"] = edition.to_activity()
+
+ if edition.cover:
+ data["edition"]["cover"]["url"] = archive_file_location(
+ edition.cover, directory="images"
+ )
+
+ # authors
+ data["authors"] = [author.to_activity() for author in edition.authors.all()]
+
+ # Shelves this book is on
+ # Every ShelfItem is this book so we don't other serializing
+ shelf_books = (
+ ShelfBook.objects.select_related("shelf")
+ .filter(user=user, book=edition)
+ .distinct()
+ )
+ data["shelves"] = [shelfbook.shelf.to_activity() for shelfbook in shelf_books]
+
+ # Lists and ListItems
+ # ListItems include "notes" and "approved" so we need them
+ # even though we know it's this book
+ list_items = ListItem.objects.filter(book=edition, user=user).distinct()
+
+ data["lists"] = []
+ for item in list_items:
+ list_info = item.book_list.to_activity()
+ list_info[
+ "privacy"
+ ] = item.book_list.privacy # this isn't serialized so we add it
+ list_info["list_item"] = item.to_activity()
+ data["lists"].append(list_info)
+
+ # Statuses
+ # Can't use select_subclasses here because
+ # we need to filter on the "book" value,
+ # which is not available on an ordinary Status
+ for status in ["comments", "quotations", "reviews"]:
+ data[status] = []
+
+ comments = Comment.objects.filter(user=user, book=edition).all()
+ for status in comments:
+ obj = status.to_activity()
+ obj["progress"] = status.progress
+ obj["progress_mode"] = status.progress_mode
+ data["comments"].append(obj)
+
+ quotes = Quotation.objects.filter(user=user, book=edition).all()
+ for status in quotes:
+ obj = status.to_activity()
+ obj["position"] = status.position
+ obj["endposition"] = status.endposition
+ obj["position_mode"] = status.position_mode
+ data["quotations"].append(obj)
+
+ reviews = Review.objects.filter(user=user, book=edition).all()
+ data["reviews"] = [status.to_activity() for status in reviews]
+
+ # readthroughs can't be serialized to activity
+ book_readthroughs = (
+ ReadThrough.objects.filter(user=user, book=edition).distinct().values()
+ )
+ data["readthroughs"] = list(book_readthroughs)
+ return data
def get_books_for_user(user):
- """Get all the books and editions related to a user"""
+ """
+ Get all the books and editions related to a user.
- editions = (
- Edition.objects.select_related("parent_work")
- .filter(
- Q(shelves__user=user)
- | Q(readthrough__user=user)
- | Q(review__user=user)
- | Q(list__user=user)
- | Q(comment__user=user)
- | Q(quotation__user=user)
- )
- .distinct()
+ We use union() instead of Q objects because it creates
+ multiple simple queries in stead of a much more complex DB query
+ that can time out.
+
+ """
+
+ shelf_eds = Edition.objects.select_related("parent_work").filter(shelves__user=user)
+ rt_eds = Edition.objects.select_related("parent_work").filter(
+ readthrough__user=user
+ )
+ review_eds = Edition.objects.select_related("parent_work").filter(review__user=user)
+ list_eds = Edition.objects.select_related("parent_work").filter(list__user=user)
+ comment_eds = Edition.objects.select_related("parent_work").filter(
+ comment__user=user
+ )
+ quote_eds = Edition.objects.select_related("parent_work").filter(
+ quotation__user=user
)
+ editions = shelf_eds.union(rt_eds, review_eds, list_eds, comment_eds, quote_eds)
+
return editions
diff --git a/bookwyrm/models/bookwyrm_import_job.py b/bookwyrm/models/bookwyrm_import_job.py
index 9a11fd932..5229430eb 100644
--- a/bookwyrm/models/bookwyrm_import_job.py
+++ b/bookwyrm/models/bookwyrm_import_job.py
@@ -42,20 +42,23 @@ def start_import_task(**kwargs):
try:
archive_file.open("rb")
with BookwyrmTarFile.open(mode="r:gz", fileobj=archive_file) as tar:
- job.import_data = json.loads(tar.read("archive.json").decode("utf-8"))
+ json_filename = next(
+ filter(lambda n: n.startswith("archive"), tar.getnames())
+ )
+ job.import_data = json.loads(tar.read(json_filename).decode("utf-8"))
if "include_user_profile" in job.required:
update_user_profile(job.user, tar, job.import_data)
if "include_user_settings" in job.required:
update_user_settings(job.user, job.import_data)
if "include_goals" in job.required:
- update_goals(job.user, job.import_data.get("goals"))
+ update_goals(job.user, job.import_data.get("goals", []))
if "include_saved_lists" in job.required:
- upsert_saved_lists(job.user, job.import_data.get("saved_lists"))
+ upsert_saved_lists(job.user, job.import_data.get("saved_lists", []))
if "include_follows" in job.required:
- upsert_follows(job.user, job.import_data.get("follows"))
+ upsert_follows(job.user, job.import_data.get("follows", []))
if "include_blocks" in job.required:
- upsert_user_blocks(job.user, job.import_data.get("blocks"))
+ upsert_user_blocks(job.user, job.import_data.get("blocks", []))
process_books(job, tar)
@@ -212,7 +215,7 @@ def upsert_statuses(user, cls, data, book_remote_id):
instance.save() # save and broadcast
else:
- logger.info("User does not have permission to import statuses")
+ logger.warning("User does not have permission to import statuses")
def upsert_lists(user, lists, book_id):
diff --git a/bookwyrm/models/connector.py b/bookwyrm/models/connector.py
index 99e73ab37..f4b5be04c 100644
--- a/bookwyrm/models/connector.py
+++ b/bookwyrm/models/connector.py
@@ -11,7 +11,7 @@ ConnectorFiles = models.TextChoices("ConnectorFiles", CONNECTORS)
class Connector(BookWyrmModel):
"""book data source connectors"""
- identifier = models.CharField(max_length=255, unique=True)
+ identifier = models.CharField(max_length=255, unique=True) # domain
priority = models.IntegerField(default=2)
name = models.CharField(max_length=255, null=True, blank=True)
connector_file = models.CharField(max_length=255, choices=ConnectorFiles.choices)
diff --git a/bookwyrm/models/federated_server.py b/bookwyrm/models/federated_server.py
index e1081ed45..5e08fc11d 100644
--- a/bookwyrm/models/federated_server.py
+++ b/bookwyrm/models/federated_server.py
@@ -16,7 +16,7 @@ FederationStatus = [
class FederatedServer(BookWyrmModel):
"""store which servers we federate with"""
- server_name = models.CharField(max_length=255, unique=True)
+ server_name = models.CharField(max_length=255, unique=True) # domain
status = models.CharField(
max_length=255, default="federated", choices=FederationStatus
)
@@ -64,5 +64,4 @@ class FederatedServer(BookWyrmModel):
def is_blocked(cls, url: str) -> bool:
"""look up if a domain is blocked"""
url = urlparse(url)
- domain = url.netloc
- return cls.objects.filter(server_name=domain, status="blocked").exists()
+ return cls.objects.filter(server_name=url.hostname, status="blocked").exists()
diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py
index 6643bdc19..75d3acf76 100644
--- a/bookwyrm/models/fields.py
+++ b/bookwyrm/models/fields.py
@@ -193,8 +193,7 @@ class UsernameField(ActivitypubFieldMixin, models.CharField):
def __init__(self, activitypub_field="preferredUsername", **kwargs):
self.activitypub_field = activitypub_field
- # I don't totally know why pylint is mad at this, but it makes it work
- super(ActivitypubFieldMixin, self).__init__( # pylint: disable=bad-super-call
+ super(ActivitypubFieldMixin, self).__init__(
_("username"),
max_length=150,
unique=True,
@@ -234,7 +233,6 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
def __init__(self, *args, **kwargs):
super().__init__(*args, max_length=255, choices=PrivacyLevels, default="public")
- # pylint: disable=invalid-name
def set_field_from_activity(
self, instance, data, overwrite=True, allow_external_connections=True
):
@@ -276,7 +274,6 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
if hasattr(instance, "mention_users"):
mentions = [u.remote_id for u in instance.mention_users.all()]
# this is a link to the followers list
- # pylint: disable=protected-access
followers = instance.user.followers_url
if instance.privacy == "public":
activity["to"] = [self.public]
@@ -444,7 +441,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
self.alt_field = alt_field
super().__init__(*args, **kwargs)
- # pylint: disable=arguments-differ,arguments-renamed,too-many-arguments
+ # pylint: disable=arguments-renamed,too-many-arguments
def set_field_from_activity(
self, instance, data, save=True, overwrite=True, allow_external_connections=True
):
diff --git a/bookwyrm/models/group.py b/bookwyrm/models/group.py
index d02b56ab1..40a32b5dc 100644
--- a/bookwyrm/models/group.py
+++ b/bookwyrm/models/group.py
@@ -1,7 +1,7 @@
""" do book related things with other users """
from django.db import models, IntegrityError, transaction
from django.db.models import Q
-from bookwyrm.settings import DOMAIN
+from bookwyrm.settings import BASE_URL
from .base_model import BookWyrmModel
from . import fields
from .relationship import UserBlocks
@@ -17,7 +17,7 @@ class Group(BookWyrmModel):
def get_remote_id(self):
"""don't want the user to be in there in this case"""
- return f"https://{DOMAIN}/group/{self.id}"
+ return f"{BASE_URL}/group/{self.id}"
@classmethod
def followers_filter(cls, queryset, viewer):
diff --git a/bookwyrm/models/hashtag.py b/bookwyrm/models/hashtag.py
index 7894a3528..5126f012d 100644
--- a/bookwyrm/models/hashtag.py
+++ b/bookwyrm/models/hashtag.py
@@ -2,18 +2,19 @@
from bookwyrm import activitypub
from .activitypub_mixin import ActivitypubMixin
from .base_model import BookWyrmModel
-from .fields import CICharField
+from .fields import CharField
class Hashtag(ActivitypubMixin, BookWyrmModel):
"a hashtag which can be used in statuses"
- name = CICharField(
+ name = CharField(
max_length=256,
blank=False,
null=False,
activitypub_field="name",
deduplication_field=True,
+ db_collation="case_insensitive",
)
name_field = "name"
diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py
index f5d86ad2e..5a6ba3f51 100644
--- a/bookwyrm/models/import_job.py
+++ b/bookwyrm/models/import_job.py
@@ -4,6 +4,7 @@ import math
import re
import dateutil.parser
+from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@@ -59,6 +60,7 @@ class ImportJob(models.Model):
created_date = models.DateTimeField(default=timezone.now)
updated_date = models.DateTimeField(default=timezone.now)
include_reviews: bool = models.BooleanField(default=True)
+ create_shelves: bool = models.BooleanField(default=True)
mappings = models.JSONField()
source = models.CharField(max_length=100)
privacy = models.CharField(max_length=255, default="public", choices=PrivacyLevels)
@@ -245,11 +247,26 @@ class ImportItem(models.Model):
"""the goodreads shelf field"""
return self.normalized_data.get("shelf")
+ @property
+ def shelf_name(self):
+ """the goodreads shelf field"""
+ return self.normalized_data.get("shelf_name")
+
@property
def review(self):
"""a user-written review, to be imported with the book data"""
return self.normalized_data.get("review_body")
+ @property
+ def review_name(self):
+ """a user-written review name, to be imported with the book data"""
+ return self.normalized_data.get("review_name")
+
+ @property
+ def review_published(self):
+ """date the review was published - included in BookWyrm export csv"""
+ return self.normalized_data.get("review_published", None)
+
@property
def rating(self):
"""x/5 star rating for a book"""
@@ -352,7 +369,7 @@ def import_item_task(item_id):
try:
item.resolve()
- except Exception as err: # pylint: disable=broad-except
+ except Exception as err:
item.fail_reason = _("Error loading book")
item.save()
item.update_job()
@@ -368,7 +385,7 @@ def import_item_task(item_id):
item.update_job()
-def handle_imported_book(item):
+def handle_imported_book(item): # pylint: disable=too-many-branches
"""process a csv and then post about it"""
job = item.job
if job.complete:
@@ -385,13 +402,31 @@ def handle_imported_book(item):
item.book = item.book.edition
existing_shelf = ShelfBook.objects.filter(book=item.book, user=user).exists()
+ if job.create_shelves and item.shelf and not existing_shelf:
+ # shelve the book if it hasn't been shelved already
- # 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()
+ shelfname = getattr(item, "shelf_name", item.shelf)
+
+ try:
+ shelf = Shelf.objects.get(name=shelfname, user=user)
+ except ObjectDoesNotExist:
+ try:
+ shelf = Shelf.objects.get(identifier=item.shelf, user=user)
+ except ObjectDoesNotExist:
+
+ shelf = Shelf.objects.create(
+ user=user,
+ identifier=item.shelf,
+ name=shelfname,
+ privacy=job.privacy,
+ )
+
ShelfBook(
- book=item.book, shelf=desired_shelf, user=user, shelved_date=shelved_date
+ book=item.book,
+ shelf=shelf,
+ user=user,
+ shelved_date=shelved_date,
).save(priority=IMPORT_TRIGGERED)
for read in item.reads:
@@ -408,19 +443,25 @@ def handle_imported_book(item):
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
+ # we don't necessarily know the publication date of the review,
+ # but "now" is a bad guess unless we have no choice
+
+ published_date_guess = (
+ item.review_published or item.date_read or item.date_added or timezone.now()
+ )
if item.review:
+
# pylint: disable=consider-using-f-string
review_title = "Review of {!r} on {!r}".format(
item.book.title,
job.source,
)
+ review_name = getattr(item, "review_name", review_title)
+
review = Review.objects.filter(
user=user,
book=item.book,
- name=review_title,
+ name=review_name,
rating=item.rating,
published_date=published_date_guess,
).first()
@@ -428,7 +469,7 @@ def handle_imported_book(item):
review = Review(
user=user,
book=item.book,
- name=review_title,
+ name=review_name,
content=item.review,
rating=item.rating,
published_date=published_date_guess,
diff --git a/bookwyrm/models/job.py b/bookwyrm/models/job.py
index 4f5cb2093..5a2653571 100644
--- a/bookwyrm/models/job.py
+++ b/bookwyrm/models/job.py
@@ -135,8 +135,7 @@ class ParentJob(Job):
)
app.control.revoke(list(tasks))
- for task in self.pending_child_jobs:
- task.update(status=self.Status.STOPPED)
+ self.pending_child_jobs.update(status=self.Status.STOPPED)
@property
def has_completed(self):
@@ -248,7 +247,7 @@ class SubTask(app.Task):
"""
def before_start(
- self, task_id, args, kwargs
+ self, task_id, *args, **kwargs
): # pylint: disable=no-self-use, unused-argument
"""Handler called before the task starts. Override.
@@ -272,7 +271,7 @@ class SubTask(app.Task):
child_job.set_status(ChildJob.Status.ACTIVE)
def on_success(
- self, retval, task_id, args, kwargs
+ self, retval, task_id, *args, **kwargs
): # pylint: disable=no-self-use, unused-argument
"""Run by the worker if the task executes successfully. Override.
diff --git a/bookwyrm/models/link.py b/bookwyrm/models/link.py
index d334a9d29..4519f0c81 100644
--- a/bookwyrm/models/link.py
+++ b/bookwyrm/models/link.py
@@ -1,4 +1,5 @@
""" outlink data """
+from typing import Optional, Iterable
from urllib.parse import urlparse
from django.core.exceptions import PermissionDenied
@@ -6,6 +7,7 @@ from django.db import models
from django.utils.translation import gettext_lazy as _
from bookwyrm import activitypub
+from bookwyrm.utils.db import add_update_fields
from .activitypub_mixin import ActivitypubMixin
from .base_model import BookWyrmModel
from . import fields
@@ -34,17 +36,19 @@ class Link(ActivitypubMixin, BookWyrmModel):
"""link name via the associated domain"""
return self.domain.name
- def save(self, *args, **kwargs):
+ def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
"""create a link"""
# get or create the associated domain
if not self.domain:
- domain = urlparse(self.url).netloc
+ domain = urlparse(self.url).hostname
self.domain, _ = LinkDomain.objects.get_or_create(domain=domain)
+ update_fields = add_update_fields(update_fields, "domain")
# this is never broadcast, the owning model broadcasts an update
if "broadcast" in kwargs:
del kwargs["broadcast"]
- return super().save(*args, **kwargs)
+
+ super().save(*args, update_fields=update_fields, **kwargs)
AvailabilityChoices = [
@@ -88,8 +92,10 @@ class LinkDomain(BookWyrmModel):
return
raise PermissionDenied()
- def save(self, *args, **kwargs):
+ def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
"""set a default name"""
if not self.name:
self.name = self.domain
- super().save(*args, **kwargs)
+ update_fields = add_update_fields(update_fields, "name")
+
+ super().save(*args, update_fields=update_fields, **kwargs)
diff --git a/bookwyrm/models/list.py b/bookwyrm/models/list.py
index 63dd5b23f..df7e8162c 100644
--- a/bookwyrm/models/list.py
+++ b/bookwyrm/models/list.py
@@ -1,4 +1,5 @@
""" make a list of books!! """
+from typing import Optional, Iterable
import uuid
from django.core.exceptions import PermissionDenied
@@ -7,7 +8,8 @@ from django.db.models import Q
from django.utils import timezone
from bookwyrm import activitypub
-from bookwyrm.settings import DOMAIN
+from bookwyrm.settings import BASE_URL
+from bookwyrm.utils.db import add_update_fields
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
from .base_model import BookWyrmModel
@@ -50,7 +52,7 @@ class List(OrderedCollectionMixin, BookWyrmModel):
def get_remote_id(self):
"""don't want the user to be in there in this case"""
- return f"https://{DOMAIN}/list/{self.id}"
+ return f"{BASE_URL}/list/{self.id}"
@property
def collection_queryset(self):
@@ -124,11 +126,13 @@ class List(OrderedCollectionMixin, BookWyrmModel):
group=None, curation="closed"
)
- def save(self, *args, **kwargs):
+ def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
"""on save, update embed_key and avoid clash with existing code"""
if not self.embed_key:
self.embed_key = uuid.uuid4()
- super().save(*args, **kwargs)
+ update_fields = add_update_fields(update_fields, "embed_key")
+
+ super().save(*args, update_fields=update_fields, **kwargs)
class ListItem(CollectionItemMixin, BookWyrmModel):
diff --git a/bookwyrm/models/move.py b/bookwyrm/models/move.py
index d6d8ef78f..5038058b7 100644
--- a/bookwyrm/models/move.py
+++ b/bookwyrm/models/move.py
@@ -10,7 +10,7 @@ from .notification import Notification, NotificationType
class Move(ActivityMixin, BookWyrmModel):
- """migrating an activitypub user account"""
+ """migrating an activitypub object"""
user = fields.ForeignKey(
"User", on_delete=models.PROTECT, activitypub_field="actor"
@@ -48,24 +48,21 @@ class MoveUser(Move):
"""update user info and broadcast it"""
# only allow if the source is listed in the target's alsoKnownAs
- if self.user in self.target.also_known_as.all():
- self.user.also_known_as.add(self.target.id)
- self.user.update_active_date()
- self.user.moved_to = self.target.remote_id
- self.user.save(update_fields=["moved_to"])
-
- if self.user.local:
- kwargs[
- "broadcast"
- ] = True # Only broadcast if we are initiating the Move
-
- super().save(*args, **kwargs)
-
- for follower in self.user.followers.all():
- if follower.local:
- Notification.notify(
- follower, self.user, notification_type=NotificationType.MOVE
- )
-
- else:
+ if self.user not in self.target.also_known_as.all():
raise PermissionDenied()
+
+ self.user.also_known_as.add(self.target.id)
+ self.user.update_active_date()
+ self.user.moved_to = self.target.remote_id
+ self.user.save(update_fields=["moved_to"])
+
+ if self.user.local:
+ kwargs["broadcast"] = True # Only broadcast if we are initiating the Move
+
+ super().save(*args, **kwargs)
+
+ for follower in self.user.followers.all():
+ if follower.local:
+ Notification.notify(
+ follower, self.user, notification_type=NotificationType.MOVE
+ )
diff --git a/bookwyrm/models/readthrough.py b/bookwyrm/models/readthrough.py
index 4911c715b..7700b4a87 100644
--- a/bookwyrm/models/readthrough.py
+++ b/bookwyrm/models/readthrough.py
@@ -1,9 +1,13 @@
""" progress in a book """
+from typing import Optional, Iterable
+
from django.core import validators
from django.core.cache import cache
from django.db import models
from django.db.models import F, Q
+from bookwyrm.utils.db import add_update_fields
+
from .base_model import BookWyrmModel
@@ -30,14 +34,17 @@ class ReadThrough(BookWyrmModel):
stopped_date = models.DateTimeField(blank=True, null=True)
is_active = models.BooleanField(default=True)
- def save(self, *args, **kwargs):
+ def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
"""update user active time"""
- cache.delete(f"latest_read_through-{self.user_id}-{self.book_id}")
- self.user.update_active_date()
# an active readthrough must have an unset finish date
if self.finish_date or self.stopped_date:
self.is_active = False
- super().save(*args, **kwargs)
+ update_fields = add_update_fields(update_fields, "is_active")
+
+ super().save(*args, update_fields=update_fields, **kwargs)
+
+ cache.delete(f"latest_read_through-{self.user_id}-{self.book_id}")
+ self.user.update_active_date()
def create_update(self):
"""add update to the readthrough"""
diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py
index 3386a02dc..ed630fbe5 100644
--- a/bookwyrm/models/relationship.py
+++ b/bookwyrm/models/relationship.py
@@ -38,14 +38,16 @@ class UserRelationship(BookWyrmModel):
def save(self, *args, **kwargs):
"""clear the template cache"""
- clear_cache(self.user_subject, self.user_object)
super().save(*args, **kwargs)
+ clear_cache(self.user_subject, self.user_object)
+
def delete(self, *args, **kwargs):
"""clear the template cache"""
- clear_cache(self.user_subject, self.user_object)
super().delete(*args, **kwargs)
+ clear_cache(self.user_subject, self.user_object)
+
class Meta:
"""relationships should be unique"""
@@ -133,7 +135,7 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
status = "follow_request"
activity_serializer = activitypub.Follow
- def save(self, *args, broadcast=True, **kwargs): # pylint: disable=arguments-differ
+ def save(self, *args, broadcast=True, **kwargs):
"""make sure the follow or block relationship doesn't already exist"""
# if there's a request for a follow that already exists, accept it
# without changing the local database state
diff --git a/bookwyrm/models/report.py b/bookwyrm/models/report.py
index 74a9bbe41..64ade3a40 100644
--- a/bookwyrm/models/report.py
+++ b/bookwyrm/models/report.py
@@ -3,7 +3,7 @@ from django.core.exceptions import PermissionDenied
from django.db import models
from django.utils.translation import gettext_lazy as _
-from bookwyrm.settings import DOMAIN
+from bookwyrm.settings import BASE_URL
from .base_model import BookWyrmModel
@@ -46,7 +46,7 @@ class Report(BookWyrmModel):
raise PermissionDenied()
def get_remote_id(self):
- return f"https://{DOMAIN}/settings/reports/{self.id}"
+ return f"{BASE_URL}/settings/reports/{self.id}"
def comment(self, user, note):
"""comment on a report"""
diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py
index 3d92f8d43..0b9ef2b09 100644
--- a/bookwyrm/models/shelf.py
+++ b/bookwyrm/models/shelf.py
@@ -1,13 +1,15 @@
""" puttin' books on shelves """
import re
+from typing import Optional, Iterable
from django.core.cache import cache
from django.core.exceptions import PermissionDenied
from django.db import models
from django.utils import timezone
from bookwyrm import activitypub
-from bookwyrm.settings import DOMAIN
+from bookwyrm.settings import BASE_URL
from bookwyrm.tasks import BROADCAST
+from bookwyrm.utils.db import add_update_fields
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
from .base_model import BookWyrmModel
from . import fields
@@ -44,8 +46,9 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
"""set the identifier"""
super().save(*args, priority=priority, **kwargs)
if not self.identifier:
+ # this needs the auto increment ID from the save() above
self.identifier = self.get_identifier()
- super().save(*args, **kwargs, broadcast=False)
+ super().save(*args, **kwargs, broadcast=False, update_fields={"identifier"})
def get_identifier(self):
"""custom-shelf-123 for the url"""
@@ -71,7 +74,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
@property
def local_path(self):
"""No slugs"""
- return self.get_remote_id().replace(f"https://{DOMAIN}", "")
+ return self.get_remote_id().replace(BASE_URL, "")
def raise_not_deletable(self, viewer):
"""don't let anyone delete a default shelf"""
@@ -100,10 +103,21 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel):
activity_serializer = activitypub.ShelfItem
collection_field = "shelf"
- def save(self, *args, priority=BROADCAST, **kwargs):
+ def save(
+ self,
+ *args,
+ priority=BROADCAST,
+ update_fields: Optional[Iterable[str]] = None,
+ **kwargs,
+ ):
if not self.user:
self.user = self.shelf.user
- if self.id and self.user.local:
+ update_fields = add_update_fields(update_fields, "user")
+
+ is_update = self.id is not None
+ super().save(*args, priority=priority, update_fields=update_fields, **kwargs)
+
+ if is_update and self.user.local:
# remove all caches related to all editions of this book
cache.delete_many(
[
@@ -111,7 +125,6 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel):
for book in self.book.parent_work.editions.all()
]
)
- super().save(*args, priority=priority, **kwargs)
def delete(self, *args, **kwargs):
if self.id and self.user.local:
diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py
index 201a499e5..6c2a73422 100644
--- a/bookwyrm/models/site.py
+++ b/bookwyrm/models/site.py
@@ -1,5 +1,6 @@
""" the particulars for this instance of BookWyrm """
import datetime
+from typing import Optional, Iterable
from urllib.parse import urljoin
import uuid
@@ -12,9 +13,10 @@ from model_utils import FieldTracker
from bookwyrm.connectors.abstract_connector import get_data
from bookwyrm.preview_images import generate_site_preview_image_task
-from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, STATIC_FULL_URL
+from bookwyrm.settings import BASE_URL, ENABLE_PREVIEW_IMAGES, STATIC_FULL_URL
from bookwyrm.settings import RELEASE_API
from bookwyrm.tasks import app, MISC
+from bookwyrm.utils.db import add_update_fields
from .base_model import BookWyrmModel, new_access_code
from .user import User
from .fields import get_absolute_url
@@ -136,16 +138,19 @@ class SiteSettings(SiteModel):
return get_absolute_url(uploaded)
return urljoin(STATIC_FULL_URL, default_path)
- def save(self, *args, **kwargs):
+ def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
"""if require_confirm_email is disabled, make sure no users are pending,
if enabled, make sure invite_question_text is not empty"""
+ if not self.invite_question_text:
+ self.invite_question_text = "What is your favourite book?"
+ update_fields = add_update_fields(update_fields, "invite_question_text")
+
+ super().save(*args, update_fields=update_fields, **kwargs)
+
if not self.require_confirm_email:
User.objects.filter(is_active=False, deactivation_reason="pending").update(
is_active=True, deactivation_reason=None
)
- if not self.invite_question_text:
- self.invite_question_text = "What is your favourite book?"
- super().save(*args, **kwargs)
class Theme(SiteModel):
@@ -188,7 +193,7 @@ class SiteInvite(models.Model):
@property
def link(self):
"""formats the invite link"""
- return f"https://{DOMAIN}/invite/{self.code}"
+ return f"{BASE_URL}/invite/{self.code}"
class InviteRequest(BookWyrmModel):
@@ -235,7 +240,7 @@ class PasswordReset(models.Model):
@property
def link(self):
"""formats the invite link"""
- return f"https://{DOMAIN}/password-reset/{self.code}"
+ return f"{BASE_URL}/password-reset/{self.code}"
# pylint: disable=unused-argument
diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py
index f6235dab6..2b357ebd2 100644
--- a/bookwyrm/models/status.py
+++ b/bookwyrm/models/status.py
@@ -1,6 +1,6 @@
""" models for storing different kinds of Activities """
from dataclasses import MISSING
-from typing import Optional
+from typing import Optional, Iterable
import re
from django.apps import apps
@@ -20,6 +20,7 @@ from model_utils.managers import InheritanceManager
from bookwyrm import activitypub
from bookwyrm.preview_images import generate_edition_preview_image_task
from bookwyrm.settings import ENABLE_PREVIEW_IMAGES
+from bookwyrm.utils.db import add_update_fields
from .activitypub_mixin import ActivitypubMixin, ActivityMixin
from .activitypub_mixin import OrderedCollectionPageMixin
from .base_model import BookWyrmModel
@@ -80,19 +81,24 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
"""default sorting"""
ordering = ("-published_date",)
+ indexes = [
+ models.Index(fields=["remote_id"]),
+ models.Index(fields=["thread_id"]),
+ ]
- def save(self, *args, **kwargs):
+ def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
"""save and notify"""
- if self.reply_parent:
+ if self.thread_id is None and self.reply_parent:
self.thread_id = self.reply_parent.thread_id or self.reply_parent_id
+ update_fields = add_update_fields(update_fields, "thread_id")
- super().save(*args, **kwargs)
+ super().save(*args, update_fields=update_fields, **kwargs)
if not self.reply_parent:
self.thread_id = self.id
super().save(broadcast=False, update_fields=["thread_id"])
- def delete(self, *args, **kwargs): # pylint: disable=unused-argument
+ def delete(self, *args, **kwargs):
""" "delete" a status"""
if hasattr(self, "boosted_status"):
# okay but if it's a boost really delete it
@@ -207,7 +213,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
**kwargs,
).serialize()
- def to_activity_dataclass(self, pure=False): # pylint: disable=arguments-differ
+ def to_activity_dataclass(self, pure=False):
"""return tombstone if the status is deleted"""
if self.deleted:
return activitypub.Tombstone(
@@ -388,10 +394,10 @@ class Quotation(BookStatus):
def _format_position(self) -> Optional[str]:
"""serialize page position"""
beg = self.position
- end = self.endposition or 0
+ end = self.endposition
if self.position_mode != "PG" or not beg:
return None
- return f"pp. {beg}-{end}" if end > beg else f"p. {beg}"
+ return f"pp. {beg}-{end}" if end else f"p. {beg}"
@property
def pure_content(self):
@@ -455,9 +461,10 @@ class Review(BookStatus):
def save(self, *args, **kwargs):
"""clear rating caches"""
+ super().save(*args, **kwargs)
+
if self.book.parent_work:
cache.delete(f"book-rating-{self.book.parent_work.id}")
- super().save(*args, **kwargs)
class ReviewRating(Review):
diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py
index 89fd39b73..93465720e 100644
--- a/bookwyrm/models/user.py
+++ b/bookwyrm/models/user.py
@@ -1,28 +1,31 @@
""" database schema for user data """
+import datetime
import re
+import zoneinfo
+from typing import Optional, Iterable
from urllib.parse import urlparse
from uuid import uuid4
from django.apps import apps
from django.contrib.auth.models import AbstractUser
-from django.contrib.postgres.fields import ArrayField, CICharField
+from django.contrib.postgres.fields import ArrayField as DjangoArrayField
from django.core.exceptions import PermissionDenied, ObjectDoesNotExist
from django.dispatch import receiver
from django.db import models, transaction, IntegrityError
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from model_utils import FieldTracker
-import pytz
from bookwyrm import activitypub
from bookwyrm.connectors import get_data, ConnectorException
from bookwyrm.models.shelf import Shelf
from bookwyrm.models.status import Status
from bookwyrm.preview_images import generate_user_preview_image_task
-from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, USE_HTTPS, LANGUAGES
+from bookwyrm.settings import BASE_URL, ENABLE_PREVIEW_IMAGES, LANGUAGES
from bookwyrm.signatures import create_key_pair
from bookwyrm.tasks import app, MISC
from bookwyrm.utils import regex
+from bookwyrm.utils.db import add_update_fields
from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin
from .base_model import BookWyrmModel, DeactivationReason, new_access_code
from .federated_server import FederatedServer
@@ -42,12 +45,6 @@ def get_feed_filter_choices():
return [f[0] for f in FeedFilterChoices]
-def site_link():
- """helper for generating links to the site"""
- protocol = "https" if USE_HTTPS else "http"
- return f"{protocol}://{DOMAIN}"
-
-
# pylint: disable=too-many-public-methods
class User(OrderedCollectionPageMixin, AbstractUser):
"""a user who wants to read books"""
@@ -81,11 +78,12 @@ class User(OrderedCollectionPageMixin, AbstractUser):
summary = fields.HtmlField(null=True, blank=True)
local = models.BooleanField(default=False)
bookwyrm_user = fields.BooleanField(default=True)
- localname = CICharField(
+ localname = models.CharField(
max_length=255,
null=True,
unique=True,
validators=[fields.validate_localname],
+ db_collation="case_insensitive",
)
# name is your display name, which you can change at will
name = fields.CharField(max_length=100, null=True, blank=True)
@@ -143,7 +141,6 @@ class User(OrderedCollectionPageMixin, AbstractUser):
hide_follows = fields.BooleanField(default=False)
# migration fields
-
moved_to = fields.RemoteIdField(
null=True, unique=False, activitypub_field="movedTo", deduplication_field=False
)
@@ -160,9 +157,10 @@ class User(OrderedCollectionPageMixin, AbstractUser):
show_suggested_users = models.BooleanField(default=True)
discoverable = fields.BooleanField(default=False)
show_guided_tour = models.BooleanField(default=True)
+ show_ratings = models.BooleanField(default=True)
# feed options
- feed_status_types = ArrayField(
+ feed_status_types = DjangoArrayField(
models.CharField(max_length=10, blank=False, choices=FeedFilterChoices),
size=8,
default=get_feed_filter_choices,
@@ -171,8 +169,8 @@ class User(OrderedCollectionPageMixin, AbstractUser):
summary_keys = models.JSONField(null=True)
preferred_timezone = models.CharField(
- choices=[(str(tz), str(tz)) for tz in pytz.all_timezones],
- default=str(pytz.utc),
+ choices=[(str(tz), str(tz)) for tz in sorted(zoneinfo.available_timezones())],
+ default=str(datetime.timezone.utc),
max_length=255,
)
preferred_language = models.CharField(
@@ -198,6 +196,14 @@ class User(OrderedCollectionPageMixin, AbstractUser):
hotp_secret = models.CharField(max_length=32, default=None, blank=True, null=True)
hotp_count = models.IntegerField(default=0, blank=True, null=True)
+ class Meta(AbstractUser.Meta):
+ """indexes"""
+
+ indexes = [
+ models.Index(fields=["username"]),
+ models.Index(fields=["is_active", "local"]),
+ ]
+
@property
def active_follower_requests(self):
"""Follow requests from active users"""
@@ -206,8 +212,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
@property
def confirmation_link(self):
"""helper for generating confirmation links"""
- link = site_link()
- return f"{link}/confirm-email/{self.confirmation_code}"
+ return f"{BASE_URL}/confirm-email/{self.confirmation_code}"
@property
def following_link(self):
@@ -326,6 +331,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
"https://w3id.org/security/v1",
{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
+ "Hashtag": "as:Hashtag",
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value",
@@ -335,13 +341,14 @@ class User(OrderedCollectionPageMixin, AbstractUser):
]
return activity_object
- def save(self, *args, **kwargs):
+ def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
"""populate fields for new local users"""
created = not bool(self.id)
if not self.local and not re.match(regex.FULL_USERNAME, self.username):
# generate a username that uses the domain (webfinger format)
actor_parts = urlparse(self.remote_id)
- self.username = f"{self.username}@{actor_parts.netloc}"
+ self.username = f"{self.username}@{actor_parts.hostname}"
+ update_fields = add_update_fields(update_fields, "username")
# this user already exists, no need to populate fields
if not created:
@@ -350,26 +357,34 @@ class User(OrderedCollectionPageMixin, AbstractUser):
elif not self.deactivation_date:
self.deactivation_date = timezone.now()
- super().save(*args, **kwargs)
+ super().save(*args, update_fields=update_fields, **kwargs)
return
# this is a new remote user, we need to set their remote server field
if not self.local:
- super().save(*args, **kwargs)
+ super().save(*args, update_fields=update_fields, **kwargs)
transaction.on_commit(lambda: set_remote_server(self.id))
return
with transaction.atomic():
# populate fields for local users
- link = site_link()
- self.remote_id = f"{link}/user/{self.localname}"
+ self.remote_id = f"{BASE_URL}/user/{self.localname}"
self.followers_url = f"{self.remote_id}/followers"
self.inbox = f"{self.remote_id}/inbox"
- self.shared_inbox = f"{link}/inbox"
+ self.shared_inbox = f"{BASE_URL}/inbox"
self.outbox = f"{self.remote_id}/outbox"
+ update_fields = add_update_fields(
+ update_fields,
+ "remote_id",
+ "followers_url",
+ "inbox",
+ "shared_inbox",
+ "outbox",
+ )
+
# an id needs to be set before we can proceed with related models
- super().save(*args, **kwargs)
+ super().save(*args, update_fields=update_fields, **kwargs)
# make users editors by default
try:
@@ -394,7 +409,6 @@ class User(OrderedCollectionPageMixin, AbstractUser):
def delete(self, *args, **kwargs):
"""We don't actually delete the database entry"""
- # pylint: disable=attribute-defined-outside-init
self.is_active = False
self.allow_reactivation = False
self.is_deleted = True
@@ -437,7 +451,6 @@ class User(OrderedCollectionPageMixin, AbstractUser):
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
@@ -445,7 +458,6 @@ class User(OrderedCollectionPageMixin, AbstractUser):
def reactivate(self):
"""Now you want to come back, huh?"""
- # pylint: disable=attribute-defined-outside-init
if not self.allow_reactivation:
return
self.is_active = True
@@ -509,18 +521,30 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
activity_serializer = activitypub.PublicKey
serialize_reverse_fields = [("owner", "owner", "id")]
+ class Meta:
+ """indexes"""
+
+ indexes = [
+ models.Index(fields=["remote_id"]),
+ ]
+
def get_remote_id(self):
# self.owner is set by the OneToOneField on User
return f"{self.owner.remote_id}/#main-key"
- def save(self, *args, **kwargs):
+ def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
"""create a key pair"""
# no broadcasting happening here
if "broadcast" in kwargs:
del kwargs["broadcast"]
+
if not self.public_key:
self.private_key, self.public_key = create_key_pair()
- return super().save(*args, **kwargs)
+ update_fields = add_update_fields(
+ update_fields, "private_key", "public_key"
+ )
+
+ super().save(*args, update_fields=update_fields, **kwargs)
@app.task(queue=MISC)
@@ -543,7 +567,7 @@ def set_remote_server(user_id, allow_external_connections=False):
user = User.objects.get(id=user_id)
actor_parts = urlparse(user.remote_id)
federated_server = get_or_create_remote_server(
- actor_parts.netloc, allow_external_connections=allow_external_connections
+ actor_parts.hostname, allow_external_connections=allow_external_connections
)
# if we were unable to find the server, we need to create a new entry for it
if not federated_server:
diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py
index 995f25bfd..66d2e2d4b 100644
--- a/bookwyrm/preview_images.py
+++ b/bookwyrm/preview_images.py
@@ -175,11 +175,13 @@ def generate_instance_layer(content_width):
site = models.SiteSettings.objects.get()
if site.logo_small:
- logo_img = Image.open(site.logo_small)
+ with Image.open(site.logo_small) as logo_img:
+ logo_img.load()
else:
try:
static_path = os.path.join(settings.STATIC_ROOT, "images/logo-small.png")
- logo_img = Image.open(static_path)
+ with Image.open(static_path) as logo_img:
+ logo_img.load()
except FileNotFoundError:
logo_img = None
@@ -211,18 +213,9 @@ def generate_instance_layer(content_width):
def generate_rating_layer(rating, content_width):
"""Places components for rating preview"""
- try:
- icon_star_full = Image.open(
- os.path.join(settings.STATIC_ROOT, "images/icons/star-full.png")
- )
- icon_star_empty = Image.open(
- os.path.join(settings.STATIC_ROOT, "images/icons/star-empty.png")
- )
- icon_star_half = Image.open(
- os.path.join(settings.STATIC_ROOT, "images/icons/star-half.png")
- )
- except FileNotFoundError:
- return None
+ path_star_full = os.path.join(settings.STATIC_ROOT, "images/icons/star-full.png")
+ path_star_empty = os.path.join(settings.STATIC_ROOT, "images/icons/star-empty.png")
+ path_star_half = os.path.join(settings.STATIC_ROOT, "images/icons/star-half.png")
icon_size = 64
icon_margin = 10
@@ -237,17 +230,23 @@ def generate_rating_layer(rating, content_width):
position_x = 0
- for _ in range(math.floor(rating)):
- rating_layer_mask.alpha_composite(icon_star_full, (position_x, 0))
- position_x = position_x + icon_size + icon_margin
+ try:
+ with Image.open(path_star_full) as icon_star_full:
+ for _ in range(math.floor(rating)):
+ rating_layer_mask.alpha_composite(icon_star_full, (position_x, 0))
+ position_x = position_x + icon_size + icon_margin
- if math.floor(rating) != math.ceil(rating):
- rating_layer_mask.alpha_composite(icon_star_half, (position_x, 0))
- position_x = position_x + icon_size + icon_margin
+ if math.floor(rating) != math.ceil(rating):
+ with Image.open(path_star_half) as icon_star_half:
+ rating_layer_mask.alpha_composite(icon_star_half, (position_x, 0))
+ position_x = position_x + icon_size + icon_margin
- for _ in range(5 - math.ceil(rating)):
- rating_layer_mask.alpha_composite(icon_star_empty, (position_x, 0))
- position_x = position_x + icon_size + icon_margin
+ with Image.open(path_star_empty) as icon_star_empty:
+ for _ in range(5 - math.ceil(rating)):
+ rating_layer_mask.alpha_composite(icon_star_empty, (position_x, 0))
+ position_x = position_x + icon_size + icon_margin
+ except FileNotFoundError:
+ return None
rating_layer_mask = rating_layer_mask.getchannel("A")
rating_layer_mask = ImageOps.invert(rating_layer_mask)
@@ -290,7 +289,8 @@ def generate_preview_image(
texts = texts or {}
# Cover
try:
- inner_img_layer = Image.open(picture)
+ with Image.open(picture) as inner_img_layer:
+ inner_img_layer.load()
inner_img_layer.thumbnail(
(inner_img_width, inner_img_height), Image.Resampling.LANCZOS
)
@@ -420,7 +420,6 @@ def save_and_cleanup(image, instance=None):
return True
-# pylint: disable=invalid-name
@app.task(queue=IMAGES)
def generate_site_preview_image_task():
"""generate preview_image for the website"""
@@ -445,7 +444,6 @@ def generate_site_preview_image_task():
save_and_cleanup(image, instance=site)
-# pylint: disable=invalid-name
@app.task(queue=IMAGES)
def generate_edition_preview_image_task(book_id):
"""generate preview_image for a book"""
diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py
index 77bec0d8e..6da6f4bae 100644
--- a/bookwyrm/settings.py
+++ b/bookwyrm/settings.py
@@ -19,7 +19,6 @@ DOMAIN = env("DOMAIN")
with open("VERSION", encoding="utf-8") as f:
version = f.read()
version = version.replace("\n", "")
-f.close()
VERSION = version
@@ -102,6 +101,7 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.humanize",
+ "oauth2_provider",
"file_resubmit",
"sass_processor",
"bookwyrm",
@@ -257,11 +257,8 @@ if env.bool("USE_DUMMY_CACHE", False):
else:
CACHES = {
"default": {
- "BACKEND": "django_redis.cache.RedisCache",
+ "BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": REDIS_ACTIVITY_URL,
- "OPTIONS": {
- "CLIENT_CLASS": "django_redis.client.DefaultClient",
- },
},
"file_resubmit": {
"BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
@@ -347,34 +344,37 @@ TIME_ZONE = "UTC"
USE_I18N = True
-USE_L10N = True
-
USE_TZ = True
-
-USER_AGENT = f"BookWyrm (BookWyrm/{VERSION}; +https://{DOMAIN}/)"
-
# Imagekit generated thumbnails
ENABLE_THUMBNAIL_GENERATION = env.bool("ENABLE_THUMBNAIL_GENERATION", False)
IMAGEKIT_CACHEFILE_DIR = "thumbnails"
IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY = "bookwyrm.thumbnail_generation.Strategy"
-# Static files (CSS, JavaScript, Images)
-# https://docs.djangoproject.com/en/3.2/howto/static-files/
-
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
CSP_ADDITIONAL_HOSTS = env.list("CSP_ADDITIONAL_HOSTS", [])
-# Storage
-
PROTOCOL = "http"
if USE_HTTPS:
PROTOCOL = "https"
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
+PORT = env.int("PORT", 443 if USE_HTTPS else 80)
+if (USE_HTTPS and PORT == 443) or (not USE_HTTPS and PORT == 80):
+ NETLOC = DOMAIN
+else:
+ NETLOC = f"{DOMAIN}:{PORT}"
+BASE_URL = f"{PROTOCOL}://{NETLOC}"
+CSRF_TRUSTED_ORIGINS = [BASE_URL]
+
+USER_AGENT = f"BookWyrm (BookWyrm/{VERSION}; +{BASE_URL})"
+
+# Storage
+
USE_S3 = env.bool("USE_S3", False)
USE_AZURE = env.bool("USE_AZURE", False)
+S3_SIGNED_URL_EXPIRY = env.int("S3_SIGNED_URL_EXPIRY", 900)
if USE_S3:
# AWS settings
@@ -386,44 +386,124 @@ if USE_S3:
AWS_S3_ENDPOINT_URL = env("AWS_S3_ENDPOINT_URL", None)
AWS_DEFAULT_ACL = "public-read"
AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"}
+ AWS_S3_URL_PROTOCOL = env("AWS_S3_URL_PROTOCOL", f"{PROTOCOL}:")
+ # Storages
+ STORAGES = {
+ "default": {
+ "BACKEND": "storages.backends.s3.S3Storage",
+ "OPTIONS": {
+ "location": "images",
+ "default_acl": "public-read",
+ "file_overwrite": False,
+ },
+ },
+ "staticfiles": {
+ "BACKEND": "storages.backends.s3.S3Storage",
+ "OPTIONS": {
+ "location": "static",
+ "default_acl": "public-read",
+ },
+ },
+ "sass_processor": {
+ "BACKEND": "storages.backends.s3.S3Storage",
+ "OPTIONS": {
+ "location": "static",
+ "default_acl": "public-read",
+ },
+ },
+ "exports": {
+ "BACKEND": "storages.backends.s3.S3Storage",
+ "OPTIONS": {
+ "location": "images",
+ "default_acl": None,
+ "file_overwrite": False,
+ },
+ },
+ }
# S3 Static settings
STATIC_LOCATION = "static"
- STATIC_URL = f"{PROTOCOL}://{AWS_S3_CUSTOM_DOMAIN}/{STATIC_LOCATION}/"
- STATICFILES_STORAGE = "bookwyrm.storage_backends.StaticStorage"
+ STATIC_URL = f"{AWS_S3_URL_PROTOCOL}//{AWS_S3_CUSTOM_DOMAIN}/{STATIC_LOCATION}/"
+ STATIC_FULL_URL = STATIC_URL
# S3 Media settings
MEDIA_LOCATION = "images"
- MEDIA_URL = f"{PROTOCOL}://{AWS_S3_CUSTOM_DOMAIN}/{MEDIA_LOCATION}/"
+ MEDIA_URL = f"{AWS_S3_URL_PROTOCOL}//{AWS_S3_CUSTOM_DOMAIN}/{MEDIA_LOCATION}/"
MEDIA_FULL_URL = MEDIA_URL
- STATIC_FULL_URL = STATIC_URL
- DEFAULT_FILE_STORAGE = "bookwyrm.storage_backends.ImagesStorage"
- CSP_DEFAULT_SRC = ["'self'", AWS_S3_CUSTOM_DOMAIN] + CSP_ADDITIONAL_HOSTS
- CSP_SCRIPT_SRC = ["'self'", AWS_S3_CUSTOM_DOMAIN] + CSP_ADDITIONAL_HOSTS
+ # Content Security Policy
+ CSP_DEFAULT_SRC = [
+ "'self'",
+ f"{AWS_S3_URL_PROTOCOL}//{AWS_S3_CUSTOM_DOMAIN}"
+ if AWS_S3_CUSTOM_DOMAIN
+ else None,
+ ] + CSP_ADDITIONAL_HOSTS
+ CSP_SCRIPT_SRC = [
+ "'self'",
+ f"{AWS_S3_URL_PROTOCOL}//{AWS_S3_CUSTOM_DOMAIN}"
+ if AWS_S3_CUSTOM_DOMAIN
+ else None,
+ ] + CSP_ADDITIONAL_HOSTS
elif USE_AZURE:
+ # Azure settings
AZURE_ACCOUNT_NAME = env("AZURE_ACCOUNT_NAME")
AZURE_ACCOUNT_KEY = env("AZURE_ACCOUNT_KEY")
AZURE_CONTAINER = env("AZURE_CONTAINER")
AZURE_CUSTOM_DOMAIN = env("AZURE_CUSTOM_DOMAIN")
+ # Storages
+ STORAGES = {
+ "default": {
+ "BACKEND": "storages.backends.azure_storage.AzureStorage",
+ "OPTIONS": {
+ "location": "images",
+ "overwrite_files": False,
+ },
+ },
+ "staticfiles": {
+ "BACKEND": "storages.backends.azure_storage.AzureStorage",
+ "OPTIONS": {
+ "location": "static",
+ },
+ },
+ "exports": {
+ "BACKEND": None, # not implemented yet
+ },
+ }
# Azure Static settings
STATIC_LOCATION = "static"
STATIC_URL = (
f"{PROTOCOL}://{AZURE_CUSTOM_DOMAIN}/{AZURE_CONTAINER}/{STATIC_LOCATION}/"
)
- STATICFILES_STORAGE = "bookwyrm.storage_backends.AzureStaticStorage"
+ STATIC_FULL_URL = STATIC_URL
# Azure Media settings
MEDIA_LOCATION = "images"
MEDIA_URL = (
f"{PROTOCOL}://{AZURE_CUSTOM_DOMAIN}/{AZURE_CONTAINER}/{MEDIA_LOCATION}/"
)
MEDIA_FULL_URL = MEDIA_URL
- STATIC_FULL_URL = STATIC_URL
- DEFAULT_FILE_STORAGE = "bookwyrm.storage_backends.AzureImagesStorage"
+ # Content Security Policy
CSP_DEFAULT_SRC = ["'self'", AZURE_CUSTOM_DOMAIN] + CSP_ADDITIONAL_HOSTS
CSP_SCRIPT_SRC = ["'self'", AZURE_CUSTOM_DOMAIN] + CSP_ADDITIONAL_HOSTS
else:
+ # Storages
+ STORAGES = {
+ "default": {
+ "BACKEND": "django.core.files.storage.FileSystemStorage",
+ },
+ "staticfiles": {
+ "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
+ },
+ "exports": {
+ "BACKEND": "django.core.files.storage.FileSystemStorage",
+ "OPTIONS": {
+ "location": "exports",
+ },
+ },
+ }
+ # Static settings
STATIC_URL = "/static/"
+ STATIC_FULL_URL = BASE_URL + STATIC_URL
+ # Media settings
MEDIA_URL = "/images/"
- MEDIA_FULL_URL = f"{PROTOCOL}://{DOMAIN}{MEDIA_URL}"
- STATIC_FULL_URL = f"{PROTOCOL}://{DOMAIN}{STATIC_URL}"
+ MEDIA_FULL_URL = BASE_URL + MEDIA_URL
+ # Content Security Policy
CSP_DEFAULT_SRC = ["'self'"] + CSP_ADDITIONAL_HOSTS
CSP_SCRIPT_SRC = ["'self'"] + CSP_ADDITIONAL_HOSTS
diff --git a/bookwyrm/signatures.py b/bookwyrm/signatures.py
index 08780b731..f59367b51 100644
--- a/bookwyrm/signatures.py
+++ b/bookwyrm/signatures.py
@@ -6,7 +6,7 @@ from base64 import b64encode, b64decode
from Crypto import Random
from Crypto.PublicKey import RSA
-from Crypto.Signature import pkcs1_15 # pylint: disable=no-name-in-module
+from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
MAX_SIGNATURE_AGE = 300
@@ -84,7 +84,6 @@ class Signature:
self.headers = headers
self.signature = signature
- # pylint: disable=invalid-name
@classmethod
def parse(cls, request):
"""extract and parse a signature from an http request"""
diff --git a/bookwyrm/static/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js
index a2351a98c..5a5e5f68e 100644
--- a/bookwyrm/static/js/bookwyrm.js
+++ b/bookwyrm/static/js/bookwyrm.js
@@ -14,6 +14,10 @@ let BookWyrm = new (class {
.querySelectorAll("[data-controls]")
.forEach((button) => button.addEventListener("click", this.toggleAction.bind(this)));
+ document
+ .querySelectorAll("[data-disappear]")
+ .forEach((button) => button.addEventListener("click", this.hideSelf.bind(this)));
+
document
.querySelectorAll(".interaction")
.forEach((button) => button.addEventListener("submit", this.interact.bind(this)));
@@ -181,6 +185,18 @@ let BookWyrm = new (class {
this.addRemoveClass(visible, "is-hidden", true);
}
+ /**
+ * Hide the element you just clicked
+ *
+ * @param {Event} event
+ * @return {undefined}
+ */
+ hideSelf(event) {
+ let trigger = event.currentTarget;
+
+ this.addRemoveClass(trigger, "is-hidden", true);
+ }
+
/**
* Execute actions on targets based on triggers.
*
diff --git a/bookwyrm/storage_backends.py b/bookwyrm/storage_backends.py
deleted file mode 100644
index 6dd9f522c..000000000
--- a/bookwyrm/storage_backends.py
+++ /dev/null
@@ -1,63 +0,0 @@
-"""Handles backends for storages"""
-import os
-from tempfile import SpooledTemporaryFile
-from storages.backends.s3boto3 import S3Boto3Storage
-from storages.backends.azure_storage import AzureStorage
-
-
-class StaticStorage(S3Boto3Storage): # pylint: disable=abstract-method
- """Storage class for Static contents"""
-
- location = "static"
- default_acl = "public-read"
-
-
-class ImagesStorage(S3Boto3Storage): # pylint: disable=abstract-method
- """Storage class for Image files"""
-
- location = "images"
- default_acl = "public-read"
- file_overwrite = False
-
- """
- This is our custom version of S3Boto3Storage that fixes a bug in
- boto3 where the passed in file is closed upon upload.
- From:
- https://github.com/matthewwithanm/django-imagekit/issues/391#issuecomment-275367006
- https://github.com/boto/boto3/issues/929
- https://github.com/matthewwithanm/django-imagekit/issues/391
- """
-
- def _save(self, name, content):
- """
- We create a clone of the content file as when this is passed to
- boto3 it wrongly closes the file upon upload where as the storage
- backend expects it to still be open
- """
- # Seek our content back to the start
- content.seek(0, os.SEEK_SET)
-
- # Create a temporary file that will write to disk after a specified
- # size. This file will be automatically deleted when closed by
- # boto3 or after exiting the `with` statement if the boto3 is fixed
- with SpooledTemporaryFile() as content_autoclose:
-
- # Write our original content into our copy that will be closed by boto3
- content_autoclose.write(content.read())
-
- # Upload the object which will auto close the
- # content_autoclose instance
- return super()._save(name, content_autoclose)
-
-
-class AzureStaticStorage(AzureStorage): # pylint: disable=abstract-method
- """Storage class for Static contents"""
-
- location = "static"
-
-
-class AzureImagesStorage(AzureStorage): # pylint: disable=abstract-method
- """Storage class for Image files"""
-
- location = "images"
- overwrite_files = False
diff --git a/bookwyrm/suggested_users.py b/bookwyrm/suggested_users.py
index a13ee97fd..3e1cf17bd 100644
--- a/bookwyrm/suggested_users.py
+++ b/bookwyrm/suggested_users.py
@@ -34,7 +34,6 @@ class SuggestedUsers(RedisStore):
def get_counts_from_rank(self, rank): # pylint: disable=no-self-use
"""calculate mutuals count and shared books count from rank"""
- # pylint: disable=c-extension-no-member
return {
"mutuals": math.floor(rank),
# "shared_books": int(1 / (-1 * (rank % 1 - 1))) - 1,
@@ -128,7 +127,6 @@ def get_annotated_users(viewer, *args, **kwargs):
),
distinct=True,
),
- # pylint: disable=line-too-long
# shared_books=Count(
# "shelfbook",
# filter=Q(
@@ -202,7 +200,7 @@ def update_suggestions_on_unfollow(sender, instance, **kwargs):
@receiver(signals.post_save, sender=models.User)
-# pylint: disable=unused-argument, too-many-arguments
+# pylint: disable=unused-argument
def update_user(sender, instance, created, update_fields=None, **kwargs):
"""an updated user, neat"""
# a new user is found, create suggestions for them
diff --git a/bookwyrm/templates/book/file_links/verification_modal.html b/bookwyrm/templates/book/file_links/verification_modal.html
index 7a9e41ad2..4f2f96a4a 100644
--- a/bookwyrm/templates/book/file_links/verification_modal.html
+++ b/bookwyrm/templates/book/file_links/verification_modal.html
@@ -21,9 +21,10 @@ Is that where you'd like to go?
+{% endif %}
{% trans "Continue" %}
-{% endif %}
+
{% endblock %}
diff --git a/bookwyrm/templates/compose.html b/bookwyrm/templates/compose.html
index ca0d8296a..112088648 100644
--- a/bookwyrm/templates/compose.html
+++ b/bookwyrm/templates/compose.html
@@ -2,10 +2,31 @@
{% load i18n %}
{% load utilities %}
-{% block title %}{% trans "Edit status" %}{% endblock %}
+{% block title %}
+ {% if draft.status_type == "Review" %}
+ {% trans "Edit review" %}
+ {% elif draft.status_type == "Quotation" %}
+ {% trans "Edit quote" %}
+ {% elif draft.status_type == "Comment" %}
+ {% trans "Edit comment" %}
+ {% else %}
+ {% trans "Edit status" %}
+ {% endif %}
+{% endblock %}
+
{% block content %}
- {% trans "Edit status" %}
+
+ {% if draft.status_type == "Review" %}
+ {% trans "Edit review" %}
+ {% elif draft.status_type == "Quotation" %}
+ {% trans "Edit quote" %}
+ {% elif draft.status_type == "Comment" %}
+ {% trans "Edit comment" %}
+ {% else %}
+ {% trans "Edit status" %}
+ {% endif %}
+
{% with 0|uuid as uuid %}
diff --git a/bookwyrm/templates/email/html_layout.html b/bookwyrm/templates/email/html_layout.html
index b9f88732f..467d6d6e5 100644
--- a/bookwyrm/templates/email/html_layout.html
+++ b/bookwyrm/templates/email/html_layout.html
@@ -2,10 +2,10 @@
-
+
@@ -18,9 +18,9 @@
diff --git a/bookwyrm/templates/email/invite/html_content.html b/bookwyrm/templates/email/invite/html_content.html
index adc993b7b..9d2cda364 100644
--- a/bookwyrm/templates/email/invite/html_content.html
+++ b/bookwyrm/templates/email/invite/html_content.html
@@ -12,6 +12,6 @@
{% url 'code-of-conduct' as coc_path %}
{% url 'about' as about_path %}
- {% blocktrans %}Learn more about {{ site_name }}.{% endblocktrans %}
+ {% blocktrans %}Learn more about {{ site_name }}.{% endblocktrans %}
{% endblock %}
diff --git a/bookwyrm/templates/email/invite/text_content.html b/bookwyrm/templates/email/invite/text_content.html
index 26dcd1720..05fe91456 100644
--- a/bookwyrm/templates/email/invite/text_content.html
+++ b/bookwyrm/templates/email/invite/text_content.html
@@ -5,6 +5,6 @@
{{ invite_link }}
-{% blocktrans %}Learn more about {{ site_name }}:{% endblocktrans %} https://{{ domain }}{% url 'about' %}
+{% blocktrans %}Learn more about {{ site_name }}:{% endblocktrans %} {{ base_url }}{% url 'about' %}
{% endblock %}
diff --git a/bookwyrm/templates/import/import.html b/bookwyrm/templates/import/import.html
index 01014fa94..57a141b7e 100644
--- a/bookwyrm/templates/import/import.html
+++ b/bookwyrm/templates/import/import.html
@@ -69,6 +69,9 @@
+
@@ -93,9 +96,14 @@
{% trans "Include reviews" %}
+
+
+
{% include 'snippets/privacy_select.html' with no_label=True privacy_uuid="import" %}
diff --git a/bookwyrm/templates/opensearch.xml b/bookwyrm/templates/opensearch.xml
index fd5c8f231..980ca5604 100644
--- a/bookwyrm/templates/opensearch.xml
+++ b/bookwyrm/templates/opensearch.xml
@@ -10,6 +10,6 @@
{{ image }}
diff --git a/bookwyrm/templates/ostatus/remote_follow.html b/bookwyrm/templates/ostatus/remote_follow.html
index ca47c529b..c54e6bfa7 100644
--- a/bookwyrm/templates/ostatus/remote_follow.html
+++ b/bookwyrm/templates/ostatus/remote_follow.html
@@ -3,8 +3,10 @@
{% load utilities %}
{% block heading %}
+{% block title %}
{% blocktrans with username=user.localname sitename=site.name %}Follow {{ username }} on the fediverse{% endblocktrans %}
{% endblock %}
+{% endblock %}
{% block content %}
diff --git a/bookwyrm/templates/ostatus/success.html b/bookwyrm/templates/ostatus/success.html
index 66577e83f..c2b8edd75 100644
--- a/bookwyrm/templates/ostatus/success.html
+++ b/bookwyrm/templates/ostatus/success.html
@@ -2,6 +2,10 @@
{% load i18n %}
{% load utilities %}
+{% block title %}
+{% blocktrans with display_name=user.display_name %}You are now following {{ display_name }}!{% endblocktrans %}
+{% endblock %}
+
{% block content %}
diff --git a/bookwyrm/templates/preferences/edit_user.html b/bookwyrm/templates/preferences/edit_user.html
index f2b14babf..c10bc1ee4 100644
--- a/bookwyrm/templates/preferences/edit_user.html
+++ b/bookwyrm/templates/preferences/edit_user.html
@@ -69,6 +69,12 @@
{% trans "Show reading goal prompt in feed" %}
+
+
+