Ver Fonte

Merge branch 'main' into production

Mouse Reeve há 9 meses atrás
pai
commit
439b0bcd27
100 ficheiros alterados com 3408 adições e 409 exclusões
  1. 9 0
      .github/workflows/django-tests.yml
  2. 1 0
      .prettierrc
  3. 3 2
      FEDERATION.md
  4. 1 0
      VERSION
  5. 1 0
      bookwyrm/activitypub/__init__.py
  6. 8 12
      bookwyrm/activitypub/base_activity.py
  7. 2 2
      bookwyrm/activitypub/book.py
  8. 2 0
      bookwyrm/activitypub/person.py
  9. 40 3
      bookwyrm/activitypub/verbs.py
  10. 1 1
      bookwyrm/book_search.py
  11. 3 4
      bookwyrm/forms/books.py
  12. 16 0
      bookwyrm/forms/edit_user.py
  13. 4 0
      bookwyrm/forms/forms.py
  14. 1 0
      bookwyrm/importers/__init__.py
  15. 24 0
      bookwyrm/importers/bookwyrm_import.py
  16. 6 1
      bookwyrm/isbn/isbn.py
  17. 1 0
      bookwyrm/middleware/__init__.py
  18. 30 0
      bookwyrm/middleware/file_too_big.py
  19. 3 1
      bookwyrm/migrations/0179_populate_sort_title.py
  20. 130 0
      bookwyrm/migrations/0182_auto_20231027_1122.py
  21. 18 0
      bookwyrm/migrations/0183_auto_20231105_1607.py
  22. 49 0
      bookwyrm/migrations/0184_auto_20231106_0421.py
  23. 42 0
      bookwyrm/migrations/0185_alter_notification_notification_type.py
  24. 212 0
      bookwyrm/migrations/0186_auto_20231116_0048.py
  25. 48 0
      bookwyrm/migrations/0186_invite_request_notification.py
  26. 54 0
      bookwyrm/migrations/0187_partial_publication_dates.py
  27. 18 0
      bookwyrm/migrations/0188_theme_loads.py
  28. 45 0
      bookwyrm/migrations/0189_alter_user_preferred_language.py
  29. 13 0
      bookwyrm/migrations/0189_merge_0186_auto_20231116_0048_0188_theme_loads.py
  30. 45 0
      bookwyrm/migrations/0190_alter_notification_notification_type.py
  31. 13 0
      bookwyrm/migrations/0191_merge_20240102_0326.py
  32. 5 1
      bookwyrm/models/__init__.py
  33. 1 1
      bookwyrm/models/activitypub_mixin.py
  34. 2 1
      bookwyrm/models/antispam.py
  35. 13 9
      bookwyrm/models/book.py
  36. 232 0
      bookwyrm/models/bookwyrm_export_job.py
  37. 459 0
      bookwyrm/models/bookwyrm_import_job.py
  38. 44 4
      bookwyrm/models/fields.py
  39. 7 6
      bookwyrm/models/group.py
  40. 14 21
      bookwyrm/models/import_job.py
  41. 308 0
      bookwyrm/models/job.py
  42. 71 0
      bookwyrm/models/move.py
  43. 92 29
      bookwyrm/models/notification.py
  44. 21 7
      bookwyrm/models/relationship.py
  45. 2 0
      bookwyrm/models/site.py
  46. 3 2
      bookwyrm/models/status.py
  47. 55 3
      bookwyrm/models/user.py
  48. 24 6
      bookwyrm/settings.py
  49. 2 3
      bookwyrm/static/css/bookwyrm.scss
  50. 2 5
      bookwyrm/static/css/bookwyrm/_all.scss
  51. 1 1
      bookwyrm/static/css/bookwyrm/components/_book_cover.scss
  52. 16 16
      bookwyrm/static/css/bookwyrm/components/_copy.scss
  53. 2 2
      bookwyrm/static/css/bookwyrm/components/_tabs.scss
  54. BIN
      bookwyrm/static/css/fonts/icomoon.eot
  55. 2 0
      bookwyrm/static/css/fonts/icomoon.svg
  56. BIN
      bookwyrm/static/css/fonts/icomoon.ttf
  57. BIN
      bookwyrm/static/css/fonts/icomoon.woff
  58. 9 13
      bookwyrm/static/css/themes/bookwyrm-dark.scss
  59. 7 10
      bookwyrm/static/css/themes/bookwyrm-light.scss
  60. 6 0
      bookwyrm/static/css/vendor/icons.css
  61. 26 0
      bookwyrm/static/js/bookwyrm.js
  62. 3 4
      bookwyrm/static/js/forms.js
  63. 10 3
      bookwyrm/suggested_users.py
  64. 20 0
      bookwyrm/templates/403.html
  65. 16 0
      bookwyrm/templates/413.html
  66. 0 8
      bookwyrm/templates/author/author.html
  67. 7 7
      bookwyrm/templates/book/book.html
  68. 1 1
      bookwyrm/templates/book/cover_add_modal.html
  69. 1 1
      bookwyrm/templates/book/edit/edit_book_form.html
  70. 7 10
      bookwyrm/templates/book/publisher_info.html
  71. 10 5
      bookwyrm/templates/book/rating.html
  72. 5 4
      bookwyrm/templates/book/series.html
  73. 1 0
      bookwyrm/templates/embed-layout.html
  74. 2 2
      bookwyrm/templates/guided_tour/home.html
  75. 5 6
      bookwyrm/templates/import/import.html
  76. 222 0
      bookwyrm/templates/import/import_user.html
  77. 28 11
      bookwyrm/templates/layout.html
  78. 14 0
      bookwyrm/templates/manifest.json
  79. 52 0
      bookwyrm/templates/moved.html
  80. 11 1
      bookwyrm/templates/notifications/item.html
  81. 20 0
      bookwyrm/templates/notifications/items/invite_request.html
  82. 4 0
      bookwyrm/templates/notifications/items/layout.html
  83. 29 0
      bookwyrm/templates/notifications/items/move_user.html
  84. 15 0
      bookwyrm/templates/notifications/items/user_export.html
  85. 16 0
      bookwyrm/templates/notifications/items/user_import.html
  86. 59 0
      bookwyrm/templates/preferences/alias_user.html
  87. 138 0
      bookwyrm/templates/preferences/export-user.html
  88. 3 3
      bookwyrm/templates/preferences/export.html
  89. 18 2
      bookwyrm/templates/preferences/layout.html
  90. 43 0
      bookwyrm/templates/preferences/move_user.html
  91. 1 1
      bookwyrm/templates/settings/celery.html
  92. 23 0
      bookwyrm/templates/settings/imports/complete_user_import_modal.html
  93. 205 80
      bookwyrm/templates/settings/imports/imports.html
  94. 43 0
      bookwyrm/templates/settings/themes.html
  95. 1 18
      bookwyrm/templates/settings/users/user_admin.html
  96. 12 12
      bookwyrm/templates/settings/users/user_info.html
  97. 76 62
      bookwyrm/templates/settings/users/user_moderation_actions.html
  98. 4 1
      bookwyrm/templates/shelf/shelf.html
  99. 1 1
      bookwyrm/templates/snippets/follow_button.html
  100. 13 0
      bookwyrm/templates/snippets/move_user_buttons.html

+ 9 - 0
.github/workflows/django-tests.yml

@@ -32,6 +32,15 @@ jobs:
       run: |
         python -m pip install --upgrade pip
         pip install -r requirements.txt
+    - name: Check migrations up-to-date
+      run: |
+        python ./manage.py makemigrations --check
+      env:
+        SECRET_KEY: beepbeep
+        DOMAIN: your.domain.here
+        EMAIL_HOST: ""
+        EMAIL_HOST_USER: ""
+        EMAIL_HOST_PASSWORD: ""
     - name: Run Tests
       env:
         SECRET_KEY: beepbeep

+ 1 - 0
.prettierrc

@@ -0,0 +1 @@
+'trailingComma': 'es5'

+ 3 - 2
FEDERATION.md

@@ -13,14 +13,15 @@ User relationship interactions follow the standard ActivityPub spec.
 - `Block`: prevent users from seeing one another's statuses, and prevents the blocked user from viewing the actor's profile
 - `Update`: updates a user's profile and settings
 - `Delete`: deactivates a user
-- `Undo`: reverses a `Follow` or `Block`
+- `Undo`: reverses a `Block` or `Follow`
 
 ### Activities
 - `Create/Status`: saves a new status in the database.
 - `Delete/Status`: Removes a status
 - `Like/Status`: Creates a favorite on the status
 - `Announce/Status`: Boosts the status into the actor's timeline
-- `Undo/*`,: Reverses a `Like` or `Announce`
+- `Undo/*`,: Reverses an `Announce`, `Like`, or `Move`
+- `Move/User`: Moves a user from one ActivityPub id to another.
 
 ### Collections
 User's books and lists are represented by [`OrderedCollection`](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollection)

+ 1 - 0
VERSION

@@ -0,0 +1 @@
+0.7.0

+ 1 - 0
bookwyrm/activitypub/__init__.py

@@ -23,6 +23,7 @@ from .verbs import Create, Delete, Undo, Update
 from .verbs import Follow, Accept, Reject, Block
 from .verbs import Add, Remove
 from .verbs import Announce, Like
+from .verbs import Move
 
 # this creates a list of all the Activity types that we can serialize,
 # so when an Activity comes in from outside, we can check if it's known

+ 8 - 12
bookwyrm/activitypub/base_activity.py

@@ -236,7 +236,7 @@ class ActivityObject:
         omit = kwargs.get("omit", ())
         data = self.__dict__.copy()
         # recursively serialize
-        for (k, v) in data.items():
+        for k, v in data.items():
             try:
                 if issubclass(type(v), ActivityObject):
                     data[k] = v.serialize()
@@ -396,19 +396,15 @@ def resolve_remote_id(
 
 def get_representative():
     """Get or create an actor representing the instance
-    to sign requests to 'secure mastodon' servers"""
-    username = f"{INSTANCE_ACTOR_USERNAME}@{DOMAIN}"
-    email = "bookwyrm@localhost"
-    try:
-        user = models.User.objects.get(username=username)
-    except models.User.DoesNotExist:
-        user = models.User.objects.create_user(
-            username=username,
-            email=email,
+    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,
-        )
-    return user
+        ),
+    )[0]
 
 
 def get_activitypub_data(url):

+ 2 - 2
bookwyrm/activitypub/book.py

@@ -22,8 +22,6 @@ class BookData(ActivityObject):
     aasin: Optional[str] = None
     isfdb: Optional[str] = None
     lastEditedBy: Optional[str] = None
-    links: list[str] = field(default_factory=list)
-    fileLinks: list[str] = field(default_factory=list)
 
 
 # pylint: disable=invalid-name
@@ -45,6 +43,8 @@ class Book(BookData):
     firstPublishedDate: str = ""
     publishedDate: str = ""
 
+    fileLinks: list[str] = field(default_factory=list)
+
     cover: Optional[Document] = None
     type: str = "Book"
 

+ 2 - 0
bookwyrm/activitypub/person.py

@@ -40,4 +40,6 @@ class Person(ActivityObject):
     manuallyApprovesFollowers: str = False
     discoverable: str = False
     hideFollows: str = False
+    movedTo: str = None
+    alsoKnownAs: dict[str] = None
     type: str = "Person"

+ 40 - 3
bookwyrm/activitypub/verbs.py

@@ -171,9 +171,19 @@ class Reject(Verb):
     type: str = "Reject"
 
     def action(self, allow_external_connections=True):
-        """reject a follow request"""
-        obj = self.object.to_model(save=False, allow_create=False)
-        obj.reject()
+        """reject a follow or follow request"""
+
+        for model_name in ["UserFollowRequest", "UserFollows", None]:
+            model = apps.get_model(f"bookwyrm.{model_name}") if model_name else None
+            if obj := self.object.to_model(
+                model=model,
+                save=False,
+                allow_create=False,
+                allow_external_connections=allow_external_connections,
+            ):
+                # Reject the first model that can be built.
+                obj.reject()
+                break
 
 
 @dataclass(init=False)
@@ -231,3 +241,30 @@ class Announce(Verb):
     def action(self, allow_external_connections=True):
         """boost"""
         self.to_model(allow_external_connections=allow_external_connections)
+
+
+@dataclass(init=False)
+class Move(Verb):
+    """a user moving an object"""
+
+    object: str
+    type: str = "Move"
+    origin: str = None
+    target: str = None
+
+    def action(self, allow_external_connections=True):
+        """move"""
+
+        object_is_user = resolve_remote_id(remote_id=self.object, model="User")
+
+        if object_is_user:
+            model = apps.get_model("bookwyrm.MoveUser")
+
+            self.to_model(
+                model=model,
+                save=True,
+                allow_external_connections=allow_external_connections,
+            )
+        else:
+            # we might do something with this to move other objects at some point
+            pass

+ 1 - 1
bookwyrm/book_search.py

@@ -137,7 +137,7 @@ def search_title_author(
 
     # filter out multiple editions of the same work
     list_results = []
-    for work_id in set(editions_of_work[:30]):
+    for work_id in editions_of_work[:30]:
         result = (
             results.filter(parent_work=work_id)
             .order_by("-rank", "-edition_rank")

+ 3 - 4
bookwyrm/forms/books.py

@@ -1,8 +1,9 @@
 """ using django model forms """
 from django import forms
 
+from file_resubmit.widgets import ResubmitImageWidget
+
 from bookwyrm import models
-from bookwyrm.models.fields import ClearableFileInputWithWarning
 from .custom_form import CustomForm
 from .widgets import ArrayWidget, SelectDateWidget, Select
 
@@ -70,9 +71,7 @@ class EditionForm(CustomForm):
             "published_date": SelectDateWidget(
                 attrs={"aria-describedby": "desc_published_date"}
             ),
-            "cover": ClearableFileInputWithWarning(
-                attrs={"aria-describedby": "desc_cover"}
-            ),
+            "cover": ResubmitImageWidget(attrs={"aria-describedby": "desc_cover"}),
             "physical_format": Select(
                 attrs={"aria-describedby": "desc_physical_format"}
             ),

+ 16 - 0
bookwyrm/forms/edit_user.py

@@ -70,6 +70,22 @@ class DeleteUserForm(CustomForm):
         fields = ["password"]
 
 
+class MoveUserForm(CustomForm):
+    target = forms.CharField(widget=forms.TextInput)
+
+    class Meta:
+        model = models.User
+        fields = ["password"]
+
+
+class AliasUserForm(CustomForm):
+    username = forms.CharField(widget=forms.TextInput)
+
+    class Meta:
+        model = models.User
+        fields = ["password"]
+
+
 class ChangePasswordForm(CustomForm):
     current_password = forms.CharField(widget=forms.PasswordInput)
     confirm_password = forms.CharField(widget=forms.PasswordInput)

+ 4 - 0
bookwyrm/forms/forms.py

@@ -25,6 +25,10 @@ class ImportForm(forms.Form):
     csv_file = forms.FileField()
 
 
+class ImportUserForm(forms.Form):
+    archive_file = forms.FileField()
+
+
 class ShelfForm(CustomForm):
     class Meta:
         model = models.Shelf

+ 1 - 0
bookwyrm/importers/__init__.py

@@ -1,6 +1,7 @@
 """ import classes """
 
 from .importer import Importer
+from .bookwyrm_import import BookwyrmImporter
 from .calibre_import import CalibreImporter
 from .goodreads_import import GoodreadsImporter
 from .librarything_import import LibrarythingImporter

+ 24 - 0
bookwyrm/importers/bookwyrm_import.py

@@ -0,0 +1,24 @@
+"""Import data from Bookwyrm export files"""
+from django.http import QueryDict
+
+from bookwyrm.models import User
+from bookwyrm.models.bookwyrm_import_job import BookwyrmImportJob
+
+
+class BookwyrmImporter:
+    """Import a Bookwyrm User export file.
+    This is kind of a combination of an importer and a connector.
+    """
+
+    # pylint: disable=no-self-use
+    def process_import(
+        self, user: User, archive_file: bytes, settings: QueryDict
+    ) -> BookwyrmImportJob:
+        """import user data from a Bookwyrm export file"""
+
+        required = [k for k in settings if settings.get(k) == "on"]
+
+        job = BookwyrmImportJob.objects.create(
+            user=user, archive_file=archive_file, required=required
+        )
+        return job

+ 6 - 1
bookwyrm/isbn/isbn.py

@@ -40,7 +40,12 @@ class IsbnHyphenator:
             self.__element_tree = ElementTree.parse(self.__range_file_path)
 
         gs1_prefix = isbn_13[:3]
-        reg_group = self.__find_reg_group(isbn_13, gs1_prefix)
+        try:
+            reg_group = self.__find_reg_group(isbn_13, gs1_prefix)
+        except ValueError:
+            # if the reg groups are invalid, just return the original isbn
+            return isbn_13
+
         if reg_group is None:
             return isbn_13  # failed to hyphenate
 

+ 1 - 0
bookwyrm/middleware/__init__.py

@@ -1,3 +1,4 @@
 """ look at all this nice middleware! """
 from .timezone_middleware import TimezoneMiddleware
 from .ip_middleware import IPBlocklistMiddleware
+from .file_too_big import FileTooBig

+ 30 - 0
bookwyrm/middleware/file_too_big.py

@@ -0,0 +1,30 @@
+"""Middleware to display a custom 413 error page"""
+
+from django.http import HttpResponse
+from django.shortcuts import render
+from django.core.exceptions import RequestDataTooBig
+
+
+class FileTooBig:
+    """Middleware to display a custom page when a
+    RequestDataTooBig exception is thrown"""
+
+    def __init__(self, get_response):
+        """boilerplate __init__ from Django docs"""
+
+        self.get_response = get_response
+
+    def __call__(self, request):
+        """If RequestDataTooBig is thrown, render the 413 error page"""
+
+        try:
+            body = request.body  # pylint: disable=unused-variable
+
+        except RequestDataTooBig:
+
+            rendered = render(request, "413.html")
+            response = HttpResponse(rendered)
+            return response
+
+        response = self.get_response(request)
+        return response

+ 3 - 1
bookwyrm/migrations/0179_populate_sort_title.py

@@ -45,5 +45,7 @@ class Migration(migrations.Migration):
     ]
 
     operations = [
-        migrations.RunPython(populate_sort_title),
+        migrations.RunPython(
+            populate_sort_title, reverse_code=migrations.RunPython.noop
+        ),
     ]

+ 130 - 0
bookwyrm/migrations/0182_auto_20231027_1122.py

@@ -0,0 +1,130 @@
+# Generated by Django 3.2.20 on 2023-10-27 11:22
+
+import bookwyrm.models.activitypub_mixin
+import bookwyrm.models.fields
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("bookwyrm", "0181_merge_20230806_2302"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="user",
+            name="also_known_as",
+            field=bookwyrm.models.fields.ManyToManyField(to=settings.AUTH_USER_MODEL),
+        ),
+        migrations.AddField(
+            model_name="user",
+            name="moved_to",
+            field=bookwyrm.models.fields.RemoteIdField(
+                max_length=255,
+                null=True,
+                validators=[bookwyrm.models.fields.validate_remote_id],
+            ),
+        ),
+        migrations.AlterField(
+            model_name="notification",
+            name="notification_type",
+            field=models.CharField(
+                choices=[
+                    ("FAVORITE", "Favorite"),
+                    ("REPLY", "Reply"),
+                    ("MENTION", "Mention"),
+                    ("TAG", "Tag"),
+                    ("FOLLOW", "Follow"),
+                    ("FOLLOW_REQUEST", "Follow Request"),
+                    ("BOOST", "Boost"),
+                    ("IMPORT", "Import"),
+                    ("ADD", "Add"),
+                    ("REPORT", "Report"),
+                    ("LINK_DOMAIN", "Link Domain"),
+                    ("INVITE", "Invite"),
+                    ("ACCEPT", "Accept"),
+                    ("JOIN", "Join"),
+                    ("LEAVE", "Leave"),
+                    ("REMOVE", "Remove"),
+                    ("GROUP_PRIVACY", "Group Privacy"),
+                    ("GROUP_NAME", "Group Name"),
+                    ("GROUP_DESCRIPTION", "Group Description"),
+                    ("MOVE", "Move"),
+                ],
+                max_length=255,
+            ),
+        ),
+        migrations.CreateModel(
+            name="Move",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("created_date", models.DateTimeField(auto_now_add=True)),
+                ("updated_date", models.DateTimeField(auto_now=True)),
+                (
+                    "remote_id",
+                    bookwyrm.models.fields.RemoteIdField(
+                        max_length=255,
+                        null=True,
+                        validators=[bookwyrm.models.fields.validate_remote_id],
+                    ),
+                ),
+                ("object", bookwyrm.models.fields.CharField(max_length=255)),
+                (
+                    "origin",
+                    bookwyrm.models.fields.CharField(
+                        blank=True, default="", max_length=255, null=True
+                    ),
+                ),
+                (
+                    "user",
+                    bookwyrm.models.fields.ForeignKey(
+                        on_delete=django.db.models.deletion.PROTECT,
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+            ],
+            options={
+                "abstract": False,
+            },
+            bases=(bookwyrm.models.activitypub_mixin.ActivityMixin, models.Model),
+        ),
+        migrations.CreateModel(
+            name="MoveUser",
+            fields=[
+                (
+                    "move_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="bookwyrm.move",
+                    ),
+                ),
+                (
+                    "target",
+                    bookwyrm.models.fields.ForeignKey(
+                        on_delete=django.db.models.deletion.PROTECT,
+                        related_name="move_target",
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+            ],
+            options={
+                "abstract": False,
+            },
+            bases=("bookwyrm.move",),
+        ),
+    ]

+ 18 - 0
bookwyrm/migrations/0183_auto_20231105_1607.py

@@ -0,0 +1,18 @@
+# Generated by Django 3.2.20 on 2023-11-05 16:07
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("bookwyrm", "0182_auto_20231027_1122"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="user",
+            name="is_deleted",
+            field=models.BooleanField(default=False),
+        ),
+    ]

+ 49 - 0
bookwyrm/migrations/0184_auto_20231106_0421.py

@@ -0,0 +1,49 @@
+# Generated by Django 3.2.20 on 2023-11-06 04:21
+
+from django.db import migrations
+from bookwyrm.models import User
+
+
+def update_deleted_users(apps, schema_editor):
+    """Find all the users who are deleted, not just inactive, and set deleted"""
+    users = apps.get_model("bookwyrm", "User")
+    db_alias = schema_editor.connection.alias
+    users.objects.using(db_alias).filter(
+        is_active=False,
+        deactivation_reason__in=[
+            "self_deletion",
+            "moderator_deletion",
+        ],
+    ).update(is_deleted=True)
+
+    # differente rules for remote users
+    users.objects.using(db_alias).filter(is_active=False, local=False,).exclude(
+        deactivation_reason="moderator_deactivation",
+    ).update(is_deleted=True)
+
+
+def erase_deleted_user_data(apps, schema_editor):
+    """Retroactively clear user data"""
+    for user in User.objects.filter(is_deleted=True):
+        user.erase_user_data()
+        user.save(
+            broadcast=False,
+            update_fields=["email", "avatar", "preview_image", "summary", "name"],
+        )
+        user.erase_user_statuses(broadcast=False)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("bookwyrm", "0183_auto_20231105_1607"),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            update_deleted_users, reverse_code=migrations.RunPython.noop
+        ),
+        migrations.RunPython(
+            erase_deleted_user_data, reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 42 - 0
bookwyrm/migrations/0185_alter_notification_notification_type.py

@@ -0,0 +1,42 @@
+# Generated by Django 3.2.20 on 2023-11-13 22:39
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("bookwyrm", "0184_auto_20231106_0421"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="notification",
+            name="notification_type",
+            field=models.CharField(
+                choices=[
+                    ("FAVORITE", "Favorite"),
+                    ("BOOST", "Boost"),
+                    ("REPLY", "Reply"),
+                    ("MENTION", "Mention"),
+                    ("TAG", "Tag"),
+                    ("FOLLOW", "Follow"),
+                    ("FOLLOW_REQUEST", "Follow Request"),
+                    ("IMPORT", "Import"),
+                    ("ADD", "Add"),
+                    ("REPORT", "Report"),
+                    ("LINK_DOMAIN", "Link Domain"),
+                    ("INVITE", "Invite"),
+                    ("ACCEPT", "Accept"),
+                    ("JOIN", "Join"),
+                    ("LEAVE", "Leave"),
+                    ("REMOVE", "Remove"),
+                    ("GROUP_PRIVACY", "Group Privacy"),
+                    ("GROUP_NAME", "Group Name"),
+                    ("GROUP_DESCRIPTION", "Group Description"),
+                    ("MOVE", "Move"),
+                ],
+                max_length=255,
+            ),
+        ),
+    ]

+ 212 - 0
bookwyrm/migrations/0186_auto_20231116_0048.py

@@ -0,0 +1,212 @@
+# Generated by Django 3.2.20 on 2023-11-16 00:48
+
+from django.conf import settings
+import django.contrib.postgres.fields
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("bookwyrm", "0185_alter_notification_notification_type"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="ParentJob",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("task_id", models.UUIDField(blank=True, null=True, unique=True)),
+                (
+                    "created_date",
+                    models.DateTimeField(default=django.utils.timezone.now),
+                ),
+                (
+                    "updated_date",
+                    models.DateTimeField(default=django.utils.timezone.now),
+                ),
+                ("complete", models.BooleanField(default=False)),
+                (
+                    "status",
+                    models.CharField(
+                        choices=[
+                            ("pending", "Pending"),
+                            ("active", "Active"),
+                            ("complete", "Complete"),
+                            ("stopped", "Stopped"),
+                            ("failed", "Failed"),
+                        ],
+                        default="pending",
+                        max_length=50,
+                        null=True,
+                    ),
+                ),
+                (
+                    "user",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+            ],
+            options={
+                "abstract": False,
+            },
+        ),
+        migrations.AddField(
+            model_name="sitesettings",
+            name="user_import_time_limit",
+            field=models.IntegerField(default=48),
+        ),
+        migrations.AlterField(
+            model_name="notification",
+            name="notification_type",
+            field=models.CharField(
+                choices=[
+                    ("FAVORITE", "Favorite"),
+                    ("BOOST", "Boost"),
+                    ("REPLY", "Reply"),
+                    ("MENTION", "Mention"),
+                    ("TAG", "Tag"),
+                    ("FOLLOW", "Follow"),
+                    ("FOLLOW_REQUEST", "Follow Request"),
+                    ("IMPORT", "Import"),
+                    ("USER_IMPORT", "User Import"),
+                    ("USER_EXPORT", "User Export"),
+                    ("ADD", "Add"),
+                    ("REPORT", "Report"),
+                    ("LINK_DOMAIN", "Link Domain"),
+                    ("INVITE", "Invite"),
+                    ("ACCEPT", "Accept"),
+                    ("JOIN", "Join"),
+                    ("LEAVE", "Leave"),
+                    ("REMOVE", "Remove"),
+                    ("GROUP_PRIVACY", "Group Privacy"),
+                    ("GROUP_NAME", "Group Name"),
+                    ("GROUP_DESCRIPTION", "Group Description"),
+                    ("MOVE", "Move"),
+                ],
+                max_length=255,
+            ),
+        ),
+        migrations.CreateModel(
+            name="BookwyrmExportJob",
+            fields=[
+                (
+                    "parentjob_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="bookwyrm.parentjob",
+                    ),
+                ),
+                ("export_data", models.FileField(null=True, upload_to="")),
+            ],
+            options={
+                "abstract": False,
+            },
+            bases=("bookwyrm.parentjob",),
+        ),
+        migrations.CreateModel(
+            name="BookwyrmImportJob",
+            fields=[
+                (
+                    "parentjob_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="bookwyrm.parentjob",
+                    ),
+                ),
+                ("archive_file", models.FileField(blank=True, null=True, upload_to="")),
+                ("import_data", models.JSONField(null=True)),
+                (
+                    "required",
+                    django.contrib.postgres.fields.ArrayField(
+                        base_field=models.CharField(blank=True, max_length=50),
+                        blank=True,
+                        size=None,
+                    ),
+                ),
+            ],
+            options={
+                "abstract": False,
+            },
+            bases=("bookwyrm.parentjob",),
+        ),
+        migrations.CreateModel(
+            name="ChildJob",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("task_id", models.UUIDField(blank=True, null=True, unique=True)),
+                (
+                    "created_date",
+                    models.DateTimeField(default=django.utils.timezone.now),
+                ),
+                (
+                    "updated_date",
+                    models.DateTimeField(default=django.utils.timezone.now),
+                ),
+                ("complete", models.BooleanField(default=False)),
+                (
+                    "status",
+                    models.CharField(
+                        choices=[
+                            ("pending", "Pending"),
+                            ("active", "Active"),
+                            ("complete", "Complete"),
+                            ("stopped", "Stopped"),
+                            ("failed", "Failed"),
+                        ],
+                        default="pending",
+                        max_length=50,
+                        null=True,
+                    ),
+                ),
+                (
+                    "parent_job",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="child_jobs",
+                        to="bookwyrm.parentjob",
+                    ),
+                ),
+            ],
+            options={
+                "abstract": False,
+            },
+        ),
+        migrations.AddField(
+            model_name="notification",
+            name="related_user_export",
+            field=models.ForeignKey(
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                to="bookwyrm.bookwyrmexportjob",
+            ),
+        ),
+    ]

+ 48 - 0
bookwyrm/migrations/0186_invite_request_notification.py

@@ -0,0 +1,48 @@
+# Generated by Django 3.2.20 on 2023-11-14 10:02
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("bookwyrm", "0185_alter_notification_notification_type"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="notification",
+            name="related_invite_requests",
+            field=models.ManyToManyField(to="bookwyrm.InviteRequest"),
+        ),
+        migrations.AlterField(
+            model_name="notification",
+            name="notification_type",
+            field=models.CharField(
+                choices=[
+                    ("FAVORITE", "Favorite"),
+                    ("BOOST", "Boost"),
+                    ("REPLY", "Reply"),
+                    ("MENTION", "Mention"),
+                    ("TAG", "Tag"),
+                    ("FOLLOW", "Follow"),
+                    ("FOLLOW_REQUEST", "Follow Request"),
+                    ("IMPORT", "Import"),
+                    ("ADD", "Add"),
+                    ("REPORT", "Report"),
+                    ("LINK_DOMAIN", "Link Domain"),
+                    ("INVITE_REQUEST", "Invite Request"),
+                    ("INVITE", "Invite"),
+                    ("ACCEPT", "Accept"),
+                    ("JOIN", "Join"),
+                    ("LEAVE", "Leave"),
+                    ("REMOVE", "Remove"),
+                    ("GROUP_PRIVACY", "Group Privacy"),
+                    ("GROUP_NAME", "Group Name"),
+                    ("GROUP_DESCRIPTION", "Group Description"),
+                    ("MOVE", "Move"),
+                ],
+                max_length=255,
+            ),
+        ),
+    ]

+ 54 - 0
bookwyrm/migrations/0187_partial_publication_dates.py

@@ -0,0 +1,54 @@
+# Generated by Django 3.2.20 on 2023-11-09 16:57
+
+import bookwyrm.models.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("bookwyrm", "0186_invite_request_notification"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="book",
+            name="first_published_date_precision",
+            field=models.CharField(
+                blank=True,
+                choices=[
+                    ("DAY", "Day prec."),
+                    ("MONTH", "Month prec."),
+                    ("YEAR", "Year prec."),
+                ],
+                editable=False,
+                max_length=10,
+                null=True,
+            ),
+        ),
+        migrations.AddField(
+            model_name="book",
+            name="published_date_precision",
+            field=models.CharField(
+                blank=True,
+                choices=[
+                    ("DAY", "Day prec."),
+                    ("MONTH", "Month prec."),
+                    ("YEAR", "Year prec."),
+                ],
+                editable=False,
+                max_length=10,
+                null=True,
+            ),
+        ),
+        migrations.AlterField(
+            model_name="book",
+            name="first_published_date",
+            field=bookwyrm.models.fields.PartialDateField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name="book",
+            name="published_date",
+            field=bookwyrm.models.fields.PartialDateField(blank=True, null=True),
+        ),
+    ]

+ 18 - 0
bookwyrm/migrations/0188_theme_loads.py

@@ -0,0 +1,18 @@
+# Generated by Django 3.2.23 on 2023-11-20 18:02
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("bookwyrm", "0187_partial_publication_dates"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="theme",
+            name="loads",
+            field=models.BooleanField(blank=True, null=True),
+        ),
+    ]

+ 45 - 0
bookwyrm/migrations/0189_alter_user_preferred_language.py

@@ -0,0 +1,45 @@
+# Generated by Django 3.2.23 on 2023-12-12 23:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("bookwyrm", "0188_theme_loads"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="user",
+            name="preferred_language",
+            field=models.CharField(
+                blank=True,
+                choices=[
+                    ("en-us", "English"),
+                    ("ca-es", "Català (Catalan)"),
+                    ("de-de", "Deutsch (German)"),
+                    ("eo-uy", "Esperanto (Esperanto)"),
+                    ("es-es", "Español (Spanish)"),
+                    ("eu-es", "Euskara (Basque)"),
+                    ("gl-es", "Galego (Galician)"),
+                    ("it-it", "Italiano (Italian)"),
+                    ("fi-fi", "Suomi (Finnish)"),
+                    ("fr-fr", "Français (French)"),
+                    ("lt-lt", "Lietuvių (Lithuanian)"),
+                    ("nl-nl", "Nederlands (Dutch)"),
+                    ("no-no", "Norsk (Norwegian)"),
+                    ("pl-pl", "Polski (Polish)"),
+                    ("pt-br", "Português do Brasil (Brazilian Portuguese)"),
+                    ("pt-pt", "Português Europeu (European Portuguese)"),
+                    ("ro-ro", "Română (Romanian)"),
+                    ("sv-se", "Svenska (Swedish)"),
+                    ("uk-ua", "Українська (Ukrainian)"),
+                    ("zh-hans", "简体中文 (Simplified Chinese)"),
+                    ("zh-hant", "繁體中文 (Traditional Chinese)"),
+                ],
+                max_length=255,
+                null=True,
+            ),
+        ),
+    ]

+ 13 - 0
bookwyrm/migrations/0189_merge_0186_auto_20231116_0048_0188_theme_loads.py

@@ -0,0 +1,13 @@
+# Generated by Django 3.2.23 on 2023-11-22 10:16
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("bookwyrm", "0186_auto_20231116_0048"),
+        ("bookwyrm", "0188_theme_loads"),
+    ]
+
+    operations = []

+ 45 - 0
bookwyrm/migrations/0190_alter_notification_notification_type.py

@@ -0,0 +1,45 @@
+# Generated by Django 3.2.23 on 2023-11-23 19:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("bookwyrm", "0189_merge_0186_auto_20231116_0048_0188_theme_loads"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="notification",
+            name="notification_type",
+            field=models.CharField(
+                choices=[
+                    ("FAVORITE", "Favorite"),
+                    ("BOOST", "Boost"),
+                    ("REPLY", "Reply"),
+                    ("MENTION", "Mention"),
+                    ("TAG", "Tag"),
+                    ("FOLLOW", "Follow"),
+                    ("FOLLOW_REQUEST", "Follow Request"),
+                    ("IMPORT", "Import"),
+                    ("USER_IMPORT", "User Import"),
+                    ("USER_EXPORT", "User Export"),
+                    ("ADD", "Add"),
+                    ("REPORT", "Report"),
+                    ("LINK_DOMAIN", "Link Domain"),
+                    ("INVITE_REQUEST", "Invite Request"),
+                    ("INVITE", "Invite"),
+                    ("ACCEPT", "Accept"),
+                    ("JOIN", "Join"),
+                    ("LEAVE", "Leave"),
+                    ("REMOVE", "Remove"),
+                    ("GROUP_PRIVACY", "Group Privacy"),
+                    ("GROUP_NAME", "Group Name"),
+                    ("GROUP_DESCRIPTION", "Group Description"),
+                    ("MOVE", "Move"),
+                ],
+                max_length=255,
+            ),
+        ),
+    ]

+ 13 - 0
bookwyrm/migrations/0191_merge_20240102_0326.py

@@ -0,0 +1,13 @@
+# Generated by Django 3.2.23 on 2024-01-02 03:26
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("bookwyrm", "0189_alter_user_preferred_language"),
+        ("bookwyrm", "0190_alter_notification_notification_type"),
+    ]
+
+    operations = []

+ 5 - 1
bookwyrm/models/__init__.py

@@ -26,13 +26,17 @@ from .federated_server import FederatedServer
 from .group import Group, GroupMember, GroupMemberInvitation
 
 from .import_job import ImportJob, ImportItem
+from .bookwyrm_import_job import BookwyrmImportJob
+from .bookwyrm_export_job import BookwyrmExportJob
+
+from .move import MoveUser
 
 from .site import SiteSettings, Theme, SiteInvite
 from .site import PasswordReset, InviteRequest
 from .announcement import Announcement
 from .antispam import EmailBlocklist, IPBlocklist, AutoMod, automod_task
 
-from .notification import Notification
+from .notification import Notification, NotificationType
 
 from .hashtag import Hashtag
 

+ 1 - 1
bookwyrm/models/activitypub_mixin.py

@@ -602,7 +602,7 @@ def to_ordered_collection_page(
     if activity_page.has_next():
         next_page = f"{remote_id}?page={activity_page.next_page_number()}"
     if activity_page.has_previous():
-        prev_page = f"{remote_id}?page=%d{activity_page.previous_page_number()}"
+        prev_page = f"{remote_id}?page={activity_page.previous_page_number()}"
     return activitypub.OrderedCollectionPage(
         id=f"{remote_id}?page={page}",
         partOf=remote_id,

+ 2 - 1
bookwyrm/models/antispam.py

@@ -10,6 +10,7 @@ from django.utils.translation import gettext_lazy as _
 
 from bookwyrm.tasks import app, MISC
 from .base_model import BookWyrmModel
+from .notification import NotificationType
 from .user import User
 
 
@@ -80,7 +81,7 @@ def automod_task():
     with transaction.atomic():
         for admin in admins:
             notification, _ = notification_model.objects.get_or_create(
-                user=admin, notification_type=notification_model.REPORT, read=False
+                user=admin, notification_type=NotificationType.REPORT, read=False
             )
             notification.related_reports.set(reports)
 

+ 13 - 9
bookwyrm/models/book.py

@@ -135,8 +135,8 @@ class Book(BookDataModel):
     preview_image = models.ImageField(
         upload_to="previews/covers/", blank=True, null=True
     )
-    first_published_date = fields.DateTimeField(blank=True, null=True)
-    published_date = fields.DateTimeField(blank=True, null=True)
+    first_published_date = fields.PartialDateField(blank=True, null=True)
+    published_date = fields.PartialDateField(blank=True, null=True)
 
     objects = InheritanceManager()
     field_tracker = FieldTracker(fields=["authors", "title", "subtitle", "cover"])
@@ -201,14 +201,13 @@ class Book(BookDataModel):
     @property
     def alt_text(self):
         """image alt test"""
-        text = self.title
-        if self.edition_info:
-            text += f" ({self.edition_info})"
-        return text
+        author = f"{name}: " if (name := self.author_text) else ""
+        edition = f" ({info})" if (info := self.edition_info) else ""
+        return f"{author}{self.title}{edition}"
 
     def save(self, *args: Any, **kwargs: Any) -> None:
         """can't be abstract for query reasons, but you shouldn't USE it"""
-        if not isinstance(self, Edition) and not isinstance(self, Work):
+        if not isinstance(self, (Edition, Work)):
             raise ValueError("Books should be added as Editions or Works")
 
         return super().save(*args, **kwargs)
@@ -367,9 +366,9 @@ class Edition(Book):
 
         # normalize isbn format
         if self.isbn_10:
-            self.isbn_10 = re.sub(r"[^0-9X]", "", self.isbn_10)
+            self.isbn_10 = normalize_isbn(self.isbn_10)
         if self.isbn_13:
-            self.isbn_13 = re.sub(r"[^0-9X]", "", self.isbn_13)
+            self.isbn_13 = normalize_isbn(self.isbn_13)
 
         # set rank
         self.edition_rank = self.get_rank()
@@ -464,6 +463,11 @@ def isbn_13_to_10(isbn_13):
     return converted + str(checkdigit)
 
 
+def normalize_isbn(isbn):
+    """Remove unexpected characters from ISBN 10 or 13"""
+    return re.sub(r"[^0-9X]", "", isbn)
+
+
 # pylint: disable=unused-argument
 @receiver(models.signals.post_save, sender=Edition)
 def preview_image(instance, *args, **kwargs):

+ 232 - 0
bookwyrm/models/bookwyrm_export_job.py

@@ -0,0 +1,232 @@
+"""Export user account to tar.gz file for import into another Bookwyrm instance"""
+
+import dataclasses
+import logging
+from uuid import uuid4
+
+from django.db.models import FileField
+from django.db.models import Q
+from django.core.serializers.json import DjangoJSONEncoder
+from django.core.files.base import ContentFile
+
+from bookwyrm.models import AnnualGoal, ReadThrough, ShelfBook, List, 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.tasks import app, IMPORTS
+from bookwyrm.utils.tar import BookwyrmTarFile
+
+logger = logging.getLogger(__name__)
+
+
+class BookwyrmExportJob(ParentJob):
+    """entry for a specific request to export a bookwyrm user"""
+
+    export_data = FileField(null=True)
+
+    def start_job(self):
+        """Start the job"""
+        start_export_task.delay(job_id=self.id, no_children=True)
+
+        return self
+
+
+@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"])
+
+    # 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"])
+    except Exception as err:  # pylint: disable=broad-except
+        logger.exception("User Export Job %s Failed with error: %s", job.id, err)
+        job.set_status("failed")
+
+    job.set_status("complete")
+
+
+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"))
+
+        # Add avatar image if present
+        if getattr(user, "avatar", False):
+            tar.add_image(user.avatar, filename="avatar")
+
+        editions = get_books_for_user(user)
+        for book in editions:
+            if getattr(book, "cover", False):
+                tar.add_image(book.cover)
+
+    file.close()
+
+
+def json_export(
+    user,
+):  # pylint: disable=too-many-locals, too-many-statements, too-many-branches
+    """Generate an export for a user"""
+
+    # 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 isinstance(
+        exported_user["icon"],
+        dataclasses._MISSING_TYPE,  # pylint: disable=protected-access
+    ):
+        exported_user["icon"] = {}
+    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
+
+    # 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)
+
+    # 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)
+
+    # 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
+    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]
+
+    # blocks - just the remote id
+    blocks = UserBlocks.objects.filter(user_subject=user).distinct()
+    blocking = User.objects.filter(userblocks_user_object__in=blocks).distinct()
+
+    exported_user["blocks"] = [b.remote_id for b in blocking]
+
+    return DjangoJSONEncoder().encode(exported_user)
+
+
+def get_books_for_user(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()
+    )
+
+    return editions

+ 459 - 0
bookwyrm/models/bookwyrm_import_job.py

@@ -0,0 +1,459 @@
+"""Import a user from another Bookwyrm instance"""
+
+import json
+import logging
+
+from django.db.models import FileField, JSONField, CharField
+from django.utils import timezone
+from django.utils.html import strip_tags
+from django.contrib.postgres.fields import ArrayField as DjangoArrayField
+
+from bookwyrm import activitypub
+from bookwyrm import models
+from bookwyrm.tasks import app, IMPORTS
+from bookwyrm.models.job import ParentJob, ParentTask, SubTask
+from bookwyrm.utils.tar import BookwyrmTarFile
+
+logger = logging.getLogger(__name__)
+
+
+class BookwyrmImportJob(ParentJob):
+    """entry for a specific request for importing a bookwyrm user backup"""
+
+    archive_file = FileField(null=True, blank=True)
+    import_data = JSONField(null=True)
+    required = DjangoArrayField(CharField(max_length=50, blank=True), blank=True)
+
+    def start_job(self):
+        """Start the job"""
+        start_import_task.delay(job_id=self.id, no_children=True)
+
+
+@app.task(queue=IMPORTS, base=ParentTask)
+def start_import_task(**kwargs):
+    """trigger the child import tasks for each user data"""
+    job = BookwyrmImportJob.objects.get(id=kwargs["job_id"])
+    archive_file = job.archive_file
+
+    # don't start the job if it was stopped from the UI
+    if job.complete:
+        return
+
+    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"))
+
+            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"))
+            if "include_saved_lists" in job.required:
+                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"))
+            if "include_blocks" in job.required:
+                upsert_user_blocks(job.user, job.import_data.get("blocks"))
+
+            process_books(job, tar)
+
+            job.set_status("complete")
+        archive_file.close()
+
+    except Exception as err:  # pylint: disable=broad-except
+        logger.exception("User Import Job %s Failed with error: %s", job.id, err)
+        job.set_status("failed")
+
+
+def process_books(job, tar):
+    """
+    Process user import data related to books
+    We always import the books even if not assigning
+    them to shelves, lists etc
+    """
+
+    books = job.import_data.get("books")
+
+    for data in books:
+        book = get_or_create_edition(data, tar)
+
+        if "include_shelves" in job.required:
+            upsert_shelves(book, job.user, data)
+
+        if "include_readthroughs" in job.required:
+            upsert_readthroughs(data.get("readthroughs"), job.user, book.id)
+
+        if "include_comments" in job.required:
+            upsert_statuses(
+                job.user, models.Comment, data.get("comments"), book.remote_id
+            )
+        if "include_quotations" in job.required:
+            upsert_statuses(
+                job.user, models.Quotation, data.get("quotations"), book.remote_id
+            )
+
+        if "include_reviews" in job.required:
+            upsert_statuses(
+                job.user, models.Review, data.get("reviews"), book.remote_id
+            )
+
+        if "include_lists" in job.required:
+            upsert_lists(job.user, data.get("lists"), book.id)
+
+
+def get_or_create_edition(book_data, tar):
+    """Take a JSON string of work and edition data,
+    find or create the edition and work in the database and
+    return an edition instance"""
+
+    edition = book_data.get("edition")
+    existing = models.Edition.find_existing(edition)
+    if existing:
+        return existing
+
+    # make sure we have the authors in the local DB
+    # replace the old author ids in the edition JSON
+    edition["authors"] = []
+    for author in book_data.get("authors"):
+        parsed_author = activitypub.parse(author)
+        instance = parsed_author.to_model(
+            model=models.Author, save=True, overwrite=True
+        )
+
+        edition["authors"].append(instance.remote_id)
+
+    # we will add the cover later from the tar
+    # don't try to load it from the old server
+    cover = edition.get("cover", {})
+    cover_path = cover.get("url", None)
+    edition["cover"] = {}
+
+    # first we need the parent work to exist
+    work = book_data.get("work")
+    work["editions"] = []
+    parsed_work = activitypub.parse(work)
+    work_instance = parsed_work.to_model(model=models.Work, save=True, overwrite=True)
+
+    # now we have a work we can add it to the edition
+    # and create the edition model instance
+    edition["work"] = work_instance.remote_id
+    parsed_edition = activitypub.parse(edition)
+    book = parsed_edition.to_model(model=models.Edition, save=True, overwrite=True)
+
+    # set the cover image from the tar
+    if cover_path:
+        tar.write_image_to_file(cover_path, book.cover)
+
+    return book
+
+
+def upsert_readthroughs(data, user, book_id):
+    """Take a JSON string of readthroughs and
+    find or create the instances in the database"""
+
+    for read_through in data:
+
+        obj = {}
+        keys = [
+            "progress_mode",
+            "start_date",
+            "finish_date",
+            "stopped_date",
+            "is_active",
+        ]
+        for key in keys:
+            obj[key] = read_through[key]
+        obj["user_id"] = user.id
+        obj["book_id"] = book_id
+
+        existing = models.ReadThrough.objects.filter(**obj).first()
+        if not existing:
+            models.ReadThrough.objects.create(**obj)
+
+
+def upsert_statuses(user, cls, data, book_remote_id):
+    """Take a JSON string of a status and
+    find or create the instances in the database"""
+
+    for status in data:
+        if is_alias(
+            user, status["attributedTo"]
+        ):  # don't let l33t hax0rs steal other people's posts
+            # update ids and remove replies
+            status["attributedTo"] = user.remote_id
+            status["to"] = update_followers_address(user, status["to"])
+            status["cc"] = update_followers_address(user, status["cc"])
+            status[
+                "replies"
+            ] = (
+                {}
+            )  # this parses incorrectly but we can't set it without knowing the new id
+            status["inReplyToBook"] = book_remote_id
+            parsed = activitypub.parse(status)
+            if not status_already_exists(
+                user, parsed
+            ):  # don't duplicate posts on multiple import
+
+                instance = parsed.to_model(model=cls, save=True, overwrite=True)
+
+                for val in [
+                    "progress",
+                    "progress_mode",
+                    "position",
+                    "endposition",
+                    "position_mode",
+                ]:
+                    if status.get(val):
+                        instance.val = status[val]
+
+                instance.remote_id = instance.get_remote_id()  # update the remote_id
+                instance.save()  # save and broadcast
+
+        else:
+            logger.info("User does not have permission to import statuses")
+
+
+def upsert_lists(user, lists, book_id):
+    """Take a list of objects each containing
+    a list and list item as AP objects
+
+    Because we are creating new IDs we can't assume the id
+    will exist or be accurate, so we only use to_model for
+    adding new items after checking whether they exist  .
+
+    """
+
+    book = models.Edition.objects.get(id=book_id)
+
+    for blist in lists:
+        booklist = models.List.objects.filter(name=blist["name"], user=user).first()
+        if not booklist:
+
+            blist["owner"] = user.remote_id
+            parsed = activitypub.parse(blist)
+            booklist = parsed.to_model(model=models.List, save=True, overwrite=True)
+
+            booklist.privacy = blist["privacy"]
+            booklist.save()
+
+        item = models.ListItem.objects.filter(book=book, book_list=booklist).exists()
+        if not item:
+            count = booklist.books.count()
+            models.ListItem.objects.create(
+                book=book,
+                book_list=booklist,
+                user=user,
+                notes=blist["list_item"]["notes"],
+                approved=blist["list_item"]["approved"],
+                order=count + 1,
+            )
+
+
+def upsert_shelves(book, user, book_data):
+    """Take shelf JSON objects and create
+    DB entries if they don't already exist"""
+
+    shelves = book_data["shelves"]
+    for shelf in shelves:
+
+        book_shelf = models.Shelf.objects.filter(name=shelf["name"], user=user).first()
+
+        if not book_shelf:
+            book_shelf = models.Shelf.objects.create(name=shelf["name"], user=user)
+
+        # add the book as a ShelfBook if needed
+        if not models.ShelfBook.objects.filter(
+            book=book, shelf=book_shelf, user=user
+        ).exists():
+            models.ShelfBook.objects.create(
+                book=book, shelf=book_shelf, user=user, shelved_date=timezone.now()
+            )
+
+
+def update_user_profile(user, tar, data):
+    """update the user's profile from import data"""
+    name = data.get("name", None)
+    username = data.get("preferredUsername")
+    user.name = name if name else username
+    user.summary = strip_tags(data.get("summary", None))
+    user.save(update_fields=["name", "summary"])
+    if data["icon"].get("url"):
+        avatar_filename = next(filter(lambda n: n.startswith("avatar"), tar.getnames()))
+        tar.write_image_to_file(avatar_filename, user.avatar)
+
+
+def update_user_settings(user, data):
+    """update the user's settings from import data"""
+
+    update_fields = ["manually_approves_followers", "hide_follows", "discoverable"]
+
+    ap_fields = [
+        ("manuallyApprovesFollowers", "manually_approves_followers"),
+        ("hideFollows", "hide_follows"),
+        ("discoverable", "discoverable"),
+    ]
+
+    for (ap_field, bw_field) in ap_fields:
+        setattr(user, bw_field, data[ap_field])
+
+    bw_fields = [
+        "show_goal",
+        "show_suggested_users",
+        "default_post_privacy",
+        "preferred_timezone",
+    ]
+
+    for field in bw_fields:
+        update_fields.append(field)
+        setattr(user, field, data["settings"][field])
+
+    user.save(update_fields=update_fields)
+
+
+@app.task(queue=IMPORTS, base=SubTask)
+def update_user_settings_task(job_id):
+    """wrapper task for user's settings import"""
+    parent_job = BookwyrmImportJob.objects.get(id=job_id)
+
+    return update_user_settings(parent_job.user, parent_job.import_data.get("user"))
+
+
+def update_goals(user, data):
+    """update the user's goals from import data"""
+
+    for goal in data:
+        # edit the existing goal if there is one
+        existing = models.AnnualGoal.objects.filter(
+            year=goal["year"], user=user
+        ).first()
+        if existing:
+            for k in goal.keys():
+                setattr(existing, k, goal[k])
+            existing.save()
+        else:
+            goal["user"] = user
+            models.AnnualGoal.objects.create(**goal)
+
+
+@app.task(queue=IMPORTS, base=SubTask)
+def update_goals_task(job_id):
+    """wrapper task for user's goals import"""
+    parent_job = BookwyrmImportJob.objects.get(id=job_id)
+
+    return update_goals(parent_job.user, parent_job.import_data.get("goals"))
+
+
+def upsert_saved_lists(user, values):
+    """Take a list of remote ids and add as saved lists"""
+
+    for remote_id in values:
+        book_list = activitypub.resolve_remote_id(remote_id, models.List)
+        if book_list:
+            user.saved_lists.add(book_list)
+
+
+@app.task(queue=IMPORTS, base=SubTask)
+def upsert_saved_lists_task(job_id):
+    """wrapper task for user's saved lists import"""
+    parent_job = BookwyrmImportJob.objects.get(id=job_id)
+
+    return upsert_saved_lists(
+        parent_job.user, parent_job.import_data.get("saved_lists")
+    )
+
+
+def upsert_follows(user, values):
+    """Take a list of remote ids and add as follows"""
+
+    for remote_id in values:
+        followee = activitypub.resolve_remote_id(remote_id, models.User)
+        if followee:
+            (follow_request, created,) = models.UserFollowRequest.objects.get_or_create(
+                user_subject=user,
+                user_object=followee,
+            )
+
+            if not created:
+                # this request probably failed to connect with the remote
+                # and should save to trigger a re-broadcast
+                follow_request.save()
+
+
+@app.task(queue=IMPORTS, base=SubTask)
+def upsert_follows_task(job_id):
+    """wrapper task for user's follows import"""
+    parent_job = BookwyrmImportJob.objects.get(id=job_id)
+
+    return upsert_follows(parent_job.user, parent_job.import_data.get("follows"))
+
+
+def upsert_user_blocks(user, user_ids):
+    """block users"""
+
+    for user_id in user_ids:
+        user_object = activitypub.resolve_remote_id(user_id, models.User)
+        if user_object:
+            exists = models.UserBlocks.objects.filter(
+                user_subject=user, user_object=user_object
+            ).exists()
+            if not exists:
+                models.UserBlocks.objects.create(
+                    user_subject=user, user_object=user_object
+                )
+                # remove the blocked users's lists from the groups
+                models.List.remove_from_group(user, user_object)
+                # remove the blocked user from all blocker's owned groups
+                models.GroupMember.remove(user, user_object)
+
+
+@app.task(queue=IMPORTS, base=SubTask)
+def upsert_user_blocks_task(job_id):
+    """wrapper task for user's blocks import"""
+    parent_job = BookwyrmImportJob.objects.get(id=job_id)
+
+    return upsert_user_blocks(
+        parent_job.user, parent_job.import_data.get("blocked_users")
+    )
+
+
+def update_followers_address(user, field):
+    """statuses to or cc followers need to have the followers
+    address updated to the new local user"""
+
+    for i, audience in enumerate(field):
+        if audience.rsplit("/")[-1] == "followers":
+            field[i] = user.followers_url
+
+    return field
+
+
+def is_alias(user, remote_id):
+    """check that the user is listed as movedTo or also_known_as
+    in the remote user's profile"""
+
+    remote_user = activitypub.resolve_remote_id(
+        remote_id=remote_id, model=models.User, save=False
+    )
+
+    if remote_user:
+
+        if remote_user.moved_to:
+            return user.remote_id == remote_user.moved_to
+
+        if remote_user.also_known_as:
+            return user in remote_user.also_known_as.all()
+
+    return False
+
+
+def status_already_exists(user, status):
+    """check whether this status has already been published
+    by this user. We can't rely on to_model() because it
+    only matches on remote_id, which we have to change
+    *after* saving because it needs the primary key (id)"""
+
+    return models.Status.objects.filter(
+        user=user, content=status.content, published_date=status.published
+    ).exists()

+ 44 - 4
bookwyrm/models/fields.py

@@ -1,5 +1,6 @@
 """ activitypub-aware django model fields """
 from dataclasses import MISSING
+from datetime import datetime
 import re
 from uuid import uuid4
 from urllib.parse import urljoin
@@ -19,6 +20,11 @@ from markdown import markdown
 from bookwyrm import activitypub
 from bookwyrm.connectors import get_image
 from bookwyrm.utils.sanitizer import clean
+from bookwyrm.utils.partial_date import (
+    PartialDate,
+    PartialDateModel,
+    from_partial_isoformat,
+)
 from bookwyrm.settings import MEDIA_FULL_URL
 
 
@@ -482,10 +488,12 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
         image_slug = value
         # when it's an inline image (User avatar/icon, Book cover), it's a json
         # blob, but when it's an attached image, it's just a url
-        if hasattr(image_slug, "url"):
-            url = image_slug.url
-        elif isinstance(image_slug, str):
+        if isinstance(image_slug, str):
             url = image_slug
+        elif isinstance(image_slug, dict):
+            url = image_slug.get("url")
+        elif hasattr(image_slug, "url"):  # Serialized to Image/Document object?
+            url = image_slug.url
         else:
             return None
 
@@ -534,8 +542,9 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
         return value.isoformat()
 
     def field_from_activity(self, value, allow_external_connections=True):
+        missing_fields = datetime(1970, 1, 1)  # "2022-10" => "2022-10-01"
         try:
-            date_value = dateutil.parser.parse(value)
+            date_value = dateutil.parser.parse(value, default=missing_fields)
             try:
                 return timezone.make_aware(date_value)
             except ValueError:
@@ -544,6 +553,37 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
             return None
 
 
+class PartialDateField(ActivitypubFieldMixin, PartialDateModel):
+    """activitypub-aware partial date field"""
+
+    def field_to_activity(self, value) -> str:
+        return value.partial_isoformat() if value else None
+
+    def field_from_activity(self, value, allow_external_connections=True):
+        # pylint: disable=no-else-return
+        try:
+            return from_partial_isoformat(value)
+        except ValueError:
+            pass
+
+        # fallback to full ISO-8601 parsing
+        try:
+            parsed = dateutil.parser.isoparse(value)
+        except (ValueError, ParserError):
+            return None
+
+        if timezone.is_aware(parsed):
+            return PartialDate.from_datetime(parsed)
+        else:
+            # Should not happen on the wire, but truncate down to date parts.
+            return PartialDate.from_date_parts(parsed.year, parsed.month, parsed.day)
+
+        # FIXME: decide whether to fix timestamps like "2023-09-30T21:00:00-03":
+        # clearly Oct 1st, not Sep 30th (an unwanted side-effect of USE_TZ). It's
+        # basically the remnants of #3028; there is a data migration pending (see …)
+        # but over the wire we might get these for an indeterminate amount of time.
+
+
 class HtmlField(ActivitypubFieldMixin, models.TextField):
     """a text field for storing html"""
 

+ 7 - 6
bookwyrm/models/group.py

@@ -1,5 +1,4 @@
 """ do book related things with other users """
-from django.apps import apps
 from django.db import models, IntegrityError, transaction
 from django.db.models import Q
 from bookwyrm.settings import DOMAIN
@@ -143,26 +142,28 @@ class GroupMemberInvitation(models.Model):
     @transaction.atomic
     def accept(self):
         """turn this request into the real deal"""
+        # pylint: disable-next=import-outside-toplevel
+        from .notification import Notification, NotificationType  # circular dependency
+
         GroupMember.from_request(self)
 
-        model = apps.get_model("bookwyrm.Notification", require_ready=True)
         # tell the group owner
-        model.notify(
+        Notification.notify(
             self.group.user,
             self.user,
             related_group=self.group,
-            notification_type=model.ACCEPT,
+            notification_type=NotificationType.ACCEPT,
         )
 
         # let the other members know about it
         for membership in self.group.memberships.all():
             member = membership.user
             if member not in (self.user, self.group.user):
-                model.notify(
+                Notification.notify(
                     member,
                     self.user,
                     related_group=self.group,
-                    notification_type=model.JOIN,
+                    notification_type=NotificationType.JOIN,
                 )
 
     def reject(self):

+ 14 - 21
bookwyrm/models/import_job.py

@@ -1,4 +1,5 @@
 """ track progress of goodreads imports """
+from datetime import datetime
 import math
 import re
 import dateutil.parser
@@ -259,38 +260,30 @@ class ImportItem(models.Model):
         except ValueError:
             return None
 
+    def _parse_datefield(self, field, /):
+        if not (date := self.normalized_data.get(field)):
+            return None
+
+        defaults = datetime(1970, 1, 1)  # "2022-10" => "2022-10-01"
+        parsed = dateutil.parser.parse(date, default=defaults)
+
+        # Keep timezone if import already had one, else use default.
+        return parsed if timezone.is_aware(parsed) else timezone.make_aware(parsed)
+
     @property
     def date_added(self):
         """when the book was added to this dataset"""
-        if self.normalized_data.get("date_added"):
-            parsed_date_added = dateutil.parser.parse(
-                self.normalized_data.get("date_added")
-            )
-
-            if timezone.is_aware(parsed_date_added):
-                # Keep timezone if import already had one
-                return parsed_date_added
-
-            return timezone.make_aware(parsed_date_added)
-        return None
+        return self._parse_datefield("date_added")
 
     @property
     def date_started(self):
         """when the book was started"""
-        if self.normalized_data.get("date_started"):
-            return timezone.make_aware(
-                dateutil.parser.parse(self.normalized_data.get("date_started"))
-            )
-        return None
+        return self._parse_datefield("date_started")
 
     @property
     def date_read(self):
         """the date a book was completed"""
-        if self.normalized_data.get("date_finished"):
-            return timezone.make_aware(
-                dateutil.parser.parse(self.normalized_data.get("date_finished"))
-            )
-        return None
+        return self._parse_datefield("date_finished")
 
     @property
     def reads(self):

+ 308 - 0
bookwyrm/models/job.py

@@ -0,0 +1,308 @@
+"""Everything needed for Celery to multi-thread complex tasks."""
+
+from django.db import models
+from django.db import transaction
+from django.utils.translation import gettext_lazy as _
+from django.utils import timezone
+from bookwyrm.models.user import User
+
+from bookwyrm.tasks import app
+
+
+class Job(models.Model):
+    """Abstract model to store the state of a Task."""
+
+    class Status(models.TextChoices):
+        """Possible job states."""
+
+        PENDING = "pending", _("Pending")
+        ACTIVE = "active", _("Active")
+        COMPLETE = "complete", _("Complete")
+        STOPPED = "stopped", _("Stopped")
+        FAILED = "failed", _("Failed")
+
+    task_id = models.UUIDField(unique=True, null=True, blank=True)
+
+    created_date = models.DateTimeField(default=timezone.now)
+    updated_date = models.DateTimeField(default=timezone.now)
+    complete = models.BooleanField(default=False)
+    status = models.CharField(
+        max_length=50, choices=Status.choices, default=Status.PENDING, null=True
+    )
+
+    class Meta:
+        """Make it abstract"""
+
+        abstract = True
+
+    def complete_job(self):
+        """Report that the job has completed"""
+        if self.complete:
+            return
+
+        self.status = self.Status.COMPLETE
+        self.complete = True
+        self.updated_date = timezone.now()
+
+        self.save(update_fields=["status", "complete", "updated_date"])
+
+    def stop_job(self, reason=None):
+        """Stop the job"""
+        if self.complete:
+            return
+
+        self.__terminate_job()
+
+        if reason and reason == "failed":
+            self.status = self.Status.FAILED
+        else:
+            self.status = self.Status.STOPPED
+        self.complete = True
+        self.updated_date = timezone.now()
+
+        self.save(update_fields=["status", "complete", "updated_date"])
+
+    def set_status(self, status):
+        """Set job status"""
+        if self.complete:
+            return
+
+        if self.status == status:
+            return
+
+        if status == self.Status.COMPLETE:
+            self.complete_job()
+            return
+
+        if status == self.Status.STOPPED:
+            self.stop_job()
+            return
+
+        if status == self.Status.FAILED:
+            self.stop_job(reason="failed")
+            return
+
+        self.updated_date = timezone.now()
+        self.status = status
+
+        self.save(update_fields=["status", "updated_date"])
+
+    def __terminate_job(self):
+        """Tell workers to ignore and not execute this task."""
+        app.control.revoke(self.task_id, terminate=True)
+
+
+class ParentJob(Job):
+    """Store the state of a Task which can spawn many :model:`ChildJob`s to spread
+    resource load.
+
+    Intended to be sub-classed if necessary via proxy or
+    multi-table inheritance.
+    Extends :model:`Job`.
+    """
+
+    user = models.ForeignKey(User, on_delete=models.CASCADE)
+
+    def complete_job(self):
+        """Report that the job has completed and stop pending
+        children. Extend.
+        """
+        super().complete_job()
+        self.__terminate_pending_child_jobs()
+
+    def notify_child_job_complete(self):
+        """let the job know when the items get work done"""
+        if self.complete:
+            return
+
+        self.updated_date = timezone.now()
+        self.save(update_fields=["updated_date"])
+
+        if not self.complete and self.has_completed:
+            self.complete_job()
+
+    def __terminate_job(self):  # pylint: disable=unused-private-member
+        """Tell workers to ignore and not execute this task
+        & pending child tasks. Extend.
+        """
+        super().__terminate_job()
+        self.__terminate_pending_child_jobs()
+
+    def __terminate_pending_child_jobs(self):
+        """Tell workers to ignore and not execute any pending child tasks."""
+        tasks = self.pending_child_jobs.filter(task_id__isnull=False).values_list(
+            "task_id", flat=True
+        )
+        app.control.revoke(list(tasks))
+
+        for task in self.pending_child_jobs:
+            task.update(status=self.Status.STOPPED)
+
+    @property
+    def has_completed(self):
+        """has this job finished"""
+        return not self.pending_child_jobs.exists()
+
+    @property
+    def pending_child_jobs(self):
+        """items that haven't been processed yet"""
+        return self.child_jobs.filter(complete=False)
+
+
+class ChildJob(Job):
+    """Stores the state of a Task for the related :model:`ParentJob`.
+
+    Intended to be sub-classed if necessary via proxy or
+    multi-table inheritance.
+    Extends :model:`Job`.
+    """
+
+    parent_job = models.ForeignKey(
+        ParentJob, on_delete=models.CASCADE, related_name="child_jobs"
+    )
+
+    def set_status(self, status):
+        """Set job and parent_job status. Extend."""
+        super().set_status(status)
+
+        if (
+            status == self.Status.ACTIVE
+            and self.parent_job.status == self.Status.PENDING
+        ):
+            self.parent_job.set_status(self.Status.ACTIVE)
+
+    def complete_job(self):
+        """Report to parent_job that the job has completed. Extend."""
+        super().complete_job()
+        self.parent_job.notify_child_job_complete()
+
+
+class ParentTask(app.Task):
+    """Used with ParentJob, Abstract Tasks execute code at specific points in
+    a Task's lifecycle, applying to all Tasks with the same 'base'.
+
+    All status & ParentJob.task_id assignment is managed here for you.
+    Usage e.g. @app.task(base=ParentTask)
+    """
+
+    def before_start(
+        self, task_id, args, kwargs
+    ):  # pylint: disable=no-self-use, unused-argument
+        """Handler called before the task starts. Override.
+
+        Prepare ParentJob before the task starts.
+
+        Arguments:
+            task_id (str): Unique id of the task to execute.
+            args (Tuple): Original arguments for the task to execute.
+            kwargs (Dict): Original keyword arguments for the task to execute.
+
+        Keyword Arguments:
+            job_id (int): Unique 'id' of the ParentJob.
+            no_children (bool): If 'True' this is the only Task expected to run
+                for the given ParentJob.
+
+        Returns:
+            None: The return value of this handler is ignored.
+        """
+        job = ParentJob.objects.get(id=kwargs["job_id"])
+        job.task_id = task_id
+        job.save(update_fields=["task_id"])
+
+        if kwargs["no_children"]:
+            job.set_status(ChildJob.Status.ACTIVE)
+
+    def on_success(
+        self, retval, task_id, args, kwargs
+    ):  # pylint: disable=no-self-use, unused-argument
+        """Run by the worker if the task executes successfully. Override.
+
+        Update ParentJob on Task complete.
+
+        Arguments:
+            retval (Any): The return value of the task.
+            task_id (str): Unique id of the executed task.
+            args (Tuple): Original arguments for the executed task.
+            kwargs (Dict): Original keyword arguments for the executed task.
+
+        Keyword Arguments:
+            job_id (int): Unique 'id' of the ParentJob.
+            no_children (bool): If 'True' this is the only Task expected to run
+                for the given ParentJob.
+
+        Returns:
+            None: The return value of this handler is ignored.
+        """
+
+        if kwargs["no_children"]:
+            job = ParentJob.objects.get(id=kwargs["job_id"])
+            job.complete_job()
+
+
+class SubTask(app.Task):
+    """Used with ChildJob, Abstract Tasks execute code at specific points in
+    a Task's lifecycle, applying to all Tasks with the same 'base'.
+
+    All status & ChildJob.task_id assignment is managed here for you.
+    Usage e.g. @app.task(base=SubTask)
+    """
+
+    def before_start(
+        self, task_id, args, kwargs
+    ):  # pylint: disable=no-self-use, unused-argument
+        """Handler called before the task starts. Override.
+
+        Prepare ChildJob before the task starts.
+
+        Arguments:
+            task_id (str): Unique id of the task to execute.
+            args (Tuple): Original arguments for the task to execute.
+            kwargs (Dict): Original keyword arguments for the task to execute.
+
+        Keyword Arguments:
+            job_id (int): Unique 'id' of the ParentJob.
+            child_id (int): Unique 'id' of the ChildJob.
+
+        Returns:
+            None: The return value of this handler is ignored.
+        """
+        child_job = ChildJob.objects.get(id=kwargs["child_id"])
+        child_job.task_id = task_id
+        child_job.save(update_fields=["task_id"])
+        child_job.set_status(ChildJob.Status.ACTIVE)
+
+    def on_success(
+        self, retval, task_id, args, kwargs
+    ):  # pylint: disable=no-self-use, unused-argument
+        """Run by the worker if the task executes successfully. Override.
+
+        Notify ChildJob of task completion.
+
+        Arguments:
+            retval (Any): The return value of the task.
+            task_id (str): Unique id of the executed task.
+            args (Tuple): Original arguments for the executed task.
+            kwargs (Dict): Original keyword arguments for the executed task.
+
+        Keyword Arguments:
+            job_id (int): Unique 'id' of the ParentJob.
+            child_id (int): Unique 'id' of the ChildJob.
+
+        Returns:
+            None: The return value of this handler is ignored.
+        """
+        subtask = ChildJob.objects.get(id=kwargs["child_id"])
+        subtask.complete_job()
+
+
+@transaction.atomic
+def create_child_job(parent_job, task_callback):
+    """Utility method for creating a ChildJob
+    and running a task to avoid DB race conditions
+    """
+    child_job = ChildJob.objects.create(parent_job=parent_job)
+    transaction.on_commit(
+        lambda: task_callback.delay(job_id=parent_job.id, child_id=child_job.id)
+    )
+
+    return child_job

+ 71 - 0
bookwyrm/models/move.py

@@ -0,0 +1,71 @@
+""" move an object including migrating a user account """
+from django.core.exceptions import PermissionDenied
+from django.db import models
+
+from bookwyrm import activitypub
+from .activitypub_mixin import ActivityMixin
+from .base_model import BookWyrmModel
+from . import fields
+from .notification import Notification, NotificationType
+
+
+class Move(ActivityMixin, BookWyrmModel):
+    """migrating an activitypub user account"""
+
+    user = fields.ForeignKey(
+        "User", on_delete=models.PROTECT, activitypub_field="actor"
+    )
+
+    object = fields.CharField(
+        max_length=255,
+        blank=False,
+        null=False,
+        activitypub_field="object",
+    )
+
+    origin = fields.CharField(
+        max_length=255,
+        blank=True,
+        null=True,
+        default="",
+        activitypub_field="origin",
+    )
+
+    activity_serializer = activitypub.Move
+
+
+class MoveUser(Move):
+    """migrating an activitypub user account"""
+
+    target = fields.ForeignKey(
+        "User",
+        on_delete=models.PROTECT,
+        related_name="move_target",
+        activitypub_field="target",
+    )
+
+    def save(self, *args, **kwargs):
+        """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:
+            raise PermissionDenied()

+ 92 - 29
bookwyrm/models/notification.py

@@ -1,12 +1,21 @@
 """ alert a user to activity """
 from django.db import models, transaction
 from django.dispatch import receiver
+from bookwyrm.models.bookwyrm_export_job import BookwyrmExportJob
 from .base_model import BookWyrmModel
-from . import Boost, Favorite, GroupMemberInvitation, ImportJob, LinkDomain
+from . import (
+    Boost,
+    Favorite,
+    GroupMemberInvitation,
+    ImportJob,
+    BookwyrmImportJob,
+    LinkDomain,
+)
 from . import ListItem, Report, Status, User, UserFollowRequest
+from .site import InviteRequest
 
 
-class Notification(BookWyrmModel):
+class NotificationType(models.TextChoices):
     """you've been tagged, liked, followed, etc"""
 
     # Status interactions
@@ -22,6 +31,8 @@ class Notification(BookWyrmModel):
 
     # Imports
     IMPORT = "IMPORT"
+    USER_IMPORT = "USER_IMPORT"
+    USER_EXPORT = "USER_EXPORT"
 
     # List activity
     ADD = "ADD"
@@ -29,6 +40,7 @@ class Notification(BookWyrmModel):
     # Admin
     REPORT = "REPORT"
     LINK_DOMAIN = "LINK_DOMAIN"
+    INVITE_REQUEST = "INVITE_REQUEST"
 
     # Groups
     INVITE = "INVITE"
@@ -40,12 +52,12 @@ class Notification(BookWyrmModel):
     GROUP_NAME = "GROUP_NAME"
     GROUP_DESCRIPTION = "GROUP_DESCRIPTION"
 
-    # pylint: disable=line-too-long
-    NotificationType = models.TextChoices(
-        # there has got be a better way to do this
-        "NotificationType",
-        f"{FAVORITE} {REPLY} {MENTION} {TAG} {FOLLOW} {FOLLOW_REQUEST} {BOOST} {IMPORT} {ADD} {REPORT} {LINK_DOMAIN} {INVITE} {ACCEPT} {JOIN} {LEAVE} {REMOVE} {GROUP_PRIVACY} {GROUP_NAME} {GROUP_DESCRIPTION}",
-    )
+    # Migrations
+    MOVE = "MOVE"
+
+
+class Notification(BookWyrmModel):
+    """a notification object"""
 
     user = models.ForeignKey("User", on_delete=models.CASCADE)
     read = models.BooleanField(default=False)
@@ -61,11 +73,15 @@ class Notification(BookWyrmModel):
     )
     related_status = models.ForeignKey("Status", on_delete=models.CASCADE, null=True)
     related_import = models.ForeignKey("ImportJob", on_delete=models.CASCADE, null=True)
+    related_user_export = models.ForeignKey(
+        "BookwyrmExportJob", on_delete=models.CASCADE, null=True
+    )
     related_list_items = models.ManyToManyField(
         "ListItem", symmetrical=False, related_name="notifications"
     )
-    related_reports = models.ManyToManyField("Report", symmetrical=False)
-    related_link_domains = models.ManyToManyField("LinkDomain", symmetrical=False)
+    related_reports = models.ManyToManyField("Report")
+    related_link_domains = models.ManyToManyField("LinkDomain")
+    related_invite_requests = models.ManyToManyField("InviteRequest")
 
     @classmethod
     @transaction.atomic
@@ -90,11 +106,11 @@ class Notification(BookWyrmModel):
             user=user,
             related_users=related_user,
             related_list_items__book_list=list_item.book_list,
-            notification_type=Notification.ADD,
+            notification_type=NotificationType.ADD,
         ).first()
         if not notification:
             notification = cls.objects.create(
-                user=user, notification_type=Notification.ADD
+                user=user, notification_type=NotificationType.ADD
             )
             notification.related_users.add(related_user)
         notification.related_list_items.add(list_item)
@@ -121,7 +137,7 @@ def notify_on_fav(sender, instance, *args, **kwargs):
         instance.status.user,
         instance.user,
         related_status=instance.status,
-        notification_type=Notification.FAVORITE,
+        notification_type=NotificationType.FAVORITE,
     )
 
 
@@ -135,7 +151,7 @@ def notify_on_unfav(sender, instance, *args, **kwargs):
         instance.status.user,
         instance.user,
         related_status=instance.status,
-        notification_type=Notification.FAVORITE,
+        notification_type=NotificationType.FAVORITE,
     )
 
 
@@ -160,7 +176,7 @@ def notify_user_on_mention(sender, instance, *args, **kwargs):
             instance.reply_parent.user,
             instance.user,
             related_status=instance,
-            notification_type=Notification.REPLY,
+            notification_type=NotificationType.REPLY,
         )
 
     for mention_user in instance.mention_users.all():
@@ -172,7 +188,7 @@ def notify_user_on_mention(sender, instance, *args, **kwargs):
         Notification.notify(
             mention_user,
             instance.user,
-            notification_type=Notification.MENTION,
+            notification_type=NotificationType.MENTION,
             related_status=instance,
         )
 
@@ -191,7 +207,7 @@ def notify_user_on_boost(sender, instance, *args, **kwargs):
         instance.boosted_status.user,
         instance.user,
         related_status=instance.boosted_status,
-        notification_type=Notification.BOOST,
+        notification_type=NotificationType.BOOST,
     )
 
 
@@ -203,7 +219,7 @@ def notify_user_on_unboost(sender, instance, *args, **kwargs):
         instance.boosted_status.user,
         instance.user,
         related_status=instance.boosted_status,
-        notification_type=Notification.BOOST,
+        notification_type=NotificationType.BOOST,
     )
 
 
@@ -218,11 +234,41 @@ def notify_user_on_import_complete(
         return
     Notification.objects.get_or_create(
         user=instance.user,
-        notification_type=Notification.IMPORT,
+        notification_type=NotificationType.IMPORT,
         related_import=instance,
     )
 
 
+@receiver(models.signals.post_save, sender=BookwyrmImportJob)
+# pylint: disable=unused-argument
+def notify_user_on_user_import_complete(
+    sender, instance, *args, update_fields=None, **kwargs
+):
+    """we imported your user details! aren't you proud of us"""
+    update_fields = update_fields or []
+    if not instance.complete or "complete" not in update_fields:
+        return
+    Notification.objects.create(
+        user=instance.user, notification_type=NotificationType.USER_IMPORT
+    )
+
+
+@receiver(models.signals.post_save, sender=BookwyrmExportJob)
+# pylint: disable=unused-argument
+def notify_user_on_user_export_complete(
+    sender, instance, *args, update_fields=None, **kwargs
+):
+    """we exported your user details! aren't you proud of us"""
+    update_fields = update_fields or []
+    if not instance.complete or "complete" not in update_fields:
+        return
+    Notification.objects.create(
+        user=instance.user,
+        notification_type=NotificationType.USER_EXPORT,
+        related_user_export=instance,
+    )
+
+
 @receiver(models.signals.post_save, sender=Report)
 @transaction.atomic
 # pylint: disable=unused-argument
@@ -233,11 +279,10 @@ def notify_admins_on_report(sender, instance, created, *args, **kwargs):
         return
 
     # moderators and superusers should be notified
-    admins = User.admins()
-    for admin in admins:
+    for admin in User.admins():
         notification, _ = Notification.objects.get_or_create(
             user=admin,
-            notification_type=Notification.REPORT,
+            notification_type=NotificationType.REPORT,
             read=False,
         )
         notification.related_reports.add(instance)
@@ -253,16 +298,33 @@ def notify_admins_on_link_domain(sender, instance, created, *args, **kwargs):
         return
 
     # moderators and superusers should be notified
-    admins = User.admins()
-    for admin in admins:
+    for admin in User.admins():
         notification, _ = Notification.objects.get_or_create(
             user=admin,
-            notification_type=Notification.LINK_DOMAIN,
+            notification_type=NotificationType.LINK_DOMAIN,
             read=False,
         )
         notification.related_link_domains.add(instance)
 
 
+@receiver(models.signals.post_save, sender=InviteRequest)
+@transaction.atomic
+# pylint: disable=unused-argument
+def notify_admins_on_invite_request(sender, instance, created, *args, **kwargs):
+    """need to handle a new invite request"""
+    if not created:
+        return
+
+    # moderators and superusers should be notified
+    for admin in User.admins():
+        notification, _ = Notification.objects.get_or_create(
+            user=admin,
+            notification_type=NotificationType.INVITE_REQUEST,
+            read=False,
+        )
+        notification.related_invite_requests.add(instance)
+
+
 @receiver(models.signals.post_save, sender=GroupMemberInvitation)
 # pylint: disable=unused-argument
 def notify_user_on_group_invite(sender, instance, *args, **kwargs):
@@ -271,7 +333,7 @@ def notify_user_on_group_invite(sender, instance, *args, **kwargs):
         instance.user,
         instance.group.user,
         related_group=instance.group,
-        notification_type=Notification.INVITE,
+        notification_type=NotificationType.INVITE,
     )
 
 
@@ -309,11 +371,12 @@ def notify_user_on_follow(sender, instance, created, *args, **kwargs):
         notification = Notification.objects.filter(
             user=instance.user_object,
             related_users=instance.user_subject,
-            notification_type=Notification.FOLLOW_REQUEST,
+            notification_type=NotificationType.FOLLOW_REQUEST,
         ).first()
         if not notification:
             notification = Notification.objects.create(
-                user=instance.user_object, notification_type=Notification.FOLLOW_REQUEST
+                user=instance.user_object,
+                notification_type=NotificationType.FOLLOW_REQUEST,
             )
         notification.related_users.set([instance.user_subject])
         notification.read = False
@@ -323,6 +386,6 @@ def notify_user_on_follow(sender, instance, created, *args, **kwargs):
         Notification.notify(
             instance.user_object,
             instance.user_subject,
-            notification_type=Notification.FOLLOW,
+            notification_type=NotificationType.FOLLOW,
             read=False,
         )

+ 21 - 7
bookwyrm/models/relationship.py

@@ -65,6 +65,13 @@ class UserRelationship(BookWyrmModel):
         base_path = self.user_subject.remote_id
         return f"{base_path}#follows/{self.id}"
 
+    def get_accept_reject_id(self, status):
+        """get id for sending an accept or reject of a local user"""
+
+        base_path = self.user_object.remote_id
+        status_id = self.id or 0
+        return f"{base_path}#{status}/{status_id}"
+
 
 class UserFollows(ActivityMixin, UserRelationship):
     """Following a user"""
@@ -105,6 +112,20 @@ class UserFollows(ActivityMixin, UserRelationship):
         )
         return obj
 
+    def reject(self):
+        """generate a Reject for this follow. This would normally happen
+        when a user deletes a follow they previously accepted"""
+
+        if self.user_object.local:
+            activity = activitypub.Reject(
+                id=self.get_accept_reject_id(status="rejects"),
+                actor=self.user_object.remote_id,
+                object=self.to_activity(),
+            ).serialize()
+            self.broadcast(activity, self.user_object)
+
+        self.delete()
+
 
 class UserFollowRequest(ActivitypubMixin, UserRelationship):
     """following a user requires manual or automatic confirmation"""
@@ -148,13 +169,6 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
             if not manually_approves:
                 self.accept()
 
-    def get_accept_reject_id(self, status):
-        """get id for sending an accept or reject of a local user"""
-
-        base_path = self.user_object.remote_id
-        status_id = self.id or 0
-        return f"{base_path}#{status}/{status_id}"
-
     def accept(self, broadcast_only=False):
         """turn this request into the real deal"""
         user = self.user_object

+ 2 - 0
bookwyrm/models/site.py

@@ -96,6 +96,7 @@ class SiteSettings(SiteModel):
     imports_enabled = models.BooleanField(default=True)
     import_size_limit = models.IntegerField(default=0)
     import_limit_reset = models.IntegerField(default=0)
+    user_import_time_limit = models.IntegerField(default=48)
 
     field_tracker = FieldTracker(fields=["name", "instance_tagline", "logo"])
 
@@ -149,6 +150,7 @@ class Theme(SiteModel):
     created_date = models.DateTimeField(auto_now_add=True)
     name = models.CharField(max_length=50, unique=True)
     path = models.CharField(max_length=50, unique=True)
+    loads = models.BooleanField(null=True, blank=True)
 
     def __str__(self):
         # pylint: disable=invalid-str-returned

+ 3 - 2
bookwyrm/models/status.py

@@ -102,7 +102,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
         if hasattr(self, "quotation"):
             self.quotation = None  # pylint: disable=attribute-defined-outside-init
         self.deleted_date = timezone.now()
-        self.save()
+        self.save(*args, **kwargs)
 
     @property
     def recipients(self):
@@ -366,7 +366,8 @@ class Quotation(BookStatus):
         quote = re.sub(r"^<p>", '<p>"', self.quote)
         quote = re.sub(r"</p>$", '"</p>', quote)
         title, href = self.book.title, self.book.remote_id
-        citation = f'— <a href="{href}"><i>{title}</i></a>'
+        author = f"{name}: " if (name := self.book.author_text) else ""
+        citation = f'— {author}<a href="{href}"><i>{title}</i></a>'
         if position := self._format_position():
             citation += f", {position}"
         return f"{quote} <p>{citation}</p>{self.content}"

+ 55 - 3
bookwyrm/models/user.py

@@ -1,13 +1,14 @@
 """ database schema for user data """
 import re
 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.core.exceptions import PermissionDenied, ObjectDoesNotExist
 from django.dispatch import receiver
-from django.db import models, transaction
+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
@@ -53,6 +54,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
 
     username = fields.UsernameField()
     email = models.EmailField(unique=True, null=True)
+    is_deleted = models.BooleanField(default=False)
 
     key_pair = fields.OneToOneField(
         "KeyPair",
@@ -140,6 +142,19 @@ class User(OrderedCollectionPageMixin, AbstractUser):
     theme = models.ForeignKey("Theme", null=True, blank=True, on_delete=models.SET_NULL)
     hide_follows = fields.BooleanField(default=False)
 
+    # migration fields
+
+    moved_to = fields.RemoteIdField(
+        null=True, unique=False, activitypub_field="movedTo", deduplication_field=False
+    )
+    also_known_as = fields.ManyToManyField(
+        "self",
+        symmetrical=False,
+        unique=False,
+        activitypub_field="alsoKnownAs",
+        deduplication_field=False,
+    )
+
     # options to turn features on and off
     show_goal = models.BooleanField(default=True)
     show_suggested_users = models.BooleanField(default=True)
@@ -314,6 +329,8 @@ class User(OrderedCollectionPageMixin, AbstractUser):
                 "schema": "http://schema.org#",
                 "PropertyValue": "schema:PropertyValue",
                 "value": "schema:value",
+                "alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"},
+                "movedTo": {"@id": "as:movedTo", "@type": "@id"},
             },
         ]
         return activity_object
@@ -379,9 +396,44 @@ class User(OrderedCollectionPageMixin, AbstractUser):
         """We don't actually delete the database entry"""
         # pylint: disable=attribute-defined-outside-init
         self.is_active = False
-        self.avatar = ""
+        self.allow_reactivation = False
+        self.is_deleted = True
+
+        self.erase_user_data()
+        self.erase_user_statuses()
+
         # skip the logic in this class's save()
-        super().save(*args, **kwargs)
+        super().save(
+            *args,
+            **kwargs,
+        )
+
+    def erase_user_data(self):
+        """Wipe a user's custom data"""
+        if not self.is_deleted:
+            raise IntegrityError(
+                "Trying to erase user data on user that is not deleted"
+            )
+
+        # mangle email address
+        self.email = f"{uuid4()}@deleted.user"
+
+        # erase data fields
+        self.avatar = ""
+        self.preview_image = ""
+        self.summary = None
+        self.name = None
+        self.favorites.set([])
+
+    def erase_user_statuses(self, broadcast=True):
+        """Wipe the data on all the user's statuses"""
+        if not self.is_deleted:
+            raise IntegrityError(
+                "Trying to erase user data on user that is not deleted"
+            )
+
+        for status in self.status_set.all():
+            status.delete(broadcast=broadcast)
 
     def deactivate(self):
         """Disable the user but allow them to reactivate"""

+ 24 - 6
bookwyrm/settings.py

@@ -4,6 +4,7 @@ from typing import AnyStr
 
 from environs import Env
 
+
 import requests
 from django.utils.translation import gettext_lazy as _
 from django.core.exceptions import ImproperlyConfigured
@@ -14,7 +15,13 @@ from django.core.exceptions import ImproperlyConfigured
 env = Env()
 env.read_env()
 DOMAIN = env("DOMAIN")
-VERSION = "0.6.6"
+
+with open("VERSION", encoding="utf-8") as f:
+    version = f.read()
+    version = version.replace("\n", "")
+f.close()
+
+VERSION = version
 
 RELEASE_API = env(
     "RELEASE_API",
@@ -24,7 +31,7 @@ RELEASE_API = env(
 PAGE_LENGTH = env.int("PAGE_LENGTH", 15)
 DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
 
-JS_CACHE = "ac315a3b"
+JS_CACHE = "8a89cad7"
 
 # email
 EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
@@ -92,6 +99,7 @@ INSTALLED_APPS = [
     "django.contrib.messages",
     "django.contrib.staticfiles",
     "django.contrib.humanize",
+    "file_resubmit",
     "sass_processor",
     "bookwyrm",
     "celery",
@@ -112,6 +120,7 @@ MIDDLEWARE = [
     "bookwyrm.middleware.IPBlocklistMiddleware",
     "django.contrib.messages.middleware.MessageMiddleware",
     "django.middleware.clickjacking.XFrameOptionsMiddleware",
+    "bookwyrm.middleware.FileTooBig",
 ]
 
 ROOT_URLCONF = "bookwyrm.urls"
@@ -235,7 +244,11 @@ if env.bool("USE_DUMMY_CACHE", False):
     CACHES = {
         "default": {
             "BACKEND": "django.core.cache.backends.dummy.DummyCache",
-        }
+        },
+        "file_resubmit": {
+            "BACKEND": "django.core.cache.backends.dummy.DummyCache",
+            "LOCATION": "/tmp/file_resubmit_tests/",
+        },
     }
 else:
     CACHES = {
@@ -245,7 +258,11 @@ else:
             "OPTIONS": {
                 "CLIENT_CLASS": "django_redis.client.DefaultClient",
             },
-        }
+        },
+        "file_resubmit": {
+            "BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
+            "LOCATION": "/tmp/file_resubmit/",
+        },
     }
 
     SESSION_ENGINE = "django.contrib.sessions.backends.cache"
@@ -311,6 +328,7 @@ LANGUAGES = [
     ("pt-pt", _("Português Europeu (European Portuguese)")),
     ("ro-ro", _("Română (Romanian)")),
     ("sv-se", _("Svenska (Swedish)")),
+    ("uk-ua", _("Українська (Ukrainian)")),
     ("zh-hans", _("简体中文 (Simplified Chinese)")),
     ("zh-hant", _("繁體中文 (Traditional Chinese)")),
 ]
@@ -359,9 +377,9 @@ if USE_S3:
     AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID")
     AWS_SECRET_ACCESS_KEY = env("AWS_SECRET_ACCESS_KEY")
     AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME")
-    AWS_S3_CUSTOM_DOMAIN = env("AWS_S3_CUSTOM_DOMAIN")
+    AWS_S3_CUSTOM_DOMAIN = env("AWS_S3_CUSTOM_DOMAIN", None)
     AWS_S3_REGION_NAME = env("AWS_S3_REGION_NAME", "")
-    AWS_S3_ENDPOINT_URL = env("AWS_S3_ENDPOINT_URL")
+    AWS_S3_ENDPOINT_URL = env("AWS_S3_ENDPOINT_URL", None)
     AWS_DEFAULT_ACL = "public-read"
     AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"}
     # S3 Static settings

+ 2 - 3
bookwyrm/static/css/bookwyrm.scss

@@ -1,4 +1,3 @@
 @charset "utf-8";
-
-@import "vendor/bulma/bulma.sass";
-@import "bookwyrm/all.scss";
+@import "vendor/bulma/bulma";
+@import "bookwyrm/all";

+ 2 - 5
bookwyrm/static/css/bookwyrm/_all.scss

@@ -16,9 +16,7 @@
 @import "components/status";
 @import "components/tabs";
 @import "components/toggle";
-
 @import "overrides/bulma_overrides";
-
 @import "utilities/a11y";
 @import "utilities/alignments";
 @import "utilities/colors";
@@ -40,10 +38,12 @@ body {
     width: 12px;
     height: 12px;
 }
+
 ::-webkit-scrollbar-thumb {
     background: $scrollbar-thumb;
     border-radius: 0.5em;
 }
+
 ::-webkit-scrollbar-track {
     background: $scrollbar-track;
 }
@@ -89,7 +89,6 @@ button::-moz-focus-inner {
 /** Utilities not covered by Bulma
  ******************************************************************************/
 
-
 .tag.is-small {
     height: auto;
 }
@@ -144,7 +143,6 @@ button.button-paragraph {
     vertical-align: middle;
 }
 
-
 /** States
  ******************************************************************************/
 
@@ -159,7 +157,6 @@ button.button-paragraph {
     cursor: not-allowed;
 }
 
-
 /* Notifications page
  ******************************************************************************/
 

+ 1 - 1
bookwyrm/static/css/bookwyrm/components/_book_cover.scss

@@ -43,7 +43,7 @@
     max-height: 100%;
 
     /* Useful when stretching under-sized images. */
-    image-rendering: optimizeQuality;
+    image-rendering: optimizequality;
     image-rendering: smooth;
 }
 

+ 16 - 16
bookwyrm/static/css/bookwyrm/components/_copy.scss

@@ -30,20 +30,20 @@
 }
 
 .copy-tooltip {
-  overflow: visible;
-  visibility: hidden;
-  width: 140px;
-  background-color: #555;
-  color: #fff;
-  text-align: center;
-  border-radius: 6px;
-  padding: 5px;
-  position: absolute;
-  z-index: 1;
-  margin-left: -30px;
-  margin-top: -45px;
-  opacity: 0;
-  transition: opacity 0.3s;
+    overflow: visible;
+    visibility: hidden;
+    width: 140px;
+    background-color: #555;
+    color: #fff;
+    text-align: center;
+    border-radius: 6px;
+    padding: 5px;
+    position: absolute;
+    z-index: 1;
+    margin-left: -30px;
+    margin-top: -45px;
+    opacity: 0;
+    transition: opacity 0.3s;
 }
 
 .copy-tooltip::after {
@@ -54,5 +54,5 @@
     margin-left: -60px;
     border-width: 5px;
     border-style: solid;
-    border-color: #555 transparent transparent transparent;
-  }
+    border-color: #555 transparent transparent;
+}

+ 2 - 2
bookwyrm/static/css/bookwyrm/components/_tabs.scss

@@ -44,12 +44,12 @@
 
 .bw-tabs a:hover {
     border-bottom-color: transparent;
-    color: $text
+    color: $text;
 }
 
 .bw-tabs a.is-active {
     border-bottom-color: transparent;
-    color: $link 
+    color: $link;
 }
 
 .bw-tabs.is-left {

BIN
bookwyrm/static/css/fonts/icomoon.eot


+ 2 - 0
bookwyrm/static/css/fonts/icomoon.svg

@@ -43,6 +43,8 @@
 <glyph unicode="&#xe937;" glyph-name="barcode" d="M0 832h128v-640h-128zM192 832h64v-640h-64zM320 832h64v-640h-64zM512 832h64v-640h-64zM768 832h64v-640h-64zM960 832h64v-640h-64zM640 832h32v-640h-32zM448 832h32v-640h-32zM864 832h32v-640h-32zM0 128h64v-64h-64zM192 128h64v-64h-64zM320 128h64v-64h-64zM640 128h64v-64h-64zM960 128h64v-64h-64zM768 128h128v-64h-128zM448 128h128v-64h-128z" />
 <glyph unicode="&#xe97a;" glyph-name="spinner" d="M384 832c0 70.692 57.308 128 128 128s128-57.308 128-128c0-70.692-57.308-128-128-128s-128 57.308-128 128zM655.53 719.53c0 70.692 57.308 128 128 128s128-57.308 128-128c0-70.692-57.308-128-128-128s-128 57.308-128 128zM832 448c0 35.346 28.654 64 64 64s64-28.654 64-64c0-35.346-28.654-64-64-64s-64 28.654-64 64zM719.53 176.47c0 35.346 28.654 64 64 64s64-28.654 64-64c0-35.346-28.654-64-64-64s-64 28.654-64 64zM448.002 64c0 0 0 0 0 0 0 35.346 28.654 64 64 64s64-28.654 64-64c0 0 0 0 0 0 0-35.346-28.654-64-64-64s-64 28.654-64 64zM176.472 176.47c0 0 0 0 0 0 0 35.346 28.654 64 64 64s64-28.654 64-64c0 0 0 0 0 0 0-35.346-28.654-64-64-64s-64 28.654-64 64zM144.472 719.53c0 0 0 0 0 0 0 53.019 42.981 96 96 96s96-42.981 96-96c0 0 0 0 0 0 0-53.019-42.981-96-96-96s-96 42.981-96 96zM56 448c0 39.765 32.235 72 72 72s72-32.235 72-72c0-39.765-32.235-72-72-72s-72 32.235-72 72z" />
 <glyph unicode="&#xe986;" glyph-name="search" d="M992.262 88.604l-242.552 206.294c-25.074 22.566-51.89 32.926-73.552 31.926 57.256 67.068 91.842 154.078 91.842 249.176 0 212.078-171.922 384-384 384-212.076 0-384-171.922-384-384s171.922-384 384-384c95.098 0 182.108 34.586 249.176 91.844-1-21.662 9.36-48.478 31.926-73.552l206.294-242.552c35.322-39.246 93.022-42.554 128.22-7.356s31.892 92.898-7.354 128.22zM384 320c-141.384 0-256 114.616-256 256s114.616 256 256 256 256-114.616 256-256-114.614-256-256-256z" />
+<glyph unicode="&#xe9ce;" glyph-name="eye" d="M512 768c-223.318 0-416.882-130.042-512-320 95.118-189.958 288.682-320 512-320 223.312 0 416.876 130.042 512 320-95.116 189.958-288.688 320-512 320zM764.45 598.296c60.162-38.374 111.142-89.774 149.434-150.296-38.292-60.522-89.274-111.922-149.436-150.296-75.594-48.218-162.89-73.704-252.448-73.704-89.56 0-176.858 25.486-252.452 73.704-60.158 38.372-111.138 89.772-149.432 150.296 38.292 60.524 89.274 111.924 149.434 150.296 3.918 2.5 7.876 4.922 11.86 7.3-9.96-27.328-15.41-56.822-15.41-87.596 0-141.382 114.616-256 256-256 141.382 0 256 114.618 256 256 0 30.774-5.452 60.268-15.408 87.598 3.978-2.378 7.938-4.802 11.858-7.302v0zM512 544c0-53.020-42.98-96-96-96s-96 42.98-96 96 42.98 96 96 96 96-42.982 96-96z" />
+<glyph unicode="&#xe9d1;" glyph-name="eye-blocked" d="M945.942 945.942c-18.746 18.744-49.136 18.744-67.882 0l-202.164-202.164c-51.938 15.754-106.948 24.222-163.896 24.222-223.318 0-416.882-130.042-512-320 41.122-82.124 100.648-153.040 173.022-207.096l-158.962-158.962c-18.746-18.746-18.746-49.136 0-67.882 9.372-9.374 21.656-14.060 33.94-14.060s24.568 4.686 33.942 14.058l864 864c18.744 18.746 18.744 49.138 0 67.884zM416 640c42.24 0 78.082-27.294 90.92-65.196l-121.724-121.724c-37.902 12.838-65.196 48.68-65.196 90.92 0 53.020 42.98 96 96 96zM110.116 448c38.292 60.524 89.274 111.924 149.434 150.296 3.918 2.5 7.876 4.922 11.862 7.3-9.962-27.328-15.412-56.822-15.412-87.596 0-54.89 17.286-105.738 46.7-147.418l-60.924-60.924c-52.446 36.842-97.202 83.882-131.66 138.342zM768 518c0 27.166-4.256 53.334-12.102 77.898l-321.808-321.808c24.568-7.842 50.742-12.090 77.91-12.090 141.382 0 256 114.618 256 256zM830.026 670.026l-69.362-69.362c1.264-0.786 2.53-1.568 3.786-2.368 60.162-38.374 111.142-89.774 149.434-150.296-38.292-60.522-89.274-111.922-149.436-150.296-75.594-48.218-162.89-73.704-252.448-73.704-38.664 0-76.902 4.76-113.962 14.040l-76.894-76.894c59.718-21.462 123.95-33.146 190.856-33.146 223.31 0 416.876 130.042 512 320-45.022 89.916-112.118 166.396-193.974 222.026z" />
 <glyph unicode="&#xe9d7;" glyph-name="star-empty" d="M1024 562.95l-353.78 51.408-158.22 320.582-158.216-320.582-353.784-51.408 256-249.538-60.432-352.352 316.432 166.358 316.432-166.358-60.434 352.352 256.002 249.538zM512 206.502l-223.462-117.48 42.676 248.83-180.786 176.222 249.84 36.304 111.732 226.396 111.736-226.396 249.836-36.304-180.788-176.222 42.678-248.83-223.462 117.48z" />
 <glyph unicode="&#xe9d8;" glyph-name="star-half" d="M1024 562.95l-353.78 51.408-158.22 320.582-158.216-320.582-353.784-51.408 256-249.538-60.432-352.352 316.432 166.358 316.432-166.358-60.434 352.352 256.002 249.538zM512 206.502l-0.942-0.496 0.942 570.768 111.736-226.396 249.836-36.304-180.788-176.222 42.678-248.83-223.462 117.48z" />
 <glyph unicode="&#xe9d9;" glyph-name="star-full" d="M1024 562.95l-353.78 51.408-158.22 320.582-158.216-320.582-353.784-51.408 256-249.538-60.432-352.352 316.432 166.358 316.432-166.358-60.434 352.352 256.002 249.538z" />

BIN
bookwyrm/static/css/fonts/icomoon.ttf


BIN
bookwyrm/static/css/fonts/icomoon.woff


+ 9 - 13
bookwyrm/static/css/themes/bookwyrm-dark.scss

@@ -1,4 +1,4 @@
-@import "../vendor/bulma/sass/utilities/initial-variables.sass";
+@import "../vendor/bulma/sass/utilities/initial-variables";
 
 /* Colors
  ******************************************************************************/
@@ -16,7 +16,7 @@ $danger-light: #481922;
 $light: #393939;
 $red: #ffa1b4;
 $black: #000;
-$white-ter:    hsl(0, 0%, 90%);
+$white-ter: hsl(0deg, 0%, 90%);
 
 /* book cover standins */
 $no-cover-color: #002549;
@@ -79,7 +79,7 @@ $info-dark: #72b6ee;
 }
 
 /* misc */
-$shadow: 0 0.5em 0.5em -0.125em rgba($black, 0.2), 0 0px 0 1px rgba($black, 0.02);
+$shadow: 0 0.5em 0.5em -0.125em rgba($black, 0.2), 0 0 0 1px rgba($black, 0.02);
 $card-header-shadow: 0 0.125em 0.25em rgba($black, 0.1);
 $invisible-overlay-background-color: rgba($black, 0.66);
 $progress-value-background-color: $border-light;
@@ -97,27 +97,23 @@ $family-secondary: $family-sans-serif;
     color: $grey-light !important;
 }
 
-
 .tabs li:not(.is-active) a {
     color: #2e7eb9 !important;
 }
-  .tabs li:not(.is-active) a:hover {
-    border-bottom-color: #2e7eb9 !important;
-} 
 
-.tabs li:not(.is-active) a {
-    color: #2e7eb9 !important;
+.tabs li:not(.is-active) a:hover {
+    border-bottom-color: #2e7eb9 !important;
 }
+
 .tabs li.is-active a {
     color: #e6e6e6 !important;
-    border-bottom-color: #e6e6e6 !important ;
+    border-bottom-color: #e6e6e6 !important;
 }
 
-
 #qrcode svg {
     background-color: #a6a6a6;
 }
 
-@import "../bookwyrm.scss";
+@import "../bookwyrm";
 @import "../vendor/icons.css";
-@import "../vendor/shepherd.scss";
+@import "../vendor/shepherd";

+ 7 - 10
bookwyrm/static/css/themes/bookwyrm-light.scss

@@ -1,4 +1,4 @@
-@import "../vendor/bulma/sass/utilities/derived-variables.sass";
+@import "../vendor/bulma/sass/utilities/derived-variables";
 
 /* Colors
  ******************************************************************************/
@@ -68,19 +68,16 @@ $family-secondary: $family-sans-serif;
 .tabs li:not(.is-active) a {
     color: #3273dc !important;
 }
-  .tabs li:not(.is-active) a:hover {
-    border-bottom-color: #3273dc !important;
-} 
 
-.tabs li:not(.is-active) a {
-    color: #3273dc !important;
+.tabs li:not(.is-active) a:hover {
+    border-bottom-color: #3273dc !important;
 }
+
 .tabs li.is-active a {
     color: #4a4a4a !important;
-    border-bottom-color: #4a4a4a !important ;
+    border-bottom-color: #4a4a4a !important;
 }
 
-
-@import "../bookwyrm.scss";
+@import "../bookwyrm";
 @import "../vendor/icons.css";
-@import "../vendor/shepherd.scss";
+@import "../vendor/shepherd";

+ 6 - 0
bookwyrm/static/css/vendor/icons.css

@@ -155,3 +155,9 @@
 .icon-barcode:before {
   content: "\e937";
 }
+.icon-eye:before {
+  content: "\e9ce";
+}
+.icon-eye-blocked:before {
+  content: "\e9d1";
+}

+ 26 - 0
bookwyrm/static/js/bookwyrm.js

@@ -30,6 +30,12 @@ let BookWyrm = new (class {
             .querySelectorAll("[data-back]")
             .forEach((button) => button.addEventListener("click", this.back));
 
+        document
+            .querySelectorAll("[data-password-icon]")
+            .forEach((button) =>
+                button.addEventListener("click", this.togglePasswordVisibility.bind(this))
+            );
+
         document
             .querySelectorAll('input[type="file"]')
             .forEach((node) => node.addEventListener("change", this.disableIfTooLarge.bind(this)));
@@ -820,4 +826,24 @@ let BookWyrm = new (class {
 
         form.querySelector('input[name="preferred_timezone"]').value = tz;
     }
+
+    togglePasswordVisibility(event) {
+        const iconElement = event.currentTarget.getElementsByTagName("button")[0];
+        const passwordElementId = event.currentTarget.dataset.for;
+        const passwordInputElement = document.getElementById(passwordElementId);
+
+        if (!passwordInputElement) return;
+
+        if (passwordInputElement.type === "password") {
+            passwordInputElement.type = "text";
+            this.addRemoveClass(iconElement, "icon-eye-blocked");
+            this.addRemoveClass(iconElement, "icon-eye", true);
+        } else {
+            passwordInputElement.type = "password";
+            this.addRemoveClass(iconElement, "icon-eye");
+            this.addRemoveClass(iconElement, "icon-eye-blocked", true);
+        }
+
+        this.toggleFocus(passwordElementId);
+    }
 })();

+ 3 - 4
bookwyrm/static/js/forms.js

@@ -47,12 +47,11 @@
         .querySelectorAll("[data-remove]")
         .forEach((node) => node.addEventListener("click", removeInput));
 
-    // Get the element, add a keypress listener...
+    // Get element, add a keypress listener...
     document.getElementById("subjects").addEventListener("keypress", function (e) {
-        // e.target is the element where it listens!
-        // if e.target is input field within the "subjects" div, do stuff
+        // Linstening to element e.target
+        // If e.target is an input field within "subjects" div preventDefault()
         if (e.target && e.target.nodeName == "INPUT") {
-            // Item found, prevent default
             if (event.keyCode == 13) {
                 event.preventDefault();
             }

+ 10 - 3
bookwyrm/suggested_users.py

@@ -8,6 +8,7 @@ from opentelemetry import trace
 
 from bookwyrm import models
 from bookwyrm.redis_store import RedisStore, r
+from bookwyrm.settings import INSTANCE_ACTOR_USERNAME
 from bookwyrm.tasks import app, SUGGESTED_USERS
 from bookwyrm.telemetry import open_telemetry
 
@@ -98,9 +99,15 @@ class SuggestedUsers(RedisStore):
             for (pk, score) in values
         ]
         # annotate users with mutuals and shared book counts
-        users = models.User.objects.filter(
-            is_active=True, bookwyrm_user=True, id__in=[pk for (pk, _) in values]
-        ).annotate(mutuals=Case(*annotations, output_field=IntegerField(), default=0))
+        users = (
+            models.User.objects.filter(
+                is_active=True, bookwyrm_user=True, id__in=[pk for (pk, _) in values]
+            )
+            .annotate(
+                mutuals=Case(*annotations, output_field=IntegerField(), default=0)
+            )
+            .exclude(localname=INSTANCE_ACTOR_USERNAME)
+        )
         if local:
             users = users.filter(local=True)
         return users.order_by("-mutuals")[:5]

+ 20 - 0
bookwyrm/templates/403.html

@@ -0,0 +1,20 @@
+{% extends 'layout.html' %}
+{% load i18n %}
+{% load utilities %}
+
+{% block title %}{% trans "Oh no!" %}{% endblock %}
+
+{% block content %}
+<div class="block">
+    <h1 class="title">{% trans "Permission Denied" %}</h1>
+    <p class="content">
+        {% blocktrans trimmed with level=request.user|get_user_permission %}
+        You do not have permission to view this page or perform this action. Your user permission level is <code>{{ level }}</code>.
+        {% endblocktrans %}
+    </p>
+    <p class="content">{% trans "If you think you should have access, please speak to your BookWyrm server administrator." %}
+    </p>
+
+</div>
+{% endblock %}
+

+ 16 - 0
bookwyrm/templates/413.html

@@ -0,0 +1,16 @@
+{% extends 'layout.html' %}
+{% load i18n %}
+
+{% block title %}{% trans "File too large" %}{% endblock %}
+
+{% block content %}
+<div class="block">
+    <h1 class="title">{% trans "File too large" %}</h1>
+    <p class="content">{% trans "The file you are uploading is too large." %}</p>
+    <p class="content">
+        {% blocktrans %}
+        You you can try using a smaller file, or ask your BookWyrm server administrator to increase the <code>DATA_UPLOAD_MAX_MEMORY_SIZE</code> setting.
+        {% endblocktrans %}
+    </p>
+</div>
+{% endblock %}

+ 0 - 8
bookwyrm/templates/author/author.html

@@ -144,14 +144,6 @@
                     </a>
                 </div>
                 {% endif %}
-
-                {% if author.isfdb %}
-                <div>
-                    <a itemprop="sameAs" href="https://www.isfdb.org/cgi-bin/ea.cgi?{{ author.isfdb }}" target="_blank" rel="nofollow noopener noreferrer">
-                        {% trans "View ISFDB entry" %}
-                    </a>
-                </div>
-                {% endif %}
             </div>
         </section>
         {% endif %}

+ 7 - 7
bookwyrm/templates/book/book.html

@@ -44,16 +44,18 @@
                     {% endif %}
 
                     {% if book.series %}
-                        <meta itemprop="isPartOf" content="{{ book.series | escape }}">
-                        <meta itemprop="volumeNumber" content="{{ book.series_number }}">
-
+                        <meta itemprop="position" content="{{ book.series_number }}">
+                        <span itemprop="isPartOf" itemscope itemtype="https://schema.org/BookSeries">
                         {% if book.authors.exists %}
-                            <a href="{% url 'book-series-by' book.authors.first.id %}?series_name={{ book.series }}">
+                            <a href="{% url 'book-series-by' book.authors.first.id %}?series_name={{ book.series | urlencode }}"
+                               itemprop="url">
                         {% endif %}
-                        {{ book.series }}{% if book.series_number %} #{{ book.series_number }}{% endif %}
+                        <span itemprop="name">{{ book.series }}</span>
+                        {% if book.series_number %} #{{ book.series_number }}{% endif %}
                         {% if book.authors.exists %}
                             </a>
                         {% endif %}
+                        </span>
                     {% endif %}
                 </p>
             {% endif %}
@@ -186,8 +188,6 @@
                     itemtype="https://schema.org/AggregateRating"
                 >
                     <meta itemprop="ratingValue" content="{{ rating|floatformat }}">
-                    {# @todo Is it possible to not hard-code the value? #}
-                    <meta itemprop="bestRating" content="5">
                     <meta itemprop="reviewCount" content="{{ review_count }}">
 
                     <span>

+ 1 - 1
bookwyrm/templates/book/cover_add_modal.html

@@ -20,7 +20,7 @@
     </div>
     <div class="column">
         <label class="label" for="id_cover_url">
-            {% trans "Load cover from url:" %}
+            {% trans "Load cover from URL:" %}
         </label>
         <input class="input" name="cover-url" id="id_cover_url">
     </div>

+ 1 - 1
bookwyrm/templates/book/edit/edit_book_form.html

@@ -247,7 +247,7 @@
                         </div>
                         <div class="field">
                             <label class="label" for="id_cover_url">
-                                {% trans "Load cover from url:" %}
+                                {% trans "Load cover from URL:" %}
                             </label>
                             <input class="input" name="cover-url" id="id_cover_url" type="url" value="{{ cover_url|default:'' }}" aria-describedby="desc_cover">
                         </div>

+ 7 - 10
bookwyrm/templates/book/publisher_info.html

@@ -1,7 +1,7 @@
 {% spaceless %}
 
 {% load i18n %}
-{% load humanize %}
+{% load date_ext %}
 
 {% firstof book.physical_format_detail book.get_physical_format_display as format %}
 {% firstof book.physical_format book.physical_format_detail as format_property %}
@@ -40,16 +40,13 @@
     </p>
 {% endif %}
 
-{% with date=book.published_date|naturalday publisher=book.publishers|join:', ' %}
-{% if date or book.first_published_date or book.publishers %}
-{% if date or book.first_published_date %}
+{% if book.published_date or book.first_published_date %}
     <meta
         itemprop="datePublished"
         content="{{ book.first_published_date|default:book.published_date|date:'Y-m-d' }}"
     >
 {% endif %}
 <p>
-
     {% comment %}
         @todo The publisher property needs to be an Organization or a Person. We’ll be using Thing which is the more generic ancestor.
         @see https://schema.org/Publisher
@@ -60,14 +57,14 @@
         {% endfor %}
     {% endif %}
 
-    {% if date and publisher %}
+    {% with date=book.published_date|default:book.first_published_date|naturalday_partial publisher=book.publishers|join:', ' %}
+    {% if book.published_date and publisher %}
         {% blocktrans %}Published {{ date }} by {{ publisher }}.{% endblocktrans %}
-    {% elif date %}
-        {% blocktrans %}Published {{ date }}{% endblocktrans %}
     {% elif publisher %}
         {% blocktrans %}Published by {{ publisher }}.{% endblocktrans %}
+    {% elif date %}
+        {% blocktrans %}Published {{ date }}{% endblocktrans %}
     {% endif %}
+    {% endwith %}
 </p>
-{% endif %}
-{% endwith %}
 {% endspaceless %}

+ 10 - 5
bookwyrm/templates/book/rating.html

@@ -5,13 +5,18 @@
             {% include 'snippets/avatar.html' with user=user %}
         </div>
 
-        <div class="media-content">
-            <div>
-                <a href="{{ user.local_path }}">{{ user.display_name }}</a>
+        <div class="media-content" itemprop="review" itemscope itemtype="https://schema.org/Review">
+            <div itemprop="author"
+                 itemscope
+                 itemtype="https://schema.org/Person"
+            >
+                <a href="{{ user.local_path }}" itemprop="url">
+                    <span itemprop="name">{{ user.display_name }}</span>
+                </a>
             </div>
-            <div class="is-flex">
+            <div class="is-flex" itemprop="reviewRating" itemscope itemtype="https://schema.org/Rating">
+                <meta itemprop="ratingValue" content="{{ rating.rating|floatformat }}">
                 <p class="mr-1">{% trans "rated it" %}</p>
-
                 {% include 'snippets/stars.html' with rating=rating.rating %}
             </div>
             <div>

+ 5 - 4
bookwyrm/templates/book/series.html

@@ -5,15 +5,15 @@
 {% block title %}{{ series_name }}{% endblock %}
 
 {% block content %}
-<div class="block">
-	<h1 class="title">{{ series_name }}</h1>
+<div class="block" itemscope itemtype="https://schema.org/BookSeries">
+	<h1 class="title" itemprop="name">{{ series_name }}</h1>
 	<div class="subtitle" dir="auto">
 		{% trans "Series by" %} <a
 			href="{{ author.local_path }}"
 			class="author {{ link_class }}"
-			itemprop="author"
+			itemprop="creator"
 			itemscope
-			itemtype="https://schema.org/Thing"
+			itemtype="https://schema.org/Person"
 			><span
 					itemprop="name"
 					>{{ author.name }}</span></a>
@@ -22,6 +22,7 @@
 	<div class="columns is-multiline is-mobile">
 	{% for book in books %}
 	{% with book=book %}
+			{# @todo Set `hasPart` property in some meaningful way #}
 			<div class="column is-one-fifth-tablet is-half-mobile is-flex is-flex-direction-column">
 				<div class="is-flex-grow-1 mb-3">
 					<span class="subtitle">{% if book.series_number %}{% blocktrans with series_number=book.series_number %}Book {{ series_number }}{% endblocktrans %}{% else %}{% trans 'Unsorted Book' %}{% endif %}</span>

+ 1 - 0
bookwyrm/templates/embed-layout.html

@@ -12,6 +12,7 @@
     <base target="_blank">
 
     <link rel="shortcut icon" type="image/x-icon" href="{% if site.favicon %}{% get_media_prefix %}{{ site.favicon }}{% else %}{% static "images/favicon.ico" %}{% endif %}">
+    <link rel="manifest" href="/manifest.json" />
 </head>
 <body>
 

+ 2 - 2
bookwyrm/templates/guided_tour/home.html

@@ -99,7 +99,7 @@ homeTour.addSteps([
         ],
     },
     {
-        text: "{% trans 'Use the <strong>Feed</strong>, <strong>Lists</strong> and <strong>Discover</strong> links to discover the latest news from your feed, lists of books by topic, and the latest happenings on this Bookwyrm server!' %}",
+        text: "{% trans 'Use the <strong>Lists</strong>, <strong>Discover</strong>, and <strong>Your Books</strong> links to discover reading suggestions and the latest happenings on this server, or to see your catalogued books!' %}",
         title: "{% trans 'Navigation Bar' %}",
         attachTo: {
             element: checkResponsiveState('#tour-navbar-start'),
@@ -197,7 +197,7 @@ homeTour.addSteps([
         ],
     },
     {
-        text: `{% trans "Your profile, books, direct messages, and settings can be accessed by clicking on your name in the menu here." %} <p class="notification is-warning is-light mt-3">{% trans "Try selecting <strong>Profile</strong> from the drop down menu to continue the tour." %}</p>`,
+        text: `{% trans "Your profile, user directory, direct messages, and settings can be accessed by clicking on your name in the menu here." %} <p class="notification is-warning is-light mt-3">{% trans "Try selecting <strong>Profile</strong> from the drop down menu to continue the tour." %}</p>`,
         title: "{% trans 'Profile and settings menu' %}",
         attachTo: {
             element: checkResponsiveState('#navbar-dropdown'),

+ 5 - 6
bookwyrm/templates/import/import.html

@@ -1,13 +1,12 @@
-{% extends 'layout.html' %}
+{% extends 'preferences/layout.html' %}
 {% load i18n %}
 {% load humanize %}
 
-{% block title %}{% trans "Import Books" %}{% endblock %}
+{% block title %}{% trans "Import Book List" %}{% endblock %}
+{% block header %}{% trans "Import Book List" %}{% endblock %}
 
-{% block content %}
+{% block panel %}
 <div class="block">
-    <h1 class="title">{% trans "Import Books" %}</h1>
-
     {% if invalid %}
     <div class="notification is-danger">
         {% trans "Not a valid CSV file" %}
@@ -21,7 +20,7 @@
                     {% blocktrans trimmed count days=import_limit_reset with display_size=import_size_limit|intcomma %}
                         Currently, you are allowed to import {{ display_size }} books every {{ import_limit_reset }} day.
                     {% plural %}
-                        Currently, you are allowed to import {{ import_size_limit }} books every {{ import_limit_reset }} days.
+                        Currently, you are allowed to import {{ display_size }} books every {{ import_limit_reset }} days.
                     {% endblocktrans %}
                 </p>
                 <p>{% blocktrans with display_left=allowed_imports|intcomma %}You have {{ display_left }} left.{% endblocktrans %}</p>

+ 222 - 0
bookwyrm/templates/import/import_user.html

@@ -0,0 +1,222 @@
+{% extends 'preferences/layout.html' %}
+{% load i18n %}
+{% load humanize %}
+
+{% block title %}{% trans "Import BookWyrm Account" %}{% endblock %}
+{% block header %}{% trans "Import BookWyrm Account" %}{% endblock %}
+
+{% block panel %}
+<div class="block">
+
+    {% if invalid %}
+    <div class="notification is-danger">
+        {% trans "Not a valid import file" %}
+    </div>
+    {% endif %}
+    <p class="notification is-warning">
+        {% spaceless %}
+        {% trans "If you wish to migrate any statuses (comments, reviews, or quotes) you must either set this account as an <strong>alias</strong> of the one you are migrating from, or <strong>move</strong> that account to this one, before you import your user data." %}
+        {% endspaceless %}
+    </p>
+    {% if not site.imports_enabled %}
+    <div class="box notification has-text-centered is-warning m-6 content">
+        <p class="mt-5">
+            <span class="icon icon-warning is-size-2" aria-hidden="true"></span>
+        </p>
+        <p class="mb-5">
+            {% trans "Imports are temporarily disabled; thank you for your patience." %}
+        </p>
+    </div>
+    {% elif next_available %}
+        <div class="notification is-warning">
+            <p>{% blocktrans %}Currently you are allowed to import one user every {{ user_import_hours }} hours.{% endblocktrans %}</p>
+            <p>{% blocktrans %}You will next be able to import a user file at {{ next_available }}{% endblocktrans %}</p>
+        </div>
+    {% else %}
+    <form class="box content" name="import-user" action="/user-import" method="post" enctype="multipart/form-data">
+        {% csrf_token %}
+
+        <div class="block">
+            <div class="notification">
+                <h2 class="is-size-5">{% trans "Step 1:" %}</h2>
+                <p>
+                    {% blocktrans trimmed %}
+                    Select an export file generated from another BookWyrm account. The file format should be <code>.tar.gz</code>.
+                    {% endblocktrans %}
+                </p>
+            </div>
+            <div class="block m-5">
+                <label class="label" for="id_archive_file">{% trans "Data file:" %}</label>
+                {{ import_form.archive_file }}
+            </div>
+        </div>
+
+        <hr aria-hidden="true">
+
+        <div class="block">
+            <div class="notification">
+                <h2 class="is-size-5">{% trans "Step 2:" %}</h2>
+                <p>
+                    {% blocktrans trimmed %}
+                    Deselect any checkboxes for data you do not wish to include in your import.
+                    {% endblocktrans %}
+                </p>
+                <p class="block">Unless specified below, importing will not delete any data. Imported data will be <strong>added if it does not already exist</strong>. For example, if you have an existing list with the same name as an imported list, the existing list settings will not change, any new list items will be added, and no existing list items will be deleted.</p>
+            </div>
+            <div class="block m-5 columns">
+                <div class="column is-half">
+                    <div class="field">
+                        <label class="label mb-0">
+                            <input type="checkbox" name="include_user_profile" checked aria-describedby="desc_include_user_profile">
+                            {% trans "User profile" %}
+                        </label>
+                        <p id="desc_include_user_profile">
+                        {% trans "Overwrites display name, summary, and avatar" %}
+                        </p>
+                    </div>
+                    <div class="field">
+                        <label class="label mb-0">
+                            <input type="checkbox" name="include_user_settings" checked aria-describedby="desc_include_user_settings">
+                            {% trans "User settings" %}
+                        </label>
+                        <div id="desc_include_user_settings">
+                            {% trans "Overwrites:" %}
+                            <ul class="mt-0">
+                                <li>
+                                    {% trans "Whether manual approval is required for other users to follow your account" %}
+                                </li>
+                                <li>
+                                    {% trans "Whether following/followers are shown on your profile" %}
+                                </li>
+                                <li>
+                                    {% trans "Whether your reading goal is shown on your profile" %}
+                                </li>
+                                <li>
+                                    {% trans "Whether you see user follow suggestions" %}
+                                </li>
+                                <li>
+                                    {% trans "Whether your account is suggested to others" %}
+                                </li>
+                                <li>
+                                    {% trans "Your timezone" %}
+                                </li>
+                                <li>
+                                    {% trans "Your default post privacy setting" %}
+                                </li>
+                            </ul>
+                        </div>
+                    </div>
+                    <div class="field">
+                        <label class="label">
+                            <input type="checkbox" name="include_follows" checked>
+                            {% trans "Followers and following" %}
+                        </label>
+                    </div>
+                    <label class="label">
+                        <input type="checkbox" name="include_blocks" checked> {% trans "User blocks" %}
+                    </label>
+                </div>
+                <div class="column is-half">
+                    <div class="field">
+                        <label class="label mb-0">
+                            <input type="checkbox" name="include_goals" checked aria-describedby="desc_include_goals">
+                            {% trans "Reading goals" %}
+                        </label>
+                        <p id="desc_include_goals">
+                            {% trans "Overwrites reading goals for all years listed in the import file" %}
+                        </p>
+                    </div>
+                    <label class="label">
+                        <input type="checkbox" name="include_shelves" checked> {% trans "Shelves" %}
+                    </label>
+                    <label class="label">
+                        <input type="checkbox" name="include_readthroughs" checked> {% trans "Reading history" %}
+                    </label>
+                    <label class="label">
+                        <input type="checkbox" name="include_reviews" checked> {% trans "Book reviews" %}
+                    </label>
+                    <label class="label">
+                        <input type="checkbox" name="include_quotations" checked> {% trans "Quotations" %}
+                    </label>
+                    <label class="label">
+                        <input type="checkbox" name="include_comments" checked> {% trans "Comments about books" %}
+                    </label>
+                    <label class="label">
+                        <input type="checkbox" name="include_lists" checked> {% trans "Book lists" %}
+                    </label>
+                    <label class="label">
+                        <input type="checkbox" name="include_saved_lists" checked> {% trans "Saved lists" %}
+                    </label>
+                </div>
+            </div>
+        </div>
+
+        {% if not import_limit_reset and not import_size_limit or allowed_imports > 0 %}
+            <button class="button is-primary" type="submit">{% trans "Import" %}</button>
+        {% else %}
+            <button class="button is-primary is-disabled" type="submit">{% trans "Import" %}</button>
+            <p>{% trans "You've reached the import limit." %}</p>
+        {% endif%}
+    </form>
+    {% endif %}
+
+</div>
+
+<div class="content block">
+    <h2 class="title">{% trans "Recent Imports" %}</h2>
+    <div class="table-container">
+        <table class="table is-striped is-fullwidth">
+            <tr>
+                <th>
+                    {% trans "Date Created" %}
+                </th>
+                <th>
+                    {% trans "Last Updated" %}
+                </th>
+                <th>
+                    {% trans "Status" %}
+                </th>
+            </tr>
+            {% if not jobs %}
+            <tr>
+                <td colspan="4">
+                    <em>{% trans "No recent imports" %}</em>
+                </td>
+            </tr>
+            {% endif %}
+            {% for job in jobs %}
+            <tr>
+                <td>
+                    <p>{{ job.created_date }}</p>
+                </td>
+                <td>{{ job.updated_date }}</td>
+                <td>
+                    <span
+                        {% if job.status == "stopped" or job.status == "failed" %}
+                        class="tag is-danger"
+                        {% elif job.status == "pending" %}
+                        class="tag is-warning"
+                        {% elif job.complete %}
+                        class="tag"
+                        {% else %}
+                        class="tag is-success"
+                        {% endif %}
+                    >
+                        {% if job.status %}
+                        {{ job.status }}
+                            {{ job.status_display }}
+                        {% elif job.complete %}
+                            {% trans "Complete" %}
+                        {% else %}
+                            {% trans "Active" %}
+                        {% endif %}
+                    </span>
+                </td>
+            </tr>
+            {% endfor %}
+        </table>
+    </div>
+
+    {% include 'snippets/pagination.html' with page=jobs path=request.path %}
+</div>
+{% endblock %}

+ 28 - 11
bookwyrm/templates/layout.html

@@ -14,6 +14,7 @@
 
     <link rel="shortcut icon" type="image/x-icon" href="{% if site.favicon %}{% get_media_prefix %}{{ site.favicon }}{% else %}{% static "images/favicon.ico" %}{% endif %}">
     <link rel="apple-touch-icon" href="{% if site.logo %}{{ media_full_url }}{{ site.logo }}{% else %}{% static "images/logo.png" %}{% endif %}">
+    <link rel="manifest" href="/manifest.json" />
 
     {% block opengraph %}
         {% include 'snippets/opengraph.html' %}
@@ -26,6 +27,7 @@
 <nav class="navbar" aria-label="main navigation">
     <div class="container">
         {% with notification_count=request.user.unread_notification_count has_unread_mentions=request.user.has_unread_mentions %}
+        {% if not request.user.moved_to %}
         <div class="navbar-brand">
             <a class="navbar-item" href="/">
                 <img class="image logo" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="{% blocktrans with site_name=site.name %}{{ site_name }} home page{% endblocktrans %}" loading="lazy" decoding="async">
@@ -33,7 +35,7 @@
             <form class="navbar-item column is-align-items-start pt-5" action="{% url 'search' %}">
                 <div class="field has-addons">
                     <div class="control">
-                        {% if user.is_authenticated %}
+                        {% if request.user.is_authenticated %}
                             {% trans "Search for a book, user, or list" as search_placeholder %}
                         {% else %}
                             {% trans "Search for a book" as search_placeholder %}
@@ -79,19 +81,18 @@
                 </strong>
             </button>
         </div>
-
         <div class="navbar-menu" id="main_nav">
             <div class="navbar-start" id="tour-navbar-start">
                 {% if request.user.is_authenticated %}
-                <a href="/#feed" class="navbar-item mt-3 py-0">
-                    {% trans "Feed" %}
-                </a>
                 <a href="{% url 'lists' %}" class="navbar-item mt-3 py-0">
                     {% trans "Lists" %}
                 </a>
                 <a href="{% url 'discover' %}" class="navbar-item mt-3 py-0">
                     {% trans "Discover" %}
                 </a>
+                <a href="{% url 'user-shelves' request.user.localname %}" class="navbar-item mt-3 py-0">
+                    {% trans "Your Books" %}
+                </a>
                 {% endif %}
             </div>
 
@@ -129,7 +130,12 @@
                                     </div>
                                     <div class="column">
                                         <label class="is-sr-only" for="id_password">{% trans "Password:" %}</label>
-                                        <input type="password" name="password" maxlength="128" class="input" required="" id="id_password" placeholder="{% trans 'password' %}">
+                                        <div class="control has-icons-right">
+                                            <input type="password" name="password" maxlength="128" class="input" required="" id="id_password" placeholder="{% trans 'password' %}">
+                                            <span data-password-icon data-for="id_password" class="icon is-right is-clickable">
+                                                <button type="button" aria-controls="id_password" class="icon-eye-blocked" title="{% trans 'Show/Hide password' %}"></button>
+                                            </span>
+                                        </div>
                                         <p class="help"><a href="{% url 'password-reset' %}">{% trans "Forgot your password?" %}</a></p>
                                     </div>
                                     <div class="column is-narrow">
@@ -151,6 +157,13 @@
                 {% endif %}
             </div>
         </div>
+        {% else %}
+        <div class="navbar-brand">
+            <a class="navbar-item" href="/">
+                <img class="image logo" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="{% blocktrans with site_name=site.name %}{{ site_name }} home page{% endblocktrans %}" loading="lazy" decoding="async">
+            </a>
+        </div>
+        {% endif %}
         {% endwith %}
     </div>
 </nav>
@@ -167,11 +180,15 @@
 
 <main class="section is-flex-grow-1">
     <div class="container">
-        {# almost every view needs to know the user shelves #}
-        {% with request.user.shelf_set.all as user_shelves %}
-        {% block content %}
-        {% endblock %}
-        {% endwith %}
+        {% if request.user.moved_to %}
+            {% include "moved.html" %}
+        {% else %}
+            {# almost every view needs to know the user shelves #}
+            {% with request.user.shelf_set.all as user_shelves %}
+            {% block content %}
+            {% endblock %}
+            {% endwith %}
+        {% endif %}
     </div>
 </main>
 

+ 14 - 0
bookwyrm/templates/manifest.json

@@ -0,0 +1,14 @@
+{% load static %}
+{
+  "name": "{{ site.name }}",
+  "description": "{{ site.description }}",
+  "icons": [
+    {
+      "src": "{% if site.logo %}{{ media_full_url }}{{ site.logo }}{% else %}{% static 'images/logo.png' %}{% endif %}",
+      "type": "image/png",
+      "sizes": "512x512"
+    }
+  ],
+	"start_url": "/",
+  "display": "standalone"
+}

+ 52 - 0
bookwyrm/templates/moved.html

@@ -0,0 +1,52 @@
+{% load i18n %}
+{% load static %}
+{% load utilities %}
+
+<div class="container my-6">
+    <div class="card">
+        <div class="card-content">
+          <div class="media">
+            <div class="media-left">
+              <figure class="image is-48x48">
+                <img src="{% if request.user.avatar %}{% get_media_prefix %}{{ request.user.avatar }}{% else %}{% static "images/default_avi.jpg" %}{% endif %}"
+                {% if ariaHide %}aria-hidden="true"{% endif %}
+                alt="{{ request.user.alt_text }}"
+                loading="lazy"
+                decoding="async">
+              </figure>
+            </div>
+            <div class="media-content">
+              <p class="title is-4">{{ request.user.display_name }}</p>
+              <p class="subtitle is-6"><s>{{request.user.username}}</s></p>
+            </div>
+          </div>
+
+          <div class="notification is-warning">
+            <p>
+                {% id_to_username request.user.moved_to  as username %}
+                {% blocktrans trimmed with moved_to=user.moved_to %}
+                    <strong>You have moved your account</strong> to <a href="{{ moved_to }}">{{ username }}</a>
+                {% endblocktrans %}
+            </p>
+            <p class="mt-2">
+                {% trans "You can undo the move to restore full functionality, but some followers may have already unfollowed this account." %}
+            </p>
+          </div>
+        </div>
+        <div class="columns is-justify-content-center">
+            <div class="column is-one-quarter">
+                <div class="level">
+                    <form class="level-left" name="remove-alias" action="{% url 'prefs-unmove' %}" method="post">
+                        {% csrf_token %}
+                        <input type="hidden" name="remote_id" id="remote_id" value="{{user.moved_to}}">
+                        <button type="submit" class="button is-medium is-danger">{% trans "Undo move" %}</button>
+                    </form>
+                    <form class="level-right" name="logout" action="{% url 'logout' %}" method="post">
+                        {% csrf_token %}
+                        <button type="submit" class="button is-medium is-primary">{% trans 'Log out' %}</button>
+                    </form>
+                </div>
+            </div>
+        </div>
+      </div>
+</div>

+ 11 - 1
bookwyrm/templates/notifications/item.html

@@ -10,15 +10,23 @@
 {% elif notification.notification_type == 'FOLLOW' %}
     {% include 'notifications/items/follow.html' %}
 {% elif notification.notification_type == 'FOLLOW_REQUEST' %}
-    {% include 'notifications/items/follow_request.html' %}
+    {% if notification.related_users.0.is_active %}
+        {% include 'notifications/items/follow_request.html' %}
+    {% endif %}
 {% elif notification.notification_type == 'IMPORT' %}
     {% include 'notifications/items/import.html' %}
+{% elif notification.notification_type == 'USER_IMPORT' %}
+    {% include 'notifications/items/user_import.html' %}
+{% elif notification.notification_type == 'USER_EXPORT' %}
+    {% include 'notifications/items/user_export.html' %}
 {% elif notification.notification_type == 'ADD' %}
     {% include 'notifications/items/add.html' %}
 {% elif notification.notification_type == 'REPORT' %}
     {% include 'notifications/items/report.html' %}
 {% elif notification.notification_type == 'LINK_DOMAIN' %}
     {% include 'notifications/items/link_domain.html' %}
+{% elif notification.notification_type == 'INVITE_REQUEST' %}
+    {% include 'notifications/items/invite_request.html' %}
 {% elif notification.notification_type == 'INVITE' %}
     {% include 'notifications/items/invite.html' %}
 {% elif notification.notification_type == 'ACCEPT' %}
@@ -35,4 +43,6 @@
     {% include 'notifications/items/update.html' %}
 {% elif notification.notification_type == 'GROUP_DESCRIPTION' %}
     {% include 'notifications/items/update.html' %}
+{% elif notification.notification_type == 'MOVE' %}
+    {% include 'notifications/items/move_user.html' %}
 {% endif %}

+ 20 - 0
bookwyrm/templates/notifications/items/invite_request.html

@@ -0,0 +1,20 @@
+{% extends 'notifications/items/layout.html' %}
+{% load humanize %}
+{% load i18n %}
+
+{% block primary_link %}{% spaceless %}
+{% url 'settings-invite-requests' %}
+{% endspaceless %}{% endblock %}
+
+{% block icon %}
+<span class="icon icon-envelope"></span>
+{% endblock %}
+
+{% block description %}
+    {% url 'settings-invite-requests' as path %}
+    {% blocktrans trimmed count counter=notification.related_invite_requests.count with display_count=notification.related_invite_requests.count|intcomma %}
+    New <a href="{{ path }}">invite request</a> awaiting response
+    {% plural %}
+    {{ display_count }} new <a href="{{ path }}">invite requests</a> awaiting response
+    {% endblocktrans %}
+{% endblock %}

+ 4 - 0
bookwyrm/templates/notifications/items/layout.html

@@ -39,6 +39,8 @@
 
                 {% with related_user=related_users.0.display_name %}
                 {% with related_user_link=related_users.0.local_path %}
+                {% with related_user_moved_to=related_users.0.moved_to %}
+                {% with related_user_username=related_users.0.username %}
                 {% with second_user=related_users.1.display_name %}
                 {% with second_user_link=related_users.1.local_path %}
                 {% with other_user_count=related_user_count|add:"-1" %}
@@ -50,6 +52,8 @@
                 {% endwith %}
                 {% endwith %}
                 {% endwith %}
+                {% endwith %}
+                {% endwith %}
             </div>
 
             {% if related_status %}

+ 29 - 0
bookwyrm/templates/notifications/items/move_user.html

@@ -0,0 +1,29 @@
+{% extends 'notifications/items/layout.html' %}
+
+{% load i18n %}
+{% load utilities %}
+{% load user_page_tags %}
+
+{% block primary_link %}{% spaceless %}
+    {{ notification.related_object.local_path }}
+{% endspaceless %}{% endblock %}
+
+{% block icon %}
+    <span class="icon icon-local"></span>
+{% endblock %}
+
+{% block description %}
+    {% if related_user_moved_to %}
+        {% id_to_username request.user.moved_to  as username %}
+        {% blocktrans trimmed %}
+        {{ related_user }} has moved to <a href="{{ related_user_moved_to }}">{{ username }}</a>
+        {% endblocktrans %}
+        <div class="row shrink my-2">
+            {% include 'snippets/move_user_buttons.html' with group=notification.related_group %}
+        </div>
+    {% else %}
+        {% blocktrans trimmed %}
+        {{ related_user }} has undone their move
+        {% endblocktrans %}
+    {% endif %}
+{% endblock %}

+ 15 - 0
bookwyrm/templates/notifications/items/user_export.html

@@ -0,0 +1,15 @@
+{% extends 'notifications/items/layout.html' %}
+{% load i18n %}
+
+{% block primary_link %}{% spaceless %}
+{% url 'prefs-user-export' %}
+{% endspaceless %}{% endblock %}
+
+{% block icon %}
+<span class="icon icon-list"></span>
+{% endblock %}
+
+{% block description %}
+    {% url 'prefs-user-export'  as url %}
+    {% blocktrans %}Your <a href="{{ url }}">user export</a> is ready.{% endblocktrans %}
+{% endblock %}

+ 16 - 0
bookwyrm/templates/notifications/items/user_import.html

@@ -0,0 +1,16 @@
+{% extends 'notifications/items/layout.html' %}
+{% load i18n %}
+
+{% block primary_link %}{% spaceless %}
+{% url 'user-import' %}
+{% endspaceless %}{% endblock %}
+
+{% block icon %}
+<span class="icon icon-list"></span>
+{% endblock %}
+
+{% block description %}
+{% url 'user-import' as import_url %}
+{% blocktrans %}Your <a href="{{ import_url }}">user import</a> is complete.{% endblocktrans %}
+
+{% endblock %}

+ 59 - 0
bookwyrm/templates/preferences/alias_user.html

@@ -0,0 +1,59 @@
+{% extends 'preferences/layout.html' %}
+{% load i18n %}
+
+{% block title %}{% trans "Move Account" %}{% endblock %}
+
+{% block header %}
+{% trans "Create Alias" %}
+{% endblock %}
+
+{% block panel %}
+<div class="block">
+    <h2 class="title is-4">{% trans "Add another account as an alias" %}</h2>
+    <div class="box">
+        <div class="notification is-info is-light">
+            <p>
+                {% trans "Marking another account as an alias is required if you want to move that account to this one." %}
+            </p>
+            <p>
+                {% trans "This is a reversable action and will not change the functionality of this account." %}
+            </p>
+        </div>
+        <form name="alias-user" action="{% url 'prefs-alias' %}" method="post">
+            {% csrf_token %}
+            <div class="field">
+                <label class="label" for="id_target">{% trans "Enter the username for the account you want to add as an alias e.g. <em>user@example.com </em>:" %}</label>
+                <input class="input {% if form.username.errors %}is-danger{% endif %}" type="text" name="username" id="id_username" required aria-describedby="desc_username">
+                {% include 'snippets/form_errors.html' with errors_list=form.username.errors id="desc_username" %}
+            </div>
+            <div class="field">
+                <label class="label" for="id_password">{% trans "Confirm your password:" %}</label>
+                <input class="input {% if form.password.errors %}is-danger{% endif %}" type="password" name="password" id="id_password" required aria-describedby="desc_password">
+                {% include 'snippets/form_errors.html' with errors_list=form.password.errors id="desc_password" %}
+            </div>
+            <button type="submit" class="button is-success">{% trans "Create Alias" %}</button>
+        </form>
+    </div>
+    {% if user.also_known_as.all.0 %}
+    <div class="box">
+        <h2 class="title is-4">{% trans "Aliases" %}</h2>
+        <div class="table-container block">
+            <table class="table is-striped is-fullwidth">
+                {% for alias in user.also_known_as.all %}
+                <tr>
+                    <td>{{ alias.username }}</td>
+                    <td>
+                        <form name="remove-alias" action="{% url 'prefs-remove-alias' %}" method="post">
+                            {% csrf_token %}
+                            <input type="hidden" name="alias" id="alias" value="{{ alias.id }}">
+                            <button type="submit" class="button is-info">{% trans "Remove alias" %}</button>
+                        </form>
+                    </td>
+                </tr>
+                {% endfor %}
+            </table>
+        </div>
+    </div>
+    {% endif %}
+</div>
+{% endblock %}

+ 138 - 0
bookwyrm/templates/preferences/export-user.html

@@ -0,0 +1,138 @@
+{% extends 'preferences/layout.html' %}
+{% load i18n %}
+{% load utilities %}
+
+{% block title %}{% trans "Export BookWyrm Account" %}{% endblock %}
+
+{% block header %}
+{% trans "Export BookWyrm Account" %}
+{% endblock %}
+
+{% block panel %}
+<div class="block content">
+    <div class="block content">
+        <p> {% trans "You can create an export file here. This will allow you to migrate your data to another BookWyrm account." %}</p>
+    </div>
+        <div class="block mx-5 columns">
+            {% blocktrans trimmed %}
+            <div class="column is-half">
+                <h2 class="is-size-5">Your file will include:</h2>
+                <ul>
+                    <li>User profile</li>
+                    <li>Most user settings</li>
+                    <li>Reading goals</li>
+                    <li>Shelves</li>
+                    <li>Reading history</li>
+                    <li>Book reviews</li>
+                    <li>Statuses</li>
+                    <li>Your own lists and saved lists</li>
+                    <li>Which users you follow and block</li>
+                </ul>
+            </div>
+            <div class="column is-half">
+                <h2 class="is-size-5">Your file will not include:</h2>
+                <ul>
+                    <li>Direct messages</li>
+                    <li>Replies to your statuses</li>
+                    <li>Groups</li>
+                    <li>Favorites</li>
+                </ul>
+            </div>
+            {% endblocktrans %}
+        </div>
+        <p class="block">{% trans "In your new BookWyrm account can choose what to import: you will not have to import everything that is exported." %}</p>
+        <p class="notification is-warning">
+            {% spaceless %}
+            {% trans "If you wish to migrate any statuses (comments, reviews, or quotes) you must either set the account you are moving to as an <strong>alias</strong> of this one, or <strong>move</strong> this account to the new account, before you import your user data." %}
+            {% endspaceless %}
+        </p>
+    {% if next_available %}
+    <p class="notification is-warning">
+        {% blocktrans trimmed %}
+        You will be able to create a new export file at {{ next_available }}
+        {% endblocktrans %}
+    </p>
+    {% else %}
+    <form name="export" method="POST" href="{% url 'prefs-user-export' %}">
+        {% csrf_token %}
+        <button type="submit" class="button">
+            <span class="icon icon-download" aria-hidden="true"></span>
+            <span>{% trans "Create user export file" %}</span>
+        </button>
+    </form>
+    {% endif %}
+
+</div>
+<div class="content block">
+    <h2 class="title">{% trans "Recent Exports" %}</h2>
+    <p class="content">
+    {% trans "User export files will show 'complete' once ready. This may take a little while. Click the link to download your file." %}
+    </p>
+    <div class="table-container">
+        <table class="table is-striped is-fullwidth">
+            <tr>
+                <th>
+                    {% trans "Date" %}
+                </th>
+                <th>
+                    {% trans "Status" %}
+                </th>
+                <th colspan="2">
+                    {% trans "Size" %}
+                </th>
+            </tr>
+            {% if not jobs %}
+            <tr>
+                <td colspan="4">
+                    <em>{% trans "No recent imports" %}</em>
+                </td>
+            </tr>
+            {% endif %}
+            {% for job in jobs %}
+            <tr>
+                <td>{{ job.updated_date }}</td>
+                <td>
+                    <span
+                        {% if job.status == "stopped" or job.status == "failed" %}
+                        class="tag is-danger"
+                        {% elif job.status == "pending" %}
+                        class="tag is-warning"
+                        {% elif job.complete %}
+                        class="tag"
+                        {% else %}
+                        class="tag is-success"
+                        {% endif %}
+                    >
+                        {% if job.status %}
+                        {{ job.status }}
+                            {{ job.status_display }}
+                        {% elif job.complete %}
+                            {% trans "Complete" %}
+                        {% else %}
+                            {% trans "Active" %}
+                        {% endif %}
+                    </span>
+                </td>
+                <td>
+                    <span>{{ job.export_data|get_file_size }}</span>
+                </td>
+                <td>
+                    {% if job.complete and not job.status == "stopped" and not job.status == "failed" %}
+                    <p>
+                        <a download="" href="/preferences/user-export/{{ job.task_id }}">
+                            <span class="icon icon-download" aria-hidden="true"></span>
+                            <span class="is-hidden-mobile">
+                                {% trans "Download your export" %}
+                            </span>
+                        </a>
+                    </p>
+                    {% endif %}
+                </td>
+            </tr>
+            {% endfor %}
+        </table>
+    </div>
+
+    {% include 'snippets/pagination.html' with page=jobs path=request.path %}
+</div>
+{% endblock %}

+ 3 - 3
bookwyrm/templates/preferences/export.html

@@ -1,16 +1,16 @@
 {% extends 'preferences/layout.html' %}
 {% load i18n %}
 
-{% block title %}{% trans "CSV Export" %}{% endblock %}
+{% block title %}{% trans "Export Book List" %}{% endblock %}
 
 {% block header %}
-{% trans "CSV Export" %}
+{% trans "Export Book List" %}
 {% endblock %}
 
 {% block panel %}
 <div class="block content">
     <p class="notification">
-        {% trans "Your export will include all the books on your shelves, books you have reviewed, and books with reading activity." %}
+        {% trans "Your CSV export file will include all the books on your shelves, books you have reviewed, and books with reading activity. <br/>Use this to import into a service like Goodreads." %}
     </p>
     <p>
         <form name="export" method="POST" href="{% url 'prefs-export' %}">

+ 18 - 2
bookwyrm/templates/preferences/layout.html

@@ -23,6 +23,14 @@
                 {% url 'prefs-2fa' as url %}
                 <a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Two Factor Authentication" %}</a>
             </li>
+            <li>
+                {% url 'prefs-alias' as url %}
+                <a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Aliases" %}</a>
+            </li>
+            <li>
+                {% url 'prefs-move' as url %}
+                <a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Move  Account" %}</a>
+            </li>
             <li>
                 {% url 'prefs-delete' as url %}
                 <a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Delete Account" %}</a>
@@ -32,11 +40,19 @@
         <ul class="menu-list">
             <li>
                 {% url 'import' as url %}
-                <a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Import" %}</a>
+                <a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Import Book List" %}</a>
             </li>
             <li>
                 {% url 'prefs-export' as url %}
-                <a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "CSV export" %}</a>
+                <a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Export Book List" %}</a>
+            </li>
+            <li>
+                {% url 'user-import' as url %}
+                <a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Import BookWyrm Account" %}</a>
+            </li>
+            <li>
+                {% url 'prefs-user-export' as url %}
+                <a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Export BookWyrm Account" %}</a>
             </li>
         </ul>
         <h2 class="menu-label">{% trans "Relationships" %}</h2>

+ 43 - 0
bookwyrm/templates/preferences/move_user.html

@@ -0,0 +1,43 @@
+{% extends 'preferences/layout.html' %}
+{% load i18n %}
+
+{% block title %}{% trans "Move Account" %}{% endblock %}
+
+{% block header %}
+{% trans "Move Account" %}
+{% endblock %}
+
+{% block panel %}
+<div class="block">
+    <h2 class="title is-4">{% trans "Migrate account to another server" %}</h2>
+    <div class="box">
+        <div class="notification is-danger is-light">
+            <p>
+                {% trans "Moving your account will notify all your followers and direct them to follow the new account." %}
+            </p>
+            <p>
+                {% blocktrans %}
+                <strong>{{ user }}</strong> will be marked as moved and will not be discoverable or usable unless you undo the move.
+                {% endblocktrans %}
+            </p>
+        </div>
+        <div class="notification is-info is-light">
+            <p>{% trans "Remember to add this user as an alias of the target account before you try to move." %}</p>
+        </div>
+        <form name="move-user" action="{% url 'prefs-move' %}" method="post">
+            {% csrf_token %}
+            <div class="field">
+                <label class="label" for="id_target">{% trans "Enter the username for the account you want to move to e.g. <em>user@example.com </em>:" %}</label>
+                <input class="input {% if form.target.errors %}is-danger{% endif %}" type="text" name="target" id="id_target" required aria-describedby="desc_target">
+                {% include 'snippets/form_errors.html' with errors_list=form.target.errors id="desc_target" %}
+            </div>
+            <div class="field">
+                <label class="label" for="id_password">{% trans "Confirm your password:" %}</label>
+                <input class="input {% if form.password.errors %}is-danger{% endif %}" type="password" name="password" id="id_password" required aria-describedby="desc_password">
+                {% include 'snippets/form_errors.html' with errors_list=form.password.errors id="desc_password" %}
+            </div>
+            <button type="submit" class="button is-danger">{% trans "Move Account" %}</button>
+        </form>
+    </div>
+</div>
+{% endblock %}

+ 1 - 1
bookwyrm/templates/settings/celery.html

@@ -29,7 +29,7 @@
         </div>
         <div class="column is-4">
             <div class="notification">
-                <p class="header">{% trans "Broadcasts" %}</p>
+                <p class="header">{% trans "Broadcast" %}</p>
                 <p class="title is-5">{{ queues.broadcast|intcomma }}</p>
             </div>
         </div>

+ 23 - 0
bookwyrm/templates/settings/imports/complete_user_import_modal.html

@@ -0,0 +1,23 @@
+{% extends 'components/modal.html' %}
+{% load i18n %}
+
+{% block modal-title %}{% trans "Stop import?" %}{% endblock %}
+
+{% block modal-body %}
+{% trans "This action will stop the user import before it is complete and cannot be un-done" %}
+{% endblock %}
+
+{% block modal-footer %}
+<form name="complete-import-{{ import.id }}" action="{% url 'settings-user-import-complete' import.id %}" method="POST" class="is-flex-grow-1">
+    {% csrf_token %}
+    <input type="hidden" name="id" value="{{ import.id }}">
+    <div class="buttons is-right is-flex-grow-1">
+        <button type="button" class="button" data-modal-close>
+            {% trans "Cancel" %}
+        </button>
+        <button class="button is-danger" type="submit">
+            {% trans "Confirm" %}
+        </button>
+    </div>
+</form>
+{% endblock %}

+ 205 - 80
bookwyrm/templates/settings/imports/imports.html

@@ -29,6 +29,7 @@
             <div class="notification">
                 {% trans "This is only intended to be used when things have gone very wrong with imports and you need to pause the feature while addressing issues." %}
                 {% trans "While imports are disabled, users will not be allowed to start new imports, but existing imports will not be affected." %}
+                {% trans "This setting prevents both book imports and user imports." %}
             </div>
             {% csrf_token %}
             <div class="control">
@@ -89,91 +90,215 @@
             </div>
         </form>
     </details>
+    <details class="details-panel box">
+        <summary>
+            <span role="heading" aria-level="2" class="title is-6">
+                {% trans "Limit how often users can import and export" %}
+            </span>
+            <span class="details-close icon icon-x" aria-hidden="true"></span>
+        </summary>
+        <form
+            name="user-imports-set-limit"
+            id="user-imports-set-limit"
+            method="POST"
+            action="{% url 'settings-user-imports-set-limit' %}"
+        >
+            <div class="notification">
+                {% trans "Some users might try to run user imports or exports very frequently, which you want to limit." %}
+                {% trans "Set the value to 0 to not enforce any limit." %}
+            </div>
+            <div class="align.to-t">
+                <label for="limit">{% trans "Restrict user imports and exports to once every " %}</label>
+                <input name="limit" class="input is-w-xs is-h-em" type="text" placeholder="0" value="{{ user_import_time_limit }}">
+                <label>{% trans "hours" %}</label>
+                {% csrf_token %}
+                <div class="control">
+                    <button type="submit" class="button is-warning">
+                        {% trans "Change limit" %}
+                    </button>
+                </div>
+            </div>
+        </form>
+    </details>
 </div>
 <div class="block">
-    <div class="tabs">
-        <ul>
-            {% url 'settings-imports' as url %}
-            <li {% if request.path in url %}class="is-active" aria-current="page"{% endif %}>
-                <a href="{{ url }}">{% trans "Active" %}</a>
-            </li>
-            {% url 'settings-imports' status="complete" as url %}
-            <li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
-                <a href="{{ url }}">{% trans "Completed" %}</a>
-            </li>
-        </ul>
+    <h4 class="title is-4">{% trans "Book Imports" %}</h4>
+    <div class="block">
+        <div class="tabs">
+            <ul>
+                {% url 'settings-imports' as url %}
+                <li {% if request.path in url %}class="is-active" aria-current="page"{% endif %}>
+                    <a href="{{ url }}">{% trans "Active" %}</a>
+                </li>
+                {% url 'settings-imports' status="complete" as url %}
+                <li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
+                    <a href="{{ url }}">{% trans "Completed" %}</a>
+                </li>
+            </ul>
+        </div>
     </div>
-</div>
 
-<div class="table-container block content">
-    <table class="table is-striped is-fullwidth">
-        <tr>
-            {% url 'settings-imports' status  as url %}
-            <th>
-                {% trans "ID" %}
-            </th>
-            <th>
-                {% trans "User" as text %}
-                {% include 'snippets/table-sort-header.html' with field="user" sort=sort text=text %}
-            </th>
-            <th>
-                {% trans "Date Created" as text %}
-                {% include 'snippets/table-sort-header.html' with field="created_date" sort=sort text=text %}
-            </th>
-            {% if status != "active" %}
-            <th>
-                {% trans "Date Updated" %}
-            </th>
-            {% endif %}
-            <th>
-                {% trans "Items" %}
-            </th>
-            <th>
-                {% trans "Pending items" %}
-            </th>
-            <th>
-                {% trans "Successful items" %}
-            </th>
-            <th>
-                {% trans "Failed items" %}
-            </th>
-            {% if status == "active" %}
-            <th>{% trans "Actions" %}</th>
-            {% endif %}
-        </tr>
-        {% for import in imports %}
-        <tr>
-            <td>{{ import.id }}</td>
-            <td class="overflow-wrap-anywhere">
-                <a href="{% url 'settings-user' import.user.id %}">{{ import.user|username }}</a>
-            </td>
-            <td>{{ import.created_date }}</td>
-            {% if status != "active" %}
-            <td>{{ import.updated_date }}</td>
+    <div class="table-container block content">
+        <table class="table is-striped is-fullwidth">
+            <tr>
+                {% url 'settings-imports' status as url %}
+                <th>
+                    {% trans "ID" %}
+                </th>
+                <th>
+                    {% trans "User" as text %}
+                    {% include 'snippets/table-sort-header.html' with field="user" sort=sort text=text %}
+                </th>
+                <th>
+                    {% trans "Date Created" as text %}
+                    {% include 'snippets/table-sort-header.html' with field="created_date" sort=sort text=text %}
+                </th>
+                {% if status != "active" %}
+                <th>
+                    {% trans "Date Updated" %}
+                </th>
+                {% endif %}
+                <th>
+                    {% trans "Items" %}
+                </th>
+                <th>
+                    {% trans "Pending items" %}
+                </th>
+                <th>
+                    {% trans "Successful items" %}
+                </th>
+                <th>
+                    {% trans "Failed items" %}
+                </th>
+                {% if status == "active" %}
+                <th>{% trans "Actions" %}</th>
+                {% endif %}
+            </tr>
+            {% for import in imports %}
+            <tr>
+                <td>{{ import.id }}</td>
+                <td class="overflow-wrap-anywhere">
+                    <a href="{% url 'settings-user' import.user.id %}">{{ import.user|username }}</a>
+                </td>
+                <td>{{ import.created_date }}</td>
+                {% if status != "active" %}
+                <td>{{ import.updated_date }}</td>
+                {% endif %}
+                <td>{{ import.item_count|intcomma }}</td>
+                <td>{{ import.pending_item_count|intcomma }}</td>
+                <td>{{ import.successful_item_count|intcomma }}</td>
+                <td>{{ import.failed_item_count|intcomma }}</td>
+                {% if status == "active" %}
+                <td>
+                    {% join "complete" import.id as modal_id %}
+                    <button type="button" data-modal-open="{{ modal_id }}" class="button is-danger">{% trans "Stop import" %}</button>
+                    {% include "settings/imports/complete_import_modal.html" with id=modal_id %}
+                </td>
+                {% endif %}
+            </tr>
+            {% endfor %}
+            {% if not imports %}
+            <tr>
+                <td colspan="6">
+                    <em>{% trans "No matching imports found." %} </em>
+                </td>
+            </tr>
             {% endif %}
-            <td>{{ import.item_count|intcomma }}</td>
-            <td>{{ import.pending_item_count|intcomma }}</td>
-            <td>{{ import.successful_item_count|intcomma }}</td>
-            <td>{{ import.failed_item_count|intcomma }}</td>
-            {% if status == "active" %}
-            <td>
-                {% join "complete" import.id as modal_id %}
-                <button type="button" data-modal-open="{{ modal_id }}" class="button is-danger">{% trans "Stop import" %}</button>
-                {% include "settings/imports/complete_import_modal.html" with id=modal_id %}
-            </td>
-            {% endif %}
-        </tr>
-        {% endfor %}
-        {% if not imports %}
-        <tr>
-            <td colspan="6">
-                <em>{% trans "No matching imports found." %} </em>
-            </td>
-        </tr>
-        {% endif %}
-    </table>
+        </table>
+    </div>
+
+    {% include 'snippets/pagination.html' with page=imports path=request.path %}
+
 </div>
 
-{% include 'snippets/pagination.html' with page=imports path=request.path %}
-{% endblock %}
+<div class="block">
+    <h4 class="title is-4">{% trans "User Imports" %}</h4>
+    <div class="block">
+        <div class="tabs">
+            <ul>
+                {% url 'settings-imports' as url %}
+                <li {% if request.path in url %}class="is-active" aria-current="page"{% endif %}>
+                    <a href="{{ url }}">{% trans "Active" %}</a>
+                </li>
+                {% url 'settings-imports' status="complete" as url %}
+                <li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
+                    <a href="{{ url }}">{% trans "Completed" %}</a>
+                </li>
+            </ul>
+        </div>
+    </div>
 
+    <div class="table-container block content">
+        <table class="table is-striped is-fullwidth">
+            <tr>
+                {% url 'settings-imports' status as url %}
+                <th>
+                    {% trans "ID" %}
+                </th>
+                <th>
+                    {% trans "User" as text %}
+                    {% include 'snippets/table-sort-header.html' with field="user" sort=sort text=text %}
+                </th>
+                <th>
+                    {% trans "Date Created" as text %}
+                    {% include 'snippets/table-sort-header.html' with field="created_date" sort=sort text=text %}
+                </th>
+                {% if status != "active" %}
+                <th>
+                    {% trans "Date Updated" %}
+                </th>
+                {% endif %}
+
+                {% if status == "active" %}
+                <th>{% trans "Actions" %}</th>
+                {% else %}
+                <th>{% trans "Status" %}</th>
+                {% endif %}
+            </tr>
+            {% for import in user_imports %}
+            <tr>
+                <td>{{ import.id }}</td>
+                <td class="overflow-wrap-anywhere">
+                    <a href="{% url 'settings-user' import.user.id %}">{{ import.user|username }}</a>
+                </td>
+                <td>{{ import.created_date }}</td>
+                {% if status != "active" %}
+                <td>{{ import.updated_date }}</td>
+                {% endif %}
+                {% if status == "active" %}
+                <td>
+                    {% join "complete" import.id as modal_id %}
+                    <button type="button" data-modal-open="{{ modal_id }}" class="button is-danger">{% trans "Stop import" %}</button>
+                    {% include "settings/imports/complete_user_import_modal.html" with id=modal_id %}
+                </td>
+                {% else %}
+                <td>
+                    <span
+                        {% if import.status == "stopped" or import.status == "failed" %}
+                        class="tag is-danger"
+                        {% elif import.status == "pending" %}
+                        class="tag is-warning"
+                        {% elif import.complete %}
+                        class="tag"
+                        {% else %}
+                        class="tag is-success"
+                        {% endif %}
+                        >{{ import.status }}
+                    </span>
+                </td>
+                {% endif %}
+            </tr>
+            {% endfor %}
+            {% if not user_imports %}
+            <tr>
+                <td colspan="6">
+                    <em>{% trans "No matching imports found." %} </em>
+                </td>
+            </tr>
+            {% endif %}
+        </table>
+    </div>
+
+    {% include 'snippets/pagination.html' with page=user_imports path=request.path %}
+</div>
+{% endblock %}

+ 43 - 0
bookwyrm/templates/settings/themes.html

@@ -12,6 +12,15 @@
 {% endblock %}
 
 {% block panel %}
+{% if broken_theme %}
+<div class="notification is-danger">
+    <span class="icon icon-warning" aria-hidden="true"></span>
+    <span>
+        {% trans "One of your themes appears to be broken. Selecting this theme will make the application unusable." %}
+    </span>
+</div>
+{% endif %}
+
 {% if success %}
 <div class="notification is-success is-light">
     <span class="icon icon-check" aria-hidden="true"></span>
@@ -98,6 +107,9 @@
                 <th>
                     {% trans "Actions" %}
                 </th>
+                <th>
+                    {% trans "Status" %}
+                </th>
             </tr>
             {% for theme in themes %}
             <tr>
@@ -112,6 +124,37 @@
                         </button>
                     </form>
                 </td>
+                <td>
+                {% if theme.loads is None %}
+
+                    <form method="POST" action="{% url 'settings-themes-test' theme.id %}">
+                        {% csrf_token %}
+                        <button type="submit" class="button is-small">
+                            <span class="icon icon-question-circle" aria-hidden="true"></span>
+                            <span>{% trans "Test theme" %}</span>
+                        </button>
+                    </form>
+
+                {% elif not theme.loads %}
+
+                    <span class="tag is-danger">
+                        <span class="icon icon-warning" aria-hidden="true"></span>
+                        <span>
+                            {% trans "Broken theme" %}
+                        </span>
+                    </span>
+
+                {% else %}
+
+                    <span class="tag is-success">
+                        <span class="icon icon-check" aria-hidden="true"></span>
+                        <span>
+                            {% trans "Loaded successfully" %}
+                        </span>
+                    </span>
+
+                {% endif %}
+                </td>
             </tr>
             {% endfor %}
         </table>

+ 1 - 18
bookwyrm/templates/settings/users/user_admin.html

@@ -74,24 +74,7 @@
             <td>{{ user.created_date }}</td>
             <td>{{ user.last_active_date }}</td>
             <td>
-                {% if user.is_active %}
-                    <span class="tag is-success" aria-hidden="true">
-                        <span class="icon icon-check"></span>
-                    </span>
-                    {% trans "Active" %}
-                {% elif user.deactivation_reason == "moderator_deletion" or user.deactivation_reason == "self_deletion" %}
-                    <span class="tag is-danger" aria-hidden="true">
-                        <span class="icon icon-x"></span>
-                    </span>
-                    {% trans "Deleted" %}
-                    <span class="help">({{ user.get_deactivation_reason_display }})</span>
-                {% else %}
-                    <span class="tag is-warning" aria-hidden="true">
-                        <span class="icon icon-x"></span>
-                    </span>
-                    {% trans "Inactive" %}
-                    <span class="help">({{ user.get_deactivation_reason_display }})</span>
-                {% endif %}
+                {% include "snippets/user_active_tag.html" with user=user %}
             </td>
             {% if status == "federated" %}
             <td>

+ 12 - 12
bookwyrm/templates/settings/users/user_info.html

@@ -1,6 +1,7 @@
 {% load i18n %}
 {% load markdown %}
 {% load humanize %}
+{% load utilities %}
 
 <div class="block columns">
     <div class="column is-flex is-flex-direction-column">
@@ -13,7 +14,17 @@
             </div>
             {% endif %}
 
+            {% if user.localname|is_instance_admin %}
+            <div class="message is-warning">
+                <div class="message-body">
+                    {% trans "This account is the instance actor for signing HTTP requests." %}
+                </div>
+            </div>
+            {% else %}
             <p class="mt-2"><a href="{{ user.local_path }}">{% trans "View user profile" %}</a></p>
+            {% endif %}
+
+
             {% url 'settings-user' user.id as url %}
             {% if not request.path == url %}
             <p class="mt-2"><a href="{{ url }}">{% trans "Go to user admin" %}</a></p>
@@ -23,18 +34,7 @@
     <div class="column is-flex is-flex-direction-column is-4">
         <h4 class="title is-4">{% trans "Status" %}</h4>
         <div class="box is-flex-grow-1 has-text-weight-bold">
-            {% if user.is_active %}
-            <p class="notification is-success">
-                {% trans "Active" %}
-            </p>
-            {% else %}
-            <p class="notification is-warning">
-                {% trans "Inactive" %}
-                {% if user.deactivation_reason %}
-                <span class="help">({% trans user.get_deactivation_reason_display %})</span>
-                {% endif %}
-            </p>
-            {% endif %}
+            {% include "snippets/user_active_tag.html" with large=True %}
             <p class="notification">
                 {% if user.local %}
                 {% trans "Local" %}

+ 76 - 62
bookwyrm/templates/settings/users/user_moderation_actions.html

@@ -1,4 +1,5 @@
 {% load i18n %}
+{% load utilities %}
 <div class="block content">
     {% if not user.is_active and user.deactivation_reason == "self_deletion" or user.deactivation_reason == "moderator_deletion" %}
     <div class="notification is-danger">
@@ -7,77 +8,90 @@
     {% else %}
     <h3>{% trans "User Actions" %}</h3>
 
-    <div class="box">
-        <div class="is-flex">
-            {% if user.is_active %}
-            <p class="mr-1">
-                <a class="button" href="{% url 'direct-messages-user' user.username %}">{% trans "Send direct message" %}</a>
-            </p>
-            {% endif %}
+        {% if user.localname|is_instance_admin %}
+        <div class="box">
+            <div class="message is-warning">
+                <div class="message-header">
+                    <p>{% trans "This is the instance admin actor" %}</p>
+                </div>
+                <div class="message-body">
+                    <p>{% trans "You must not delete or disable this account as it is critical to the functioning of your server. This actor signs outgoing GET requests to smooth interaction with secure ActivityPub servers." %}</p>
+                    <p>{% trans "This account is not discoverable by ordinary users and does not have a profile page." %}</p>
+                </div>
+            </div>
+        </div>
+        {% else %}
+        <div class="box">
+            <div class="is-flex">
+                {% if user.is_active %}
+                <p class="mr-1">
+                    <a class="button" href="{% url 'direct-messages-user' user.username %}">{% trans "Send direct message" %}</a>
+                </p>
+                {% endif %}
 
-            {% if not user.is_active and user.deactivation_reason == "pending" %}
-            <form name="activate" method="post" action="{% url 'settings-activate-user' user.id  %}" class="mr-1">
-                {% csrf_token %}
-                <button type="submit" class="button is-success is-light">{% trans "Activate user" %}</button>
-            </form>
-            {% endif %}
-            {% if user.is_active or user.deactivation_reason == "pending" %}
-            <form name="suspend" method="post" action="{% url 'settings-report-suspend' user.id report.id %}" class="mr-1">
-                {% csrf_token %}
-                <button type="submit" class="button is-danger is-light">{% trans "Suspend user" %}</button>
-            </form>
-            {% else %}
-            <form name="unsuspend" method="post" action="{% url 'settings-report-unsuspend' user.id report.id %}" class="mr-1">
-                {% csrf_token %}
-                <button class="button">{% trans "Un-suspend user" %}</button>
-            </form>
-            {% endif %}
+                {% if not user.is_active and user.deactivation_reason == "pending" %}
+                <form name="activate" method="post" action="{% url 'settings-activate-user' user.id  %}" class="mr-1">
+                    {% csrf_token %}
+                    <button type="submit" class="button is-success is-light">{% trans "Activate user" %}</button>
+                </form>
+                {% endif %}
+                {% if user.is_active or user.deactivation_reason == "pending" %}
+                <form name="suspend" method="post" action="{% url 'settings-report-suspend' user.id report.id %}" class="mr-1">
+                    {% csrf_token %}
+                    <button type="submit" class="button is-danger is-light">{% trans "Suspend user" %}</button>
+                </form>
+                {% else %}
+                <form name="unsuspend" method="post" action="{% url 'settings-report-unsuspend' user.id report.id %}" class="mr-1">
+                    {% csrf_token %}
+                    <button class="button">{% trans "Un-suspend user" %}</button>
+                </form>
+                {% endif %}
+
+                {% if user.local %}
+                <div>
+                    {% trans "Permanently delete user" as button_text %}
+                    {% include "snippets/toggle/open_button.html" with controls_text="delete_user" text=button_text class="is-danger is-light" %}
+                </div>
+                {% endif %}
+            </div>
 
             {% if user.local %}
             <div>
-                {% trans "Permanently delete user" as button_text %}
-                {% include "snippets/toggle/open_button.html" with controls_text="delete_user" text=button_text class="is-danger is-light" %}
+                {% include "settings/users/delete_user_form.html" with controls_text="delete_user" class="mt-2 mb-2" %}
             </div>
             {% endif %}
-        </div>
 
-        {% if user.local %}
-        <div>
-            {% include "settings/users/delete_user_form.html" with controls_text="delete_user" class="mt-2 mb-2" %}
-        </div>
-        {% endif %}
-
-        {% if user.local %}
-        <div>
-            <form name="permission" method="post" action="{% url 'settings-user' user.id report.id %}">
-                {% csrf_token %}
-                <label class="label" for="id_user_group">{% trans "Access level:" %}</label>
-                {% if group_form.non_field_errors %}
-                {{ group_form.non_field_errors }}
-                {% endif %}
-                {% with group=user.groups.first %}
-                <div class="select">
-                    <select name="groups" id="id_user_group" aria-describedby="desc_user_group">
-                        {% for value, name in group_form.fields.groups.choices %}
-                        <option value="{{ value }}" {% if name == group.name %}selected{% endif %}>
-                            {{ name|title }}
-                        </option>
-                        {% endfor %}
-                        <option value="" {% if not group %}selected{% endif %}>
-                            User
-                        </option>
-                    </select>
-                </div>
+            {% if user.local %}
+            <div>
+                <form name="permission" method="post" action="{% url 'settings-user' user.id report.id %}">
+                    {% csrf_token %}
+                    <label class="label" for="id_user_group">{% trans "Access level:" %}</label>
+                    {% if group_form.non_field_errors %}
+                    {{ group_form.non_field_errors }}
+                    {% endif %}
+                    {% with group=user.groups.first %}
+                    <div class="select">
+                        <select name="groups" id="id_user_group" aria-describedby="desc_user_group">
+                            {% for value, name in group_form.fields.groups.choices %}
+                            <option value="{{ value }}" {% if name == group.name %}selected{% endif %}>
+                                {{ name|title }}
+                            </option>
+                            {% endfor %}
+                            <option value="" {% if not group %}selected{% endif %}>
+                                User
+                            </option>
+                        </select>
+                    </div>
 
-                {% include 'snippets/form_errors.html' with errors_list=group_form.groups.errors id="desc_user_group" %}
-                {% endwith %}
-                <button class="button">
-                    {% trans "Save" %}
-                </button>
-            </form>
+                    {% include 'snippets/form_errors.html' with errors_list=group_form.groups.errors id="desc_user_group" %}
+                    {% endwith %}
+                    <button class="button">
+                        {% trans "Save" %}
+                    </button>
+                </form>
+            </div>
+            {% endif %}
         </div>
         {% endif %}
-    </div>
-
     {% endif %}
 </div>

+ 4 - 1
bookwyrm/templates/shelf/shelf.html

@@ -18,7 +18,9 @@
         {% include 'user/books_header.html' %}
     </h1>
 </header>
-
+{% if user.moved_to %}
+    {% include "snippets/moved_user_notice.html" with user=user %}
+{% else %}
 <nav class="breadcrumb subtitle" aria-label="breadcrumbs">
     <ul>
         <li><a href="{% url 'user-feed' user|username %}">{% trans "User profile" %}</a></li>
@@ -215,6 +217,7 @@
 <div>
     {% include 'snippets/pagination.html' with page=books path=request.path %}
 </div>
+{% endif %}
 {% endblock %}
 
 {% block scripts %}

+ 1 - 1
bookwyrm/templates/snippets/follow_button.html

@@ -43,7 +43,7 @@
     </div>
     {% if not minimal %}
     <div class="control">
-        {% include 'snippets/user_options.html' with user=user class="is-small" %}
+        {% include 'snippets/user_options.html' with user=user followers_page=followers_page class="is-small" %}
     </div>
     {% endif %}
 </div>

+ 13 - 0
bookwyrm/templates/snippets/move_user_buttons.html

@@ -0,0 +1,13 @@
+{% load i18n %}
+{% load utilities %}
+
+
+{% if related_user_moved_to|user_from_remote_id not in request.user.following.all %}
+<div class="field is-grouped">
+    <form action="{% url 'follow' %}" method="POST" class="interaction follow_{{ related_users.0.id }}" data-id="follow_{{ related_users.0.id }}">
+        {% csrf_token %}
+        <input type="hidden" name="user" value="{% id_to_username related_user_moved_to %}">
+        <button class="button is-link is-small" type="submit">{% trans "Follow at new account" %}</button>
+    </form>
+</div>
+{% endif %}

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff