From 688978369f3615e8a4d5b6743c97ecaf8a18e782 Mon Sep 17 00:00:00 2001 From: CSDUMMI Date: Tue, 5 Sep 2023 22:35:26 +0000 Subject: [PATCH 01/38] Implement self-contained archives to import and export entire users between instances (#38) Co-authored-by: Daniel Burgess Co-authored-by: Hugh Rundle Co-authored-by: dannymate Co-authored-by: hughrun Reviewed-on: https://codeberg.org/GuildAlpha/bookwyrm/pulls/38 Co-authored-by: CSDUMMI Co-committed-by: CSDUMMI --- bookwyrm/forms/forms.py | 4 + bookwyrm/importers/__init__.py | 1 + bookwyrm/importers/bookwyrm_import.py | 19 + ...ob_bookwyrmimportjob_childjob_parentjob.py | 165 ++++++ .../migrations/0182_merge_20230905_2240.py | 13 + bookwyrm/models/__init__.py | 1 + bookwyrm/models/bookwyrm_export_job.py | 216 +++++++ bookwyrm/models/bookwyrm_import_job.py | 505 ++++++++++++++++ bookwyrm/models/job.py | 290 +++++++++ bookwyrm/templates/import/import_user.html | 163 ++++++ .../templates/preferences/export-user.html | 89 +++ bookwyrm/templates/preferences/export.html | 6 +- bookwyrm/templates/preferences/layout.html | 12 +- .../tests/data/bookwyrm_account_export.json | 452 +++++++++++++++ .../tests/data/bookwyrm_account_export.tar.gz | Bin 0 -> 161361 bytes bookwyrm/tests/data/simple_user_export.json | 26 + .../models/test_bookwyrm_import_model.py | 548 ++++++++++++++++++ bookwyrm/tests/utils/test_tar.py | 23 + .../tests/views/imports/test_user_import.py | 68 +++ .../views/preferences/test_export_user.py | 74 +++ bookwyrm/urls.py | 11 + bookwyrm/utils/tar.py | 40 ++ bookwyrm/views/__init__.py | 4 +- bookwyrm/views/imports/import_data.py | 46 ++ bookwyrm/views/preferences/export.py | 50 ++ 25 files changed, 2819 insertions(+), 7 deletions(-) create mode 100644 bookwyrm/importers/bookwyrm_import.py create mode 100644 bookwyrm/migrations/0179_bookwyrmexportjob_bookwyrmimportjob_childjob_parentjob.py create mode 100644 bookwyrm/migrations/0182_merge_20230905_2240.py create mode 100644 bookwyrm/models/bookwyrm_export_job.py create mode 100644 bookwyrm/models/bookwyrm_import_job.py create mode 100644 bookwyrm/models/job.py create mode 100644 bookwyrm/templates/import/import_user.html create mode 100644 bookwyrm/templates/preferences/export-user.html create mode 100644 bookwyrm/tests/data/bookwyrm_account_export.json create mode 100644 bookwyrm/tests/data/bookwyrm_account_export.tar.gz create mode 100644 bookwyrm/tests/data/simple_user_export.json create mode 100644 bookwyrm/tests/models/test_bookwyrm_import_model.py create mode 100644 bookwyrm/tests/utils/test_tar.py create mode 100644 bookwyrm/tests/views/imports/test_user_import.py create mode 100644 bookwyrm/tests/views/preferences/test_export_user.py create mode 100644 bookwyrm/utils/tar.py diff --git a/bookwyrm/forms/forms.py b/bookwyrm/forms/forms.py index ea6093750..3d555f308 100644 --- a/bookwyrm/forms/forms.py +++ b/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 diff --git a/bookwyrm/importers/__init__.py b/bookwyrm/importers/__init__.py index 6ce50f160..8e92872f2 100644 --- a/bookwyrm/importers/__init__.py +++ b/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 diff --git a/bookwyrm/importers/bookwyrm_import.py b/bookwyrm/importers/bookwyrm_import.py new file mode 100644 index 000000000..a2eb71725 --- /dev/null +++ b/bookwyrm/importers/bookwyrm_import.py @@ -0,0 +1,19 @@ +"""Import data from Bookwyrm export files""" +from bookwyrm import settings +from bookwyrm.models.bookwyrm_import_job import BookwyrmImportJob + + +class BookwyrmImporter: + """Import a Bookwyrm User export JSON file. + This is kind of a combination of an importer and a connector. + """ + + def process_import(self, user, archive_file, settings): + """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 diff --git a/bookwyrm/migrations/0179_bookwyrmexportjob_bookwyrmimportjob_childjob_parentjob.py b/bookwyrm/migrations/0179_bookwyrmexportjob_bookwyrmimportjob_childjob_parentjob.py new file mode 100644 index 000000000..d13668cc4 --- /dev/null +++ b/bookwyrm/migrations/0179_bookwyrmexportjob_bookwyrmimportjob_childjob_parentjob.py @@ -0,0 +1,165 @@ +# Generated by Django 3.2.19 on 2023-08-31 22:57 + +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", "0178_auto_20230328_2132"), + ] + + 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"), + ], + 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.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"), + ], + 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, + }, + ), + ] diff --git a/bookwyrm/migrations/0182_merge_20230905_2240.py b/bookwyrm/migrations/0182_merge_20230905_2240.py new file mode 100644 index 000000000..83920a9c7 --- /dev/null +++ b/bookwyrm/migrations/0182_merge_20230905_2240.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.19 on 2023-09-05 22:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0179_bookwyrmexportjob_bookwyrmimportjob_childjob_parentjob"), + ("bookwyrm", "0181_merge_20230806_2302"), + ] + + operations = [] diff --git a/bookwyrm/models/__init__.py b/bookwyrm/models/__init__.py index 7b779190b..c2e5308cc 100644 --- a/bookwyrm/models/__init__.py +++ b/bookwyrm/models/__init__.py @@ -26,6 +26,7 @@ from .federated_server import FederatedServer from .group import Group, GroupMember, GroupMemberInvitation from .import_job import ImportJob, ImportItem +from .bookwyrm_import_job import BookwyrmImportJob from .site import SiteSettings, Theme, SiteInvite from .site import PasswordReset, InviteRequest diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py new file mode 100644 index 000000000..c262d9b5c --- /dev/null +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -0,0 +1,216 @@ +import logging + +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 import models +from bookwyrm.settings import DOMAIN +from bookwyrm.tasks import app, IMPORTS +from bookwyrm.models.job import ParentJob, ParentTask, SubTask, create_child_job +from uuid import uuid4 +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 + + # 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"]) + + +def tar_export(json_data: str, user, f): + f.open("wb") + with BookwyrmTarFile.open(mode="w:gz", fileobj=f) 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, books = get_books_for_user(user) + for book in editions: + tar.add_image(book.cover) + + f.close() + + +def json_export(user): + """Generate an export for a user""" + # user + exported_user = {} + vals = [ + "username", + "name", + "summary", + "manually_approves_followers", + "hide_follows", + "show_goal", + "show_suggested_users", + "discoverable", + "preferred_timezone", + "default_post_privacy", + ] + for k in vals: + exported_user[k] = getattr(user, k) + + if getattr(user, "avatar", False): + exported_user["avatar"] = f'https://{DOMAIN}{getattr(user, "avatar").url}' + + # reading goals + reading_goals = models.AnnualGoal.objects.filter(user=user).distinct() + goals_list = [] + try: + for goal in reading_goals: + goals_list.append( + {"goal": goal.goal, "year": goal.year, "privacy": goal.privacy} + ) + except Exception: + pass + + try: + readthroughs = models.ReadThrough.objects.filter(user=user).distinct().values() + readthroughs = list(readthroughs) + except Exception as e: + readthroughs = [] + + # books + editions, books = get_books_for_user(user) + final_books = [] + + for book in books.values(): + edition = editions.filter(id=book["id"]) + book["edition"] = edition.values()[0] + # authors + book["authors"] = list(edition.first().authors.all().values()) + # readthroughs + book_readthroughs = ( + models.ReadThrough.objects.filter(user=user, book=book["id"]) + .distinct() + .values() + ) + book["readthroughs"] = list(book_readthroughs) + # shelves + shelf_books = models.ShelfBook.objects.filter( + user=user, book=book["id"] + ).distinct() + shelves_from_books = models.Shelf.objects.filter( + shelfbook__in=shelf_books, user=user + ) + + book["shelves"] = list(shelves_from_books.values()) + book["shelf_books"] = {} + + for shelf in shelves_from_books: + shelf_contents = models.ShelfBook.objects.filter( + user=user, shelf=shelf + ).distinct() + + book["shelf_books"][shelf.identifier] = list(shelf_contents.values()) + + # book lists + book_lists = models.List.objects.filter( + books__in=[book["id"]], user=user + ).distinct() + book["lists"] = list(book_lists.values()) + book["list_items"] = {} + for blist in book_lists: + list_items = models.ListItem.objects.filter(book_list=blist).distinct() + book["list_items"][blist.name] = list(list_items.values()) + + # reviews + reviews = models.Review.objects.filter(user=user, book=book["id"]).distinct() + + book["reviews"] = list(reviews.values()) + + # comments + comments = models.Comment.objects.filter(user=user, book=book["id"]).distinct() + + book["comments"] = list(comments.values()) + logger.error("FINAL COMMENTS") + logger.error(book["comments"]) + + # quotes + quotes = models.Quotation.objects.filter(user=user, book=book["id"]).distinct() + # quote_statuses = models.Status.objects.filter( + # id__in=quotes, user=kwargs["user"] + # ).distinct() + + book["quotes"] = list(quotes.values()) + + logger.error("FINAL QUOTES") + logger.error(book["quotes"]) + + # append everything + final_books.append(book) + + # saved book lists + saved_lists = models.List.objects.filter(id__in=user.saved_lists.all()).distinct() + saved_lists = [l.remote_id for l in saved_lists] + + # follows + follows = models.UserFollows.objects.filter(user_subject=user).distinct() + following = models.User.objects.filter( + userfollows_user_object__in=follows + ).distinct() + follows = [f.remote_id for f in following] + + # blocks + blocks = models.UserBlocks.objects.filter(user_subject=user).distinct() + blocking = models.User.objects.filter(userblocks_user_object__in=blocks).distinct() + + blocks = [b.remote_id for b in blocking] + + data = { + "user": exported_user, + "goals": goals_list, + "books": final_books, + "saved_lists": saved_lists, + "follows": follows, + "blocked_users": blocks, + } + + return DjangoJSONEncoder().encode(data) + + +def get_books_for_user(user): + """Get all the books and editions related to a user + :returns: tuple of editions, books + """ + all_books = models.Edition.viewer_aware_objects(user) + editions = all_books.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() + books = models.Book.objects.filter(id__in=editions).distinct() + return editions, books diff --git a/bookwyrm/models/bookwyrm_import_job.py b/bookwyrm/models/bookwyrm_import_job.py new file mode 100644 index 000000000..696f8061a --- /dev/null +++ b/bookwyrm/models/bookwyrm_import_job.py @@ -0,0 +1,505 @@ +from functools import reduce +import json +import operator + +from django.db.models import FileField, JSONField, CharField +from django.db.models import Q +from django.utils.dateparse import parse_datetime +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, + ChildJob, + SubTask, + create_child_job, +) +from bookwyrm.utils.tar import BookwyrmTarFile +import json + + +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 + + 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.get("user")) + if "include_user_settings" in job.required: + update_user_settings(job.user, job.import_data.get("user")) + 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("blocked_users")) + + process_books(job, tar) + + job.save() + archive_file.close() + + +def process_books(job, tar): + """process user import data related to books""" + + # create the books. We need to merge Book and Edition instances + # and also check whether these books already exist in the DB + 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_reviews" in job.required: + get_or_create_statuses( + job.user, models.Review, data.get("reviews"), book.id + ) + + if "include_comments" in job.required: + get_or_create_statuses( + job.user, models.Comment, data.get("comments"), book.id + ) + + if "include_quotes" in job.required: + get_or_create_statuses( + job.user, models.Quotation, data.get("quotes"), book.id + ) + if "include_lists" in job.required: + upsert_lists(job.user, data.get("lists"), data.get("list_items"), book.id) + + +def get_or_create_edition(book_data, tar): + """Take a JSON string of book and edition data, + find or create the edition in the database and + return an edition instance""" + + cover_path = book_data.get( + "cover", None + ) # we use this further down but need to assign a var before cleaning + + clean_book = clean_values(book_data) + book = clean_book.copy() # don't mutate the original book data + + # prefer edition values only if they are not null + edition = clean_values(book["edition"]) + for key in edition.keys(): + if key not in book.keys() or ( + key in book.keys() and (edition[key] not in [None, ""]) + ): + book[key] = edition[key] + + existing = find_existing(models.Edition, book, None) + if existing: + return existing + + # the book is not in the local database, so we have to do this the hard way + local_authors = get_or_create_authors(book["authors"]) + + # get rid of everything that's not strictly in a Book + # or is many-to-many so can't be set directly + associated_values = [ + "edition", + "authors", + "readthroughs", + "shelves", + "shelf_books", + "lists", + "list_items", + "reviews", + "comments", + "quotes", + ] + + for val in associated_values: + del book[val] + + # now we can save the book as an Edition + new_book = models.Edition.objects.create(**book) + new_book.authors.set(local_authors) # now we can add authors with set() + + # get cover from original book_data because we lost it in clean_values + if cover_path: + tar.write_image_to_file(cover_path, new_book.cover) + + # NOTE: clean_values removes "last_edited_by" because it's a user ID from the old database + # if this is required, bookwyrm_export_job will need to bring in the user who edited it. + + # create parent + work = models.Work.objects.create(title=book["title"]) + work.authors.set(local_authors) + new_book.parent_work = work + + new_book.save(broadcast=False) + return new_book + + +def clean_values(data): + """clean values we don't want when creating new instances""" + + values = [ + "id", + "pk", + "remote_id", + "cover", + "preview_image", + "last_edited_by", + "last_edited_by_id", + "user", + "book_list", + "shelf_book", + "parent_work_id", + ] + + common = data.keys() & values + new_data = data + for val in common: + del new_data[val] + return new_data + + +def find_existing(cls, data, user): + """Given a book or author, find any existing model instances""" + + identifiers = [ + "openlibrary_key", + "inventaire_id", + "librarything_key", + "goodreads_key", + "asin", + "isfdb", + "isbn_10", + "isbn_13", + "oclc_number", + "origin_id", + "viaf", + "wikipedia_link", + "isni", + "gutenberg_id", + ] + + match_fields = [] + for i in identifiers: + if data.get(i) not in [None, ""]: + match_fields.append({i: data.get(i)}) + + if len(match_fields) > 0: + match = cls.objects.filter(reduce(operator.or_, (Q(**f) for f in match_fields))) + return match.first() + return None + + +def get_or_create_authors(data): + """Take a JSON string of authors find or create the authors + in the database and return a list of author instances""" + + authors = [] + for author in data: + clean = clean_values(author) + existing = find_existing(models.Author, clean, None) + if existing: + authors.append(existing) + else: + new = models.Author.objects.create(**clean) + authors.append(new) + return authors + + +def upsert_readthroughs(data, user, book_id): + """Take a JSON string of readthroughs, find or create the + instances in the database and return a list of saved instances""" + + for rt in data: + start_date = ( + parse_datetime(rt["start_date"]) if rt["start_date"] is not None else None + ) + finish_date = ( + parse_datetime(rt["finish_date"]) if rt["finish_date"] is not None else None + ) + stopped_date = ( + parse_datetime(rt["stopped_date"]) + if rt["stopped_date"] is not None + else None + ) + readthrough = { + "user": user, + "book": models.Edition.objects.get(id=book_id), + "progress": rt["progress"], + "progress_mode": rt["progress_mode"], + "start_date": start_date, + "finish_date": finish_date, + "stopped_date": stopped_date, + "is_active": rt["is_active"], + } + + existing = models.ReadThrough.objects.filter(**readthrough).exists() + if not existing: + models.ReadThrough.objects.create(**readthrough) + + +def get_or_create_statuses(user, cls, data, book_id): + """Take a JSON string of a status and + find or create the instances in the database""" + + for book_status in data: + + keys = [ + "content", + "raw_content", + "content_warning", + "privacy", + "sensitive", + "published_date", + "reading_status", + "name", + "rating", + "quote", + "raw_quote", + "progress", + "progress_mode", + "position", + "position_mode", + ] + common = book_status.keys() & keys + status = {k: book_status[k] for k in common} + status["published_date"] = parse_datetime(book_status["published_date"]) + if "rating" in common: + status["rating"] = float(book_status["rating"]) + book = models.Edition.objects.get(id=book_id) + exists = cls.objects.filter(**status, book=book, user=user).exists() + if not exists: + cls.objects.create(**status, book=book, user=user) + + +def upsert_lists(user, lists, items, book_id): + """Take a list and ListItems as JSON and create DB entries if they don't already exist""" + + book = models.Edition.objects.get(id=book_id) + + for lst in lists: + book_list = models.List.objects.filter(name=lst["name"], user=user).first() + if not book_list: + book_list = models.List.objects.create( + user=user, + name=lst["name"], + description=lst["description"], + curation=lst["curation"], + privacy=lst["privacy"], + ) + + # If the list exists but the ListItem doesn't don't try to add it + # with the same order as an existing item + count = models.ListItem.objects.filter(book_list=book_list).count() + + for i in items[lst["name"]]: + if not models.ListItem.objects.filter( + book=book, book_list=book_list, user=user + ).exists(): + models.ListItem.objects.create( + book=book, + book_list=book_list, + user=user, + notes=i["notes"], + order=i["order"] + count, + ) + + +def upsert_shelves(book, user, book_data): + """Take shelf and ShelfBooks 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, + identifier=shelf["identifier"], + description=shelf["description"], + editable=shelf["editable"], + privacy=shelf["privacy"], + ) + + for shelfbook in book_data["shelf_books"][book_shelf.identifier]: + + shelved_date = parse_datetime(shelfbook["shelved_date"]) + + 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=shelved_date, + ) + + +def update_user_profile(user, tar, data): + """update the user's profile from import data""" + name = data.get("name") + username = data.get("username").split("@")[0] + user.name = name if name else username + user.summary = data.get("summary") + user.save(update_fields=["name", "summary"]) + + if data.get("avatar") is not None: + 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", + "show_goal", + "show_suggested_users", + "discoverable", + "preferred_timezone", + "default_post_privacy", + ] + + for field in update_fields: + setattr(user, field, data[field]) + user.save(update_fields=update_fields) + + +@app.task(queue=IMPORTS, base=SubTask) +def update_user_settings_task(job_id, child_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 instead of making a new 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, child_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, child_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 + # that means we should save to trigger a re-broadcast + follow_request.save() + + +@app.task(queue=IMPORTS, base=SubTask) +def upsert_follows_task(job_id, child_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, child_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") + ) diff --git a/bookwyrm/models/job.py b/bookwyrm/models/job.py new file mode 100644 index 000000000..6e8d0dc5c --- /dev/null +++ b/bookwyrm/models/job.py @@ -0,0 +1,290 @@ +"""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") + + 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: + 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): + """Stop the job""" + if self.complete: + return + + self.__terminate_job() + + 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 + + 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): + """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): + """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): + """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): + """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): + """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 diff --git a/bookwyrm/templates/import/import_user.html b/bookwyrm/templates/import/import_user.html new file mode 100644 index 000000000..86e99f657 --- /dev/null +++ b/bookwyrm/templates/import/import_user.html @@ -0,0 +1,163 @@ +{% extends 'layout.html' %} +{% load i18n %} +{% load humanize %} + +{% block title %}{% trans "Import User" %}{% endblock %} + +{% block content %} +
+

{% trans "Import User" %}

+ + {% if invalid %} +
+ {% trans "Not a valid JSON file" %} +
+ {% endif %} + + + {% if import_size_limit and import_limit_reset %} +
+

{% blocktrans %}Currently you are allowed to import one user every {{ user_import_limit_reset }} days.{% endblocktrans %}

+

{% blocktrans %}You have {{ allowed_imports }} left.{% endblocktrans %}

+
+ {% endif %} + {% if recent_avg_hours or recent_avg_minutes %} +
+

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

+
+ {% endif %} + +
+ {% csrf_token %} + +
+
+
+ + {{ import_form.archive_file }} +
+
+

{% trans "Importing this file will overwrite any data you currently have saved." %}

+

{% trans "Deselect any data you do not wish to include in your import. Books will always be imported" %}

+
+
+ +
+
+ + + + + + + + + + + + +
+
+
+ {% if not import_limit_reset and not import_size_limit or allowed_imports > 0 %} + + {% else %} + +

{% trans "You've reached the import limit." %}

+ {% endif%} +
+ +
+ +
+

{% trans "Recent Imports" %}

+
+ + + + + + + {% if not jobs %} + + + + {% endif %} + {% for job in jobs %} + + + + + + {% endfor %} +
+ {% trans "Date Created" %} + + {% trans "Last Updated" %} + + {% trans "Status" %} +
+ {% trans "No recent imports" %} +
+

{{ job.created_date }}

+
{{ job.updated_date }} + + {% if job.status %} + {{ job.status }} + {{ job.status_display }} + {% elif job.complete %} + {% trans "Complete" %} + {% else %} + {% trans "Active" %} + {% endif %} + +
+
+ + {% include 'snippets/pagination.html' with page=jobs path=request.path %} +
+{% endblock %} diff --git a/bookwyrm/templates/preferences/export-user.html b/bookwyrm/templates/preferences/export-user.html new file mode 100644 index 000000000..81f13bc22 --- /dev/null +++ b/bookwyrm/templates/preferences/export-user.html @@ -0,0 +1,89 @@ +{% extends 'preferences/layout.html' %} +{% load i18n %} + +{% block title %}{% trans "User Export" %}{% endblock %} + +{% block header %} +{% trans "User Export" %} +{% endblock %} + +{% block panel %} +
+

+ {% trans "Your exported archive file will include all user data for import into another Bookwyrm server" %} +

+

+

+ {% csrf_token %} + +
+

+
+
+

{% trans "Recent Exports" %}

+

+ {% trans "User export files will show 'complete' once ready. This may take a little while. Click the link to download your file." %} +

+
+ + + + + + + {% if not jobs %} + + + + {% endif %} + {% for job in jobs %} + + + + + + {% endfor %} +
+ {% trans "Date Created" %} + + {% trans "Last Updated" %} + + {% trans "Status" %} +
+ {% trans "No recent imports" %} +
+ {% if job.complete %} +

{{ job.created_date }}

+ {% else %} +

{{ job.created_date }}

+ {% endif %} +
{{ job.updated_date }} + + {% if job.status %} + {{ job.status }} + {{ job.status_display }} + {% elif job.complete %} + {% trans "Complete" %} + {% else %} + {% trans "Active" %} + {% endif %} + +
+
+ + {% include 'snippets/pagination.html' with page=jobs path=request.path %} +
+{% endblock %} diff --git a/bookwyrm/templates/preferences/export.html b/bookwyrm/templates/preferences/export.html index 61933be3e..6976c5e27 100644 --- a/bookwyrm/templates/preferences/export.html +++ b/bookwyrm/templates/preferences/export.html @@ -1,16 +1,16 @@ {% extends 'preferences/layout.html' %} {% load i18n %} -{% block title %}{% trans "CSV Export" %}{% endblock %} +{% block title %}{% trans "Books Export" %}{% endblock %} {% block header %} -{% trans "CSV Export" %} +{% trans "Books Export" %} {% endblock %} {% block panel %}

- {% 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.
Use this to import into a service like Goodreads." %}

diff --git a/bookwyrm/templates/preferences/layout.html b/bookwyrm/templates/preferences/layout.html index ca63ec93d..8a03e7723 100644 --- a/bookwyrm/templates/preferences/layout.html +++ b/bookwyrm/templates/preferences/layout.html @@ -32,11 +32,19 @@ diff --git a/bookwyrm/tests/data/bookwyrm_account_export.json b/bookwyrm/tests/data/bookwyrm_account_export.json new file mode 100644 index 000000000..1652d9e45 --- /dev/null +++ b/bookwyrm/tests/data/bookwyrm_account_export.json @@ -0,0 +1,452 @@ +{ + "user": { + "username": "rat@www.example.com", + "name": "Rat", + "summary": "I love to make soup in Paris and eat pizza in New York", + "manually_approves_followers": true, + "hide_follows": true, + "show_goal": false, + "show_suggested_users": false, + "discoverable": false, + "preferred_timezone": "Australia/Adelaide", + "default_post_privacy": "followers" + }, + "goals": [ + { + "goal": 12, + "year": 2023, + "privacy": "followers" + } + ], + "books": [ + { + "id": 4880, + "created_date": "2023-08-14T02:03:12.509Z", + "updated_date": "2023-08-14T02:04:51.602Z", + "remote_id": "https://www.example.com/book/4880", + "origin_id": "https://bookwyrm.social/book/9389", + "openlibrary_key": "OL680025M", + "inventaire_id": "isbn:9780300070163", + "librarything_key": null, + "goodreads_key": null, + "bnf_id": null, + "viaf": null, + "wikidata": null, + "asin": null, + "aasin": null, + "isfdb": null, + "search_vector": "'c':16C 'certain':6B 'condit':12B 'fail':14B 'human':11B 'improv':9B 'james':15C 'like':2A 'scheme':7B 'scott':17C 'see':1A 'state':4A", + "last_edited_by_id": 243, + "connector_id": null, + "title": "Seeing Like a State", + "sort_title": "seeing like a state", + "subtitle": "how certain schemes to improve the human condition have failed", + "description": "

Examines how (sometimes quasi-) authoritarian high-modernist planning fails to deliver the goods, be they increased resources for the state or a better life for the people.

", + "languages": [ + "English" + ], + "series": "", + "series_number": "", + "subjects": [], + "subject_places": [], + "cover": "covers/d273d638-191d-4ebf-b213-3c60dbf010fe.jpeg", + "preview_image": "", + "first_published_date": null, + "published_date": "1998-03-30T00:00:00Z", + "edition": { + "id": 4880, + "created_date": "2023-08-14T02:03:12.509Z", + "updated_date": "2023-08-14T02:04:51.602Z", + "remote_id": "https://www.example.com/book/4880", + "origin_id": "https://bookwyrm.social/book/9389", + "openlibrary_key": "OL680025M", + "inventaire_id": "isbn:9780300070163", + "librarything_key": null, + "goodreads_key": null, + "bnf_id": null, + "viaf": null, + "wikidata": null, + "asin": null, + "aasin": null, + "isfdb": null, + "search_vector": "'c':16C 'certain':6B 'condit':12B 'fail':14B 'human':11B 'improv':9B 'james':15C 'like':2A 'scheme':7B 'scott':17C 'see':1A 'state':4A", + "last_edited_by_id": 243, + "connector_id": null, + "title": "Seeing Like a State", + "sort_title": "seeing like a state", + "subtitle": "how certain schemes to improve the human condition have failed", + "description": "

Examines how (sometimes quasi-) authoritarian high-modernist planning fails to deliver the goods, be they increased resources for the state or a better life for the people.

", + "languages": [ + "English" + ], + "series": "", + "series_number": "", + "subjects": [], + "subject_places": [], + "cover": "covers/d273d638-191d-4ebf-b213-3c60dbf010fe.jpeg", + "preview_image": "", + "first_published_date": null, + "published_date": "1998-03-30T00:00:00Z", + "book_ptr_id": 4880, + "isbn_10": "0300070160", + "isbn_13": "9780300070163", + "oclc_number": "", + "pages": 445, + "physical_format": "", + "physical_format_detail": "", + "publishers": [], + "parent_work_id": 4877, + "edition_rank": 8 + }, + "authors": [ + { + "id": 1189, + "created_date": "2023-08-14T02:03:11.578Z", + "updated_date": "2023-08-14T02:03:11.578Z", + "remote_id": "https://www.example.com/author/1189", + "origin_id": "https://bookwyrm.social/author/1110", + "openlibrary_key": "OL4398216A", + "inventaire_id": "wd:Q3025403", + "librarything_key": "scottjamesc", + "goodreads_key": "11958", + "bnf_id": "120602158", + "viaf": "47858502", + "wikidata": "Q3025403", + "asin": "B001H9W1D2", + "aasin": null, + "search_vector": null, + "last_edited_by_id": 62, + "wikipedia_link": "https://en.wikipedia.org/wiki/James_C._Scott", + "isni": "0000000108973024", + "gutenberg_id": null, + "isfdb": null, + "website": "", + "born": "1934-12-01T23:00:00Z", + "died": null, + "name": "James C. Scott", + "aliases": [ + "James Campbell Scott", + "\u30b8\u30a7\u30fc\u30e0\u30ba\u30fbC. \u30b9\u30b3\u30c3\u30c8", + "\u30b8\u30a7\u30fc\u30e0\u30ba\u30fbC\u30fb\u30b9\u30b3\u30c3\u30c8", + "\u062c\u06cc\u0645\u0632 \u0633\u06cc. \u0627\u0633\u06a9\u0627\u062a", + "Jim Scott", + "\u062c\u064a\u0645\u0633 \u0633\u0643\u0648\u062a", + "James C. Scott", + "\u0414\u0436\u0435\u0439\u043c\u0441 \u0421\u043a\u043e\u0442\u0442", + "\u30b8\u30a7\u30fc\u30e0\u30b9\u30fbC \u30b9\u30b3\u30c3\u30c8", + "James Cameron Scott" + ], + "bio": "

American political scientist and anthropologist

" + } + ], + "readthroughs": [ + { + "id": 1, + "created_date": "2023-08-14T04:00:27.544Z", + "updated_date": "2023-08-14T04:00:27.546Z", + "remote_id": "https://www.example.com/user/rat/readthrough/1", + "user_id": 1, + "book_id": 4880, + "progress": null, + "progress_mode": "PG", + "start_date": "2018-01-01T00:00:00Z", + "finish_date": "2023-08-13T00:00:00Z", + "stopped_date": null, + "is_active": false + } + ], + "shelves": [ + { + "id": 3, + "created_date": "2023-08-13T05:02:16.554Z", + "updated_date": "2023-08-13T05:02:16.554Z", + "remote_id": "https://www.example.com/user/rat/books/read", + "name": "Read", + "identifier": "read", + "description": null, + "user_id": 1, + "editable": false, + "privacy": "public" + }, + { + "id": 1, + "created_date": "2023-08-13T05:02:16.551Z", + "updated_date": "2023-08-13T05:02:16.552Z", + "remote_id": "https://www.example.com/user/rat/books/to-read", + "name": "To Read", + "identifier": "to-read", + "description": null, + "user_id": 1, + "editable": false, + "privacy": "public" + } + ], + "shelf_books": { + "read": [ + { + "id": 1, + "created_date": "2023-08-14T02:51:09.005Z", + "updated_date": "2023-08-14T02:51:09.015Z", + "remote_id": "https://www.example.com/user/rat/shelfbook/1", + "book_id": 4880, + "shelf_id": 3, + "shelved_date": "2023-08-13T03:52:49.196Z", + "user_id": 1 + } + ], + "to-read": [ + { + "id": 2, + "created_date": "2023-08-14T04:00:27.558Z", + "updated_date": "2023-08-14T04:00:27.564Z", + "remote_id": "https://www.example.com/user/rat/shelfbook/2", + "book_id": 4880, + "shelf_id": 1, + "shelved_date": "2023-08-13T03:51:13.175Z", + "user_id": 1 + } + ] + }, + "lists": [ + { + "id": 2, + "created_date": "2023-08-14T04:00:27.585Z", + "updated_date": "2023-08-14T04:02:54.826Z", + "remote_id": "https://www.example.com/list/2", + "name": "my list of books", + "user_id": 1, + "description": "Here is a description of my list", + "privacy": "followers", + "curation": "closed", + "group_id": null, + "embed_key": "6759a53e-3581-4685-b77a-7de765c03480" + } + ], + "list_items": { + "my list of books": [ + { + "id": 1, + "created_date": "2023-08-14T04:02:54.806Z", + "updated_date": "2023-08-14T04:02:54.808Z", + "remote_id": "https://www.example.com/user/rat/listitem/1", + "book_id": 4880, + "book_list_id": 2, + "user_id": 1, + "notes": "It's fun.", + "approved": true, + "order": 1 + } + ] + }, + "reviews": [ + { + "id": 1082, + "created_date": "2023-08-14T04:09:18.354Z", + "updated_date": "2023-08-14T04:09:18.382Z", + "remote_id": "https://www.example.com/user/rat/review/1082", + "user_id": 1, + "content": "

I like it

", + "raw_content": "I like it", + "local": true, + "content_warning": "Here's a spoiler alert", + "privacy": "followers", + "sensitive": true, + "published_date": "2023-08-14T04:09:18.343Z", + "edited_date": null, + "deleted": false, + "deleted_date": null, + "reply_parent_id": null, + "thread_id": 1082, + "ready": true, + "status_ptr_id": 1082, + "book_id": 4880, + "reading_status": null, + "name": "great book", + "rating": "5.00" + } + ], + "comments": [], + "quotes": [] + }, + { + "id": 6190, + "created_date": "2023-08-14T04:48:02.034Z", + "updated_date": "2023-08-14T04:48:02.174Z", + "remote_id": "https://www.example.com/book/6190", + "origin_id": "https://bookrastinating.com/book/330127", + "openlibrary_key": null, + "inventaire_id": "isbn:9780062975645", + "librarything_key": null, + "goodreads_key": null, + "bnf_id": null, + "viaf": null, + "wikidata": null, + "asin": null, + "aasin": null, + "isfdb": null, + "search_vector": "'indigen':4A 'sand':1A 'save':7A 'talk':2A 'think':5A 'tyson':10C 'world':9A 'yunkaporta':11C", + "last_edited_by_id": null, + "connector_id": null, + "title": "Sand Talk: How Indigenous Thinking Can Save the World", + "sort_title": null, + "subtitle": null, + "description": null, + "languages": [ + "English" + ], + "series": "", + "series_number": "", + "subjects": [], + "subject_places": [], + "cover": "covers/6a553a08-2641-42a1-baa4-960df9edbbfc.jpeg", + "preview_image": "", + "first_published_date": null, + "published_date": "2020-11-26T00:00:00Z", + "edition": { + "id": 4265, + "created_date": "2023-08-24T10:18:16.563Z", + "updated_date": "2023-08-24T10:18:16.649Z", + "remote_id": "https://www.example.com/book/4265", + "origin_id": "https://bookwyrm.social/book/65189", + "openlibrary_key": "OL28216445M", + "inventaire_id": null, + "librarything_key": "", + "goodreads_key": null, + "bnf_id": null, + "viaf": null, + "wikidata": null, + "asin": null, + "aasin": null, + "isfdb": null, + "search_vector": "'indigen':4B 'sand':1A 'save':7B 'talk':2A 'think':5B 'tyson':10C 'world':9B 'yunkaporta':11C", + "last_edited_by_id": 241, + "connector_id": null, + "title": "Sand Talk", + "sort_title": null, + "subtitle": "How Indigenous Thinking Can Save the World", + "description": "

As an indigenous person, Tyson Yunkaporta looks at global systems from a unique perspective, one tied to the natural and spiritual world. In considering how contemporary life diverges from the pattern of creation, he raises important questions. How does this affect us? How can we do things differently?

\n

In this thoughtful, culturally rich, mind-expanding book, he provides answers. Yunkaporta\u2019s writing process begins with images. Honoring indigenous traditions, he makes carvings of what he wants to say, channeling his thoughts through symbols and diagrams rather than words. He yarns with people, looking for ways to connect images and stories with place and relationship to create a coherent world view, and he uses sand talk, the Aboriginal custom of drawing images on the ground to convey knowledge.

\n

In Sand Talk, he provides a new model for our everyday lives. Rich in ideas and inspiration, it explains how lines and symbols and shapes can help us make sense of the world. It\u2019s about how we learn and how we remember. It\u2019s about talking to everyone and listening carefully. It\u2019s about finding different ways to look at things.

\n

Most of all it\u2019s about a very special way of thinking, of learning to see from a native perspective, one that is spiritually and physically tied to the earth around us, and how it can save our world.

\n

Sand Talk include 22 black-and-white illustrations that add depth to the text.

", + "languages": [], + "series": "", + "series_number": "", + "subjects": [], + "subject_places": [], + "cover": "covers/70d90f7d-8b81-431d-9b00-ca2656b06ca0.jpeg", + "preview_image": "", + "first_published_date": null, + "published_date": "2020-05-12T00:00:00Z", + "book_ptr_id": 4265, + "isbn_10": "", + "isbn_13": "", + "oclc_number": "", + "pages": 256, + "physical_format": "", + "physical_format_detail": "hardcover", + "publishers": [ + "HarperOne" + ], + "parent_work_id": 4263, + "edition_rank": 5 + }, + "authors": [ + { + "id": 1390, + "created_date": "2023-08-14T04:48:00.433Z", + "updated_date": "2023-08-14T04:48:00.436Z", + "remote_id": "https://www.example.com/author/1390", + "origin_id": "https://bookrastinating.com/author/52150", + "openlibrary_key": null, + "inventaire_id": null, + "librarything_key": null, + "goodreads_key": null, + "bnf_id": null, + "viaf": null, + "wikidata": null, + "asin": null, + "aasin": null, + "search_vector": null, + "last_edited_by_id": null, + "wikipedia_link": "", + "isni": null, + "gutenberg_id": null, + "isfdb": null, + "website": "", + "born": null, + "died": null, + "name": "Tyson Yunkaporta", + "aliases": [], + "bio": null + } + ], + "readthroughs": [], + "shelves": [], + "shelf_books": {}, + "lists": [], + "list_items": {}, + "reviews": [], + "comments": [ + { + "id": 1083, + "created_date": "2023-08-14T04:48:18.753Z", + "updated_date": "2023-08-14T04:48:18.769Z", + "remote_id": "https://www.example.com/user/rat/comment/1083", + "user_id": 1, + "content": "

this is a comment about an amazing book

", + "raw_content": "this is a comment about an amazing book", + "local": true, + "content_warning": null, + "privacy": "followers", + "sensitive": false, + "published_date": "2023-08-14T04:48:18.746Z", + "edited_date": null, + "deleted": false, + "deleted_date": null, + "reply_parent_id": null, + "thread_id": 1083, + "ready": true, + "status_ptr_id": 1083, + "book_id": 6190, + "reading_status": null, + "progress": null, + "progress_mode": "PG" + } + ], + "quotes": [ + { + "id": 1084, + "created_date": "2023-08-14T04:48:50.216Z", + "updated_date": "2023-08-14T04:48:50.234Z", + "remote_id": "https://www.example.com/user/rat/quotation/1084", + "user_id": 1, + "content": "

not actually from this book lol

", + "raw_content": "not actually from this book lol", + "local": true, + "content_warning": "spoiler ahead!", + "privacy": "followers", + "sensitive": true, + "published_date": "2023-08-14T04:48:50.207Z", + "edited_date": null, + "deleted": false, + "deleted_date": null, + "reply_parent_id": null, + "thread_id": 1084, + "ready": true, + "status_ptr_id": 1084, + "book_id": 6190, + "reading_status": null, + "quote": "

To be or not to be

", + "raw_quote": "To be or not to be", + "position": 1, + "endposition": null, + "position_mode": "PG" + } + ] + } + ], + "saved_lists": [ + "https://local.lists/9999" + ], + "follows": [ + "https://your.domain.here/user/rat" + ], + "blocked_users": ["https://your.domain.here/user/badger"] +} \ No newline at end of file diff --git a/bookwyrm/tests/data/bookwyrm_account_export.tar.gz b/bookwyrm/tests/data/bookwyrm_account_export.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..7612db57ed6897816af33a834ec7e19694efe592 GIT binary patch literal 161361 zcmV(rK<>XEiwFQ)3iV_F1MIqYR8w2DFT8EXE;d9#$x)gL0YQpFAjb-b7#kopM`=3!->{0|+^Q5D@_Z0jU8JlB1MJGtz|sp(7<7NJ5gG7w^0GzBgVO-+Onw_s2KB zZzX%|k?ghiUUSVkfAja-YrCVuJRb(@0slAKsZ*y+jEsJj@AXZLPW{*We{a9)8ycDz z8JsfG*Ejt2l>TWW17qMASeGll_d#6r8-R~M08yVj_W$b?Ul&>IY zY-spD@xQ)_iN3M^zxm(LK;PikQwI9_Mn=G|r~XeH`#);`ANRlPyKEfTedV(CWnkSp z09Ysg05Upo0obs9{m-wwZIpjEZP~PGi>%uqKWF2nt(!J&-M@4D&i((}P4*A4XUn?f zbv7H;9Rt?yS+`-&I$1LSk&koJx_@c^X2!bp@-hChdCS&q+vOc7Wr4nX{%BE?&B9X=Q!o)@?g`hdYi=?jD|A-uHccgF`~Y!Xwa;PoBm;i+lbe zJ|*>4S~~9UjLf%r@A3-@-xn2ER#n&366)$3J~uVDv{JvcwfFS)^$!ew9~z#RoSL4Q zo%=Drz~%9mS5^f=(b`YD)&U#V{agDV+OOCwQkV4Ewx1jxmi!87X0#wTTlfynU{>nIEX&N^4)pzd>pA^f&`86z~Cq#40#~!mW08!v(;P5 zJd7Py^+?f@R6RqRG8Dpn5*lD_psvW?;Kg>?jQgz)4_&zs~W=rR1pMU;9LQ0fx z+F%wR`-n!Ju-)98Of6q@I)Hok!LN-WCYlh65j@D$k_PrZKO=6cZC$~=FyuDDP(Jy7d`XZ)bom%ju!T~F_ zitz78Ym#O|A-pyvdiW@ve}jz}o%Q-FR@p)_UfQjJO<~V|z3`jRyHJ=TJ!Y$AQyojb zTo)BOjAw=vu_cGbp?g^i1I#5>!OMpibd0x8Uu87$W6k{hn%AUbg#j8%-znAEq| zrr;&lXW<;A>2H*dstmB?#64TlzE9?zJ2ql6>Q=1pLdsG3_AVI;IbYRl1jQRV48$j! zVRsG6fXQ^CpRH=Z1zKMK%%n&dBLil|=B-H<-$^vw3`+lz4EUq}$OYu!O4M1MnvzC~ zwf(~hI|b<}GN7`3>X-6tbmE`;C=JQ6Q|bu$oD=uDw$Z9}U3k;FktlQ>=r$XB~Aw4>-R? zoqtaTkmEY+^mJrE_+iR|3~1m=m+7ttqXVuQ*5?&R<O6cYSAy3$oqsZ z!*QZ}m4n_gfNGBdPm&qcM1C~owN+`mDQ3_~9BpwdM-#S8_A6e2<4(U$J(N5dQ~ELi zZ`oZo|C%8=0gpp2AB{lDfQ_pR$t4Kq9e4!$Ep`Gc*+<866^HWT_AkuCPRGlDSS`*X z_*8GZ3>eR$%Yf&3;C_5l`Py!ho(y;#g#wQeeUEQ=@&EVxKi#kVD{6S~A5gJJ2He+E zbfODUR|wDGl>zZbj1@0+z0(2$1m%5el2&Pj449sV$vqi}U9;Q-L?jj!4*A4v=Xj;0 z#+=>SH={x zZ2NSX2eh>UOZP2Z?+Fwa)BW;NU}LLs-#jx$qYqo;-al z`em35z`z@;gbvaL=5kchN|^P=C>3PwT4v$!M=fXe228ZG9lLyJ7Pz_9`V+)M`B&%Xd`Z}i+~6oVp{0@z(Dwm zo24VpjWf-em_l5mylmNor=r@?cLYI%aIVAIrw*(>q6}~_{xFSmNfK*ck^z6&QCLnB zYC}+VSQu&|u}ar4LDjIqPvuV81VK(+Qc**9-4~`~xMXBQ4GNLcW2HNZwrG1pf7{qa z<+@Bfs3@177GcM4!|t>^G+J@tVxMCBdlrph*mP;Hn&@>d|yOWq9{b=h3) zo#G5UF()aVViGAtF;S&NO;2pgHxV1rgeXKsZTHIknwMDJ&pl|*tPUAqx2<3ZCg)-v ztYP^q+McXOn}28b#hRRHs8JkfLpp~OGH!`=J8XKE@j|ONq=cifUWqO6smWPPogAwz zX$_?qO0)8~_@XuB*;236?>n_-bs71$lr7u|eKo(|VeI6<8?jqW5cu{WIEfM$GdL&l zcM_JRN9cCM6zu-d!ZqUz$ViR-R2OPObl>C_7~skrMGwUU{B1gm&qvDuo`H4Ier%?S zYmeL=1pXn+;Hvb%uV zANkIw$hGYq{c4uQ0JpaJZhN`-EV$y$zGi7VaZSUf%UnWPey*MXVTqEL#5K#vC7RfI zOk$iq_?fJiW5eEtvkEZUEXw}~E)WEHGT@uv+~%Y^-;B1759E=mL38+0MK#BzmYqhJ zyH_?KAO3e0gyM)Liagc>dZ3PiOw#vJUTw_c5uzRCMM-y_>EDjQw<1Bv>Cc__Jw+_# z2!9ups`Q??{2JwjSmB-Dfwi<(N(TgcMjb~qg@M0fek{by@6c|OV7UsD3XdeG6=(Bz zy(n}Y^D~YNt^W4iWx-i3)7t^M@uBvYPsV%X-j5+`NprA1jn3n41({xNQ+zFU)?pdW zB79>NZ7hN!%#?1T^pfCQ{HNiNWMAAqpPUfog!H`p5wwAN5noLky9?gXzZ!8h0OoM?LY8?ona{iKbC15Rtrn0*K)b zH$FtGev7u9h{9}dR&t7a0fnv{tl3h~7f5Qmc@ZA|@R7LIm0KwT_I_EE*yVe2cE-u%sS+tu5}_1Z^YQD2X!xcV^)V&pNChL^|ygjcgIc$6$>L?ejeM(d8)Sw|1{djC@cTNbKci^zv^?! zHl%=?B{c%=KnHDSQ~W1}!a~e3yj@+o4CrWRc)ZHLYEx@t*70}Pmi%@AMNG_OXfABNaEzFAuV3zAbjnI+;XM81#?{Kf))4e9c1heo1+#l`L4Qh9M zpwELhkv@n$4bQd<_4e3b(lpSB9X&hD(2cSqvY)E%?>AK0d5nEcd@aihcS4brVr)F3 z{qBumS`N9a+1hCBiMtavXMa^LEBa8KMCvFWMYDU?IlQ?cyuKOd7}FPNAiTzC9&Sx2 zjtIUiFQ5a4^wKL9G} zEpI0Z>Jl`~veA(_60AKa8EiibCi4(A&>e2h5p}; z^0FNWrBfQ62JkzpOHhX_C#^O6bj!ydJ4c-|d8Q z!HAD!5xLHnzLiN#q9z(m#lMmJ&o7)eN%u;OQh)i2iP;@!6FY&7s&@f%xlJuM7!i;5 zHSOHp3-k1wp#PSVSDU*d`Qr2=iXcGTMbDbCTQdr`*e5*uHo~m=z*>LfKndpkun*72 zzSq};{owcN@E4sY@DrokqPygY>iZ!n)@ct{Gq-q>IYaJauK4`UmjMq54_Yfe6jMI8 z+)$)6R{n9;tUM{~uX$RLZK3Ze%rn|`xwN`uW0x{{mZ|kvOVr)YR1IWy)Q3gWad*oK z23wTN6JT2l{9=@BPa)8T*GkQs!_#LKOy)dNwi@f7+|^T>(0^8VE0>B6j`G)C(hIcc zYh?|DS>Gu0h_}8%;7GpZ+fFFNDWe{%hxN{plk*Kag62j7onK6+MtxoUoUt8y;P?jb z-;@hiHM69(0bf5t^TgiYpXjK@bW=JurV_$}>Sie>SbwbOtb$xj(eYsNCuE_KC_(Hl z1KPesP{0uxu$;IJ;OHHQcHwSrwuOI=bcki@sbyLB9usSPzWn7yRN40N128$VS;jG$cW?W>O5dtP z%fz%Rv)CuV@=ivLgV0^fct~PQ&J7!4_glV?c+#@h`tEdxQMGAE6xeHOkaXhhoPQMd zMTyrW;}${e+Sm+YrhCzYKH-66-+*Cdb6UD99*qkeo{~6DU{0F5+x9KXfLbTpovBx8 z8lOehYL!W8kXbuV6l+iiX>NDIBB^l2o&4_Xi8JRyU8CitD}Op%RlR%3XggMSO8{sE z)$u>H&p!b_Mi>>rNnJOcN6Z`NhFI`$BO=I!b;-H(y1Rn((go$G4Q_Eh1wNVnxN!EI z2*=QVr>}a5lpE`Kh|FS(=3Ou6PBd=ge-6W`VzQroWNTNR)1=0!T%H%q)G}|2d>42; z@u>3^YR|M|*VRo3PqT(dWJj;$In4U~CGRpP->~a3v#fSKeRXi_AUjmd_@^IbBb^SC zYp?QpGKPo!n8%hA2)PCzy6nPFUgT=LR=o{+b&WY;malaYKv7whP5<7nXIao`o{^Tc z6EbqGlOgCruiQNzZF6hoPq@aFZ6Odtx37Z66;onV2lOVqY9warT(4X!Jj7ECi#qhQ zgy9Ys3dfA`LGb4_&!FctKoMo!Zmw2-f4>xS}LKvGhtTcg>)$VdKO^Uq;-d47iZu_k1dcX5Zi~He-cHWPHT4@A_Q}&Kk#&;Nrm5 zJL4d&nvm!rCiF^WfOzE*Ur%heG4dJSOO7P>kBga`LBC)&j|5J8c-nw|JN5UF5o4%BK;|@OLK(j~WKRbNIep5pq)HbwLQFu&#mM@p zP;c6CtL2HyuS$=zQJqEaeZnj6V;80qMlBq8^f|hK?8~BfQ-%>pp~8DnN4T_RqT>ZA ztI@!G>=vP`agsLbDbJVnUIS!k0d_&mM%LZT-7X4#gdloxW z6&V?;-XmG#qm*zCvsGob?D_4qt=gEy+?h#Eb(OEtQ72gYS6JCuA`m7L_ z8*RgBDQlddPm_A)5aVz|sKjbfqN817ttkDf&25@UYQZ0ptRH+cDAAgBo%aWA!54;N z*o-aZ6OBU{EAPcW!m5&txQx$x7RiTmtfrAEeA@=IK&S?&5o+G3A}nE^`n&F)JKIN> zF^x6i>g^b4)24W%Z;}(~Xp^;^>UUE;^$;bABPaZi!0{R`9HAU!FxS86pg@z?i zL8D1GP2nT%hJOZSfC^g%Oc*ru|H_$ZteiJ?us3cyY~VUxl9K!krN=6p71fU-?Ts@$ zuroahsL@h1Si&)BYWTwCHY(jLOt>3=3w+$OKK|6>jSTN~z`pvU1#dPOiQnelJAe0+&U&*>dcY zRYy*W(A4~wS-xGLVi`)eth}tuu1rs(;hB{u`p&P1wrVQ*mKkg1Bf`ac5$9#Vxd7z} z_DpYAOBeMOzkDn^IXi|us-hGSWO^{ELO0VF1J$9cU(qkR1V`73?28JjnT>;}u^d}WAla1 z&Y1NR)y!LWG8fLcCl`n{D$>tjBk~{61e77gAGOGJ)!WZT>m8$E@Cpk!)5fy-XUb?v z*93^nz(xxprFyN9M^2E|5{!Yl8~b{1K?Ofe*KjEPtm+|m{jTjPrLRK#_c#|DHDZ9>?_|?0lHS=dMCl1tQAtvGN{A(8p;0qWzO*v3 z9ySqXMdZk}9pG10r8p`#qITF>>?~wKy%UcYAWvA(0#|?hp{V7P=rD4+%i{;O68TtS z=6|m@VdZ1`*w?X%+6L!W>4?Z! z3l7=Rn}m!h0sIKea|*R3!gHB+h&qwh&sq`lOGiI#YeAuWX{+}=b)V||UWp1I*0%bQ zgyG!HEioF>0@9^ixu4cB+ifZ;a#UOmS`UR#eF836xy^Nq8OQ(wU3O*zd{0bU&YnTo zY42POR%wBgliS$nCtdu*iuRXbNag|2am!beR|8sEcq4zI_xLW{1mt;-kZ3a)+TrL$ zAk@_L-duoJjOgqZ=~orv>OLY@$t!#Eoffkhc9>{N=WMe(ul{C{^6sjj6zi7zBfZz8 zq7a^WVEpm&>%u?Y#vcdDJ5h89c~h#F$0wL-L%^fz$N4oFkXz;s`TD110J6s^5_& zr_0glykmjDT(G{l!&y1*k8);=Dst$Dz@aL+VD|HIZ-&;eaTUve=w&|=<4V0R10u-} zBTir_=42_NqG`6fkmC~tb?tHXkpUZpx0*mmUE)`EGeIdb_Q{gB>$TR==%Kfmo-1V{ zFMH4XF{=yHD+xPScHR=(F`?77byk}E^0EWYh2B#S%XKPBP~{&jj3kE$TuKx3bn_F( zKA1_6-}Hf+tLG{=x?HP#4?n$WjQ%PRRBj_mUq>rUCMa18pSXWm$gCM$#eOfHsd;j5 zWa3VG8mw`lv-@qj_~&vc_GU@vinD8Y1=G?nzvGZ0Ycwa*HN@hfoMTKtdO^q{V#@rN z$<3H^luA@7bO`hq-DWu_9YEq{k*vYL{8Sabk?!V8Q~Ye&RD@m@-nuSJOrKUg(-$>y)YtH^8!PDs3pu;?+LHoQ8*j$Cxu6wDGQvMz&Az2!LY1HRv+VgUJARr;^a&=Ofz4`m<1Wi1|&-={ps56@9rP_39k+ zB#`EWwFdYmHEiJZHyj2F?c(Hy3r}$|NYi z%{M!XY3BlwiLM1QExaZ)(_8X5r91iyedgKdxJctNBs85ww@FYFYY=JT4A%lsJ>++y zIZjA<)?f>7-b+PWWCVrJG(AXYVC|$cAd$x4B9?gPaOi#ZLO?|1S%3BlqZ$^O_Il9b zYLb9Z+rO(z2E1Vv?-A;C)bUDnr||8UY!C#?#%^q^-M42>6mPlOI5QRBV+D3?i{V-z z`8DtRjb27frHqY5OxO3h224FWYvI8VOe0xLasPu1szw|7@CyDBONXt7_NaQa7iHE~ zo#fs{5aLpKgEnZg8P-^DEE+Fd#O}P_vs{nTVHxc-U@xVI?=wJKtC*QS`%F+T@2q)= zdT%q)c-|(8vZ8O%K@3Tny96HUwiJ+U>3;53esh7@S1i=~plT^mQO_kEfB*hyi4rbQ zq9KhalmIs)cfTpVIhhpnDVj??d`gl6=e{LyvHv&~yE%{17jte}%7A^2WSdvw=R*H? zVlunSH77TZlrE1SNiAXw8?>;CjH&74LvKzs2MJS5Y^CZ>m8OZQ&Klwi%p5ZJ{rQla27LIy-yy@)PExh@7YqLc8v>dz!uzRYr)yAjOH39XGG^oyae%akR83JWWIs?$b2TT$XCo{ zG2O*~!ed`ycgFk~(}AyP&Ehs}S+@1==q91|Mi0n@>e*BN+dT8{8H}7gw%o`K>$=}aw zz2fSrC9wt$XDvo$1vB$Yr7k?S>i7Gq2cE5}bj2|2`j05D4q90Zfib^rHA;(U^__KfX4;i#V!{zc`O#t46BwIGY|Dx2Mc2z`NGf^f zcIK2%WM83Sve!CNzHiuYeE;z|c9a7C4F~x-3%*5it`E9Y-G|zOi65A3Js}r$N3xe5 zUQubcN8+YEM32f8O0HBB1Y!J!Q82D(hbzo5%tb76Tq>QZpP_7%oER^EX8NN$8mSov zZx+|*6XI@EClLM~s@M~ZAN{hT^xR_eNCa|n6IeJ=B3#lj!ZEP-(vvAE?G&_yYkN=m zYa%7CbCfS_XOXpII!%#@u)CZ8ga)#(wa`_f6O4W=J2h}XuC3bZK;>m|>9K&D4BHD2 zRs{nuRa`W~=VqF;u~G1`()qhH9ZL%>x(<0f8E`tCtP2|(c$M|gjTeQ)CHRx8_Ixtn1`3^8ZX<69I!jSRhe{tplD-;g&i%KZiVCUy~uetwO)_z z(BsIc3tA&swtf5Ia6Ns}iUp&$KzJabpI~mYG_fu+Cg$zj6 zmD47qzP(GCX_T*JctZzmi9s_XG>y!w>_)wJ8?n^HM{NxIi|vpk96Lf{O_HxYsKz5Y z!K7F#hy1NQID@e8EA!>9iB&e-jT8z2tGn@qg&&)x*CDQ1dOI6xLJe1M%pdNZIO=*N z95X=UI-Hri%$r@rDBKo%lEOF|?_ylcjCH?&h_o2HDWRX%ou1{=^OHT$dt22qx=D@r zB^eMZhZ98?FWd*43G}aoc(^cq#OxTI%14`==s5ZF#hNeuQO(Lt3wv6V;A^Mz)YMoZ zmjm(-RY}Zne%LCF!*j_i=uhHCz4Dve6OO>(>Z#6*5i6Ehh1o65mRdMIIEE4`dW<=a zE+|3n_rk*?Smhcdh~_Gyp)HlyMgZ5GhI{24@o*n>dT3*&ZF>Ov=wrl*QS6JbKTEKc zjIftU3S(6D7loft@ZlqFXWz*H%ZM-n_xL8~88JPq#-|e*bL-C%bchMCK%^^Trr$*n%#8E6<9?KX8xuN(Ph!P;`eEj1;Qq zL+n2FM1+P6m|+J>J7^sev*P?l3LV}f|0`*0i~uhK^qgxG=-5~+ceWJX5G*YVe7W}R zb056NXgl#ZCE|zp(732u$rqnNW(z>4mOfbdPC6CJz`;z~Dgqf&-)kfpz<@SXvtYmF zKuE;yl`YVz*j~W_(5-nGJ5zEBWOJczDPOhEs#$x01Ejuvf+PXxMywOr`R)SI4@DuE zuh*t*aMuap=?c0^BG`Ly?n|ARF(wMPKTs4DbT~adg$DAes%n$_105f}!$BS3{e{JjyoUWh{LeqyQw;ESN!H#XishCzgMLf^z+iN)^ z+InN__mxb$quv6@L$DK4BLgIwRo{hhYR{!YVey^8CFMA)8Vz_TLf|O_yoDu;&$YhD zHCZHOzphRXk1>}z$B+RVE|<%**%Lumod|@&l(?!g04gwK#)OMyz;gQpB^ArCTlxDi z4KJ_*5fa)zXR02}oj0J)#B;~4+XS+-384Z=WY0io_V}wP>~;BvAzpI_)y_x_0yEhm<$6!^P_QYb-eXT@Hreb_Wg5WNh2FX8H65-r3h*va3?7kC` z@Ij)#v#JUE##$NVehq7@^PZhsE&{jw^MVugk8aTni^Gg-#|MMvQ%u*X@DFpU*q6CM zI1N!`S;K4BXBCt5UVFuBM6&GeH#=^|s2@+CQG+?_x^q ztv>@fbgJ(+_b;hh@77VhXEKJx$vL85z(eKPXDODjo7BU9 zb!KTiLu4PZjSN06Zelx}C=s6TxIR!Cny-r+_{+whLJBB9ByMjL#PFmGcZI53x$fie zIE#}JstUV0Hb#1a849Uy7lNq#A@|sX3PB#Kp#zC`)8UYb97ap3(7WY8${pWYIbrXV z0d~Rix^o*fX;car#|X8 zQ!|yVW3+y_KwHutULk*OoH>uMqd?cn`ysDu1vxHw7+!6lrtz~2ItZSz9zGT#1E|j6 zhTAZoamwy!ZLSBX#JvddpdfO#KNU?jBndrUP+mAy1?s}Pr(b|FV8lTN+~*?qm>Lx7 zy5euGMeTf%|MFc)Ps%H&?$S)tmr-Bx>c)83TzcLGxh$p-L8n@a)ddRry^CX8 zp5GclmjUO_$P@UFfm(c-i%*PeW6MYoTRevS`lFblttUt$vj#-PPqE&K@RbxNEx}Z( z`~>8r*W0gCOApUg%E1ehU?{&4tE6V#88-{=x1mMI{XX@Ybeja6%H2o0wd7T|@Y6}@ z#&Q%W(VS||jC;27qF)y}R~0g}O2I9O3Em-B<#3!KfsYpHs$p(*q`|(dYOZF@44HJL z*(ZT$xz~in1NfkzLSf9=&@<2_Sb2(JL#o$`8Ts}tzCo#r79Ups`PA|f%DQmgwD8$< z)Juf92BKPrfLx4bOnA9V?Am?^@cunh&sYk}e&{sa-cAwQJ51yt?Y9$C!ZL~@-XfRK zKi6LHZK453=&Ex;|L^pNxpL^ofsTXcrfgN)8K1*m6iS=3qRga%d!_aAs@?b1OoMW| zx8j%FWA|g!s|hbV6F*NVO((H5Bg7QW$(dytU~HBUrj^>6dC>VGItxVVlbInFFalyp zx@~pL8kUh8P_88|4&yrFl3S$prOqAUN1mE|BjDsL>sVmuEkS}~ufjwT+K(4@I#7*o zcxDC-qnHLHldzhtMHaN6!Z9cHinh(KvLxT(Q4+W=gB@5j)wA*vV{s;@$BgJLQa(QI zcq?3xfsF&#_uuNUg@YDQU7dZ>gfDmjjU%sLcKD+MCBf*o@3O#d3M~<_sxCSr?%#Qy z#@xe37#M=;*lcazGh>ryHR2;!6QDe2vC`}hC-bM1U7V>E>Pb;c-r#1rII1$8(#61U z{lE_;t+v;3cI9o3CHeELCr;ODo7+zFJ$tbYW#2orz$eIDWv>cU(a^b|fCo7H!L4MK z=a5LNxZEAu+(aAf=}v@RC!*f1LwpA8H;wzp=DT5lueA|!ql<;|Alv45s&JCVSu4fp zZazI-bCMW>p^QV4g1k3eP}=IwYw^vh3&CYF^=N(4tGf<*EElqt%e+66b$elUZ=Dv_ z%vZ?y?_4#7fp1bH8$*0Q4X!y1XkuYs_db2C`C(w5h0lQQf93Vp_WwXV5p&K;Xisw1 z4bN+Kt#9!dlCrBVxklGMp0xeSteW{9A3nSDHJH{a*?k%DguKN+nw@*6c2rE689qwQ z53A*gDpGDti84-&a=z148}F}=#@1Xb@Sl4q=f_Sdf53_bpDc{(xwC1L2VuS*-2r9O zVcnAQlbfqUTL-l9`Z%_>QRD$)?j2#ORb$V}@x2SJs|N^~NWfq*bPUY ze^P%%Jn$i_>{_4hbK{2g*ejHpOYq^slzsd0p0{y=_#dTndIC#0)Qvaj#h57EW5Y67 zv4!fYm8sqC9y47YeOwv(^JnQ+n95|_XNrR4cMd5B!o5`Z_lw4*nu9W6YO))H z3BoFKM@oOW1ZtGIG1^n61eMYFU@Z6T&u7T0HHXe$JKdePRO&v9q(N@;NHjs<1!-Tx1k5#k?vnW89^bv_BvgZsgi^Oak)~M&pyjG- zo=}M*@jTP5-E4Uc#TEAjSmM;Vp8#h9S%iOoZX6~9Nc~!WbKAmEL5G7z=Pr00Pm6(GV_j;5#Jiz^>NLrLT?5HJ) zL5cAUk<-VT?X+s22c}rsq3C8AP#Zuj6dS?gbygHAhHZA_cr`P~TO@-sLG{?tQnhch zuguvN_!n50KAoB_i=Or|c4xgUWl=34t5(9$mj|-nfl1yZ4_-TR?r_wey9_@y=e(&T zQqDaV9qc++xVXm>wb3aj3#Hloy%OXNI53F~tWs?Z{EBq6rDe1wFHb@$vL;a&p4aiWrD!6*W=Y-z{;OAo0|N4Ry{5SG8Y}-MOlJpN9_;QZv5C57*lN)4mMK$^H zB(JSompPT}IPT^0{(%L{zOMYyaCS3er$pu6aA>nVHzL?JMZ1^Ko@Lxo@38q{GB{Wo z_I&VV$<-2{jbSlY8b(BTv$kG8-LXI1XS*!*T?#(tzs(?tvce8yBR~9=eBfM7(aN27 z6d(J&F^eGDpS_q__1h+cvnB-#r^dX(xzgdsWAnNfUNLxPYJs+-q*B1tCZbTks(_c& z7%YL>pGbb&YFw(tZ9WwD-p42C2oO=808@G^ z`HeHmh=bYOU6ra%v9-9L8BNDbIF*BQ$X43pHO7+M>z1BHZJMPMi*p{mHGfZiPYvhI zfJ^a|CK(VtxL{F{Cwk}rnM~R~Z(+!h3gTdvcUAM>x=K=<+oY`G4(Y&7ffGsMw)f26 ztA28@-4&#&^=mmx+?UU_Tye$E#yH6U-%H+yqvXgM3SV`i6t=Cl!5DgS=b(h}ad?ZX z9u@I8vRpop$uCTnx;!Gs-pBy%iGIox6!IjJ^@So7jFnv*n-YX_L+`i$`P$(kB>htCAsU0Gi!29Rxvn7Ljp{*6LBCR%qbH=o((V1cs>2gOy-BQRIz6~y~LA-JS zI&JeZ;)tB<8mL{+5=HBVh4u zIbCn%0wwf9I#N7~&s#WP+Kj!|r=wP#sh4B6Xv?r*kji;U86_vjCUoJUsBlfH3`n=| zcvvC>{<)@|%aOiD&PPUC^I&HE@JAY6At+onCG>c5YMfT1O8z2c7Gh3`9S=$E5MS&L zWN3oL`Nd=S2Y61(ol829h0@;oLd^#k z7oW)Kc~5gvuz9t$27}|Q%lP>kj12I~czu*6*zMxjl0V`*e7k~L;=%~LWChN>q9@Lx zCdB=1Ldf;HM6;dyoXK@!1l#NJBwU0Z`uyQ4JT7o`)D@b8M+KqOov>?2?w6DR5k1g3 zCdNxX6TX-WO<`{8NnW|DZL_f!w1A+EeoCo1&VE5eaMP82f9f3yJzaXmrTw-be%aQi zL3H111d}pmtwDG~3lSaqRl4+^j*ZVFkh9RAUB?qLFO7#V9k%h}$p%#WNNCCFxe13hVc_WkiMUd(^$HGU#SM z+P@tqhu;mu))XNn{*72=#Cmqvde-&;BL$J&3kjE&)>H)r;&92sPUqe`FCdK>V<-ke z=pg?7A^W^#ucTuL8@?@CE-kHLMR(<~wf`$EAYu&_{7&f3)oX@qk49;|glWvs8RS0V zH)MDk7Q$8F^Bv~PKj|fm9T5&q99&^2j6B3^%;JwqrSI_ku0fFr*I_1fCTq;?9Lhuz zW2*Dbzix+DwivVmNBW)Lu+e)Y8+(VRCsldR5%_^g(yS^ah{s4 zKGIKU-7B_z4T3$$NlWB3qs8rA(rlvN;>4M+uy6gRMFlm=bxtX{Zn_39s}OlwZ~wYE z@a%ts#m797$Y(l5e;guKl1n$KR${}Kqr6+FzuAwe!Qxu%m1%H`3}{p0C`ezfVZl_Y zYYlLN7|)!kHRfCHzapm=4oP7v^luN1`SFdH9E=ydyyaM65bzz*A-cXP{`lKJw*A~n z10E1mR&`#~)PmeYSe=2u40MT2iSI|>12^bH7Z+Ex-+PSCU3QoqgU3Fxr*`qcYGCbO z&ox-tX)b5Ir|)!28kQqF`V5!r690g?x8&^WmiSD_Gh|mp`e$>Si)7oB^D^L*bZ8Hm z_p>}gV;)NUg?gnREk;MI$j&Gd-V@1j@K&h^t5z=I;l!1Cpt;0oEnAN4u0b)UI5A06 zNjn)rzvfy9s#`DBBN?lV4ZDb%@B|N(!G2<}s5E1RWZrDV+kr3)E!2xQmkegYp&oS+ z%<(!aT`eo5AZerjk1PtQX$DmGoSBU+Ml1ZJf-Qo4OgC1?!JC^moEQiSiK>D(iT?!kq01^91pS^2|nc+<_QpQS!(I$}9q!EWeP8 z@?$y3XVI-w46##UQwpz3IXWzP1YL`-iyaNWq{>c(pldUyKq9D>w@FgVuktBD8$>;y zFMm8IY2k%h%OH4WaizJ}*pi9dlU5O0Px0oA( zQy9Jj(Q{fgpsVv@J#`^x12KMdtZnLX0A8m^tBWX%aoFVaF^`navt}}<#F_+m{udCL zrr3AaA#7PpL1+6B34^*uwz^R-+ZEIqacPEaud&g-TOLMFRCz*Y%jfRwlAy|JZ%C0{}r1CMmn7CQ^&l_D9c_BcSi zs-7hS#?3BUtf+E_Y|_Kh2;$PAjEW(-Gv^;V03tx$zxr^j*K5ut#V~&`KGT!rL^{ci zUG3u7=JVc%XMMNmVwQx>CUB$kgifVxsP`_>Jn9VFRt(N{NRVraH$Z9~--LvRbKzZ< zXMaSU3{HZkmHI&wQ~!bNUnzg4s`q44>1m!=W7_QAU`0-|S^jaN+#v6c zcKJp=6`@IOUfU>U>RMQr%8QJ1z$tOJ>A$486`kIg-JH^*mCpJz0URc9YW>QE|A~<| zjY}$h{J)uLs;Qe*rm31YL997ry0QNr?)lddQcCY*orjoU(nsI@1U|1q2su*DE1+6bWFu zgLlsUGp9H04kelp``?#ye7<^8&FevjyvnLQZ>4DG2%W40U zyZ`k${y$l&_di+M`+sMtLx=9hKKp}FJ}RsO4Qgc=Y@`wr+j3A9rIhEHloJ?ZwBZP5 zo#eCg&Ra?dyuVPo((Dc!@=@Jq%@;~*-CEhd5sK<ituy>y&+Mo6t+d=8S>~wB_C%LtlMRpyyH;xTM#G_dvYTXV75!6;E6^b{)5mpA=MBMmJ) zsu)tO3^z#*A$UbnF_q?;-kr;}&Jr91!d-xbkjKN~O@_U{@Y`ip*4fZy)ne4}W0zsP z+uAyEjiHTjscbA4^dfZWsNn9MAMS(_VyVztC0bV6o4 z{*aH#?|qm`|9%SS-Ga4LLTDlR7>{8*;nwu1S(X8TbuWE7Nx_NZ#j;34xv0r4j2jAd z7PS`7{L;G@uu)wsGmz4D{$y-ONl&L4zDU0FcnhDBu$st**P@1HB-tD|X?7tCCDk8e zwGg-?`96WrS!V|t`4duDU%KKD z*wOtR3+0DA*yAawAJjh4Fk(C|(`1l4;*Yp}!QMHv+Epqik~t2=Uf!MhKuC%+R^Why zj;(}zmu8pc^>f(EdDREq9l>Ok*l$;LKSRdTT~Z^0y?lV;y&Y?didKC|)Y+O$ekRzg z*lMEd#5gA|nYkk34mcub%#q1DIxZ9v%Px3P+wOg8N96rjgVkq5-?(m2P2>)c_C)k| z`mSLbp&5H~&C)sFYyly)yo>;@Zw!1E-7@Bdm|yeba&U@+Y78D+6}aUt;DQpOsp`LN zVeOSPRsmr)e?zqb=Le-7Ocz^21@mGi4I!Oj{VPj~obD8+XHqN{nwcmL^00$0VSrqH z9645SVFsN`k!kBSyu36$D&9F=fRn<{zP?{%(QHGxF$eqZFf}0oCMgm(?P-}UtfG_R zmp4yj{asM`=7*>Pm zI3$@^uKB8jT#XuNrMb&?Rj31d`Co4h{2P6H{-5ZJD5?lW{G=k%SCTDYn@mk{ zD5Vm8cDar9ige5hd%whcuF9?l-x+z==FG3pP4!S2`%YfxmsQEPJvqN{f-(Gx&q0_t zAIgVv`Zhk@P*)Co)@7-sUj@6sp+*5XB$D10Mj{!+JEe*R9w~MiX!XGU!`d#nagHBR z=QnxYsl>uLB;2uW(D+`!?+#fHjW<%EJw$f#jCF>APEaEU65Y2_m7^Lg)(dCZMf8s4 zy~5t&TZ<1*B(#RM02bEU$42Wrry?ABdG~LIR45_F)4*gIMcdvs%54JLW}@XNNiJnb zB0qbiWDtKF?94XtZFaxqytlujtEO$bhAYKH7Iy1)d5txP+b6I_Xz9TI$f^U&e#sWi z*8hN}{5ISxJ$_YOR?py)Z(WlU4u59vW}o$c#x9vx>*PlA>;q?Ee!fHCakB(QI{)0x3*7Y;nvxORHI>a!>MLtfU7 zkxk_R7_W@^l-#zv3ppNM4yU0ua({_gf&AezMdVs!_}$XenOC^br;r7}npn(aN@9=@ zf3zT7nw z&4lBbK)Dj)Et^wuIcT7SjFwGli~LqduaBNx@T?etn=`TdDB0`U3_Gjytl?836f?vG zZRp$l+Pl~qSZ!=C7kJdT_<|{Lo@aL`2H}*MDn?)&XA{bB?L6k*5jnJ7Wt(_l^Dz32 zDoKR9c=}0K8`5Xf>@2MhN}>AryAn58I6xZuHpiDf@@(={tyV>k$`ML0^~&)VU@h)e z(NpvMFQQC!?*fh^^8`36J4%URmvH1)1)jVbTz4*}m(@|d10Z!_I7fu&V;*~j*k;}X zAO6M0m?EY_Q5%~_pe^B44IWF8l+MUoQ=COA*e2!9rul)Euoji5D{!Pi3D?wE2^qqG zeAd^1_fl35tW~A)+^Xe4sDDh<^h+v_r(k!mRE7y7v8m{8~z5VgoZThOgQYigL@`(D{hrQw(I+Kj=QD-{i5$Y-yhQMnlKrS(8I)r(dQc$K z385Z^i8)e~X4CpSHBT*_O=DS~5r#-T8ey9j)h15N4y-2qJWZ*M{nP7=c0n^fmeJVJ zMFod;VC`g;xqDpNF1~tw;9lWKIEG~(Kh!)+w>W7z^7PWuq!Qwwx@{uUxsl!;l-Iy2 zR6J$g;_Ahlu~b^Wk<1dim&PQu@;8(Oh(_%{6?!>0zr)Tb#%GTBUBhNYGU?(#SiO6Y zdQeFTqqD@v6I`=N+DExhBh}PUI~(}xDKGT%x|scw&WV(-0??VloK=+eV=cAG!**Nwg3rFY_xv9E zXgHAWUcc&WrYCm&dO9w|=j-lJgeBMp>?xF#EC;q(?y;66v~Uy|u`|#-=cK<>`t+m- zw8bJWot?RG{ZwQX>DknY_Rvw>m%q9Ve$zxvlm;BOBJ+fX|Esd9J<W;8ot_Xa!Y#Cp2va$E&n@KK!=L_=b+zWP zq0HEwycmKET*J(xY_oAQz;cCQ6UD^1KbsvYo^KdQTJrL5jJzz_v_|0uWA1X z2{Togxg(A1UY@+T(1qAV#BBUZ`I)?Zf%P+aqvZPn-?2D~(&k2sr<8aGd>P;n8@=>5 zJrM?9NQ{hSe4;f0SS4hAEsJI|;TiCL%%8?4Ru<+5@^n5uE<|*6Ur%{OT6PGBadSjh zw(dLT^xCexv9W1v9P{&q$rtt-lKW`nZfx6RCs6k+CL;F@?pSVrP z!G3i+Qt7XRbex861;69)V{VyQz)rMT0GmaZuPI*bP(muPl1dkV1WN+fs$Q=sAx2&) zCrC<&o@6`&oU~pZSzO-=_@$i>6CME4&I2k@G_S)TG5xroSmTIRwUxuu>7Ul+Z<-}*;NSwyfx132QjRKhiow>R`~@f+qhxA82^r9@&7!734cUOm z3D87S{=tkMc5+1cyd_Vzy`38K0sKfnqjRsoQ4FKew zxHgE5un&i#Fv!UQcth;~*RY>bry`xY5YWj_sM$AI#lR5V0h(;+8(k82LnS&-XNdxn zRg>FL$&`YZs+b)W*Y{B+9MBJI>i}!1$R+1`h697$X)x1EE0O|%sV~4h=qT*LH5IcM zNS^E4>41rbshXNJrt@XwY8F(`r3o%ngFGg~X`;es&+`lg`(u6Pqq*T9onJM&L?pdh z@eMB>egu1Id&Z8nMzm0LwJ0IKHPwh@T|^H&ebst)Rt$F0dzz~TY0F?rj(3xHo9tQE ztXq>T57BL-7>1!rZK5Uz~r` zg8$vzc2V0xFYI_eRg)#2pQa*V*#t29(!LA|kq9u$WpeV=(<+N;;$qeGJxN?o|Ee0t z#ti={nAe==OgxbFc!RW2>iJ;!LsZ<&TI^?l7Lloh_@7rTy%`6}3Tb z{%I_Z^;hzpSh3W4gA0;dzc4o|M*XJuOd&V8x3s!aS5@WtMpRO`pry4#N&wNnw$!MN zhlMnZeV4IJ9N4r@HtDM4y0*>l=h=!*tBEY^1VbFiXfIBcCbH#;LSDLpm-#*owu+Uj zi79lD*^L&*D@lt^B3meX7PGXmtxCwIz@8$9*n=H949_w7u+{;WAD@SG=$kRHkz$ zKqQWD;N#HUntC@gH+eH{@O$ zG$y?t@FS!dl?CjJ;4~x-TAV;O&m0}5B-Z6R8+tDfkv_AtXMwyej`v*@^sQgJHdmdH z4dwUfv~gJZZ0IJ;NeXA{@+00gwHVb8v}Z07F}en(Zn_p)#4HF&vvS)w4MV||5cR+W zj?0t+nCMNyY-uZ$q=l$m9wIsK$}RuG(d{&`Iu^3D5V0Z~^~mQJjN@L&4fvFE68!Sl zAMXzv#$DvMrzAF7Zneuk%TNty-Z$0K#Dq|m?fO6(Ml-z~JR&hEbetges?T*!KFjiq z*V$ycKmJSW7XiFh^QICKjnA?+Iu-CJ=E5lJ3Qf18Xnw8eRUKVz$C*i;Md;QGn-wqH z`=*W*bAQO3bUl3Q$VRryZN&_@FfaEU4u89IqA2kJ$r?iB@2}3V_@c&V)Nq~wtl$hG z@1GL1i_8M;*etOt{T`HeP!e!3miSrsaLU)1*8{h0k^%^iO1BeX>|E<*v8*(2aZ;zC zN`AI~WmlvI# zLQKrcZIjlYAN@&PZ)3vqIbue%iw-QY=?&tiKHqOhwN`_Eau-uepoCyXK-Zpvw`7NG zm756J94X-%X!?ok#C$odBHHdin64>!2yD$Ev>~^Q!T%2AP0%YYLiLSugBq@12$O#L zrOfyNY%|*u`O?R}pR6rk$A_vK0;>^K(3&2webHlQ+up?R5IahCxc$)8+k(+Z6eS$9 z==xd-*?=T(Rzg0(R&fcg{IMY2{qSE{p0%gK)V=J6mge=-9Mp@AN7F94RNAzWb@+zy z_Ud2{#%j4EVfB*SVIsRW7S`%IY^8287#*CMZ+5rgg{kOf_*plo8MA;X->Rn#_ytj# zXi(>#9zJ!vo6{%9YXo=Gv)G@X zpYTGbf%#Hn)6;?{fg`pL?`*lIf_1ZLMlxOds#rGcSa3`xcyDuV$j~2t~ z1$QX{*yQLDmHcf-A18f$36osB&)>U?Y|x;D;9`Pa=j=9zHQkpIfM^S72jYz1er@T% zB&%ia--9oo;?#qg;v2Qj_YGdsx?-S4)Xc3EKbw0=J?yEAQaE2;N5gcJPmI{cE<1hB z!Mu{FuMR1@MFMe=-CX$^TQw~(lf8lrE0(MZkdH^ z!m-)4nJo55F8VhKvOs=o*;@&DZuwnK48rEocoL2u^pT4a645+za{%ny_wZCZj@@~F zieuLq+!HXIv8lbnFu<^o;2hlh#rttfG^>TOdx5FJ-;23eU1Z-b0H>*Pb3>US&mthA}$*aZ+%L4Bl#hx3j(O}Z} z&nA{8uKiBLpe$d(@5vZpRd3U|co`);s8|&m=g#pBGeQAb7U{>pwG%czheR{HVJ47xtY!l37_I3>dz24S_jlM^OYH)4Po&fVm03g@T_ zE>0}w@ZYPH!M#&l+);ht71tnzL!>m%pwN#{n zP>fk{m)8;$ROg?9U#KAI5iCKGQ>~kl+SxoSJOrfX!2uDrM;t^MJ2GvUy;0H)+t)=EZn(mO`C20p0{**<|mb z_xmH9G_@_ER!spRAt7{5;~l54L6iTym%w+UD^a|v8_Itu2YqVEtD zZjVO4rdad-&aQ6qs+MMC>wu#pB~NhGrG*1Xl3ez3-sw1X_R4Fs!?KalvcU4DdhBS~ z9@{Tv!gK%quB_7sM;~ex8Osc2*qY_e$QHhXaK_#sL_Z0X4-Nb+x zm}t7jECyYYk8%&j9~*lA=V3*Fe|&oML3CB&;f})JFH8a3sG-vz8b6R&W1hugjC)1H z2glduzup%Wg$8=-oK@j-%=(zt1lWITQX*oWamo_(x>S&Z@XUQ*4LCw zc+MdIa{X(+xFQk1D_x`Z`P0??vHshyy;OxN33e(R;?|I76NEBIe|FxKb>;$z&pflY zdLO?lH-j>2c`V$geB)r>jG;J7_;@YmOdJ*f{2bxkYk3Qsj;Cm~5L+)R|e9+T-INCr*E#8>BRv7_7lk zrL%saN{F*<(|}Z;@LdTZjtWa16zodq=e442Xu8?j9PXZKBF651NcA>OL*;on+CGe( zMIPsId@w!~OSJxcOvh@5?fIiuMSQvCsoX>}r2fY!QsV*EteJ{2>|AbfkmAz#tb&Ca zFUJY$==4*K1kSERu`PR!N|e-drJ2Q!p)my=hMa4IU_HtXdZJ1rR%#Q zEP$K&_K@UM_j>T~z-ki8IFC8CG!m1}Vfo!7j78B@U+r6I>~-cXu4(^dzm4J!cZd*7 zX9w`BnBTtwiVq*6VDK2bM;nLby?Ezsogw;!o6f|C<=y5i)D1TM-(5>$k6zvl@teEC z1r{?X%EtkFGp$PmU&+O$hS<-vL)&_pQcNHwYp<(lEh|AzF`Y4#ri0ngxBs4_*0^Zh zzJtzmw9g00LGW}xvBm=$6`V3t2(NoCs+9+6UnxSaZ!YXE5jjcjVW%Tx^9PlX8oD>Y zDfCmIsW}6=18fZW*_z!{=S1vg<|H5y$9n>XRPeBSfj4VevmCJc6sX4zcVEd$B2WLWO4%-D|SNJz1{xEG5LYPznX5|7omQO8v234s=opk%cKC z{U?l;mRB_cFFOdgJ&tq7-1by6NgwE2psZWjIR01fC+rWv_aFaF#;MA0tD0@{oxSoS zRsZL21r=3$Bq`9JqnzA_xoT_nxqg;oIBti|G0>%NxuElz!S%~KhMLuKDS=hA_Sp#q zU1Ii+z`^>J4pruwe{^G3lN^vW4S^QaKbz?!j zr-|kKnUF@msVM6?q5JzqHiby0z*_GFP8}U(GSuZ3T;LY_q^?%N!|GRhx-mYFGOA{) z&XcF&2GDwsCk-F}p5`2?e%uk9bG<{SBt4I)oSLxaY?^{Uebf^EA*f~{2a#jnr%D3t zTY2{OVUKYpDO?piKc1;N$44vye8UM?JhU|@ye*~3z6z_T1oI9v|Y>LrqSw7Ybn14wP{W{G-?R`%zGB9Kp8V||~#AuKKc$3sow zd(+MHcIA@x6>2F)2#o5)DYNYz6R=G|rv@8#e0T2&W1TZcrXXzktW-8r)WTJkkN z6oejkIv(5PoQUJL{;cAWxn9nbDx4bFn`e^hWjyk0Mb2IoDtdfURl+{ss$QK~`>jTw zo2>FNsk8+l>gh@D`+(t@?d7%=Z%RMXUMBy01pAXWu4yGdpY=3S%r5xo{yy)p(NohS zC(GisOF!~Ltl5C~Z`t{w7+Em#k~^*hk-ix?Q%1W~CEh+*i2)#&jqbl;TtLQmK>hWxH)QE!V1E zH`UG`zWnCh%4K{)m-6|RIJ^(Kt=xpnj!Cr`q6MOu@Wake!G!sS|_T zMsIA6-LT-O?dD53v~%XUv?juV5E3}AYm(AC3H-6T6?!8SRkWLjtXwt1WeN@my1tM! z*Wb&|!1&S;n(<|)EDyL)D@W#l^Ru@upG?suZ?i~?XbQA)EDZN+X4K)AFu7Xu?T-;# zG2ao}N$?PUTFdEv+Y3N}=cj-WqvTU3f4-38c#VzT{wwhsMHDDQ!bx%0Yo0FjpKqvt zyX)5Uxr}hV{Vyee=uiGn5v;?i0Jj3i5}`68;jby$|Wueo@lQZIJ`zq$$_TS(Qk#VoNpXj-FJCn z5;as1B8#jM;EWsypoiOhPi}U|!8wmGe)=cLGoft!q+O^V&2 zymh-~-DF>g8fCF89=>LrZmd zhgk|Io4*ACPxi=r@;+!yzJbPMJg>+YOFf-CMKx_ZfgBHEt@MpZ)JZvZHzsX;c7rs8 z*5V`ozmPcD<@4XiA`Bm++qAeG#VoVxw4$==UA3ZUb=2cbP*cASYVQVLDd(dn`*%9# zUX>+(-}Cw>f9H>uI6j^=8Z#Ikdby8foq=b#=yW3{r4b{EFH2y^8_XC=x587ur7zwk z?o~hs^OYy*M)Pn}Dy0<$CI5x2M2Aiuo{XjVwXM*7m%ev$2;f=;e48AQyqy^B_l9*u zH_!zkq;yUyM@qhv#?|a^kr|;c@?D?5i%MvT4%V)JTQb@f`G6NujXFwwqY;em5fjYB zi+>D}Ov)ukc{jeTE*kdPI)nt**j02PWxp8~w6Y>4KtlLq=ShDVC|FvR_qt+5hi*q- zy*V??uYJ%INcgrU97l38kW-4=wt7?FO)RiGh*GPjj5oRE-06Az$pg1U(riyj5WR}S z9++izRuAwDFg`&!c5ReAakVr$t9#Knv<9PuJi;NffrJpu3rw%7PiG`;%Wmg~dGCdm zxz039J~-CLr+^!*EMx6mQZi!t%gQ{87|<(J!n;+9|kDZ7zgn{{67j5_NSyG93n3t-_)xHtErrLo;kjsFL1Pt11)3drIxb z&lHIlZqsVjwhK_X^EG=$Aw^+Zbd<7eMnq&s+vm?XsM@rVJf)|EtTX$i!fP}h`h9RV zr57P|pf6tuQRF{eYj%(MR)cxFr_Wymdg1(uX(wj@>AVuss$kT8^N&pR9AzzPg?8$H zP2QSzh$%k~ka`3aj|7)$JXLHWJIZ+bwmm;o)NH;NhCaxYkfR}+)cK7RqF{LB*{W0WqbYB`7rxW( z&EfjY-0AP!mSq`{?4fe9KjTXj83B>{ITeN>-_;fvhgBIO@SIo{6qe zgUW&~&{c*ZXrnxLe@vt@#GaBsIOiE&sO~t;5$e0 zaR=f|cjM7(*z;Ayh%r+$T_$Yhg)OC7aEK>cDE9L4a-6ZpL9>n*r5}U~&_yx|cQ7q* zlgjf&!VW-fxlOP%%d@;mNyfbq-O`<6EPkd`Ylo_+!%hqN8U-gRy|cz* zte0-{*V}HDos%FrYSZouUupMVDIpy*56hGgz&7*W<96o|^~O<(0+Kg&?LTB0#8x}D zH2svz5=UBb@fVelvHR?xc~(l(cYa4}Zbj_gUk8IN7SkNcZv6va%vrOoFMKyIf^C~3 zHMi`5YeDPF6|Sr}*m-cXngs9jBWxA&QFYaO<-N4?*7Cz}Xs~IKY!~nKB3M{}{dgFL_7Bjg+cEyDL#O$QB?>H0T_f0NU#Dhs3oRvUHaAd8c_Zs1M#O#s1_5c$Ria^5E78%xDMT)ZkZ0W<`}0*gLLTJ zfE?tJ^Y#%|Y_*W?!CouGFA`RZwd=6qXF{KMY=v3a8KIA-o)HMDU&8)m*u@g}V1SE0 zpC6WmP(A=xeXoH-EhC>A7xigD_~c0${hQ+S;J8!sshLCPQw@?cQo+UhF(cD%Pq|C- z162smg*#z{OryVYc2n5+-(S2#-_F=HiW3E`jErZshZs)zJ#)K?hu#^?SA5O?a7mik ze_Q-|tzGPVaisfM~?U3&5X6_CcM=QnZ`wY_psmd?$ZyRvZ0kCxrSM5%>qJQZZt6n zm;_sln$=Ur(j4EsD^m$><{@(U-~k!tPce*U{`D2;@*$7cg!ho9@>}J>qF=6T+?`Q(!gZf*VYYv*$Ltc zgAOlcXt=By?jVJJb^qli-plwtp%x3i#%@%+we<`w_NGPoIn>qSYYt>vE?#}_#nL~6 zr9Cn~ef6*8vs?kV`jC0Np-xhi?`4{}E%$b106DyiK`W1lJY1qXrT&g452@KPM5EDAa_Y|qOqVoN{IhyLOnJ%M3E8l zOW6*(u?-E84bWtNF)9`Dl;kZx0>}jq}etvs?SoouNN+5m}02f)c`Ng3~Xb z*eOa3_%LX~S!&0Ih#KJCy@}8VSH~TbztA816nZdOCUFZn@M$#DJIl5zY4OJBW%D#b zt|yv{)#dj-S#{p{$x2IWJ|K9%lS9g3?x?9l4!e)GPOx(ChJvQJ-=7N_WFl7EU@JQi zsXjLRU&!YDVJCK&`jKiPF64#zrTH_+2rI;7iGNn6W|XLzLrvz5EIx-k}ZP25UrwB!$W1-CyamkkgFKGUQhRC)p&!#6)h1GupWoD z3@qlmD;qX>?}OS-p~>NFELNk4tPTc2_FI?=I<6Wfn|nNNtNt>uE7 zq()bDd;!A>HXBN|UlB^=7r~fRHWD{QLCG+QVg^v3b|@z>f}hOPh?w z=${U)IKmQL%4V>%xCur_2li<+zOBG3fNZ$@c90O^7!F-yUr;O*02)YW54aLKGBi2C zDM78kHcWK4yZhRkQ<@Ybb0XjnT61q=F$y^=Usg5l^sto4P4 zEP!{^?LhvRC)4wjjAD)?cPb$@bwQ4$Kx@BEBxoVMA=#>u7?HHw_Y>phaFSDAOACDww9;hiChWc$#+c5r~?+o_9MsT zNor$KH!fbaNUHd5c0~)=!i8O#Yx?Tmi`!oPc}@wjbY>UZ`I)ERv#xj3^<05Aw%g*=!U5D72;D2f3iP-aM}0yk7t635X-D?Pu2jSA?ag}E29_)3rXcU`_=!yQ zH+q}SSm5S*S-k&xO0^;su^#sSevrsxtws7>LKV>^S|yh3qBg{)Pu8N$?7CIYnCrV7 z^{_goI~r+yPkLoA?#N$qOG&y0YwIeNrRFcvp20^^f`{}eW5P3fz45Ueto30fWMPUf zgf070E-=>RyFp6ENB*JI`YpAT%lD1UO5&D3t6868_Ahwo2T+z+Rswzz|3bqfN(;yv zlfJ?1x|v($MKa^CSOBlGy7>u6J?! zkMOjIb#x?Jic>rFh=;zY?9xpM2|zawi3y((NjZLP-anZBp1i{G&`7Kb%PrC9_5Ygx zdT;nox;h1JUBFg~ztLNdSfgvik9fZ3DxPZ4rpjBq7x(+q-@a&c-~-RPXJZh#GoY!% zwZV>ma9WE{b&GvhoZW8u-8Fcft}=oZv&6lZ^2tk>u*=$ZT`Uxa^JwB2)Ky$y)W*fZ zEuvBFo^Cdn4$bwyoOf$9sIscR2F%D-1{M3}d#wF!M~*B{O&&_;!#?lw+21#t66Bd7 z9ol>R!v2Y=6QIHVittC;&Ck^V8vhFtthg+1s+rs z$(@4tdU<%X>`S0Qd_6C|blS5FfJ`5ImD|Db;6}-Obo>nO2~JCYMRRSMi;Meyivy+p@!0BgY%>xlQ%?1G7yg z=ts2!D=-?xEAG1IrSkWA12-FpVT!fW^_^q`>7Y5$h#k~hmo0~VkmO$Bvl1+Cl8N6e zUxaUw+5BbSBl_!ESEOfw4Z47f%jLzgvbc&PGEcOPlUZU@WJ<#K-}Ek}byht1TX;Xu zyX}sk1zq~7(8~>rzAFj$D-F;l7l!)%6y5eA!(f0uZ@#`he#fB$&Pjyshpyh7=it^- zs?hDqq1PotCDUAT4AkbeuSU?s!jd0_Incv*&^s!vKuf0l;P89CT8r71z~l1t-Xc#@S%*3*s3DZ+6C(>4?TMAy zF7iFw#p=0b&@n@Kof6{oBE)jFj@|q04Y3V$nA~&0+QFt}AZxTtQ|t)M5k=}EM)#TM zrTAY{fiw=owQZkecPtNz1ePE2ZLdoo=@neCPCfL65azw45iiXO4{)4FcD36T^P$Pf z$&#*NRi&qo_lx9y-f#1MpAxcI7(LqpY`DPJ6FEwrAD!=0LW1X-(pYCZb=!5ljwL|F zi&63*;hM$$>t1AMMaOCc%)Ibfk#^0nX=qX;J$n?E(Q7h5ItP2U%K1^|7=ngr53lGZ87u)|>y7-g+u5mFeLv!F2J_jarxy^N`Vh4>Wl}??kGG z!<4fssp z7CICJ$iKP%bN0lSyMq;nFFMXg>MtoF&fA{$dNlHV%aDq?NZW{sVFuUu{jEaTr~tQl z!$>$oXatl=gH|&;fAlz=_kz+z(UH6P3p`0y_3&+ZS&16fI~|*D-c~}4-%51idEAku zi|M;hg}fwuy{;jpjGw3~=(InIGaHmYZq2#&^z&D3!=57KGx?n2V;D6V5M>Qb;o~RR z^|dvLY0&KN4KVYA$vuL4R?|=m+Q&23`H|rB?r{CVuo#3T_kke-wp97I!_2WQLOIKR zNC}CQ4e@XqtDZ&(T3oxU$Foe1NqbhX=*HNm+1SjmKQa$!#Lw7CPP&ZABGIdCK*K6( z@gk9!aOLHK>b_(Cs(bdZo8>>Y;B~&7>G4F?n-$qZCXIQ)P&ef8WxtVolFqa7+tjf{ z?a+}A)rk1TL0S5^b}VHa@kfOrLFW7(+=piwacID!xZ6H;6RhQ{`%Jg48&^W&mQ3!w zJwOKd1yZmbZFju7n()aEdiqO#+TGouZ&K;lk;gEP@2} z?-^bLDztl4zE~(xLOA<#i+Lu0N1&_~U%t?Bfzl$!Z^*EoGeynSgpb=Tb~u{xU_6)rZI$$-IAMKL47F%b^tfyeN_n$vmY>n%6W?mq@rdDPk<1#GN&GpKTVzVFDkzJhY#C}4LPzEl8rE9;B@(D=MHCSGA(2u@3 z5KD9dcy#BkD)|qD4%Kp7$iqF%iN}c?$?z#R(2m6pTIiJ=PH1Ti$Nn(qV^>2E9{T1h zGf<%+*mL~04!F)x#rYvdQ+8@M9rZ@RN-|6*VY+lm=&+LT^9;rP^I1!S$RHarVkUTW zEVh7);V2=AFq%i&q`>IJbYxs_qwG(8TC(HDlP08Wv=pEil@|R~0gBer9q>u;Zz@E# zmgcm-;}F5WKJ{tBh>DqVPpozKs8+UKlZ6e8LgWruI+}%Wjg8Am4Ks-_w7{_$%2m)# zo?PRi9zQ>2CDyqXgKFN`i8RgEEmU`8zbD=f07kuu!xf5@kULCAk}N67AJJJ)h?_p| z0vaMk6r#JeZg83-Qm)=z+$o1z+mtF8Z5<+XPVfyy>OSq=23U!XqV${Dp3eDFxljE$ z;}uCc&zjk(pIA+KEJrGswX#m#uP1H4Po++PKY^B@L1|{>t~GRya|VSs^L%JJcP8w& zSgr~zqZ8IHus`%tjxXI*LhkV$Xr^xw40~_gEVt%d0^;CFy%;++XMoWel{jnKi(`+q zx#TJ#XG^o7L_(f^-pPJh2pz!eqOnZ0(U|+KKMPO*ER7|V2lU!Z$WpmH&a z&}#Vaj;WUdL?Pw+hZ3-I6GDuA_>u#*NB#RS*+_<@w@V3mmMNZPbO{#)n^$E$)pKjV z`5<~_j9!Hjl0zzJA2zd2tYpckDkZ-~5%vW2eMSlt@T5sgwY!_Hc#;{xy-d}D+O%I{ zGah%m{Y#h~jC#|VsI}Dh5Os2$y3;d8GrWBh6&r%?(i3QeeFf zbOZXwV*GWKUn5eBquJB-Y40u~=$`%H&fWG?<8A|!hD-imLm7v9 zXXRB}_WIh#?<#6JdC`Iw4YNz}xA7_p zeZq7~JGe}<)$JqdLF9D7FFOSQH9*S0Zz~X)(dbCZUEI}6wqMXVe5Y9^aR*$*=?HJ& zlZw|T5ixw-J(2Cx^Q@+H-UFC_)tgVgF4dY7mm~1aD+1Wv$p>4g=H->-yyJYmNjKoehW)3OY>TCC23E56M z2}?2wmxdpy^}KW?tt3PgpK~m$;SZ`tRB9Hta+M)#W`*&pzQK3S?b0<~<`%vWjF=C< z9rIw>d8fQcF? z|1Y-QG%U$2Y}jV+cB8wjOf5~>rDo-{bF4IIS5BGHq^73qN^?rd9MDwQO_rLHnwpwY zIpv%qDhQc#XyzOslH!m9GAV<=%}3Aiz2A@b`F`++g9EtNy07ax&vmT(TDI!ciYd3< zN9hOIl26i#!H|hc{vGpb9LhxW4A=#J>TNWpO2E|)t?0Y1-qhT+K0e?0^h1zF7R%yC zzuniEq<%|=OT0l?#sxe3J`kWM#>y&54~Rrrqs8T7)K}DBPrm#mxlN;~^KD2O#b>0F z4-b~ei@d3$=tzMCqmyVVnr?c$RW~g{d?Jk<1O41RZ}rR^gG)X+HhCPN7*sg7j`^Rs z5seu!)@U96;nrade#}r~MbNLF9#6m6JVbM9Y2i_$DhtgHT=*Lt`g=v3dwHe^{v*cO zduVddV^Q<_Zl~n47kzw#USAD=NgRHdV9DY`f-CEU!QPLjPlXbWOh;9$iaJ`h|M$p= zWeVJd#qD3V{LF1huFXPB2GixBl<|l0a|MBT_jPQE-}SQV6qw1Y8|f)!g5hB4WcSRQyhOMPF`ZTXolv{`9ogU~HE)-w_Ir~k_U z6NK8ciX}%NBj|pzKvPzDvr@-;rpH|c{k0M|TLIo5K!wyU^^Sj?k8!Z0)3Lg>!- zYMkpGIWMfpf9&Y5DS5%ZjnDVc@tS8f-%UsL=j)}p7yUBElPP}<{FbSibn|>@UBoR`YjaKpNo8tZL%God{zcrQokmX?3;T0WPi~V!Nm8QLb;dqb0`#{dL?#TGL zkF~=&ZOc_g2gJnh1^WzFyfVn97IC3+&>!>7o|xaGDsKK+T6UEDw{^}Pp4;4=>U^?A zXSJfPS9XQx8D^GWZg}&6UeaI1NaZ0qSo&8)p-g(%6pMw26$H5P$bLJ;UQ_Aw$r4Oc z_e$mEhQYgQT7Kl3kzjLS=BbyJOxCrRjk}ayo{c3%HLG7o3=8%~Hu zBKu|3nU0~Ej`MFK8q0^nV<@gcYkkEj7F4VAMhq4sgC5;_5q={@3B7%Dkf*Su#)s=#n?FsgXOmL2e5redQ}eEvQiqeD~5HrLV&!C*U?ecYyoF`w%|Mj zJQQN`{t_-LjE~)Qt2JMh?CghXp`LsIHWQJqc%2oLa|hyQ2MyQut*kl)RH&V6cZ|?T zIKZ$B+r6TRmX+}asGHk`x7x2HIShyBG}M!vovzi^rZ8FHyBQY(8n1YqIH--tYO#Q4 zo`m}2@p0Ay!iX9$4gE;4bg~&DGP2007h$#&xe`-FmiN-??oif@symafv?L~+x|=iF zu9OrY9Jrr~W#}jhh}-OE-~|a^6bKIzKd=wgV6CzwKm5{6Q500VXCG@u6fq^%+MjFmz)D;Im>j|U)UH9!6essJ#MipS1oI{1ZGzsWT^*1HE6 zdzGbj?DS=l&yi(*jFYvxYo-0xr2++EN3UW(+B z6re$e@A8&jA8XLe179A$4C&rP2_73eMGCtw8;LN;3hw8-4U;&q!*du1Zhhqjnyi>d zy*|z0E&C4`q@iiS4ScbW97G+@lY?~Dv`s#_l%#!)Fl;u=^4i}>%&9)Re4=p=m9EiL zo^Wu?yvaf-TBoTo3z^A@qvKnb>XC{4ri_DlCh$jKd^KY392NFKo$k+S;m+fi(m? z8{KIC-|!TJwYA*lQgo#Dfu|9<2qE_Vc@k%zPUNrk*x5UKT1O$>3;R`)llNcX)JZ(5 zT}YD{le!@(If2D8o_XYhUNepBhO-ScTK6aHPc6JG1|RBD&^v+j;6v{{UR)XrkN|Tq zS!PKJboIF$)GS*UM`OS3xxLqk&MqmSlHVQ5s&Yb_)i0hYa1Yv{sl$nj~j#3QVJY8)DH@u3EHevx% z#?lISKAhbU#v8kU#QKfc(4uwm1lZbrQcnUf>KIzGAP8mS(;OKhWN)3=HRDa>Is_{# zL2e|3-8k4YALJqR=x*Un#bkt$3TVnGd{vC88PT&!a?ly7mmpV1R9by9CB(bCoUURHBgGNS8$E_ z=MwV4Mx@9t3%v)C3*IHsQNlDtl(ZiGquPWUKOk~oL(1i#x7$|#rb#iG(+<$vo42~d z*@KHHv2+jf$H0H2W%w_Cjau3uc*DsXPbO{<4LH==W%8WVa`rMML*_mXWf1%^Pldl? zr0yh;9Q6Ng@vQplpXuWhnxXTD+vq5!J_!?XTZ~tFo~fb&Q_-5z(_73N=mkH7Q#7!G zfFTcA8uVvmA8f#*ClOMjsj1CgIiw& zu$5x^z)A?CSDH}qf*7JmXByw?XIImd{^>!*ieOP8fXLt*{5(oZ#~&N)lO3S zTy$nn@@!$+0~wTECS6AjZ*)CIy&NM4c_}Zo?D`z4rWV1}Kk4#5JtYUZn&$VDok9x6 zJZss6xfdSO8|zdV?Kw46`8Mqk^pF&P67-+3ri8IPKA+rG6RMjvj?=h{MCRlTI}4uC zdFC&^lDoad z?B%wc&M_AL5I$RjYeY@MMCB2!nB%cl-=~aTKEX^I(((yrz_yLkhY>`RfCttW)0B*k zc-?{#YaNf-$U(?ikF~g0ADd&F>%)RpAO|xoGh;eO;u7adY|Sgnf{i@l(gVA5{PPbC z2iG5uFde_A8J=~X5Ya@3ez&Z#wG-Pb*J%jKDKlVmaui6TAxidqqL5$m&Pa=aY{Msd!yR zl$l)xWdN@+|0Te#As|e5$SY!k#iXv?f3sgrQm5U z3zMxPyRHGZXXl$Tj}&0~(r1$st}*Z-YZJ}d4O~DVAxzN)DVG@vZ(>ztbMP0V^E z)?y>sDy`kkE_mXWr`U;P0$LjC|tk|dYvCwaRl%9IjLFCSypQ)t}+S^)^EIi zyY|^-cc13lN+kVT*Wsp^Hnz} zU^S2+t&^>Qp*`!9=JWZs-UV5vAty_Un(G_q>wsaXGJ47{R)q^m-f!nLg9|GE8^URN zCQM~6l5s!R#E**tmMUdgmzsF<@tVGTBNZW>Abuo$%#JNf#v>-HDYt&R(jYdH*hZ_$! zvK=N}yB0i9@3gV+9iunCQ5d#%dT;A4;^pS+Qd$PlLl!a?8&(u$-)?0!J}mAnrgn!$ zAc7i-Qp}{jX2KvYFgIxV z*X+48e+$j9=m5q}I#)kJ`#Zamo&TdQ;MDHD@7!|daMv-#jCb;NVka9!EXs zG~F@Fot1=p{TnpGx4jmWa~MXl${K^&%_n`8e3rLy4a(HdV~9OQATsP##X>2=iFi~}e33 zWcUfxH@L=%9OU2ccm5Iid=m#q`nMyXC3Zm~5u|>+@!_}^@iLVpTDu~uA&(oMZzI-P^U1kZJtXmNe zlA`mP;47^1j@xBlwZFh@gGpC!JbU){wWH|3n+pvtjidA-c5uDeN}Q*%pbpv2iMO!D zNHniC-58iLTxih?p4#g>N(_e+%7HrMgc#K=duRi-e9CDFV%N5KE@hc9+Vu}2b0@jcL9&+WT`=3LnkfvB8M9FnR&Wp$;KCM>OQ?4@vzPX4#BZjzD-0j{jf4zx8x7pPrvHUMd{Sd9IvV%hma_$}Ra36&vV+hKg zt@jAf3>RJG?(R^Yy#&jag9H`>WNAtH5x%5$6m`L-|0vIaHANz%Le>>UDsCS-{;foF z(s7Oe+?I7l0Wir@9z(?PZfe4f7)ncnb<+Gvqy5qVML!mwR45bNuUS*{RaeOi(BiG}Ik|<`rOb(h|LH|&c;iv)_MskMdl;D>lQ-JPR zzkYAXi)aZWI_CrWW(j2`hQuH-uEne+Sjs^$h{-_G2loY@dpBYa_Ghogh~IGM=4W5*cEU3_a**j$5_Qe_O!Qqs4-l8Wcucy2T`rNhpr#!vmznkTDKI^& z5I{07VF>$Shf3$%y8{9?Q;bqAP4y>aftYMLDE6xL0jD%xIv#z0)nEd}o&liE-o8w{ z`r}Sy@BDm7n;i6Vtkupz4jLs1wY&+;uFCnSe6iqms3P^I7)4wX5$*f^INqPFXt}F? zY;+q~d=U6e4yr-J{s%^@95q}exo!$C&$uTCZAJ~sLEB=c#xv^t1X|DXNNkqm;Hr>b zi|4~{p^f*owEj0zzm$7>kBYd)G#Yt?cu05GGx#+8G8`IJAlN4go+eD9{cc1duDz+i zDZiV@c-~vTvL$7o3-TDC%JLSLFJ21ZLi6q!o-6(6rA5+-WC9znGRR2Zq8pV+k z_W8-~Iot@RVZb%xB%>pF`N3;LvSubZcdP3qL|udI0r&_;qz`|z8`x@0xH|3hoGuW1 z`#`P8pJ&c~o(dX|RgqZ79v3;@KIy-%Pg(JEt_|F-n>HCdb-$LkG%ku9e>(~JkdyIZ zta3jUWm8^$71wWXXxS9}DfH{?yexpM1+}=+UE?4JhUMfY~YGyuE5&6BJr)!R%%pc!=`Ln0DQHkF-nM#8! z9@zCy>8WASeW${3YH&!%OcXBUE6_|AjT7x2nSX!##|9<%S0dIVIYg%(rnKa(n-=s_ zFVoXW>!P0=Lm2DBRuB7G*?83fvJ03ZJg-G!((p9mYOKRdxT0#J)KIX`i3Gc z&|0<12=q4miML)p`wI5+(Afxdi~D)6ew7e(c8bD74Z`5bjGcrF!M;Nwfc6r^B>%Hu zR0EJ)Zpq%a()WUR%~Ln{TfxzzA&Az?Q6bP>i|XpgtyQA5@cL)5`KS`TmNPWeY6Xww z)lZtGpIf+EDLscR`_mYzo;7*hBkgfaQ)6|A0D23e36M!je>7?oWj$UqvSd0C5C<#* z4!QAFAMW00eAXDXP*pBS6T#5jXH)g-n{p!r*6F5B!?N(3#3->&fPvJ7gO;k?zCIeP zfGH=*My_pF<@-SIO~8e+RJ`&gk-pd1{C%U< zN=VH39Wn8Gf#0bIGV0gk9;mO^Dl&w%HLbD3>d&jUt8_3J$w60!?X2_iN*(1vu#5W>04*D}=Q@CR|W?KM>BDm@_Pj0c#$aj8+RJ#we zRTUj5jauql?_U-YO}Ov`qc!J?v(+IX%#j&%hPXRp?xCsy;tgD9yh(8{}AQ!jh!PSxZU50PS!&;n1=sY)Nq za?KkXWz;?guSrpVbV(iWwBpWCgw)?-8kJAN z&RSA34SHOprJIj-!nhmUM?Vi`%4VWapQJ7lP0=CA zb!)5Fkuc1gT+)6hDL|{9+otvA$ev5&klg!yw9t>rS$U+qd+=R8<3B_OQ}xOG|m=SFeOF6>4fPAbU;lwiBcC*{_(rh8LEz_!cPo#j}D$ z(WptABxt)OSwV{1{Jjm5S`$B><<(YP@9AQZ}sUY@N`@+BkzXQV! zO*3+kJ#yFVh7OAFBDP~xRE5%xs`KE`$DbR|wFLQKSb?}YQ;RX5?|>^)(z~Z|&Sf59 zv6{7PGBuzg(i9Ac&g#wPjlEfa6&9U|vx3@ub1XT(aHy~D?h(l+6dQ&=_hC%%W-u?y zB_uE9{Ph=BwXXuN64tHPR>rTgxE%X#?~|hRVo}z#OJ06zOy~w9x~wdE>CcxYJ%fkE zxBXld{EE!N%M#Z&Gr{f2u<4_spsO6=a}%RAqu=>aod9?cB(kF7uk3Ni z^eml9=~9i%_G`ORAWE{Lcb}+kXJ|xqJ97O}Vr}Doh}p3w@1t6R%0u;5)7{-E3jsEc zA>UuDnMaej%Bj5_cWrSfsO zT6($E9`WIIZ2S!gOLTPTY528C)U3;r7|N`xA> zcl=#dxmjpswYyp-_24QN(ml~S-ys0_c1yF*0r=rMjrz)_m6Bo+G-@t(V{cIr$DTtNG_$Mk1WU_RDjN& zp`Ic7c5|E!(CX&9j`L}$VerZ|vC{qwtq!aoW(L@n=A0?umaNyG`v43eXS>+o)cHee z_7X|p_F0p<5hEtrq(7HUqMzq8_BvvBO`<=cSAX+<3RF!xr^*IeYY78gH9Ao_9QM~U z=$}enuhG-cv-4r&cg@v*?dnv1?E0Dcqq}|j!sC#qB`x9*@&F~hYDNh!c`vIV(9ZtR-< zn3P|=^zE`uO2Mu}!frBt1&GL)?%xL8B|=;t_Dwl4G^h5$kk+UMXwmL5Vk8Zll5bWT zat1z3dVaiiMBsVN2UA1m`_yPb;|qz`c`HFTZaDe{=~n#F_b&JD?EtjE4(1`9m$gD~ zA(F6KsFR+e?oWaoUqP}lW@G&~FM9VM<>tZqlbhMGmApm|lD*OGG^Gef{h0U-vLMz3 zGGbOr?s?Gvi9e!x5;lNpkLnq-5d#}SCjqM1~f!Nyg_r5?fxijj-Yv( zBJDq89pBVCqL;EZljow9(O6%`3vs(X>H`P|E?s5<&~bpmJ?o|T;m?ftA$xQMQMTd2 zC^{y>_pac)>tUl*LQ!y~c|x-%Ss_95m4~4>+0?AAoR~#*@BKV&kwvT2?E%3tBD^hE zSQm}qqq)$?@>sV~od(g=59KZX`G-f?t1x9zPB*JmmZ18`W0H`17jER}xYt(F+SlJ) zfS!6F%LqO>jW#B)*jW;AsROmHzP^7=ot8*01~|Qb&EsKr0=?i`Ij9r;gw&wjz*Xr2 zz(buxT*<`u0c0WE-ncvSdhylSV9iU>5IEU;K@QsZ2lokan=A!5s3T*mBLrX+p!#1v?_xFe9f^;vbjU` zf^~f9I0N`x^=R8)g`5eMZpQ)HIXB7g#5nLX!*IoPN5xzjDFJ#%L$1uSOwuD*t$kbi z)U0|BlL%gT6c6BLN4B`dO*Na#L3;yp8WTc)$gVm!HaT9kt!W@p9=yo^r_tTxv`fKt z3J{FXE);tZh(p_9=Qwc$62+3|zn1SYcbzoB3)>Lg(fr}z02lE zsmFvZx^T%cPH=?DE0IZ~Za36OFO5@hlNMh39lQOzZ3*}{z3RETw(gLDBj0Q5j%j{$ z8F{~gNS8E->l94V!zFl>u)>&Io@|;lI@^9>>E{IvvO(0u_Of{GlsXH2!)8xsW>$R?-fh1;5$ zh7cE5+8F6?h*Ioku1V*>BWLwL?e+&qfB!Jx$7yHC_3)ns zO$oEKJ*lKzLq7Q`4#obn1WKznAh}3QRoruvu-)Z}^kt=POla;8FZ@=Au zbp^00ft^u{F+JYXUyXLi=Hj7*pGJ)Pm=9IdrhymLv(kfH{9Oj_EYTXxqW|2$RsZ~; zL=cBPFrr?E5j?AlVd^K1(rqQ}jh_Fn58Loq3RFCn7n@B9f{YMky;U;7nuS~(wCf=GqNFw{y>dgc<4a2a-%TPgNsW}hk z8&NXruA1?mzl%H3mA~7oQZ~@+-6_#qLTa$3dp1l-xBC@Q7q`ySCvKeH=fy4jT3hU% zWER3`&ShKm^GteAgQMi2HV25AEvH0qceK7d=4Yh^V=q*&{joLcdu=s)=6Eml-~`Ei zH)%h*-dxhF>v-4a<1jj68}%Rk+@ry++8#bn#kq5LXmD3_+Zo~!59xhbSp-1;e=a za%4T0K2iUha9paQE{JU=aJw3~)*aWIS~xT5KOY1WObtB#iIju5JnlxQf#LmVKaAL4 z4thX3BXvZ~MPqJMFVi@AD5coF9LCd$XQV`Mec`GzkX_N zUHV@3!Da_`yBy>{pcatH6+Z}GWl+ZwesXG5XEo)|PM%BYQ+C;! zhuPD*rkEZnElMguu*FHjGJ5MgWk$^8fkn7(7b9R0XgnF|mh9)Z; zF#u0{9$H`lel0-jb)e!Dflo##3fx0iKfiW%@B*%XXI!2RUz;JsPU7UC=|HDwW5@tF zLqFS(B!fxdlkXjXZe0a_RWzcRz`VrBb;WqMbvW8Y!Q{6v9S4*pk#tgJVwM`6E0f% z0*S@1kk&!f*P1XaFj|OZjb}wo+9^mepDoNQkb?$;Jg1Oiuvy1WS%)=z)sKz*|LZ*V zf3;7Kdnc{kbJ-U3e_r;wK}Yx8Z1yZ0pDIqB&Jtbbs&s&<5A5CoTe(UDeslC_WR0h1 zT#yY@>>Q9(wD3z?UqBf4;$X%nE!Id?->9o?5CO3*?ZK8q^ z?Sq)cu#Hf1(6^1AJhUVQqyw2%ocS6-R2d0kso_i2X#ocL%#u4#K%5u?!2-y7Q3yiqLhDhg*F#lS&n{kpC-L4GMlQji&|lF z|D;+kj9X@b0~6;u*^$u+e|2Z^y3K-pCmLATMrDE9Q)md?CBlChXh&LRS5Hj9elHr& z`V+kP?iEw$`%auC%Jq|k|K|1s*$Lhn6(i^rImHwQ99ziJcslUC!Rb5GzbJo>H&{uf zWc_AYIoV5!{6Hdz-DV!hVyi!6_bujcYU19CJ2)FC|JRL{?n0c5fxlWMxrQigQ?7PE~eEgYcO}o`xeZvSf(z^f>vr#$_hG zkMFqx@Fi>O_6qblh0h@i47tOd)--lz+rDosYv*N9H52~k0W^LMt{n>oI*E^%9wH1P zCK&NHV9eBL`KqU*W=QH^ztQeG^UdtY4MbOX`Ylk5P`W!zg5opPlh=YnVv=wp=39y- zzC*D>>kx`fzZ}G)ttetUQ07!kW{C6}A3~mT6@29?JX!DYY7Ee5uyb;^(%ns{5r3Fa zejObk2mMw(auj6nQdDyQ_H^=Zgy8_ro`5!)71wN#lK$4=R}q^SNipcDYa()g+Vz7P z7IWf%H4ANd;JM4PMUWIK%Zo@p$ox$GnoxbVJgE|~EO*72Qo(WOAFZ%#RNr|nS!JGd?x&NNvRG<`>G7DL>o(UVgWdQq>9ev4tR-Et zj(xU{enJSCc0MG85mVUqL&3oz7CvLas&Y{Mm|m2Ik@JQAKTTt4w|Ju-FjGr0ZT_vY zF~>WF7X|Ntszz8lL3h$_SUW?hRTS-gvjNb^m3!r_$lBNnS^=P(MvRvwDHIB}E!I=i zKJ|+oH5%VlLLLXH)giYpYrbtA(Qxxxdf4EiVXhGQTp@{cLk?oW6`$M^h3-J9kF1t& zH3s%9jlrp53kqI_eyi`aeEw3mI>hzRYtudk8oA!eF#fVi5*@;HstyrPiiA@$II3>W z#qwg$zP3bw&X67mFy;8EM5?z0nKqAf4 zaIecq?4vDm5bgY2$JzXJ%-XwU-{Yjxxjl>Ft=NJ=>}xfiO(+{?2K?s1pn}^O;y1UIjHoSLT^F|&_KA3W|p)Qe)uLHn`yiD&LXi#emww_|>;TSV|p;;>{)TnU>+ zN;F}@gn^xe1YI{ON}Vq$YF0vA2SR;i3M~ySqtAA;Z5ZNUDV#N;U7@ee4h|lN zrD_o+)|ex5P;{^r{a1-|kiMQtV~D^qxCFdBqK+k`6r?cwaH(J!k_!d;PlxehNEYkw zosuxdup`hP=8|Y_N9cSGdU}#1ZHvL#W1>#Y695%T|TQ%wpNsG;x zB_5~9L0fIv(hYLZX^*wTGmeTp?=FO#=Y#h-%0a3|>`{f8yFC+O-!c89B$sC*k{xR8 z|LWSltW~(HIe9~qq^ujU)F1l4y0r&Nu8J>}2jBN|(~NX@?FyfoxTdX)Y6wg$DOpsb z7QM1^P45mvm?BcH!ymLrEI>E@zf-m6%e(Xcat`av62Ju3_Vr^3qkYmY{vCJ&UJe3j zx)AzFmaKm5Mc`?Wm^Nzjk1>{s(WD&8o&T9dT^5qJuWrlQLe?dod-0uKAB{DYjRk_1 z&1I0G0l`Pk2r;gEU^)m~=QPWXe$}f@XFVSmHP#(f+(<#fAyPhLNTGhrywd1A#IxeBwB$KY| zwQwX`T1>}G2B+AIQ691(5q;W)^Q;Z46@)NJ6=>sU~kLnD0 zYdXv{J7%$vL+C<}!i&uT`Vv}X2rw_Ld#n5beT$*AsQs6$8m0AfTwRc}k}&ntm~htD zs4(~a)8cxV8FD2nEX3bUUU?h^+R7Vml!nUQq4uGMAO|=YYCXeLc{@y2x;Kr%a&t?a@O0dt+NRcZbntvB zsSg9V7ms;>xD2ZDly^4MS*4?;s&W&yG>Qb{%m{;J$@fY(VKt5xy--r$ZvOD*1i!Z- zmjym5%{5rX3H-TdquK>=B4ou$-LM<}uBcmu54)yz7u<(ZJ)|=tR#4~^e1j;ay)pnX z#oj3@(rHS8E4ZILpy$7^#Fo`qD%jo zYGOwmU$O8Re22XV-%hpb5&rG`#RO}73lq-MvC5JR&{t^EfOSP?-gt5XdK;{9_ct2r zB=UZ@8zZuzNL7id8Im^H-ZLM9ZKY6!{-SkzOA8Icow# zSk>LuN-@8wr9GlfDq$K9o*O?Aoc7cu&6H+|6QUDI7t49Mz*Z{_8Zd}f{VRSMPCZ*# z=6N2|YD20o^&-4M<6B6s{-7#-rsi{Cmxx~UaAn$ShlsF~IG&37Dt0*zJ;v&c9Q`7- z!!h92KR|m`}Ex8{H39K>`|_M*1g6=zcd%gmG>RX z{Wa(@x0kVCZc)~B&`nE=*^ILNI{PU zp|S#^dH_-7BLUlj_U_ z&JHcW`epxmNREDZ!-fup&P056)Uc1PVe)qYcIQ{=7ony{`IWaAY&-2x}PYI|S zC;PL$hroecnrsXqEJ#%cDC7N>yi*)@m@Cc2UuZgXZYGM6Imjku@~Lb9jz!Ezhor**LH=0`MSCG zZYjI~8VlKGSIzPKbkxob*d>GhkE7Ln&0{U zh#$tO;YCpee{o$uhr%WL^ks*ktE{*%71z|F*oQTHjJ5xrRbe*tdAa z7|OWF*{@$a#JlJ|IV<;FN36Ywp}?*uZujh`qjl>)Mo|MS^kMWf_>Wd#kt*_}@^f>x z%7R);aYnQ%kQcExQ}a3eeoOu|0V;SSQYn}IOXSGDX4zb^EymZc2Y=JPHk;9CtwM3K zB0I}+h+9O}Q`*&4&J}^KynvRZ6{jtxYL=S!zL=f{Y{s+K5;+|bW4q~f+PN68OS{un zmaOb5;9aqQmi8)lxx<-brK}~43Yj5 zXSkr%QrP$fXZnh#*A{WP83M<{zeS*}9~5B)B^|K6CwmddXiI#YDw*EPC*@i{rII=i z%|=dCp4RgFBnR#GmxDMsn|%R5Rhx}-@ePlFtk7h>3(*yjp`>p7 ze$L7%Gnism^=O}T z^h+681mF4SmWDQw`S!&Kgm-xDje`sDH*gT3@)Iz`I1d$l95u9fPLN%RGj*6rFbQWE z`84FY|6OaxfcD{Y!&6!Cd}5K}OTR=_z7?T4*#x&u{$f7^hw;K%wnAx@6a`X{fieDs_Hz3h5^J37c?rGQymh?C7 z4ke@A&XD%=!-j90S~>I2*<%KkeqUW2>S<4!VtWtPmS7Fh(LQ40-1;2K2rhD()-I`7 z1h(TPn_hieJjoD*zjSy1k`>gVIdIG}KId5TvHih##A})>QIS+0g#*3V9fZpg6My1i zZuO&j(#|E+0Rn*-ly_yq$@E;E`LVM3m8$Q;SQ3Az6~}a2?)|ltkNq8Tko*Jl`%e$c zMyeLMJ?!>P^}ObTw^SXqbF;W_m6M2rQ>8ZQ0i{x}6y~zV_F+#^j2)-)3EL>${hxzQ z^Cu>1>W3Us8`;x5&6V+bIjGGTV&uqGxi0Dr5@^P4>z14uxZF4t+^(NxnA*FaU7wDV zT`<6jBHh|h2SjrK)aegkXhdglbv{viH+G=@e6r!axYAphuFAXta8IRz82A6$U!5xl z?XuWzB4{NHS!`v-Aa;vTSO2(N+@Gr}ITP|pTzK?rAER^b(&lkE=UTu%=q-lE-6$X^ z@5ElHLN$v!dDRjZ@!6^4v%^yGp_;-r?V_6@$rckwG*69X-}T|iLFmKIH5C=TV2Sq~ z277!_4w9;>`$>M9Q3D+Eh0?mZqqp;F5=Ab;7aNB>UsjY;<~8)hk8PUzf@#ShqYT- zo$u#;Np5J1wF2l+b3;bp(#UMwHZoJ=`+R1;0cCB?Cg}p zypF$|KTIAC0UTz% z-}rTgAF2Pkx`Bd@tp80gE>#U`8y{!XP3AG3!K|G@DCMz(#Ke_vth6mYeOL0@MJS(v zA(Eku`}Ah6Zt9{&e6HV3be!*Q!$wm>M;;4idac*x4ppHG6`q9tQOO$D=~E^{1<;Jb zes+t|dFDPmUpvXXXBXk?CI|i3sL83GXS898Jsm4(qLgVxS|BBE4PKefQ=6K@^CO-3 zJ8c?WCdopac??nygi1uW9U#Q)gCzhP8XgH4_Vukvu0zwU;(QA<{rroz(|5dH&M(0W zT}1y#v}3O#9$_1-{v-TjF;0m+R_<+?9=a`V;NX1tQPvw)NBN)0d^Gu_jPZ6$l@4F0 z3fz$?2NBZnxP=>x=SFf6@-+A_6g_`4dM|1KDIBz)yYbWNa-e}6bb~ZX0<85=kpbMI zPC|^dM-G~({*Kul6*AnJ4;~|&h>?S|A2hsbG)3c&)`lpqa;3U^J7m(!c$$5o6+CFg`BcPS50^%*$eotY@hQdTsv2DVDV5;6z&VeaAYX;`bjF;?l_}Z!u^_ z7JE=z4l=uSDf~=zYrjaP8!T-EHj58qgsHu<{*jwYA+AHF?v2pfc|V74b=V5+xUAD zDJJ79=|58Ex|wj`2UeQ;tFb!irW{l_I3B=9$U%3SNdIf~*Ihc~mS}QN7oP9j%9ulZ zElB%|vnOk20&*wC8EZB2?`Vq$$iKK05Gci5I}>Xieq15<{*atzYr6~oYRbDtb!{?&2?{D4$YCYHF!o}m5+qE6OScu9|c1iphODc$O)IvxX{z!7vN26PjR9_&LVjr2zu!o|_|{Sz1!Na!m&ULiJ|*KJQb-89Yg|JJZ&@Pj%JOe*@0|*fzy<3bto{ zeLN+=GqkZ&YjpoqDF>!2>3Go@+K?J4t%Pq6%qetz5ZC#t)?Sm;s{`PX8|lJK5(;97 z>VJhj%1PPD-unE&0@1e>LBC*vQq}yVLVJ@{3!QZVbI2H}^UV zdmR$bqdFnsE)hr3c^>h5aLES~em}UqtnBZyQyXEY!Tnwxss0aUWi8Q}Sf3W2Pa-sR zFpF&}2Yn*4Vld0uOebZ9f;GTDC%ZafST19TzZxYzxfzwNQ-uvaw6Cm6smpr%H2k~u z5B(&IWH*;lqUHilBc;+jOFe6=4cF`ot99b;%ivoR(x#FqPr+`H%8%Bwj)Xy?Huayd zBJZkqclh33u$c+tAzhko#XjOW$>giw6c*BU`R>@fz?hp$cw`a31j|rF;nIp83s^&4 z6T!MN-SRi%ImCC`S;aKx7`1}=xOAUt71OL#--k-wwa}IdqBliXjJ#VtX`&_Ca9UI_ z}nGzdMqKK&J{b++811zcR)UPT(@2}Ss{>v znll58ZD`0m&$%?i+u?W1rEvZbDMU~9AIVQ#$pAY*#J{?Uj(>2wGjQ8yOz1rqvvG{+ zyL4ddg2blRx?jKD2x5;W3`B$}Lc)IohtZ`mmvIPts`VAh_l=daMd5IFt=n@sh-}ra z3KzPPVrNHmaI$o7!O!KVHY)Sy6%rqQ-M6Lj-mIt zf1-!Y4#1?5t#XhAGQ14EB{4+%P-bLVx5VDwbiCy%4Kwvtc|Pfy=p$g$#96H_UU+bU z8d2fOZLNqglJ-*qCJ(%FMNCsk9E~-`cP00%t1<#g%iFj+D4{{c#+7;3lT;UCH|za- zHP;A*MYj`3e(BMtzDP}nnbEiqUdYPjXkannG+eYj9K9WVoADUCCH8v09tZvKu6gi% zqb|+D{D9cBFFl=V_Wu@^pImW~)c?k65P3=brRMP8P~)VX;jhDcy~ka1f!%w9D{lvQ zF;im~_U*ZiVB0MJ>i!xY)JHss`hVDa@2DoCw|_8pQ4o%BD(pw-lx)1^BO{Ie& zB7_=?(sC*>t~nt;6&l|`)UYMws(Z zkG`?n*Q}9U4?0}=%GiJ7jqn$@=f;wO<8)Q#^_}(O$7X>P5+=NEyvSGKPc3_MKZ6<3 zvpE&g82!U6x7&@&eu_o6&YPVedj@0f--=1$aW!`9`1g{v2X&cT7ePi*egRK8;bjD# zX`ag~xI5%{+~EB%am>LGcg!3ZR7Cu6O0ydoPUB6yFSz@ZmNB<6X&%i6@-IWl(d^W* zQV`cQ|De!`H&sFtjv}DGREp^${5>~fZw#D3O&PqiXhH+5W4sf&5%sYFol7)884ugx z8ehMyTF_j-WJO+z(AQnfQ!DXKbXWOE3MbWe#)GA`20^|wK=aZmy{@SDpaF8Ica`8uc5Gy7h2grGM7P_SlK~yQCJx-f=v= zL1a3xO#s2ES3Gy@e%l^OP9WBlO*M<%IDJC)*CY>AR%JJCdMsN_ca*i&yrwVv&7HbT zrTYfw*kPx<6Z`k%cn>+1Ay`dGu76eDxs>~w3MIiq8dK{j(MltmX{6YdZy5Tri7rKx z%{<{YN(tU`0%gnMd~z*5HRF2$)@-d84H@m(;D#XU_Tn$eRm4xne1w(uOJcd&)f5SQ zn^QTfwMWhEJIn8oujq;!b65&Uk3Lccj+0>lc`eAWMim04oq4D9x5ZNF$JAyWy;G=Z zUR!CI!lV7HtR=dgAaUCD@oygUaiF&vlZJZHL5G`x;_f#Kb*?8@M%1Ox4C~xq8m-=Q zJ+7KQVYC`5I+(f|tzr}{A?Ci|dqs`-g*aUFU{fL=+A)zFnmD@>N~pnM6^P9W&VDlo_2BWsVl8WT5PqO7k* zU!YDy-MV%7W1hrnOV&quVg*gzX(=;UV3MAyUyIR~0&z468s zdWiC63sA~6JbuajF2ssFWoGJE05>5d<$3JH&Y^=*qI?%;Hd1f6Dku+@X-3Fh0eM0h z>FSmc$-gFzxV?wsXCPxo<(ei!Ay#+iGy*{34PyEE=pPsxq{pEUH0SPHY2VWLdR9VR zyBa$&g&?Ux2r9Lkw**~rengeY{r$_Y2%zMiV-<5{tBESt>+aup>{D?=njx*GfI5(On6MF8S%E&VO9hHXCo700XshQHBW3;K zEnM=<&Ha!*iy_>w%x{4zrk%W^1Y9eIo&1UzJ;!&M_@lY`BmY`FDE25=>3bag;?PhNu02S$5h5kIl|W%*(-7HXJ(zpbc#t=~ww~&5yW#|) z$Bce^{$1bWo2`QQ=*)Z$yWq^&7}#wyFfadJQ)=@CYz*;6kkkrJ+dw?B)3#US?{&Uu5f7RUy<1APp?d^ZAggJUnQx?x^so~ z&)MFBdk}jUa16e&&Cak+GS}aY=({R;1o5h9%6ArYsLP~sj9o1BD9GJ^d^qFs2%?J2 zlZB%JTrAx*mWPzM3@W%@)V#`)2>x~tIJRGAP-rk5;m_+WbFcXQcQBJdZeCys-hFEf zlelGwh-|zPqs>}x`8}(}yCcsaR5+088V?N*V%3dilf-Xa3WUsxE4C|_&y?G}c_hun zQJO$igJ0QU3ngs-r6xMe73%6TGTGYln9Sbyo`slA`s__qn8f1m{Otg;63+dVc>gz2 zg@@A_b#-YtZlIoR3m)J1oEtqWZspl(5(<(vtsNR{fI|1wB9heKwdgW%!W>8weHPjA z&kSdBPujQCYR6z{e2gxp{<&E|Z*TcHxz?6lav`^?bWhNSl)3HeE*YYO;xGvBp}3}A zt=^9H15(`h24| zYB21c3}=6)wixGc&v|V5kbsVH2hG9Y3WV8;OJO-fvUV_+g2&FkpfOtJRppEY<3gl6 zWYck=1MDv*RGGJs?~|A9H5Z&SMrNweY8~Hz7UcSKm9y+PZ5yF%Ya3f>P%%bD>yK?h zB-jhN#Vw{gNopoojHF;zPsVuNphf$n1z$`hBhyA|RYHzy)i^fh%LdOu(GB+iN2T8$ zWRTa`$+v_AqQ7Z785-I2><+9T-sVO(~~RO6D?=A z7yEJlhVPJX?lAG~|0hj`@0ywJrOT^95t=e9#o;s~qSm~P#&$i5685Dbwo^iFi$p}Xt2s;V(`Z;aD8 zE9~R1qZPfkQvo<#zv+b_A@bS5pnGt!iXDk81i~fPf`W8tE&TcI`0>>w5zX<4?*!WdFp+r)zzq6^lvn7n z+t@ut?olyO-ObA7E=uV+E3H2}t~PbCUER%abpJ+XyI1{9yd zF3z&Px06lcm6YXpSIems+0rGiKBGDGCTR{4VnaU)S8=Yu<(4fYTy0%%_@Nbw)*s83 zPX8Te!IWu!F#TJEWH8(|LuiYzmX8FtNpE9lh$E5P_R`xgVW`3s%fZ zas2$Huq_+qs&`z>e9SHj;U8{JRgFn;xQQ;W_#{3ThPWbvH#ojBU(|SI^^lJZ_;BH% zt`1^qF}yIGgZ8n#ZTIx$7f9chsxxthkoA;!XU;9q)S$9@WDDxPFudtSdPwR{3!OH& z=*#Ct9u41#T(Auby6rZORS2WY5r?!6BrU5~S0?Jd5b4Q;<2L8N%wkhk8lxRKCYgr za97~__dYM;)ELzw2{NsmJ`v&v>9$R?f)$aQ?M8zVz;MSAGR+_IYs@Kh0f4k0P;hK~z?K0$N8p+U9XIV|EPH!Px@*;V+>#CX_K@d3u8(&-FU~`Q^c`cl7BPd96}N z@;qM%gsenq#8OjJZ>k_FgGTw{(x*I@op{{9>SO!c*VlgG){+NYp?fi$)@vs%&UjW z;w32OS{Nn-@6NgO5={jun{>u&g8T||cT6`?dA{r|^6bh!QE(&n(8{RlWHf z`MajZ;BpGZCEVq%7K6YkC9hHEq;r<)mr_24t!G~3N+Y`cdyR9$#iB31wjn9U4VEL# zabb~J^vQFW%AdB2B6*+cJu`jBX8pW)UNxJ=T$;RHUTywJ;X77ev~;GIlT0UE=4nl>zr&|Il_^6yHu({pHzv2^vVV zvXEtA_xO7s9d9l|8eKX-68K0O;9Ln}0g6|h+x`&eD=~jEx&4A5*OdLX^_DF^8!@CsDE`8Exu)WW!pW zv^*rt1FtbZv{!qmczo%pPf4;dAu0H~jcQ3wvDw6Q(cQK0H8on(j})b^YaRQNW<>jC zW23hNNe^{*dSzQuhjT?dQAeKX}|_SA?9^_8&%G0>V_Dv{;T6te1(XF8QV@KG~*^g z3|^-!UR*tMM{q+eqcv+rh2F0BSQQfru_SgAXn-d~r@#G7>#6o9K4GvKRyJ)ftbgU6@wg?Pw$SRjSq2 z$#M;b=zzPrT4j69u3{9W3NqP=ie^hGcti+aF}w;if`4_MaG%CV>KtVqQIE(MQ;q3s zunLlZu}B8pW7GYux0>?pnwkdKPWTr;!~Ke~g*|Fd5N|a&T;%8^3FysEdVCZ|T{#oF z)u^vVAWaoO9v?ynP@;&CfvT*Z*+Cazfk44HDh=S8$qVi5pXT#{_&er!{(LZod{r5< z3hYWqxw}iQKJJeFvIW{CSUf(+nlQvuY-oV57?f14E*7&6#GL4dc3?OfFS|AJ6O)TK zIDML;MJ)qRPtc=w1p2CQ$*teLOVv%CA^kIbOLK*j2uIu}Q{*KzW)~ao5%>5bL(`=B zbu$ft8y%aQh18Bd(cwWJ{wro33x&K2LB_pnSbQ5sB0mqv?tk}tUN3u!P;#x1(cXs` zQ~I0tj40T?o33630qS)a_u7V-9urt(+lz${>0bog%c1HHb>0^HWn+WJPzl4KKEK%~ z?*%lxy_)U3Zh^D_QjqR_(0vl(=sI%;k4WGG{*GeN`mNT8D} zT^P7jbeuEMXm&)cvTy|~wae;O z2TGY5--hjtpz|B4j_=)%{J()O%$p05>%|O3zf$@Jv~0J}SRefnhz(W)aL@oL|M_Bw z^9D))X>sCTnPrleC!Pwr__5EaRjAVabxR2K!DXILFqX4Iav&pd zx&Nv~v6@Z-*gYM1pq9Y!?e81k(xSPa+6sXy-f1ptTiC8%*Nl~wFG+6m(8lDZ|R;(CpEZsVwWrU4@@{k%|}oAiH9F1zbK0^~M(Ki7^U(DfKQct`iLs(iaL} z3d=KzazSxM_}e~Nk(0%{CzZ|70H~gfi_p+9Hmce%EUoyiE|FY^XS?XJz|t9xoDz)cCL zCd0doYdH!kv*T`w3ZtUda*N|B$ALv|7V>2|zLu>gFLtQm^WFq{!^YU}YvrwhcBf)} zwR&oG%9CJ;pvh7vO7{2hplk5w<6O-qL)z@E5OLXGzsQknKZ*oyAW2RFh~F8Fqp^>M zU!|H_a=qD=U*qbsO>Y3RzS7llkAjF@-ez_;TsyKHF%^4z{Id%<=8nxlRU-VI>Wm`h zlq-?H+?fpZ#b?)0!iAGA9D?NjkVX}_3iu-y{b%OxuF5?%9_K3QDLwg8=RKDn$6uB$ zzb25D&_%ciMa}YJU^obB5Jw|)&!f^`tlk( z4?b&(VHOH;0veaH5@nhZ*J*&)AjZ{`nAB8cNWz~;52ynWvh+f0J;f;IedC7dxKAyt zB#$rA2g*94`lu&j18L(5{`Fgk*-t7Z&YMmiJ{2fq-hHOm&~T_X4{hgar7mLTD*Gy@ zSD*O<92?NLmIL9O^4v@9e>`?+6Gc$b+`%_Yr@E&s)C|pcjmXmgzcNF;T$O+Ije(;5 zL7Uszt2VbNnO^vSKaIC`MjIM(6ZqGa!6CARY0mDqfwvRCzMkd&j>Rc-+B!eByHNeP z{k*d^TJiZ*O@pgANblG15crqH)bFCVI7fBsb{tFAyQMD-AtoK39f zTc28VeLTD~FR;@8CWx8L5Yph zBbx;=wlsqW2%J0_2i#@@BuJ^;U^a*J@G_ffLG?3x(jQWCyPOwHCp};+T%cO z(Do9yo>rHo=!_5kh}Xt6W3+1rB|IG4aQB--;H&XgC_$WKXM6KkqZsjbqb%{02Y1pe z|Iq%Oowm=8+se6X+##gAH<475|E@OI9GWZ~x z>_5ct;N*hR|3Fx4A>lXfK)-J4)u^HP4%Nd(7*;0m)bZ#>L{c3MpoVItq?1s*0d*=> zr5NMMi~}{X4=vA&R;yd9tJ|beHLPE3VpFdjITM51PhtEfRggNu$0&>BK2ARZ}@KWmXoC-JKP`$CNbRo7r85 zxGL~Leqos9?T2k(_in7`$ z_x)pZ(Aiv4IOPP!x{lYFn1IhXKzIqzmHHf;3c0~siE`&9+G3-LZJ(C%=C zGeAW?X7CPr_xsD64o^$i7l9DInV&#~?~muw`L7Cp|1f(q?wKH6`K|7VEua)x8^N% zT*gmML;nQf;TXxIjgf3GJ_$@%iCY$HF-}cpNMO7vV^=prvPP@YMbKelRcfyhub^5 zt%o@rG{kW4lS1SB>g%mQzBw~|nQxW_D5+j`pq`=FBgEURc(8N|H?r<0xjnXfhScJ} z*I#+k;%7IRgkPl0fcesdPD(M){9KNPiPQtN%!7Ms;K&_`!18$zkr>?bFBfeyv3ysO zs3k!I40=;$Rn;C^fAooOKN2kAYBk-&9t`fD;AwyXbr)Sk+@I7@N(*dbsuMY2`Um_! z&dKZpA2==IsJ$M@|2BI?y-&>@wZHQB3EQshuXcepJ4frD*rQ}@(W~#^*|%#IZg7+; zfTjISaL!}ZY*R+AuJ2LchM+ogL;`2U1D3nLP)LIh2Y;it@PkjU&;9ghxz2-J;{Ss5BZFH_=_9O<#$8Gljg^*KJ781HNr<|09Keh zSN5+e=?Ho{WFA zOy?o+T0>hwHsCRH(jH&u7-Oxr*Yp zjCMzF-oiv0S10LKXAjS?Ja!jo&O)$R0+(y}qIeZ*cZ5P{fJ(fH5-=Gj-AMxg+XV^c zUGTWK$&cn3T_ns7RLIM};HD**5TI&6L3{3uYq>>U>gys?c=?&}7mD3>D8EZhNbJ!c z%=PTbU-hYa=S-{PAafxU9r2zV#HU}Pvw~i+%kIe8qVFcG3%z=$&_*_$V<1`F47D-R zJ9tnCy*}~K=k@MuFH`4lkVFLu?^<4C%w)0Cnlb0#)u1JE5^h-`@jYh4aM~)T@`kQt zCyP!`@9GZ}4Z!pTQ!731VwWmshWD0RS7K7JX8e^^qhxzt=}fnArP z0nm-AF}R~$g%Y5Z>ZrilTh98#ylWe~z`OHc2^{6b9#I$DmDgDgj-vs#YvX5C0ubq^ z^n$k@Qy!RoTWMsuR%Z&gZ6a8aKm%~+FHDkQhM<{|3E^M;h$xhJyT?)|SL>;y z7%Rz$S<~^YNbSxkBx%vqVcC>*-UT%8<4FUY`R8KXG!b#FsbecY8W0+YhkO@$2FbiT z=ia=sy*3dNCE3?|=v)?zJi;h%3ncivmkRH&)#UkV{tbOs|2W^WUucr7YUe(L{XJAJ z+RV271#xXjXFsg;nJ#BVM;jvbz8xg|M>Pf*@6s|PKBdRwd zkyD33e=-aRUvUfZ-h#IQP|%yhp0&{>r(nB(2xSJVGfiTx{-`eVq+1rk?5^==1Dib* zy;g3NFL>3j0Uem8BEO{q2JROv4BgQo_ZiBD>U>&>=Dh=Lkes2yOpW4u8A`G47iwIF zJ1-nU6Nsji(j(V(wICDHUr&o0PZ+=U74x#%5@~?QMs+>jO*qcKz_!g@AmHh=`byT% zjB_FS%3Dk6ix}0 z=n`r@=ehU%zE%&P|GTc4>jzZh#D#m<@n+@35oyiyV~0cC+}kvOSYPwrl`eAJTZ!r3 zC(~3f`mMv#C@Mz~_NXfL+gQf^U(CKQz=yiv>ksd)IVE_HIY#oWAHnO;rV~lYQZtsV zVuk`ut32jv8~0Y#Uxv0Cw&3E{(bh3h8ds{=X2;~gQ0Z}tCIU3%HTh+xE2ruShuGGf zbGGPFO+VPfGV-A!>$q^fgvr}SRH3isIHAKOS?}Z0=s{r&dAA$4ZA3>ixWetLHNA9SMJ`DriZqYWyl+8uv2A&1 z7woKnh&p#Re1mJ-VE;Aa68N^YH@dHwEH|kGj9aSnKOo0&qU@AB1(PD75;@v(y1I6+ zd6!ut=Ey(w=R$+@M-Nur`Wq$4&uWRls??T~r#`csiS_w<75Ez6WN32mZXzgSeX+RS zbOCw=Zq_)sG}uZ5G>69fWshb}^R_R!@OZx;xIaY$oSR0i`X_zLg&XXo7bj)qU5&mw z&zh#5fgs6P(=qF z;+MPZoih^`;_q_2tY#4y)$BF&UDtIOx}}?LAub__deq|xv#mT2^A7inP5JN*l8g2A z*n~x)+$Fzy$m)#xpL-!)_LQuuGmg5=J4 zuPqZ0;Ym^?+BGY?#{~#QyT3>&qV~(u01bZ1cyFUm#^6L_OpQ$>lz@IXqQ(GTC|uo= zosxp2b=*?V2yk5O-d&VeF$|GF%0yMhn>vsAiqsbr8F{#OPWh~wjS7IPtf!9O2CW?( z50h6(HOE|(RPt%p{E*@70-f&8NO5`epnYnM{ue`9-~ERb+cPh^MJZZ)nv-B}l_PslyM{Y<+yQ z6uJPCiLHLCocK-o!l(L|E5YFR^SX+!UwGgDcK`Tr<=#*YW3V`Z@#)UtuSS*KyA33J z>-- zo+4IFVa)8w*tNR(nHzme`_U#Fo4p>6dnJ^P@)pIwC{UQwR%~j~8n46vDiEZd1|Ai6 z7e^_6tP)>Y!As%$7W>6__bfcyYq-nNaw#Rzu?ndmHceg zyuQ}bmm}5wQ*1}6(9E>|^Vj2aP`=B1@J6TEsVT(`vjktmF>2GE)(BgmB{)6RDPVlI zt4(h`;*HvKD&N*$lcj7%7yCGs&Nm%tKAmV!${Rq|ECapDWHC@hcoY_Zxoe&dx!9i2 z^*M<$%Rj9_0~{9q{0aYw31{ARrvcs@pMhS;Exywk@2NUeuqiEbi*B`necMyBiI2&V z1Gx4rG*}HEvfqmOn67PK(G!p5-YPy*B#kFNkB3xbh_S zd8Osoo9^0TB?(P=uS8bwwP6%vW& zFV{sr4;II!Hqw!=5ku!d?2n6mk*%KHPD{&r_ncgGI}}a!=-O~;TjPVKS9wnKp!{r4 zA+UH*;+MmfU?W+bK5dZvOl6jMjky}Q&V26IV6$et0y-m4Lx#(U!xTG$!VjAB-ba>$ znk!S;ti|iYtbp>Q(8Oy>&mv=!SvO7sKPnq17N`GY!cH}nY4ZJecXkXMMRxm6ys8)a ze0=*xQ+uCT(5TRiW)QpLL4LjJ{AKdvSIPG+W-Qx=q@}G}UO_T{O*eey$@~ENIH;e* z8Zd>K1$6z?NwsEBi>a~lYadTQNo1{u_lUC$)f_)LKpVn zy{@lN4FBxwatE7f={awYJ$Hvc#df`yN!SXyw|zG2zuuM9=pI?)Yv@NV6q%pBi%BKOMk4T{KG2G3efjNI?}JR+J$6Ua z?`=DLoVNW9aks`%*!YV-UAme?l}N+j#5rF#vHJ(L$Z*Forx0#IgcZ0@ z?+liZ1q!U24$;vLZ|a));!>3^djA3hZl|JwI_So$L{D+dM=^Y<okp z7pFdaD_bSLct0)ORkPwT!?gZ5gV?2RQ|JMt!E-MVH_x{<%16{Jf?2;EZ(&rU=-=e+ zIlj?(=o3BVc`x#F&m+oqgV|f%zha!OVTm-lRo_NWW`TO_`h@f3^eL6g=EH%>WdY08G0 z6y*!~-uy;yKugsTdzq_$V!h9emZ4XdQXWZf^(i<($(Q^}YKL1t(*R9&pk~Y1OV(3n z+OO%4$^@mbKN|6MF}r5U`PP4e_T5o)v&;nViacL!^1}r%PwaJ~s=X(9488ev zHmXe6c>)51%J@vrm|&c@BW)xwwZVGBaE2lmA4&dJ)&A~U=O+>8!$lBRWYZGS5bA-q zNafrm6On%1JaZq-$Y>RMQ{V-49}29N1!o3Bz`1u!vAxqP!o%R{!iuG*WQ#oZb9yHR-Xt_KZsVwRAX+? z03EIi)N?dIW`viI0LL|p|BS`gslP;m`5raX0M~$$PS9*$k9pJ(_&O6XGW~pbuR(@eQi0je~P`vEYD|)xS{8V%i|EJCT5B{zK(Q4E%c{L!|Sd0ovCX z$t=wC+^<``z*j#Y*2Y$7fOz2_mA68`L}AUAmp=s&le`?0YloJxRFKgr2L5lSul}eA zf6EJk(g0!7`QC%T`{w?LAJYiJ$pYMn^vumhkp}p98mCNlFd_EH$2FFPO!VwhVulV% zO?|4uKWe1371zDyEDZ-X+y9AilW+IJPCNDNzY4ghSI!aWORh!FeM5|+`e^_`19cyP zDA3$r3=?Se>Rc@OLNyJraA0Fg6gF)!ej=d0gPCn7`&-cfyXq`PfXG8K@jdHb;{mc{ zGO7R2GP*qh*MF3|hD49IXlIYaLnNC$`TLBG@j^}c2D=0WfsIt*A`)j{(!GC)GvJSn zmJzRrw%(U%fLP1eZN#{L#5nZ|JArdvrD(O0T`&C#*2>?B8Zf9XmBdHA!bG^vkKDAq zx`?L%lK-`p75hgJ1R2JLWC211EZ5k*$MGy}ZOiv&FPrgymy%3R?AYj+HLx0O6!xcv z+VIWgubpK5sHfBi3Ct3KSX4CPv74HC)d2|eBf^77)oRn7IWA8QNBqrH17)mep{jRI8^5HuMCh|N%b`qddSUy; zpwTMK&NFbBU63sJYAgx%uLbuFECX}x4owe6AZRj+(7f2#=MJporz7PLh(UJ{k>Y95 zj;9c$eP3s&K(39MbDD8#L6%oN_8>B>(<Mt$lsP)l1Yo0WKjRTG`Xcl=LW_!t* z2EcNHL$+srX!C00g{J#5rE6N6zrDA7e0V}27xQ#GXK+Y=cOIdnuDqM*C)WpD5iY(5 z7aimnAtDQf*O6$(7#Po^rbBCi2j;~J$FCH(5`$jUO#fJ5ATHMjYf^pz4d!^xwMsjy zaqi?s`yc7|1~}w(Bm^BD#mFo#T*xwtGEnzJVZx87n#GTad_K#t*_#v$mESdd&ic`Y z)=tlOjZ~*n#aG!(O`+Kq-0X36J8;~H5x=p}KE#83u#+8u7P}!|mgWk9CZFqHU@@?M z-pII7uH;6x9ryMtZAM@#TGh@HoMLUYhrAT$>uO^>A%ZfM23qItd)ca#1bUhdAC?lh zsRE64yu3TpK83l!Ts&lpzHgY#TWtBQKG)XCseom=lW^R!KTL4cd~)A5ZVwUCD7_KH zyJM2Y8r|?k`)!jWeYslu+k#dFrOCLpXX_x8Z3~0<*2dGu5%u2S&gGkeN#52S*ZRs4 zjIP2ZHv>ye0;P#BS0fZoBaB+NAwCyM3moz|y z5oS)Tjt0nh7Nkc5v>K@UyV+Tm;dYX&X@GuV&|Ml}J|8hBReySfJPmNvjfiG3(R^}8 z;xxuJIr-55KnP|#>GYt&b5`T-cSrAfn;ppy`GRJ6_YL&9D$=;_1qg49?}KQQfd9Yz zzp2XDOp(sIqCbdGWVr!-Itb)~(b3UgrN7C*a8vp=_igF_%Z1hiV7h$v+8LX3 zXZQhUna-SJIzwv#aGlD1>CFG2#+kF{&R@8A>2xROsTSw{sq)`<(y##fb7uf&ub;bq z`d5~EwvDZ$)QA4D`%bR8j|~CIS4IS@oj(fJSl_1W?Rngo53z|_e(W~9*Gre@?#%z@ zR`~A^LT4|3yZqlbgVQD*dYZxPlHYk!$`(+`0wCLmN1wwhN@Vz*2h)b>%hye`j0_w* zwfhYm%si2L$-N4Jwn}$Hb^5+Ndo^+6cH;ZH#gpr|Ivy_!X(BYGw{8ktv^7W; z7153XKU^8A(F|Fi(a~WBn-~r9e*KIcRJx~k!HF*IDLLsm1Eok61&0);XVZGpvX{TAtG&CIqW^ipYPN}Gt!4Kls-dF)QA0}q z_|r%>8Eo~EPNh?VYS;#3Vk56;VGx(98+qI43xB+k{cU3S>#>mRsh(D=^RTLZ%`Uk> zUfxE*usT0=1;yZ}GCRF5dMjNce$eGO$yewXsQd<;Ngi?Nru*eIX7`}NwJQ9|SYv9L zB}__5xp+j#QN3+67JZv|HyqrW(N+C4RNhjPUpw|rO~o1K;sFgW6m>k&3bOcmZbEK& z?uTlj`qLB<5kasy>wZ5y(>nltW0r)aZn(r1#r`M-)t{exvcAUhdTRGB)~ebBg$R$t z*k*dP8A~seJ!xsrR8rCjGaUF+nP2(Ff%CJ0;h0LFodQ%c_mO*Z)}QR6(K5~@o1t$m zdW=uQbFC>?`1^GnHunBqA<~U=>ZahxD+`8b-P`9ZuW2*o{&=p~7-x1gb- zMq4)b=)SyncI7SMkExDri5EU=NY|12e3!rR7(ZukKqzOPANY^;aRh2up^-PV{sQQp zUH)?hO@ID{qu61Dm8p6#();|P`qBw&Dmy1rwgs<_v8>!3PdeTF57#xNFZ0N%mfYIz z934#=g2A!BnZKUa^&jo^BSo&>4;TId`1B6&xd*`X{%ZwRM2d%vK}G2#!!CLCOZ-*g z4)qMdFQZS`Z(*lRl*5({FBdvh-AtD395wzn=tZ7?$NT{B&%`A19wiBfKfDP5Fyucv z|3d4ZxY4RV(wGO`)+XO8q%5A?f1wc}`|bDV$zBHq9{S}c@!?^=yX4N#zXBur@2oYC z=!<2Pe!CBlK8dP<$+P^GUA_GKzjVB>RrFuXG7`YZ?~yVR;2-xB;=ich5f@DbUH-rY zyPKcg{D_VvU9JC%S4QdC7N(8ms8ju{^@A021Eu-hR>r0hH-d!?Dqd(*W)1+({3jel z(X0Q8age46351qu*(*`9WJO-uC&V!qeASAd5^j3pQjqE)EY9Z9nP+BouK^@~Dp-B% z$g0dcW0{wN>pAi2;&Hd0>kIODl@Zc^iG8wjs4jS2!@6je-DkjW-l8-D8Dmafjn)?N z))u5I$Wiwfo0DEp{29kPv|%rr`RJoQ+PYU6WZTU^ zTrw_*e{2%wtC3DG%rxw%kgDZjx;W2%pTjqT+}3ypw!9>wAT(UOx+O86!hA)`kjrC4 zq}#oGBtE-bBCQ8DPvWse!)K1zX@BBX;#q{^@BJ$L&Ga zXT(+gtLCfPrrXTFm<*k*6X;O*Z*oVs}f((cLZ@;gSH(Zpa7pknNWK?cAkh-Go zT{2#}RPKe|Asl3k?adqd@1H0ru9DZuQdt;G|KReLt)T{~_R#%AEg(e#f>0Q! zUT}@(i_zAJ|MB&FeCpS;<=W3s^7@0*`*6zsgi&dCMCq8ruR|~XNIiGBch?9$&XYH~ zS7zhM_*D1c@T$}lFY?@-D~_Ig1{6nrd^S6WmFZZ+_RFd^a(TGJV=Qgcx<{W|>s!ddJQge0e0wv%h>YCXSMmq5 zu|ma}d62E7m^~;zMy8}RbuZM+R$)l-25f}s%Sgxd%8wCu?$DK&io>aS5`w*Ceb|l1 z+(__n87G-ay<>c6s*Qxfg|5kY;U3?dvbtMN9Ch3`ul0d?SSq^_22~2@@U5iMyJ2f>2-lca!>&vh7R||H%09@A>%yx`37aqDfoLt-+ zs*tr(=4CC)-oH71AL_KGzA85!;5$9^M}G3el71`+nbq{i?I-ltAxFQn3xA!0K0-6D zJJA`Z&`|bgKgx0Tm0fkz$%dfzfNZkksKhJ}NvuJ(U?m-N$>wBO=hxwv8*Bp7sVUdP z49-S=F@NpSK9>794%1(Y9pZdSr@IriHg~t%xy@0kv|)M5%zS0(#k>z^31|e*{6eqi zp{W(dT3n`-pJ~Uk&yv}*ylFqI%D)u2RM?O^@-SMd=DS%S-MgQ)Pp>mPxqd(1W!d~7 ze833??*N=1?tKs(;`C$PbBRSLn>{K1^Q(%a=h-v4d{!>bC?x^8@4ahx$^bn;!oL7a zem;gvAA2R|G%SFbH+vEnH~ccobUD`HhTY5^lP zjX_^vw{{Pb9;p9e%Dg9{6+>c9Hc!9e1n$XsS@zG_^{&Z;Fg{Ek<-&?i`$bPINzx^{ zXRj)`r5Z_Pb-MOu0wViP6lBCi!4K?fF(TO1O0lq;y$u(9no><;YcmryvR?!Pf*r=3 zBv2A!v$1K~RbdkGhd+|hmhLveO5gjsGh976?GW#AZ=2lYC@)qX8}!8la|$-I7j*(* ze|p{z9D|IP1SzvzKe=70-xJD1Yy|$4g=AQnB$@@D{qV#!OS{Pam0*frN^`M(uu9g- z!<%$hA}@^%@b;~;N=O#W-Yij=y#6+$JdxEZ50$xMnwq7!XZ^WPHlxR04LtezZC`T&nKvpnfKfBi+w!_XjG%((E0G*=t<93M z{l)SNQ)H?Jr0d?erDfLTu>7v7=e^ds=w3^aKdyj6t$I!hr;y^=R;{eSV>j)b2GRo}mFy;Y~Hx=yd&YhP@IqUuDDWidNq9a}(GV*H$`tf4KYkZd*sLT{wU)Mn zUUygO>=&k-BoY%EVId47q}7WMdY(Hx0ZG$cwuZYhqk?u4W1h3vcJfBX!5CEYbQtR* zjxcHpYRJNj#ls99{BRM0dq7rV!8VPZW%Tcj26CGN0hCO-dE+2JaAt50s8R`$khx@1 zpezP(TroxqqYt26bNriX_G+X-t_ogFw1`O{ZI5Dy6*4*&Q}X!t={IGQmidx8OKpT+ z4)AwA%RAe6_)$NwNs3?Rs_XlxCQ*)I$+mMg5WC9C`mb)D9ti*n0tN~J1_1){f13KQW*z{J41t1#ibl*vM8b?t z%EThXO7@*y_^ZMH_GJPB0je513_uUkL=O^DZ>uG7C=;DsVQ-Y *QYZ41$Xu+whN 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 -s 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 IXaLXx0HeG@zW@LL literal 0 HcmV?d00001 diff --git a/bookwyrm/tests/data/simple_user_export.json b/bookwyrm/tests/data/simple_user_export.json new file mode 100644 index 000000000..39d9074ae --- /dev/null +++ b/bookwyrm/tests/data/simple_user_export.json @@ -0,0 +1,26 @@ +{ + "user": { + "username": "hugh@example.com", + "name": "Hugh", + "summary": "just a test account", + "manually_approves_followers": false, + "hide_follows": false, + "show_goal": true, + "show_suggested_users": true, + "discoverable": true, + "preferred_timezone": "Australia/Broken_Hill", + "default_post_privacy": "public", + "avatar": "" + }, + "goals": [ + { + "goal": 12, + "year": 2023, + "privacy": "public" + } + ], + "books": [], + "saved_lists": [], + "follows": [], + "blocked_users": [] +} \ No newline at end of file diff --git a/bookwyrm/tests/models/test_bookwyrm_import_model.py b/bookwyrm/tests/models/test_bookwyrm_import_model.py new file mode 100644 index 000000000..644cbd265 --- /dev/null +++ b/bookwyrm/tests/models/test_bookwyrm_import_model.py @@ -0,0 +1,548 @@ +""" testing models """ + +import json +import pathlib +from unittest.mock import patch + +from django.db.models import Q +from django.utils import timezone +from django.utils.dateparse import parse_datetime +from django.test import TestCase + +from bookwyrm import models +from bookwyrm.settings import DOMAIN +from bookwyrm.utils.tar import BookwyrmTarFile +import bookwyrm.models.bookwyrm_import_job as bookwyrm_import_job + + +class BookwyrmImport(TestCase): + """testing user import functions""" + + def setUp(self): + """setting stuff up""" + with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch( + "bookwyrm.activitystreams.populate_stream_task.delay" + ), patch("bookwyrm.lists_stream.populate_lists_task.delay"), patch( + "bookwyrm.suggested_users.rerank_user_task.delay" + ): + + self.local_user = models.User.objects.create_user( + "mouse", + "mouse@mouse.mouse", + "password", + local=True, + localname="mouse", + name="Mouse", + summary="I'm a real bookmouse", + manually_approves_followers=False, + hide_follows=False, + show_goal=True, + show_suggested_users=True, + discoverable=True, + preferred_timezone="America/Los Angeles", + default_post_privacy="public", + ) + + self.rat_user = models.User.objects.create_user( + "rat", "rat@rat.rat", "password", local=True, localname="rat" + ) + + self.badger_user = models.User.objects.create_user( + "badger", + "badger@badger.badger", + "password", + local=True, + localname="badger", + ) + + self.work = models.Work.objects.create(title="Test Book") + + self.book = models.Edition.objects.create( + title="Test Book", + remote_id="https://example.com/book/1234", + openlibrary_key="OL28216445M", + parent_work=self.work, + ) + + archive_file = pathlib.Path(__file__).parent.joinpath( + "../data/bookwyrm_account_export.tar.gz" + ) + self.tarfile = BookwyrmTarFile.open( + mode="r:gz", fileobj=open(archive_file, "rb") + ) + self.import_data = json.loads( + self.tarfile.read("archive.json").decode("utf-8") + ) + + def test_update_user_profile(self): + """Test update the user's profile from import data""" + + # TODO once the tar is set up + pass + + def test_update_user_settings(self): + """Test updating the user's settings from import data""" + + with patch("bookwyrm.suggested_users.remove_user_task.delay"), patch( + "bookwyrm.models.activitypub_mixin.broadcast_task.apply_async" + ): + + models.bookwyrm_import_job.update_user_settings( + self.local_user, self.import_data.get("user") + ) + self.local_user.refresh_from_db() + + self.assertEqual(self.local_user.manually_approves_followers, True) + self.assertEqual(self.local_user.hide_follows, True) + self.assertEqual(self.local_user.show_goal, False) + self.assertEqual(self.local_user.show_suggested_users, False) + self.assertEqual(self.local_user.discoverable, False) + self.assertEqual(self.local_user.preferred_timezone, "Australia/Adelaide") + self.assertEqual(self.local_user.default_post_privacy, "followers") + + def test_update_goals(self): + """Test update the user's goals from import data""" + + models.AnnualGoal.objects.create( + user=self.local_user, + year=2023, + goal=999, + privacy="public", + ) + + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"): + + models.bookwyrm_import_job.update_goals( + self.local_user, self.import_data.get("goals") + ) + + self.local_user.refresh_from_db() + goal = models.AnnualGoal.objects.get() + self.assertEqual(goal.year, 2023) + self.assertEqual(goal.goal, 12) + self.assertEqual(goal.privacy, "followers") + + def test_upsert_saved_lists_existing(self): + """Test upserting an existing saved list""" + + with patch("bookwyrm.lists_stream.remove_list_task.delay"), patch( + "bookwyrm.models.activitypub_mixin.broadcast_task.apply_async" + ): + book_list = models.List.objects.create( + name="My cool list", + user=self.rat_user, + remote_id="https://local.lists/9999", + ) + + self.assertFalse(self.local_user.saved_lists.filter(id=book_list.id).exists()) + + self.local_user.saved_lists.add(book_list) + + self.assertTrue(self.local_user.saved_lists.filter(id=book_list.id).exists()) + + with patch("bookwyrm.activitypub.base_activity.resolve_remote_id"): + models.bookwyrm_import_job.upsert_saved_lists( + self.local_user, ["https://local.lists/9999"] + ) + saved_lists = self.local_user.saved_lists.filter( + remote_id="https://local.lists/9999" + ).all() + self.assertEqual(len(saved_lists), 1) + + def test_upsert_saved_lists_not_existing(self): + """Test upserting a new saved list""" + + with patch("bookwyrm.lists_stream.remove_list_task.delay"), patch( + "bookwyrm.models.activitypub_mixin.broadcast_task.apply_async" + ): + book_list = models.List.objects.create( + name="My cool list", + user=self.rat_user, + remote_id="https://local.lists/9999", + ) + + self.assertFalse(self.local_user.saved_lists.filter(id=book_list.id).exists()) + + with patch("bookwyrm.activitypub.base_activity.resolve_remote_id"): + models.bookwyrm_import_job.upsert_saved_lists( + self.local_user, ["https://local.lists/9999"] + ) + + self.assertTrue(self.local_user.saved_lists.filter(id=book_list.id).exists()) + + def test_upsert_follows(self): + """Test take a list of remote ids and add as follows""" + + before_follow = models.UserFollows.objects.filter( + user_subject=self.local_user, user_object=self.rat_user + ).exists() + + self.assertFalse(before_follow) + + with patch("bookwyrm.activitystreams.add_user_statuses_task.delay"), patch( + "bookwyrm.lists_stream.add_user_lists_task.delay" + ), patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"): + models.bookwyrm_import_job.upsert_follows( + self.local_user, self.import_data.get("follows") + ) + + after_follow = models.UserFollows.objects.filter( + user_subject=self.local_user, user_object=self.rat_user + ).exists() + self.assertTrue(after_follow) + + def test_upsert_user_blocks(self): + """test adding blocked users""" + + blocked_before = models.UserBlocks.objects.filter( + Q( + user_subject=self.local_user, + user_object=self.badger_user, + ) + ).exists() + self.assertFalse(blocked_before) + + with patch("bookwyrm.suggested_users.remove_suggestion_task.delay"), patch( + "bookwyrm.activitystreams.remove_user_statuses_task.delay" + ), patch("bookwyrm.lists_stream.remove_user_lists_task.delay"), patch( + "bookwyrm.models.activitypub_mixin.broadcast_task.apply_async" + ): + models.bookwyrm_import_job.upsert_user_blocks( + self.local_user, self.import_data.get("blocked_users") + ) + + blocked_after = models.UserBlocks.objects.filter( + Q( + user_subject=self.local_user, + user_object=self.badger_user, + ) + ).exists() + self.assertTrue(blocked_after) + + def test_get_or_create_authors(self): + """Test taking a JSON string of authors find or create the authors + in the database and returning a list of author instances""" + + author_exists = models.Author.objects.filter(isni="0000000108973024").exists() + self.assertFalse(author_exists) + + authors = self.import_data.get("books")[0]["authors"] + bookwyrm_import_job.get_or_create_authors(authors) + + author = models.Author.objects.get(isni="0000000108973024") + self.assertEqual(author.name, "James C. Scott") + + def test_get_or_create_edition_existing(self): + """Test take a JSON string of books and editions, find or create the editions in the database and return a list of edition instances""" + + self.assertEqual(models.Edition.objects.count(), 1) + self.assertEqual(models.Edition.objects.count(), 1) + + bookwyrm_import_job.get_or_create_edition( + self.import_data["books"][1], self.tarfile + ) # Sand Talk + + self.assertEqual(models.Edition.objects.count(), 1) + + def test_get_or_create_edition_not_existing(self): + """Test take a JSON string of books and editions, find or create the editions in the database and return a list of edition instances""" + + self.assertEqual(models.Edition.objects.count(), 1) + + bookwyrm_import_job.get_or_create_edition( + self.import_data["books"][0], self.tarfile + ) # Seeing like a state + + self.assertTrue(models.Edition.objects.filter(isbn_13="9780300070163").exists()) + self.assertEqual(models.Edition.objects.count(), 2) + + def test_clean_values(self): + """test clean values we don't want when creating new instances""" + + author = self.import_data.get("books")[0]["authors"][0] + edition = self.import_data.get("books")[0]["edition"] + + cleaned_author = bookwyrm_import_job.clean_values(author) + cleaned_edition = bookwyrm_import_job.clean_values(edition) + + self.assertEqual(cleaned_author["name"], "James C. Scott") + self.assertEqual(cleaned_author.get("id"), None) + self.assertEqual(cleaned_author.get("remote_id"), None) + self.assertEqual(cleaned_author.get("last_edited_by"), None) + self.assertEqual(cleaned_author.get("last_edited_by_id"), None) + + self.assertEqual(cleaned_edition.get("title"), "Seeing Like a State") + self.assertEqual(cleaned_edition.get("id"), None) + self.assertEqual(cleaned_edition.get("remote_id"), None) + self.assertEqual(cleaned_edition.get("last_edited_by"), None) + self.assertEqual(cleaned_edition.get("last_edited_by_id"), None) + self.assertEqual(cleaned_edition.get("cover"), None) + self.assertEqual(cleaned_edition.get("preview_image "), None) + self.assertEqual(cleaned_edition.get("user"), None) + self.assertEqual(cleaned_edition.get("book_list"), None) + self.assertEqual(cleaned_edition.get("shelf_book"), None) + + def test_find_existing(self): + """Given a book or author, find any existing model instances""" + + self.assertEqual(models.Book.objects.count(), 2) # includes Work + self.assertEqual(models.Edition.objects.count(), 1) + self.assertEqual(models.Edition.objects.first().title, "Test Book") + self.assertEqual(models.Edition.objects.first().openlibrary_key, "OL28216445M") + + existing = bookwyrm_import_job.find_existing( + models.Edition, {"openlibrary_key": "OL28216445M", "isbn_10": None}, None + ) + self.assertEqual(existing.title, "Test Book") + + def test_upsert_readthroughs(self): + """Test take a JSON string of readthroughs, find or create the + instances in the database and return a list of saved instances""" + + readthroughs = [ + { + "id": 1, + "created_date": "2023-08-24T10:18:45.923Z", + "updated_date": "2023-08-24T10:18:45.928Z", + "remote_id": "https://example.com/mouse/readthrough/1", + "user_id": 1, + "book_id": 1234, + "progress": None, + "progress_mode": "PG", + "start_date": "2022-12-31T13:30:00Z", + "finish_date": "2023-08-23T14:30:00Z", + "stopped_date": None, + "is_active": False, + } + ] + + self.assertEqual(models.ReadThrough.objects.count(), 0) + bookwyrm_import_job.upsert_readthroughs( + readthroughs, self.local_user, self.book.id + ) + + self.assertEqual(models.ReadThrough.objects.count(), 1) + self.assertEqual(models.ReadThrough.objects.first().progress_mode, "PG") + self.assertEqual( + models.ReadThrough.objects.first().start_date, + parse_datetime("2022-12-31T13:30:00Z"), + ) + self.assertEqual(models.ReadThrough.objects.first().book_id, self.book.id) + self.assertEqual(models.ReadThrough.objects.first().user, self.local_user) + + def test_get_or_create_review_status(self): + """Test get_or_create_review_status with a review""" + + self.assertEqual(models.Review.objects.filter(user=self.local_user).count(), 0) + reviews = self.import_data["books"][0]["reviews"] + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"): + bookwyrm_import_job.get_or_create_statuses( + self.local_user, models.Review, reviews, self.book.id + ) + self.assertEqual(models.Review.objects.filter(user=self.local_user).count(), 1) + self.assertEqual( + models.Review.objects.filter(book=self.book).first().raw_content, + "I like it", + ) + self.assertEqual( + models.Review.objects.filter(book=self.book).first().content_warning, + "Here's a spoiler alert", + ) + self.assertEqual( + models.Review.objects.filter(book=self.book).first().sensitive, True + ) + self.assertEqual( + models.Review.objects.filter(book=self.book).first().published_date, + parse_datetime("2023-08-14T04:09:18.343Z"), + ) + self.assertEqual( + models.Review.objects.filter(book=self.book).first().name, "great book" + ) + self.assertEqual( + models.Review.objects.filter(book=self.book).first().rating, 5.00 + ) + + def test_get_or_create_comment_status(self): + """Test get_or_create_review_status with a comment""" + + self.assertEqual(models.Comment.objects.filter(user=self.local_user).count(), 0) + comments = self.import_data["books"][1]["comments"] + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"): + bookwyrm_import_job.get_or_create_statuses( + self.local_user, models.Comment, comments, self.book.id + ) + self.assertEqual(models.Comment.objects.filter(user=self.local_user).count(), 1) + self.assertEqual( + models.Comment.objects.filter(book=self.book).first().raw_content, + "this is a comment about an amazing book", + ) + self.assertEqual( + models.Comment.objects.filter(book=self.book).first().content_warning, None + ) + self.assertEqual( + models.Comment.objects.filter(book=self.book).first().sensitive, False + ) + self.assertEqual( + models.Comment.objects.filter(book=self.book).first().published_date, + parse_datetime("2023-08-14T04:48:18.746Z"), + ) + self.assertEqual( + models.Comment.objects.filter(book=self.book).first().progress_mode, "PG" + ) + + def test_get_or_create_comment_quote(self): + """Test get_or_create_review_status with a quote""" + + self.assertEqual( + models.Quotation.objects.filter(user=self.local_user).count(), 0 + ) + quotes = self.import_data["books"][1]["quotes"] + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"): + bookwyrm_import_job.get_or_create_statuses( + self.local_user, models.Quotation, quotes, self.book.id + ) + self.assertEqual( + models.Quotation.objects.filter(user=self.local_user).count(), 1 + ) + self.assertEqual( + models.Quotation.objects.filter(book=self.book).first().raw_content, + "not actually from this book lol", + ) + self.assertEqual( + models.Quotation.objects.filter(book=self.book).first().content_warning, + "spoiler ahead!", + ) + self.assertEqual( + models.Quotation.objects.filter(book=self.book).first().raw_quote, + "To be or not to be", + ) + self.assertEqual( + models.Quotation.objects.filter(book=self.book).first().published_date, + parse_datetime("2023-08-14T04:48:50.207Z"), + ) + self.assertEqual( + models.Quotation.objects.filter(book=self.book).first().position_mode, "PG" + ) + self.assertEqual( + models.Quotation.objects.filter(book=self.book).first().position, 1 + ) + + def test_upsert_list_existing(self): + """Take a list and ListItems as JSON and create DB entries if they don't already exist""" + + book_data = self.import_data["books"][0] + + other_book = models.Edition.objects.create( + title="Another Book", remote_id="https://example.com/book/9876" + ) + + with patch("bookwyrm.lists_stream.remove_list_task.delay"), patch( + "bookwyrm.models.activitypub_mixin.broadcast_task.apply_async" + ): + book_list = models.List.objects.create( + name="my list of books", user=self.local_user + ) + + list_item = models.ListItem.objects.create( + book=self.book, book_list=book_list, user=self.local_user, order=1 + ) + + self.assertTrue(models.List.objects.filter(id=book_list.id).exists()) + self.assertEqual(models.List.objects.filter(user=self.local_user).count(), 1) + self.assertEqual( + models.ListItem.objects.filter( + user=self.local_user, book_list=book_list + ).count(), + 1, + ) + + with patch("bookwyrm.lists_stream.remove_list_task.delay"), patch( + "bookwyrm.models.activitypub_mixin.broadcast_task.apply_async" + ): + bookwyrm_import_job.upsert_lists( + self.local_user, + book_data["lists"], + book_data["list_items"], + other_book.id, + ) + + self.assertEqual(models.List.objects.filter(user=self.local_user).count(), 1) + self.assertEqual(models.List.objects.filter(user=self.local_user).count(), 1) + self.assertEqual( + models.ListItem.objects.filter( + user=self.local_user, book_list=book_list + ).count(), + 2, + ) + + def test_upsert_list_not_existing(self): + """Take a list and ListItems as JSON and create DB entries if they don't already exist""" + + book_data = self.import_data["books"][0] + + self.assertEqual(models.List.objects.filter(user=self.local_user).count(), 0) + self.assertFalse(models.ListItem.objects.filter(book=self.book.id).exists()) + + with patch("bookwyrm.lists_stream.remove_list_task.delay"), patch( + "bookwyrm.models.activitypub_mixin.broadcast_task.apply_async" + ): + bookwyrm_import_job.upsert_lists( + self.local_user, + book_data["lists"], + book_data["list_items"], + self.book.id, + ) + + self.assertEqual(models.List.objects.filter(user=self.local_user).count(), 1) + self.assertEqual( + models.ListItem.objects.filter(user=self.local_user).count(), 1 + ) + + def test_upsert_shelves_existing(self): + """Take shelf and ShelfBooks JSON objects and create + DB entries if they don't already exist""" + + self.assertEqual( + models.ShelfBook.objects.filter(user=self.local_user.id).count(), 0 + ) + + shelf = models.Shelf.objects.get(name="Read", user=self.local_user) + + with patch("bookwyrm.activitystreams.add_book_statuses_task.delay"), patch( + "bookwyrm.models.activitypub_mixin.broadcast_task.apply_async" + ): + models.ShelfBook.objects.create( + book=self.book, shelf=shelf, user=self.local_user + ) + + book_data = self.import_data["books"][0] + with patch("bookwyrm.activitystreams.add_book_statuses_task.delay"), patch( + "bookwyrm.models.activitypub_mixin.broadcast_task.apply_async" + ): + bookwyrm_import_job.upsert_shelves(self.book, self.local_user, book_data) + + self.assertEqual( + models.ShelfBook.objects.filter(user=self.local_user.id).count(), 2 + ) + + def test_upsert_shelves_not_existing(self): + """Take shelf and ShelfBooks JSON objects and create + DB entries if they don't already exist""" + + self.assertEqual( + models.ShelfBook.objects.filter(user=self.local_user.id).count(), 0 + ) + + book_data = self.import_data["books"][0] + + with patch("bookwyrm.activitystreams.add_book_statuses_task.delay"), patch( + "bookwyrm.models.activitypub_mixin.broadcast_task.apply_async" + ): + bookwyrm_import_job.upsert_shelves(self.book, self.local_user, book_data) + + self.assertEqual( + models.ShelfBook.objects.filter(user=self.local_user.id).count(), 2 + ) + self.assertEqual( + models.Shelf.objects.filter(user=self.local_user.id).count(), 2 + ) diff --git a/bookwyrm/tests/utils/test_tar.py b/bookwyrm/tests/utils/test_tar.py new file mode 100644 index 000000000..5989d3bb9 --- /dev/null +++ b/bookwyrm/tests/utils/test_tar.py @@ -0,0 +1,23 @@ +from bookwyrm.utils.tar import BookwyrmTarFile +import pytest + + +@pytest.fixture +def read_tar(): + archive_path = "../data/bookwyrm_account_export.tar.gz" + with open(archive_path, "rb") as archive_file: + with BookwyrmTarFile.open(mode="r:gz", fileobj=archive_file) as tar: + yield tar + + +def get_write_tar(): + archive_path = "/tmp/test.tar.gz" + with open(archive_path, "wb") as archive_file: + with BookwyrmTarFile.open(mode="w:gz", fileobj=archive_file) as tar: + return tar + + os.remove(archive_path) + + +def test_write_bytes(write_tar): + write_tar.write_bytes(b"ABCDEF", filename="example.txt") diff --git a/bookwyrm/tests/views/imports/test_user_import.py b/bookwyrm/tests/views/imports/test_user_import.py new file mode 100644 index 000000000..db5837101 --- /dev/null +++ b/bookwyrm/tests/views/imports/test_user_import.py @@ -0,0 +1,68 @@ +""" test for app action functionality """ +import pathlib +from unittest.mock import patch + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.template.response import TemplateResponse +from django.test import TestCase +from django.test.client import RequestFactory + +from bookwyrm import forms, models, views +from bookwyrm.tests.validate_html import validate_html + + +class ImportUserViews(TestCase): + """user import views""" + + # pylint: disable=invalid-name + def setUp(self): + """we need basic test data and mocks""" + self.factory = RequestFactory() + with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch( + "bookwyrm.activitystreams.populate_stream_task.delay" + ), patch("bookwyrm.lists_stream.populate_lists_task.delay"): + self.local_user = models.User.objects.create_user( + "mouse@local.com", + "mouse@mouse.mouse", + "password", + local=True, + localname="mouse", + ) + models.SiteSettings.objects.create() + + def test_get_user_import_page(self): + """there are so many views, this just makes sure it LOADS""" + view = views.UserImport.as_view() + request = self.factory.get("") + request.user = self.local_user + result = view(request) + self.assertIsInstance(result, TemplateResponse) + validate_html(result.render()) + self.assertEqual(result.status_code, 200) + + def test_user_import_post(self): + """does the import job start?""" + + view = views.UserImport.as_view() + form = forms.ImportUserForm() + archive_file = pathlib.Path(__file__).parent.joinpath( + "../../data/bookwyrm_account_export.tar.gz" + ) + + form.data["archive_file"] = SimpleUploadedFile( + # pylint: disable=consider-using-with + archive_file, + open(archive_file, "rb").read(), + content_type="application/gzip", + ) + + form.data["include_user_settings"] = "" + form.data["include_goals"] = "on" + + request = self.factory.post("", form.data) + request.user = self.local_user + + with patch("bookwyrm.models.bookwyrm_import_job.BookwyrmImportJob.start_job"): + view(request) + job = models.BookwyrmImportJob.objects.get() + self.assertEqual(job.required, ["include_goals"]) diff --git a/bookwyrm/tests/views/preferences/test_export_user.py b/bookwyrm/tests/views/preferences/test_export_user.py new file mode 100644 index 000000000..c7594749b --- /dev/null +++ b/bookwyrm/tests/views/preferences/test_export_user.py @@ -0,0 +1,74 @@ +""" test for app action functionality """ +from collections import namedtuple +from unittest.mock import patch + +from django.http import HttpResponse +from django.test import TestCase +from django.test.client import RequestFactory + +from bookwyrm import models, views +from bookwyrm.tests.validate_html import validate_html + + +class ExportUserViews(TestCase): + """exporting user data""" + + def setUp(self): + self.factory = RequestFactory() + models.SiteSettings.objects.create() + with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch( + "bookwyrm.activitystreams.populate_stream_task.delay" + ): + self.local_user = models.User.objects.create_user( + "hugh@example.com", + "hugh@example.com", + "password", + local=True, + localname="Hugh", + summary="just a test account", + remote_id="https://example.com/users/hugh", + preferred_timezone="Australia/Broken_Hill", + ) + + def test_export_user_get(self, *_): + """request export""" + request = self.factory.get("") + request.user = self.local_user + result = views.ExportUser.as_view()(request) + validate_html(result.render()) + + def test_trigger_export_user_file(self, *_): + """simple user export""" + + request = self.factory.post("") + request.user = self.local_user + with patch("bookwyrm.models.bookwyrm_export_job.start_export_task.delay"): + export = views.ExportUser.as_view()(request) + self.assertIsInstance(export, HttpResponse) + self.assertEqual(export.status_code, 302) + + jobs = models.bookwyrm_export_job.BookwyrmExportJob.objects.count() + self.assertEqual(jobs, 1) + + def test_download_export_user_file(self, *_): + """simple user export""" + + # TODO: need some help with this one + job = models.bookwyrm_export_job.BookwyrmExportJob.objects.create( + user=self.local_user + ) + MockTask = namedtuple("Task", ("id")) + with patch( + "bookwyrm.models.bookwyrm_export_job.start_export_task.delay" + ) as mock: + mock.return_value = MockTask(b'{"name": "mouse"}') + job.start_job() + + request = self.factory.get("") + request.user = self.local_user + job.refresh_from_db() + export = views.ExportArchive.as_view()(request, job.id) + self.assertIsInstance(export, HttpResponse) + self.assertEqual(export.status_code, 200) + # pylint: disable=line-too-long + self.assertEqual(export.content, b'{"name": "mouse"}') diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index 0ebd7925c..5b83acb85 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -396,6 +396,7 @@ urlpatterns = [ re_path(r"^search/?$", views.Search.as_view(), name="search"), # imports re_path(r"^import/?$", views.Import.as_view(), name="import"), + re_path(r"^user-import/?$", views.UserImport.as_view(), name="user-import"), re_path( r"^import/(?P\d+)/?$", views.ImportStatus.as_view(), @@ -593,6 +594,16 @@ urlpatterns = [ name="prompt-2fa", ), re_path(r"^preferences/export/?$", views.Export.as_view(), name="prefs-export"), + re_path( + r"^preferences/user-export/?$", + views.ExportUser.as_view(), + name="prefs-user-export", + ), + path( + "preferences/user-export/", + views.ExportArchive.as_view(), + name="prefs-export-file", + ), re_path(r"^preferences/delete/?$", views.DeleteUser.as_view(), name="prefs-delete"), re_path( r"^preferences/deactivate/?$", diff --git a/bookwyrm/utils/tar.py b/bookwyrm/utils/tar.py new file mode 100644 index 000000000..448df48d9 --- /dev/null +++ b/bookwyrm/utils/tar.py @@ -0,0 +1,40 @@ +from uuid import uuid4 +from django.core.files import File +import tarfile +import io + + +class BookwyrmTarFile(tarfile.TarFile): + def write_bytes(self, data: bytes, filename="archive.json"): + """Add a file containing :data: bytestring with name :filename: to the archive""" + buffer = io.BytesIO(data) + info = tarfile.TarInfo("archive.json") + info.size = len(data) + self.addfile(info, fileobj=buffer) + + def add_image(self, image, filename=None, directory=""): + """ + Add an image to the tar archive + :param str filename: overrides the file name set by image + :param str directory: the directory in the archive to put the image + """ + if filename is not None: + file_type = image.name.rsplit(".", maxsplit=1)[-1] + filename = f"{directory}{filename}.{file_type}" + else: + filename = f"{directory}{image.name}" + + info = tarfile.TarInfo(name=filename) + info.size = image.size + + self.addfile(info, fileobj=image) + + def read(self, filename): + with self.extractfile(filename) as reader: + return reader.read() + + def write_image_to_file(self, filename, file_field): + extension = filename.rsplit(".")[-1] + with self.extractfile(filename) as reader: + filename = f"{str(uuid4())}.{extension}" + file_field.save(filename, File(reader)) diff --git a/bookwyrm/views/__init__.py b/bookwyrm/views/__init__.py index 84060acb7..c044200e3 100644 --- a/bookwyrm/views/__init__.py +++ b/bookwyrm/views/__init__.py @@ -36,7 +36,7 @@ from .admin.user_admin import UserAdmin, UserAdminList, ActivateUserAdmin # user preferences from .preferences.change_password import ChangePassword from .preferences.edit_user import EditUser -from .preferences.export import Export +from .preferences.export import Export, ExportUser, ExportArchive from .preferences.delete_user import DeleteUser, DeactivateUser, ReactivateUser from .preferences.block import Block, unblock from .preferences.two_factor_auth import ( @@ -80,7 +80,7 @@ from .shelf.shelf_actions import create_shelf, delete_shelf from .shelf.shelf_actions import shelve, unshelve # csv import -from .imports.import_data import Import +from .imports.import_data import Import, UserImport from .imports.import_status import ImportStatus, retry_item, stop_import from .imports.troubleshoot import ImportTroubleshoot from .imports.manually_review import ( diff --git a/bookwyrm/views/imports/import_data.py b/bookwyrm/views/imports/import_data.py index 01812e1d5..69a87c0c2 100644 --- a/bookwyrm/views/imports/import_data.py +++ b/bookwyrm/views/imports/import_data.py @@ -15,12 +15,14 @@ from django.views import View from bookwyrm import forms, models from bookwyrm.importers import ( + BookwyrmImporter, CalibreImporter, LibrarythingImporter, GoodreadsImporter, StorygraphImporter, OpenLibraryImporter, ) +from bookwyrm.models.bookwyrm_import_job import BookwyrmImportJob from bookwyrm.settings import PAGE_LENGTH from bookwyrm.utils.cache import get_or_set @@ -127,3 +129,47 @@ def get_average_import_time() -> float: if recent_avg: return recent_avg.total_seconds() return None + + +# pylint: disable= no-self-use +@method_decorator(login_required, name="dispatch") +class UserImport(View): + """import user view""" + + def get(self, request, invalid=False): + """load user import page""" + + jobs = BookwyrmImportJob.objects.filter(user=request.user).order_by( + "-created_date" + ) + paginated = Paginator(jobs, PAGE_LENGTH) + page = paginated.get_page(request.GET.get("page")) + data = { + "import_form": forms.ImportUserForm(), + "jobs": page, + "page_range": paginated.get_elided_page_range( + page.number, on_each_side=2, on_ends=1 + ), + "invalid": invalid, + } + + return TemplateResponse(request, "import/import_user.html", data) + + def post(self, request): + """ingest a Bookwyrm json file""" + + importer = BookwyrmImporter() + + form = forms.ImportUserForm(request.POST, request.FILES) + if not form.is_valid(): + return HttpResponseBadRequest() + + job = importer.process_import( + user=request.user, + archive_file=request.FILES["archive_file"], + settings=request.POST, + ) + + job.start_job() + + return redirect("user-import") diff --git a/bookwyrm/views/preferences/export.py b/bookwyrm/views/preferences/export.py index 6880318bc..28e83051e 100644 --- a/bookwyrm/views/preferences/export.py +++ b/bookwyrm/views/preferences/export.py @@ -3,13 +3,17 @@ import csv import io from django.contrib.auth.decorators import login_required +from django.core.paginator import Paginator from django.db.models import Q from django.http import HttpResponse from django.template.response import TemplateResponse from django.views import View from django.utils.decorators import method_decorator +from django.shortcuts import redirect from bookwyrm import models +from bookwyrm.models.bookwyrm_export_job import BookwyrmExportJob +from bookwyrm.settings import PAGE_LENGTH # pylint: disable=no-self-use @method_decorator(login_required, name="dispatch") @@ -84,3 +88,49 @@ class Export(View): "Content-Disposition": 'attachment; filename="bookwyrm-export.csv"' }, ) + + +# pylint: disable=no-self-use +@method_decorator(login_required, name="dispatch") +class ExportUser(View): + """Let users export user data to import into another Bookwyrm instance""" + + def get(self, request): + """Request tar file""" + + jobs = BookwyrmExportJob.objects.filter(user=request.user).order_by( + "-created_date" + ) + paginated = Paginator(jobs, PAGE_LENGTH) + page = paginated.get_page(request.GET.get("page")) + data = { + "jobs": page, + "page_range": paginated.get_elided_page_range( + page.number, on_each_side=2, on_ends=1 + ), + } + + return TemplateResponse(request, "preferences/export-user.html", data) + + def post(self, request): + """Download the json file of a user's data""" + + job = BookwyrmExportJob.objects.create(user=request.user) + job.start_job() + + return redirect("prefs-user-export") + + +@method_decorator(login_required, name="dispatch") +class ExportArchive(View): + """Serve the archive file""" + + def get(self, request, archive_id): + export = BookwyrmExportJob.objects.get(task_id=archive_id, user=request.user) + return HttpResponse( + export.export_data, + content_type="application/gzip", + headers={ + "Content-Disposition": 'attachment; filename="bookwyrm-account-export.tar.gz"' + }, + ) From a4bfcb34d5c2e53448700c5970b2c3634fc1245c Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sun, 15 Oct 2023 15:09:19 +1100 Subject: [PATCH 02/38] fix tests and clean up * cleans up some test logging * cleans up some commented-out code * adds export_job model tests * reconsiders some tests in export user view tests --- bookwyrm/models/bookwyrm_export_job.py | 8 - .../templates/preferences/export-user.html | 16 +- .../tests/models/test_bookwyrm_export_job.py | 233 ++++++++++++++++++ ...t_model.py => test_bookwyrm_import_job.py} | 27 +- .../views/preferences/test_export_user.py | 23 -- 5 files changed, 261 insertions(+), 46 deletions(-) create mode 100644 bookwyrm/tests/models/test_bookwyrm_export_job.py rename bookwyrm/tests/models/{test_bookwyrm_import_model.py => test_bookwyrm_import_job.py} (96%) diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index c262d9b5c..c3a0b652a 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -153,20 +153,12 @@ def json_export(user): comments = models.Comment.objects.filter(user=user, book=book["id"]).distinct() book["comments"] = list(comments.values()) - logger.error("FINAL COMMENTS") - logger.error(book["comments"]) # quotes quotes = models.Quotation.objects.filter(user=user, book=book["id"]).distinct() - # quote_statuses = models.Status.objects.filter( - # id__in=quotes, user=kwargs["user"] - # ).distinct() book["quotes"] = list(quotes.values()) - logger.error("FINAL QUOTES") - logger.error(book["quotes"]) - # append everything final_books.append(book) diff --git a/bookwyrm/templates/preferences/export-user.html b/bookwyrm/templates/preferences/export-user.html index 81f13bc22..393d8990e 100644 --- a/bookwyrm/templates/preferences/export-user.html +++ b/bookwyrm/templates/preferences/export-user.html @@ -12,15 +12,13 @@

{% trans "Your exported archive file will include all user data for import into another Bookwyrm server" %}

-

- - {% csrf_token %} - -

-

+
+ {% csrf_token %} + +

{% trans "Recent Exports" %}

diff --git a/bookwyrm/tests/models/test_bookwyrm_export_job.py b/bookwyrm/tests/models/test_bookwyrm_export_job.py new file mode 100644 index 000000000..bd314e60e --- /dev/null +++ b/bookwyrm/tests/models/test_bookwyrm_export_job.py @@ -0,0 +1,233 @@ +import datetime +import time +import json +from unittest.mock import patch + +from django.core.serializers.json import DjangoJSONEncoder +from django.test import TestCase +from django.utils import timezone + +from bookwyrm import models +import bookwyrm.models.bookwyrm_export_job as export_job + + +class BookwyrmExport(TestCase): + """testing user export functions""" + + def setUp(self): + """lots of stuff to set up for a user export""" + with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch( + "bookwyrm.activitystreams.populate_stream_task.delay" + ), patch("bookwyrm.lists_stream.populate_lists_task.delay"), patch( + "bookwyrm.suggested_users.rerank_user_task.delay" + ), patch( + "bookwyrm.lists_stream.remove_list_task.delay" + ), patch( + "bookwyrm.models.activitypub_mixin.broadcast_task.apply_async" + ), patch( + "bookwyrm.activitystreams.add_book_statuses_task" + ): + + self.local_user = models.User.objects.create_user( + "mouse", + "mouse@mouse.mouse", + "password", + local=True, + localname="mouse", + name="Mouse", + summary="I'm a real bookmouse", + manually_approves_followers=False, + hide_follows=False, + show_goal=False, + show_suggested_users=False, + discoverable=True, + preferred_timezone="America/Los Angeles", + default_post_privacy="followers", + ) + + self.rat_user = models.User.objects.create_user( + "rat", "rat@rat.rat", "ratword", local=True, localname="rat" + ) + + self.badger_user = models.User.objects.create_user( + "badger", + "badger@badger.badger", + "badgerword", + local=True, + localname="badger", + ) + + models.AnnualGoal.objects.create( + user=self.local_user, + year=timezone.now().year, + goal=128937123, + privacy="followers", + ) + + self.list = models.List.objects.create( + name="My excellent list", + user=self.local_user, + remote_id="https://local.lists/1111", + ) + + self.saved_list = models.List.objects.create( + name="My cool list", + user=self.rat_user, + remote_id="https://local.lists/9999", + ) + + self.local_user.saved_lists.add(self.saved_list) + self.local_user.blocks.add(self.badger_user) + self.rat_user.followers.add(self.local_user) + + # book, edition, author + self.author = models.Author.objects.create(name="Sam Zhu") + self.work = models.Work.objects.create( + title="Example Work", remote_id="https://example.com/book/1" + ) + self.edition = models.Edition.objects.create( + title="Example Edition", parent_work=self.work + ) + + self.edition.authors.add(self.author) + + # readthrough + self.readthrough_start = timezone.now() + finish = self.readthrough_start + datetime.timedelta(days=1) + models.ReadThrough.objects.create( + user=self.local_user, + book=self.edition, + start_date=self.readthrough_start, + finish_date=finish, + ) + + # shelve + read_shelf = models.Shelf.objects.get( + user=self.local_user, identifier="read" + ) + models.ShelfBook.objects.create( + book=self.edition, shelf=read_shelf, user=self.local_user + ) + + # add to list + item = models.ListItem.objects.create( + book_list=self.list, + user=self.local_user, + book=self.edition, + approved=True, + order=1, + ) + + # review + models.Review.objects.create( + content="awesome", + name="my review", + rating=5, + user=self.local_user, + book=self.edition, + ) + # comment + models.Comment.objects.create( + content="ok so far", + user=self.local_user, + book=self.edition, + progress=15, + ) + # quote + models.Quotation.objects.create( + content="check this out", + quote="A rose by any other name", + user=self.local_user, + book=self.edition, + ) + + def test_json_export_user_settings(self): + """Test the json export function for basic user info""" + data = export_job.json_export(self.local_user) + user_data = json.loads(data)["user"] + self.assertEqual(user_data["username"], "mouse") + self.assertEqual(user_data["name"], "Mouse") + self.assertEqual(user_data["summary"], "I'm a real bookmouse") + self.assertEqual(user_data["manually_approves_followers"], False) + self.assertEqual(user_data["hide_follows"], False) + self.assertEqual(user_data["show_goal"], False) + self.assertEqual(user_data["show_suggested_users"], False) + self.assertEqual(user_data["discoverable"], True) + self.assertEqual(user_data["preferred_timezone"], "America/Los Angeles") + self.assertEqual(user_data["default_post_privacy"], "followers") + + def test_json_export_extended_user_data(self): + """Test the json export function for other non-book user info""" + data = export_job.json_export(self.local_user) + json_data = json.loads(data) + + # goal + self.assertEqual(len(json_data["goals"]), 1) + self.assertEqual(json_data["goals"][0]["goal"], 128937123) + self.assertEqual(json_data["goals"][0]["year"], timezone.now().year) + self.assertEqual(json_data["goals"][0]["privacy"], "followers") + + # saved lists + self.assertEqual(len(json_data["saved_lists"]), 1) + self.assertEqual(json_data["saved_lists"][0], "https://local.lists/9999") + + # follows + self.assertEqual(len(json_data["follows"]), 1) + self.assertEqual(json_data["follows"][0], "https://your.domain.here/user/rat") + # blocked users + self.assertEqual(len(json_data["blocked_users"]), 1) + self.assertEqual( + json_data["blocked_users"][0], "https://your.domain.here/user/badger" + ) + + def test_json_export_books(self): + """Test the json export function for extended user info""" + + data = export_job.json_export(self.local_user) + json_data = json.loads(data) + start_date = json_data["books"][0]["readthroughs"][0]["start_date"] + + self.assertEqual(len(json_data["books"]), 1) + self.assertEqual(json_data["books"][0]["title"], "Example Edition") + self.assertEqual(len(json_data["books"][0]["authors"]), 1) + self.assertEqual(json_data["books"][0]["authors"][0]["name"], "Sam Zhu") + self.assertEqual( + f'"{start_date}"', DjangoJSONEncoder().encode(self.readthrough_start) + ) + self.assertEqual(json_data["books"][0]["shelves"][0]["identifier"], "read") + self.assertEqual( + json_data["books"][0]["shelf_books"]["read"][0]["book_id"], self.edition.id + ) + + self.assertEqual(len(json_data["books"][0]["lists"]), 1) + self.assertEqual(json_data["books"][0]["lists"][0]["name"], "My excellent list") + self.assertEqual(len(json_data["books"][0]["list_items"]), 1) + self.assertEqual( + json_data["books"][0]["list_items"]["My excellent list"][0]["book_id"], + self.edition.id, + ) + + self.assertEqual(len(json_data["books"][0]["reviews"]), 1) + self.assertEqual(len(json_data["books"][0]["comments"]), 1) + self.assertEqual(len(json_data["books"][0]["quotes"]), 1) + + self.assertEqual(json_data["books"][0]["reviews"][0]["name"], "my review") + self.assertEqual(json_data["books"][0]["reviews"][0]["content"], "awesome") + self.assertEqual(json_data["books"][0]["reviews"][0]["rating"], "5.00") + + self.assertEqual(json_data["books"][0]["comments"][0]["content"], "ok so far") + self.assertEqual(json_data["books"][0]["comments"][0]["progress"], 15) + self.assertEqual(json_data["books"][0]["comments"][0]["progress_mode"], "PG") + + self.assertEqual( + json_data["books"][0]["quotes"][0]["content"], "check this out" + ) + self.assertEqual( + json_data["books"][0]["quotes"][0]["quote"], "A rose by any other name" + ) + + def test_tar_export(self): + """test the tar export function""" + + # TODO + pass diff --git a/bookwyrm/tests/models/test_bookwyrm_import_model.py b/bookwyrm/tests/models/test_bookwyrm_import_job.py similarity index 96% rename from bookwyrm/tests/models/test_bookwyrm_import_model.py rename to bookwyrm/tests/models/test_bookwyrm_import_job.py index 644cbd265..61713cd17 100644 --- a/bookwyrm/tests/models/test_bookwyrm_import_model.py +++ b/bookwyrm/tests/models/test_bookwyrm_import_job.py @@ -70,15 +70,28 @@ class BookwyrmImport(TestCase): self.tarfile = BookwyrmTarFile.open( mode="r:gz", fileobj=open(archive_file, "rb") ) - self.import_data = json.loads( - self.tarfile.read("archive.json").decode("utf-8") - ) + self.import_data = json.loads(self.tarfile.read("archive.json").decode("utf-8")) def test_update_user_profile(self): """Test update the user's profile from import data""" - # TODO once the tar is set up - pass + with patch("bookwyrm.suggested_users.remove_user_task.delay"), patch( + "bookwyrm.models.activitypub_mixin.broadcast_task.apply_async" + ): + + models.bookwyrm_import_job.update_user_profile( + self.local_user, self.tarfile, self.import_data.get("user") + ) + self.local_user.refresh_from_db() + + self.assertEqual( + self.local_user.username, "mouse" + ) # username should not change + self.assertEqual(self.local_user.name, "Rat") + self.assertEqual( + self.local_user.summary, + "I love to make soup in Paris and eat pizza in New York", + ) def test_update_user_settings(self): """Test updating the user's settings from import data""" @@ -543,6 +556,8 @@ class BookwyrmImport(TestCase): self.assertEqual( models.ShelfBook.objects.filter(user=self.local_user.id).count(), 2 ) + + # check we didn't create an extra shelf self.assertEqual( - models.Shelf.objects.filter(user=self.local_user.id).count(), 2 + models.Shelf.objects.filter(user=self.local_user.id).count(), 4 ) diff --git a/bookwyrm/tests/views/preferences/test_export_user.py b/bookwyrm/tests/views/preferences/test_export_user.py index c7594749b..1483fc4ec 100644 --- a/bookwyrm/tests/views/preferences/test_export_user.py +++ b/bookwyrm/tests/views/preferences/test_export_user.py @@ -49,26 +49,3 @@ class ExportUserViews(TestCase): jobs = models.bookwyrm_export_job.BookwyrmExportJob.objects.count() self.assertEqual(jobs, 1) - - def test_download_export_user_file(self, *_): - """simple user export""" - - # TODO: need some help with this one - job = models.bookwyrm_export_job.BookwyrmExportJob.objects.create( - user=self.local_user - ) - MockTask = namedtuple("Task", ("id")) - with patch( - "bookwyrm.models.bookwyrm_export_job.start_export_task.delay" - ) as mock: - mock.return_value = MockTask(b'{"name": "mouse"}') - job.start_job() - - request = self.factory.get("") - request.user = self.local_user - job.refresh_from_db() - export = views.ExportArchive.as_view()(request, job.id) - self.assertIsInstance(export, HttpResponse) - self.assertEqual(export.status_code, 200) - # pylint: disable=line-too-long - self.assertEqual(export.content, b'{"name": "mouse"}') From 781b01a007b8248992e5c98ceda958ec520aed4c Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sat, 21 Oct 2023 19:43:44 +1100 Subject: [PATCH 03/38] add error handling and status for user exports * fix Safari not downloading with the correct filename * add FAILED status * don't provide download link for stopped jobs --- bookwyrm/models/bookwyrm_export_job.py | 18 ++++++++++-------- bookwyrm/models/job.py | 12 ++++++++++-- .../templates/preferences/export-user.html | 6 +++--- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index c3a0b652a..00cab7559 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -35,13 +35,14 @@ def start_export_task(**kwargs): # don't start the job if it was stopped from the UI if job.complete: return - - # 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) - + 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) + except Exception as err: # pylint: disable=broad-except + logger.exception("Job %s Failed with error: %s", job.id, err) + job.set_status("failed") job.save(update_fields=["export_data"]) @@ -56,7 +57,8 @@ def tar_export(json_data: str, user, f): editions, books = get_books_for_user(user) for book in editions: - tar.add_image(book.cover) + if getattr(book, "cover", False): + tar.add_image(book.cover) f.close() diff --git a/bookwyrm/models/job.py b/bookwyrm/models/job.py index 6e8d0dc5c..7557c5855 100644 --- a/bookwyrm/models/job.py +++ b/bookwyrm/models/job.py @@ -19,6 +19,7 @@ class Job(models.Model): ACTIVE = "active", _("Active") COMPLETE = "complete", _("Complete") STOPPED = "stopped", _("Stopped") + FAILED = "failed", _("Failed") task_id = models.UUIDField(unique=True, null=True, blank=True) @@ -43,14 +44,17 @@ class Job(models.Model): self.save(update_fields=["status", "complete", "updated_date"]) - def stop_job(self): + def stop_job(self, reason=None): """Stop the job""" if self.complete: return self.__terminate_job() - self.status = self.Status.STOPPED + if reason and reason is "failed": + self.status = self.Status.FAILED + else: + self.status = self.Status.STOPPED self.complete = True self.updated_date = timezone.now() @@ -72,6 +76,10 @@ class Job(models.Model): self.stop_job() return + if status == self.Status.FAILED: + self.stop_job(reason="failed") + return + self.updated_date = timezone.now() self.status = status diff --git a/bookwyrm/templates/preferences/export-user.html b/bookwyrm/templates/preferences/export-user.html index 393d8990e..2dd3f6de3 100644 --- a/bookwyrm/templates/preferences/export-user.html +++ b/bookwyrm/templates/preferences/export-user.html @@ -48,8 +48,8 @@ {% for job in jobs %} - {% if job.complete %} -

{{ job.created_date }}

+ {% if job.complete and not job.status == "stopped" and not job.status == "failed" %} +

{{ job.created_date }}

{% else %}

{{ job.created_date }}

{% endif %} @@ -57,7 +57,7 @@ {{ job.updated_date }} Date: Sun, 22 Oct 2023 09:03:28 +1100 Subject: [PATCH 04/38] add notifs and error handling for user export/import --- .../migrations/0183_auto_20231021_2050.py | 34 ++++++++++++++ bookwyrm/models/bookwyrm_export_job.py | 3 +- bookwyrm/models/bookwyrm_import_job.py | 46 +++++++++++-------- bookwyrm/models/job.py | 2 +- bookwyrm/models/notification.py | 38 ++++++++++++++- bookwyrm/templates/import/import_user.html | 2 +- bookwyrm/templates/notifications/item.html | 4 ++ .../notifications/items/user_export.html | 11 +++++ .../notifications/items/user_import.html | 10 ++++ 9 files changed, 126 insertions(+), 24 deletions(-) create mode 100644 bookwyrm/migrations/0183_auto_20231021_2050.py create mode 100644 bookwyrm/templates/notifications/items/user_export.html create mode 100644 bookwyrm/templates/notifications/items/user_import.html diff --git a/bookwyrm/migrations/0183_auto_20231021_2050.py b/bookwyrm/migrations/0183_auto_20231021_2050.py new file mode 100644 index 000000000..201a9201a --- /dev/null +++ b/bookwyrm/migrations/0183_auto_20231021_2050.py @@ -0,0 +1,34 @@ +# Generated by Django 3.2.20 on 2023-10-21 20:50 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0182_merge_20230905_2240'), + ] + + operations = [ + migrations.AddField( + model_name='notification', + name='related_user_export', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.bookwyrmexportjob'), + ), + migrations.AlterField( + model_name='childjob', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), ('active', 'Active'), ('complete', 'Complete'), ('stopped', 'Stopped'), ('failed', 'Failed')], default='pending', max_length=50, null=True), + ), + 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'), ('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')], max_length=255), + ), + migrations.AlterField( + model_name='parentjob', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), ('active', 'Active'), ('complete', 'Complete'), ('stopped', 'Stopped'), ('failed', 'Failed')], default='pending', max_length=50, null=True), + ), + ] diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index 00cab7559..96e602cc9 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -41,8 +41,9 @@ def start_export_task(**kwargs): json_data = json_export(job.user) tar_export(json_data, job.user, job.export_data) except Exception as err: # pylint: disable=broad-except - logger.exception("Job %s Failed with error: %s", job.id, err) + logger.exception("User Export Job %s Failed with error: %s", job.id, err) job.set_status("failed") + job.set_status("complete") # need to explicitly set this here to trigger notifications job.save(update_fields=["export_data"]) diff --git a/bookwyrm/models/bookwyrm_import_job.py b/bookwyrm/models/bookwyrm_import_job.py index 696f8061a..73372829b 100644 --- a/bookwyrm/models/bookwyrm_import_job.py +++ b/bookwyrm/models/bookwyrm_import_job.py @@ -1,5 +1,6 @@ from functools import reduce import json +import logging import operator from django.db.models import FileField, JSONField, CharField @@ -18,7 +19,8 @@ from bookwyrm.models.job import ( create_child_job, ) from bookwyrm.utils.tar import BookwyrmTarFile -import json + +logger = logging.getLogger(__name__) class BookwyrmImportJob(ParentJob): @@ -43,27 +45,33 @@ def start_import_task(**kwargs): if job.complete: return - 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")) + 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.get("user")) - if "include_user_settings" in job.required: - update_user_settings(job.user, job.import_data.get("user")) - 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("blocked_users")) + if "include_user_profile" in job.required: + update_user_profile(job.user, tar, job.import_data.get("user")) + if "include_user_settings" in job.required: + update_user_settings(job.user, job.import_data.get("user")) + 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("blocked_users")) - process_books(job, tar) + process_books(job, tar) - job.save() - archive_file.close() + job.set_status("complete") # set here to trigger notifications + job.save() + 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): diff --git a/bookwyrm/models/job.py b/bookwyrm/models/job.py index 7557c5855..4ba4bc2d7 100644 --- a/bookwyrm/models/job.py +++ b/bookwyrm/models/job.py @@ -51,7 +51,7 @@ class Job(models.Model): self.__terminate_job() - if reason and reason is "failed": + if reason and reason == "failed": self.status = self.Status.FAILED else: self.status = self.Status.STOPPED diff --git a/bookwyrm/models/notification.py b/bookwyrm/models/notification.py index 522038f9a..4c420a2e1 100644 --- a/bookwyrm/models/notification.py +++ b/bookwyrm/models/notification.py @@ -2,7 +2,8 @@ from django.db import models, transaction from django.dispatch import receiver from .base_model import BookWyrmModel -from . import Boost, Favorite, GroupMemberInvitation, ImportJob, LinkDomain +from . import Boost, Favorite, GroupMemberInvitation, ImportJob, BookwyrmImportJob, LinkDomain +from bookwyrm.models.bookwyrm_export_job import BookwyrmExportJob from . import ListItem, Report, Status, User, UserFollowRequest @@ -22,6 +23,8 @@ class Notification(BookWyrmModel): # Imports IMPORT = "IMPORT" + USER_IMPORT = "USER_IMPORT" + USER_EXPORT = "USER_EXPORT" # List activity ADD = "ADD" @@ -44,7 +47,7 @@ class Notification(BookWyrmModel): 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}", + f"{FAVORITE} {REPLY} {MENTION} {TAG} {FOLLOW} {FOLLOW_REQUEST} {BOOST} {IMPORT} {USER_IMPORT} {USER_EXPORT} {ADD} {REPORT} {LINK_DOMAIN} {INVITE} {ACCEPT} {JOIN} {LEAVE} {REMOVE} {GROUP_PRIVACY} {GROUP_NAME} {GROUP_DESCRIPTION}", ) user = models.ForeignKey("User", on_delete=models.CASCADE) @@ -61,6 +64,7 @@ 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" ) @@ -222,6 +226,36 @@ def notify_user_on_import_complete( 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=Notification.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 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: + print("RETURNING", instance.status) + return + print("NOTIFYING") + Notification.objects.create( + user=instance.user, + notification_type=Notification.USER_EXPORT, + related_user_export=instance, + ) @receiver(models.signals.post_save, sender=Report) @transaction.atomic diff --git a/bookwyrm/templates/import/import_user.html b/bookwyrm/templates/import/import_user.html index 86e99f657..e48f0198d 100644 --- a/bookwyrm/templates/import/import_user.html +++ b/bookwyrm/templates/import/import_user.html @@ -133,7 +133,7 @@ {{ job.updated_date }} +{% endblock %} + +{% block description %} + {% url 'prefs-export-file' notification.related_user_export.task_id as url %} + {% blocktrans %}Your user export is ready.{% endblocktrans %} +{% endblock %} diff --git a/bookwyrm/templates/notifications/items/user_import.html b/bookwyrm/templates/notifications/items/user_import.html new file mode 100644 index 000000000..e0b3ddaad --- /dev/null +++ b/bookwyrm/templates/notifications/items/user_import.html @@ -0,0 +1,10 @@ +{% extends 'notifications/items/layout.html' %} +{% load i18n %} + +{% block icon %} + +{% endblock %} + +{% block description %} + {% blocktrans %}Your user import is complete.{% endblocktrans %} +{% endblock %} From 836127f369d5e352c118d486944651f139066af3 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sun, 22 Oct 2023 10:49:13 +1100 Subject: [PATCH 05/38] cooldown period for user exports add USER_EXPORT_COOLDOWN_HOURS setting for controlling user exports and imports --- bookwyrm/settings.py | 3 +++ bookwyrm/templates/import/import_user.html | 27 +++++-------------- .../templates/preferences/export-user.html | 9 +++++++ bookwyrm/views/imports/import_data.py | 7 ++++- bookwyrm/views/preferences/export.py | 9 ++++++- 5 files changed, 32 insertions(+), 23 deletions(-) diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index 9a4c9b5a4..854f05973 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -423,3 +423,6 @@ if HTTP_X_FORWARDED_PROTO: # Do not change this setting unless you already have an existing # user with the same username - in which case you should change it! INSTANCE_ACTOR_USERNAME = "bookwyrm.instance.actor" + +# exports +USER_EXPORT_COOLDOWN_HOURS = 48 \ No newline at end of file diff --git a/bookwyrm/templates/import/import_user.html b/bookwyrm/templates/import/import_user.html index e48f0198d..1eee017fa 100644 --- a/bookwyrm/templates/import/import_user.html +++ b/bookwyrm/templates/import/import_user.html @@ -15,28 +15,12 @@ {% endif %} - {% if import_size_limit and import_limit_reset %} -
-

{% blocktrans %}Currently you are allowed to import one user every {{ user_import_limit_reset }} days.{% endblocktrans %}

-

{% blocktrans %}You have {{ allowed_imports }} left.{% endblocktrans %}

+ {% if next_available %} +
+

{% blocktrans %}Currently you are allowed to import one user every {{ user_import_hours }} hours.{% endblocktrans %}

+

{% blocktrans %}You will next be able to import a user file at {{ next_available }}{% endblocktrans %}

- {% endif %} - {% if recent_avg_hours or recent_avg_minutes %} -
-

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

-
- {% endif %} - + {% else %}
{% csrf_token %} @@ -100,6 +84,7 @@

{% trans "You've reached the import limit." %}

{% endif%}
+ {% endif %}
diff --git a/bookwyrm/templates/preferences/export-user.html b/bookwyrm/templates/preferences/export-user.html index 2dd3f6de3..2f63c9e1c 100644 --- a/bookwyrm/templates/preferences/export-user.html +++ b/bookwyrm/templates/preferences/export-user.html @@ -9,6 +9,13 @@ {% block panel %}
+ {% if next_available %} +

+ {% blocktrans %} + You will be able to create a new export file at {{ next_available }} + {% endblocktrans %} +

+ {% else %}

{% trans "Your exported archive file will include all user data for import into another Bookwyrm server" %}

@@ -19,6 +26,8 @@ {% trans "Create user export file" %} + {% endif %} +

{% trans "Recent Exports" %}

diff --git a/bookwyrm/views/imports/import_data.py b/bookwyrm/views/imports/import_data.py index 69a87c0c2..aa561d367 100644 --- a/bookwyrm/views/imports/import_data.py +++ b/bookwyrm/views/imports/import_data.py @@ -23,7 +23,7 @@ from bookwyrm.importers import ( OpenLibraryImporter, ) from bookwyrm.models.bookwyrm_import_job import BookwyrmImportJob -from bookwyrm.settings import PAGE_LENGTH +from bookwyrm.settings import PAGE_LENGTH, USER_EXPORT_COOLDOWN_HOURS from bookwyrm.utils.cache import get_or_set # pylint: disable= no-self-use @@ -142,11 +142,16 @@ class UserImport(View): jobs = BookwyrmImportJob.objects.filter(user=request.user).order_by( "-created_date" ) + hours = USER_EXPORT_COOLDOWN_HOURS + allowed = jobs.first().created_date < timezone.now() - datetime.timedelta(hours=hours) + next_available = jobs.first().created_date + datetime.timedelta(hours=hours) if not allowed else False paginated = Paginator(jobs, PAGE_LENGTH) page = paginated.get_page(request.GET.get("page")) data = { "import_form": forms.ImportUserForm(), "jobs": page, + "user_import_hours": hours, + "next_available": next_available, "page_range": paginated.get_elided_page_range( page.number, on_each_side=2, on_ends=1 ), diff --git a/bookwyrm/views/preferences/export.py b/bookwyrm/views/preferences/export.py index 28e83051e..49b19aea8 100644 --- a/bookwyrm/views/preferences/export.py +++ b/bookwyrm/views/preferences/export.py @@ -1,4 +1,5 @@ """ Let users export their book data """ +from datetime import timedelta import csv import io @@ -7,11 +8,12 @@ from django.core.paginator import Paginator from django.db.models import Q from django.http import HttpResponse from django.template.response import TemplateResponse +from django.utils import timezone from django.views import View from django.utils.decorators import method_decorator from django.shortcuts import redirect -from bookwyrm import models +from bookwyrm import models, settings from bookwyrm.models.bookwyrm_export_job import BookwyrmExportJob from bookwyrm.settings import PAGE_LENGTH @@ -101,10 +103,15 @@ class ExportUser(View): jobs = BookwyrmExportJob.objects.filter(user=request.user).order_by( "-created_date" ) + hours = settings.USER_EXPORT_COOLDOWN_HOURS + allowed = jobs.first().created_date < timezone.now() - timedelta(hours=hours) + next_available = jobs.first().created_date + timedelta(hours=hours) if not allowed else False + paginated = Paginator(jobs, PAGE_LENGTH) page = paginated.get_page(request.GET.get("page")) data = { "jobs": page, + "next_available": next_available, "page_range": paginated.get_elided_page_range( page.number, on_each_side=2, on_ends=1 ), From a27c6525019839120c81f662aac98c786e6e1405 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sun, 22 Oct 2023 15:07:49 +1100 Subject: [PATCH 06/38] admin view for user imports - makes user_import_time_limit a site setting rather than a value in settings.py (note this applies to exports as well as imports) - admins can change user_import_time_limit from UI - admins can cancel stuck user imports - disabling new imports also disables user imports --- ...184_sitesettings_user_import_time_limit.py | 18 ++ bookwyrm/models/site.py | 1 + bookwyrm/settings.py | 5 +- bookwyrm/templates/import/import_user.html | 146 ++++----- .../imports/complete_user_import_modal.html | 23 ++ .../templates/settings/imports/imports.html | 286 +++++++++++++----- bookwyrm/urls.py | 10 + bookwyrm/views/__init__.py | 2 + bookwyrm/views/admin/imports.py | 30 ++ bookwyrm/views/imports/import_data.py | 7 +- bookwyrm/views/preferences/export.py | 6 +- 11 files changed, 374 insertions(+), 160 deletions(-) create mode 100644 bookwyrm/migrations/0184_sitesettings_user_import_time_limit.py create mode 100644 bookwyrm/templates/settings/imports/complete_user_import_modal.html diff --git a/bookwyrm/migrations/0184_sitesettings_user_import_time_limit.py b/bookwyrm/migrations/0184_sitesettings_user_import_time_limit.py new file mode 100644 index 000000000..a23161db1 --- /dev/null +++ b/bookwyrm/migrations/0184_sitesettings_user_import_time_limit.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.20 on 2023-10-22 02:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0183_auto_20231021_2050'), + ] + + operations = [ + migrations.AddField( + model_name='sitesettings', + name='user_import_time_limit', + field=models.IntegerField(default=48), + ), + ] diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py index a27c4b70d..cce055999 100644 --- a/bookwyrm/models/site.py +++ b/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"]) diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index 854f05973..f74ef0093 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -422,7 +422,4 @@ if HTTP_X_FORWARDED_PROTO: # Mastodon servers. # Do not change this setting unless you already have an existing # user with the same username - in which case you should change it! -INSTANCE_ACTOR_USERNAME = "bookwyrm.instance.actor" - -# exports -USER_EXPORT_COOLDOWN_HOURS = 48 \ No newline at end of file +INSTANCE_ACTOR_USERNAME = "bookwyrm.instance.actor" \ No newline at end of file diff --git a/bookwyrm/templates/import/import_user.html b/bookwyrm/templates/import/import_user.html index 1eee017fa..8e7bb1a09 100644 --- a/bookwyrm/templates/import/import_user.html +++ b/bookwyrm/templates/import/import_user.html @@ -10,81 +10,89 @@ {% if invalid %}
- {% trans "Not a valid JSON file" %} + {% trans "Not a valid import file" %}
{% endif %} + {% if not site.imports_enabled %} +
+

+ +

+

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

+
+ {% elif next_available %} +
+

{% blocktrans %}Currently you are allowed to import one user every {{ user_import_hours }} hours.{% endblocktrans %}

+

{% blocktrans %}You will next be able to import a user file at {{ next_available }}{% endblocktrans %}

+
+ {% else %} +
+ {% csrf_token %} - {% if next_available %} -
-

{% blocktrans %}Currently you are allowed to import one user every {{ user_import_hours }} hours.{% endblocktrans %}

-

{% blocktrans %}You will next be able to import a user file at {{ next_available }}{% endblocktrans %}

+
+
+
+ + {{ import_form.archive_file }}
+
+

{% trans "Importing this file will overwrite any data you currently have saved." %}

+

{% trans "Deselect any data you do not wish to include in your import. Books will always be imported" %}

+
+
+ +
+
+ + + + + + + + + + + + +
+
+
+ {% if not import_limit_reset and not import_size_limit or allowed_imports > 0 %} + {% else %} - - {% csrf_token %} - -
-
-
- - {{ import_form.archive_file }} -
-
-

{% trans "Importing this file will overwrite any data you currently have saved." %}

-

{% trans "Deselect any data you do not wish to include in your import. Books will always be imported" %}

-
-
- -
-
- - - - - - - - - - - - -
-
-
- {% if not import_limit_reset and not import_size_limit or allowed_imports > 0 %} - - {% else %} - -

{% trans "You've reached the import limit." %}

- {% endif%} - - {% endif %} + +

{% trans "You've reached the import limit." %}

+ {% endif%} + + {% endif %}
diff --git a/bookwyrm/templates/settings/imports/complete_user_import_modal.html b/bookwyrm/templates/settings/imports/complete_user_import_modal.html new file mode 100644 index 000000000..74004b7a2 --- /dev/null +++ b/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 %} +
+ {% csrf_token %} + +
+ + +
+
+{% endblock %} diff --git a/bookwyrm/templates/settings/imports/imports.html b/bookwyrm/templates/settings/imports/imports.html index 8819220fb..09d12b04a 100644 --- a/bookwyrm/templates/settings/imports/imports.html +++ b/bookwyrm/templates/settings/imports/imports.html @@ -29,6 +29,7 @@
{% 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." %}
{% csrf_token %}
@@ -89,91 +90,214 @@
+
+ + + {% trans "Limit how often users can import and export" %} + + + +
+
+ {% 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." %} +
+
+ + + + {% csrf_token %} +
+ +
+
+
+
-
- +

{% trans "Book Imports" %}

+
+
+ +
+ +
+ + + {% url 'settings-imports' status as url %} + + + + {% if status != "active" %} + + {% endif %} + + + + + {% if status == "active" %} + + {% endif %} + + {% for import in imports %} + + + + + {% if status != "active" %} + + {% endif %} + + + + + {% if status == "active" %} + + {% endif %} + + {% endfor %} + {% if not imports %} + + + + {% endif %} +
+ {% trans "ID" %} + + {% trans "User" as text %} + {% include 'snippets/table-sort-header.html' with field="user" sort=sort text=text %} + + {% trans "Date Created" as text %} + {% include 'snippets/table-sort-header.html' with field="created_date" sort=sort text=text %} + + {% trans "Date Updated" %} + + {% trans "Items" %} + + {% trans "Pending items" %} + + {% trans "Successful items" %} + + {% trans "Failed items" %} + {% trans "Actions" %}
{{ import.id }} + {{ import.user|username }} + {{ import.created_date }}{{ import.updated_date }}{{ import.item_count|intcomma }}{{ import.pending_item_count|intcomma }}{{ import.successful_item_count|intcomma }}{{ import.failed_item_count|intcomma }} + {% join "complete" import.id as modal_id %} + + {% include "settings/imports/complete_import_modal.html" with id=modal_id %} +
+ {% trans "No matching imports found." %} +
+
+ + {% include 'snippets/pagination.html' with page=imports path=request.path %} +
-
- - - {% url 'settings-imports' status as url %} - - - - {% if status != "active" %} - +
+

{% trans "User Imports" %}

+
+
+ +
+
+ +
+
- {% trans "ID" %} - - {% trans "User" as text %} - {% include 'snippets/table-sort-header.html' with field="user" sort=sort text=text %} - - {% trans "Date Created" as text %} - {% include 'snippets/table-sort-header.html' with field="created_date" sort=sort text=text %} - - {% trans "Date Updated" %} -
+ + {% url 'settings-imports' status as url %} + + + + {% if status != "active" %} + + {% endif %} + + {% if status == "active" %} + + {% else %} + + {% endif %} + + {% for import in user_imports %} + + + + + {% if status != "active" %} + + {% endif %} + {% if status == "active" %} + + {% else %} + + + {% endif %} + + {% endfor %} + {% if not user_imports %} + + + {% endif %} - - - - - {% if status == "active" %} - - {% endif %} - - {% for import in imports %} - - - - - {% if status != "active" %} - - {% endif %} - - - - - {% if status == "active" %} - - {% endif %} - - {% endfor %} - {% if not imports %} - - - - {% endif %} -
+ {% trans "ID" %} + + {% trans "User" as text %} + {% include 'snippets/table-sort-header.html' with field="user" sort=sort text=text %} + + {% trans "Date Created" as text %} + {% include 'snippets/table-sort-header.html' with field="created_date" sort=sort text=text %} + + {% trans "Date Updated" %} + {% trans "Actions" %}{% trans "Status" %}
{{ import.id }} + {{ import.user|username }} + {{ import.created_date }}{{ import.updated_date }} + {% join "complete" import.id as modal_id %} + + {% include "settings/imports/complete_user_import_modal.html" with id=modal_id %} + + {{ import.status }}
+ {% trans "No matching imports found." %} +
- {% trans "Items" %} - - {% trans "Pending items" %} - - {% trans "Successful items" %} - - {% trans "Failed items" %} - {% trans "Actions" %}
{{ import.id }} - {{ import.user|username }} - {{ import.created_date }}{{ import.updated_date }}{{ import.item_count|intcomma }}{{ import.pending_item_count|intcomma }}{{ import.successful_item_count|intcomma }}{{ import.failed_item_count|intcomma }} - {% join "complete" import.id as modal_id %} - - {% include "settings/imports/complete_import_modal.html" with id=modal_id %} -
- {% trans "No matching imports found." %} -
+ +
+ + {% include 'snippets/pagination.html' with page=user_imports path=request.path %} + {% endblock %}
- -{% include 'snippets/pagination.html' with page=imports path=request.path %} -{% endblock %} - diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index 5b83acb85..2871ef282 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -316,6 +316,11 @@ urlpatterns = [ views.ImportList.as_view(), name="settings-imports-complete", ), + re_path( + r"^settings/user-imports/(?P\d+)/complete/?$", + views.set_user_import_completed, + name="settings-user-import-complete", + ), re_path( r"^settings/imports/disable/?$", views.disable_imports, @@ -331,6 +336,11 @@ urlpatterns = [ views.set_import_size_limit, name="settings-imports-set-limit", ), + re_path( + r"^settings/user-imports/set-limit/?$", + views.set_user_import_limit, + name="settings-user-imports-set-limit", + ), re_path( r"^settings/celery/?$", views.CeleryStatus.as_view(), name="settings-celery" ), diff --git a/bookwyrm/views/__init__.py b/bookwyrm/views/__init__.py index c044200e3..d98dffdcc 100644 --- a/bookwyrm/views/__init__.py +++ b/bookwyrm/views/__init__.py @@ -16,6 +16,8 @@ from .admin.imports import ( disable_imports, enable_imports, set_import_size_limit, + set_user_import_completed, + set_user_import_limit ) from .admin.ip_blocklist import IPBlocklist from .admin.invite import ManageInvites, Invite, InviteRequest diff --git a/bookwyrm/views/admin/imports.py b/bookwyrm/views/admin/imports.py index 7ae190ce8..4da7acf0e 100644 --- a/bookwyrm/views/admin/imports.py +++ b/bookwyrm/views/admin/imports.py @@ -40,9 +40,17 @@ class ImportList(View): paginated = Paginator(imports, PAGE_LENGTH) page = paginated.get_page(request.GET.get("page")) + user_imports = models.BookwyrmImportJob.objects.filter(complete=complete).order_by( + "created_date" + ) + + user_paginated = Paginator(user_imports, PAGE_LENGTH) + user_page = user_paginated.get_page(request.GET.get("page")) + site_settings = models.SiteSettings.objects.get() data = { "imports": page, + "user_imports": user_page, "page_range": paginated.get_elided_page_range( page.number, on_each_side=2, on_ends=1 ), @@ -50,6 +58,7 @@ class ImportList(View): "sort": sort, "import_size_limit": site_settings.import_size_limit, "import_limit_reset": site_settings.import_limit_reset, + "user_import_time_limit": site_settings.user_import_time_limit, } return TemplateResponse(request, "settings/imports/imports.html", data) @@ -95,3 +104,24 @@ def set_import_size_limit(request): site.import_limit_reset = import_limit_reset site.save(update_fields=["import_size_limit", "import_limit_reset"]) return redirect("settings-imports") + +@require_POST +@login_required +@permission_required("bookwyrm.moderate_user", raise_exception=True) +# pylint: disable=unused-argument +def set_user_import_completed(request, import_id): + """Mark a user import as complete""" + import_job = get_object_or_404(models.BookwyrmImportJob, id=import_id) + import_job.stop_job() + return redirect("settings-imports") + + +@require_POST +@permission_required("bookwyrm.edit_instance_settings", raise_exception=True) +# pylint: disable=unused-argument +def set_user_import_limit(request): + """Limit how ofter users can import and export their account""" + site = models.SiteSettings.objects.get() + site.user_import_time_limit = int(request.POST.get("limit")) + site.save(update_fields=["user_import_time_limit"]) + return redirect("settings-imports") \ No newline at end of file diff --git a/bookwyrm/views/imports/import_data.py b/bookwyrm/views/imports/import_data.py index aa561d367..4fd50d9ce 100644 --- a/bookwyrm/views/imports/import_data.py +++ b/bookwyrm/views/imports/import_data.py @@ -23,7 +23,7 @@ from bookwyrm.importers import ( OpenLibraryImporter, ) from bookwyrm.models.bookwyrm_import_job import BookwyrmImportJob -from bookwyrm.settings import PAGE_LENGTH, USER_EXPORT_COOLDOWN_HOURS +from bookwyrm.settings import PAGE_LENGTH from bookwyrm.utils.cache import get_or_set # pylint: disable= no-self-use @@ -142,8 +142,9 @@ class UserImport(View): jobs = BookwyrmImportJob.objects.filter(user=request.user).order_by( "-created_date" ) - hours = USER_EXPORT_COOLDOWN_HOURS - allowed = jobs.first().created_date < timezone.now() - datetime.timedelta(hours=hours) + site = models.SiteSettings.objects.get() + hours = site.user_import_time_limit + allowed = jobs.first().created_date < timezone.now() - datetime.timedelta(hours=hours) if jobs.first() else True next_available = jobs.first().created_date + datetime.timedelta(hours=hours) if not allowed else False paginated = Paginator(jobs, PAGE_LENGTH) page = paginated.get_page(request.GET.get("page")) diff --git a/bookwyrm/views/preferences/export.py b/bookwyrm/views/preferences/export.py index 49b19aea8..5e70f896e 100644 --- a/bookwyrm/views/preferences/export.py +++ b/bookwyrm/views/preferences/export.py @@ -103,10 +103,10 @@ class ExportUser(View): jobs = BookwyrmExportJob.objects.filter(user=request.user).order_by( "-created_date" ) - hours = settings.USER_EXPORT_COOLDOWN_HOURS - allowed = jobs.first().created_date < timezone.now() - timedelta(hours=hours) + site = models.SiteSettings.objects.get() + hours = site.user_import_time_limit + allowed = jobs.first().created_date < timezone.now() - timedelta(hours=hours) if jobs.first() else True next_available = jobs.first().created_date + timedelta(hours=hours) if not allowed else False - paginated = Paginator(jobs, PAGE_LENGTH) page = paginated.get_page(request.GET.get("page")) data = { From b34a49117263d9136e94669d9edef5bd04ae8df2 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sun, 22 Oct 2023 15:34:25 +1100 Subject: [PATCH 07/38] run black --- .../migrations/0183_auto_20231021_2050.py | 77 +++++++++++++++---- ...184_sitesettings_user_import_time_limit.py | 6 +- bookwyrm/models/bookwyrm_export_job.py | 4 +- bookwyrm/models/bookwyrm_import_job.py | 2 +- bookwyrm/models/notification.py | 19 ++++- bookwyrm/settings.py | 2 +- bookwyrm/views/__init__.py | 2 +- bookwyrm/views/admin/imports.py | 9 ++- bookwyrm/views/imports/import_data.py | 12 ++- bookwyrm/views/preferences/export.py | 10 ++- 10 files changed, 111 insertions(+), 32 deletions(-) diff --git a/bookwyrm/migrations/0183_auto_20231021_2050.py b/bookwyrm/migrations/0183_auto_20231021_2050.py index 201a9201a..c960fe5bd 100644 --- a/bookwyrm/migrations/0183_auto_20231021_2050.py +++ b/bookwyrm/migrations/0183_auto_20231021_2050.py @@ -7,28 +7,79 @@ import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ('bookwyrm', '0182_merge_20230905_2240'), + ("bookwyrm", "0182_merge_20230905_2240"), ] operations = [ migrations.AddField( - model_name='notification', - name='related_user_export', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.bookwyrmexportjob'), + model_name="notification", + name="related_user_export", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="bookwyrm.bookwyrmexportjob", + ), ), migrations.AlterField( - model_name='childjob', - name='status', - field=models.CharField(choices=[('pending', 'Pending'), ('active', 'Active'), ('complete', 'Complete'), ('stopped', 'Stopped'), ('failed', 'Failed')], default='pending', max_length=50, null=True), + model_name="childjob", + name="status", + field=models.CharField( + choices=[ + ("pending", "Pending"), + ("active", "Active"), + ("complete", "Complete"), + ("stopped", "Stopped"), + ("failed", "Failed"), + ], + default="pending", + max_length=50, + null=True, + ), ), 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'), ('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')], max_length=255), + 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"), + ("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"), + ], + max_length=255, + ), ), migrations.AlterField( - model_name='parentjob', - name='status', - field=models.CharField(choices=[('pending', 'Pending'), ('active', 'Active'), ('complete', 'Complete'), ('stopped', 'Stopped'), ('failed', 'Failed')], default='pending', max_length=50, null=True), + model_name="parentjob", + name="status", + field=models.CharField( + choices=[ + ("pending", "Pending"), + ("active", "Active"), + ("complete", "Complete"), + ("stopped", "Stopped"), + ("failed", "Failed"), + ], + default="pending", + max_length=50, + null=True, + ), ), ] diff --git a/bookwyrm/migrations/0184_sitesettings_user_import_time_limit.py b/bookwyrm/migrations/0184_sitesettings_user_import_time_limit.py index a23161db1..24b4dad37 100644 --- a/bookwyrm/migrations/0184_sitesettings_user_import_time_limit.py +++ b/bookwyrm/migrations/0184_sitesettings_user_import_time_limit.py @@ -6,13 +6,13 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('bookwyrm', '0183_auto_20231021_2050'), + ("bookwyrm", "0183_auto_20231021_2050"), ] operations = [ migrations.AddField( - model_name='sitesettings', - name='user_import_time_limit', + model_name="sitesettings", + name="user_import_time_limit", field=models.IntegerField(default=48), ), ] diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index 96e602cc9..65f209905 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -43,7 +43,9 @@ def start_export_task(**kwargs): 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") # need to explicitly set this here to trigger notifications + job.set_status( + "complete" + ) # need to explicitly set this here to trigger notifications job.save(update_fields=["export_data"]) diff --git a/bookwyrm/models/bookwyrm_import_job.py b/bookwyrm/models/bookwyrm_import_job.py index 73372829b..6e71aa4b5 100644 --- a/bookwyrm/models/bookwyrm_import_job.py +++ b/bookwyrm/models/bookwyrm_import_job.py @@ -65,7 +65,7 @@ def start_import_task(**kwargs): process_books(job, tar) - job.set_status("complete") # set here to trigger notifications + job.set_status("complete") # set here to trigger notifications job.save() archive_file.close() diff --git a/bookwyrm/models/notification.py b/bookwyrm/models/notification.py index 4c420a2e1..c8140bce9 100644 --- a/bookwyrm/models/notification.py +++ b/bookwyrm/models/notification.py @@ -2,7 +2,14 @@ from django.db import models, transaction from django.dispatch import receiver from .base_model import BookWyrmModel -from . import Boost, Favorite, GroupMemberInvitation, ImportJob, BookwyrmImportJob, LinkDomain +from . import ( + Boost, + Favorite, + GroupMemberInvitation, + ImportJob, + BookwyrmImportJob, + LinkDomain, +) from bookwyrm.models.bookwyrm_export_job import BookwyrmExportJob from . import ListItem, Report, Status, User, UserFollowRequest @@ -64,7 +71,9 @@ 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_user_export = models.ForeignKey( + "BookwyrmExportJob", on_delete=models.CASCADE, null=True + ) related_list_items = models.ManyToManyField( "ListItem", symmetrical=False, related_name="notifications" ) @@ -226,6 +235,7 @@ def notify_user_on_import_complete( related_import=instance, ) + @receiver(models.signals.post_save, sender=BookwyrmImportJob) # pylint: disable=unused-argument def notify_user_on_user_import_complete( @@ -236,10 +246,10 @@ def notify_user_on_user_import_complete( if not instance.complete or "complete" not in update_fields: return Notification.objects.create( - user=instance.user, - notification_type=Notification.USER_IMPORT + user=instance.user, notification_type=Notification.USER_IMPORT ) + @receiver(models.signals.post_save, sender=BookwyrmExportJob) # pylint: disable=unused-argument def notify_user_on_user_export_complete( @@ -257,6 +267,7 @@ def notify_user_on_user_export_complete( related_user_export=instance, ) + @receiver(models.signals.post_save, sender=Report) @transaction.atomic # pylint: disable=unused-argument diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index f74ef0093..9a4c9b5a4 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -422,4 +422,4 @@ if HTTP_X_FORWARDED_PROTO: # Mastodon servers. # Do not change this setting unless you already have an existing # user with the same username - in which case you should change it! -INSTANCE_ACTOR_USERNAME = "bookwyrm.instance.actor" \ No newline at end of file +INSTANCE_ACTOR_USERNAME = "bookwyrm.instance.actor" diff --git a/bookwyrm/views/__init__.py b/bookwyrm/views/__init__.py index d98dffdcc..2746ab9f9 100644 --- a/bookwyrm/views/__init__.py +++ b/bookwyrm/views/__init__.py @@ -17,7 +17,7 @@ from .admin.imports import ( enable_imports, set_import_size_limit, set_user_import_completed, - set_user_import_limit + set_user_import_limit, ) from .admin.ip_blocklist import IPBlocklist from .admin.invite import ManageInvites, Invite, InviteRequest diff --git a/bookwyrm/views/admin/imports.py b/bookwyrm/views/admin/imports.py index 4da7acf0e..a85d6c79e 100644 --- a/bookwyrm/views/admin/imports.py +++ b/bookwyrm/views/admin/imports.py @@ -40,9 +40,9 @@ class ImportList(View): paginated = Paginator(imports, PAGE_LENGTH) page = paginated.get_page(request.GET.get("page")) - user_imports = models.BookwyrmImportJob.objects.filter(complete=complete).order_by( - "created_date" - ) + user_imports = models.BookwyrmImportJob.objects.filter( + complete=complete + ).order_by("created_date") user_paginated = Paginator(user_imports, PAGE_LENGTH) user_page = user_paginated.get_page(request.GET.get("page")) @@ -105,6 +105,7 @@ def set_import_size_limit(request): site.save(update_fields=["import_size_limit", "import_limit_reset"]) return redirect("settings-imports") + @require_POST @login_required @permission_required("bookwyrm.moderate_user", raise_exception=True) @@ -124,4 +125,4 @@ def set_user_import_limit(request): site = models.SiteSettings.objects.get() site.user_import_time_limit = int(request.POST.get("limit")) site.save(update_fields=["user_import_time_limit"]) - return redirect("settings-imports") \ No newline at end of file + return redirect("settings-imports") diff --git a/bookwyrm/views/imports/import_data.py b/bookwyrm/views/imports/import_data.py index 4fd50d9ce..1a9085ce1 100644 --- a/bookwyrm/views/imports/import_data.py +++ b/bookwyrm/views/imports/import_data.py @@ -144,8 +144,16 @@ class UserImport(View): ) site = models.SiteSettings.objects.get() hours = site.user_import_time_limit - allowed = jobs.first().created_date < timezone.now() - datetime.timedelta(hours=hours) if jobs.first() else True - next_available = jobs.first().created_date + datetime.timedelta(hours=hours) if not allowed else False + allowed = ( + jobs.first().created_date < timezone.now() - datetime.timedelta(hours=hours) + if jobs.first() + else True + ) + next_available = ( + jobs.first().created_date + datetime.timedelta(hours=hours) + if not allowed + else False + ) paginated = Paginator(jobs, PAGE_LENGTH) page = paginated.get_page(request.GET.get("page")) data = { diff --git a/bookwyrm/views/preferences/export.py b/bookwyrm/views/preferences/export.py index 5e70f896e..037b8dbdc 100644 --- a/bookwyrm/views/preferences/export.py +++ b/bookwyrm/views/preferences/export.py @@ -105,8 +105,14 @@ class ExportUser(View): ) site = models.SiteSettings.objects.get() hours = site.user_import_time_limit - allowed = jobs.first().created_date < timezone.now() - timedelta(hours=hours) if jobs.first() else True - next_available = jobs.first().created_date + timedelta(hours=hours) if not allowed else False + allowed = ( + jobs.first().created_date < timezone.now() - timedelta(hours=hours) + if jobs.first() + else True + ) + next_available = ( + jobs.first().created_date + timedelta(hours=hours) if not allowed else False + ) paginated = Paginator(jobs, PAGE_LENGTH) page = paginated.get_page(request.GET.get("page")) data = { From fd1ebf5f71faf84cc35d1ad5199ddeaea9b9b7b7 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sun, 22 Oct 2023 16:52:29 +1100 Subject: [PATCH 08/38] formatting and pylint fixes --- bookwyrm/importers/bookwyrm_import.py | 7 +-- bookwyrm/models/bookwyrm_export_job.py | 22 ++++---- bookwyrm/models/bookwyrm_import_job.py | 51 ++++++++++--------- bookwyrm/models/job.py | 20 ++++++-- bookwyrm/models/notification.py | 2 +- .../templates/settings/imports/imports.html | 6 +-- .../tests/models/test_bookwyrm_export_job.py | 5 +- .../tests/models/test_bookwyrm_import_job.py | 20 +++++--- .../views/preferences/test_export_user.py | 3 +- bookwyrm/utils/tar.py | 13 +++-- bookwyrm/views/preferences/export.py | 5 +- 11 files changed, 91 insertions(+), 63 deletions(-) diff --git a/bookwyrm/importers/bookwyrm_import.py b/bookwyrm/importers/bookwyrm_import.py index a2eb71725..c8f4433ca 100644 --- a/bookwyrm/importers/bookwyrm_import.py +++ b/bookwyrm/importers/bookwyrm_import.py @@ -1,14 +1,15 @@ """Import data from Bookwyrm export files""" -from bookwyrm import settings from bookwyrm.models.bookwyrm_import_job import BookwyrmImportJob class BookwyrmImporter: - """Import a Bookwyrm User export JSON file. + """Import a Bookwyrm User export file. This is kind of a combination of an importer and a connector. """ - def process_import(self, user, archive_file, settings): + def process_import( + self, user, archive_file, settings + ): # pylint: disable=no-self-use """import user data from a Bookwyrm export file""" required = [k for k in settings if settings.get(k) == "on"] diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index 65f209905..e3fb2a81f 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -1,4 +1,7 @@ +"""Export user account to tar.gz file for import into another Bookwyrm instance""" + import logging +from uuid import uuid4 from django.db.models import FileField from django.db.models import Q @@ -6,10 +9,9 @@ from django.core.serializers.json import DjangoJSONEncoder from django.core.files.base import ContentFile from bookwyrm import models +from bookwyrm.models.job import ParentJob, ParentTask from bookwyrm.settings import DOMAIN from bookwyrm.tasks import app, IMPORTS -from bookwyrm.models.job import ParentJob, ParentTask, SubTask, create_child_job -from uuid import uuid4 from bookwyrm.utils.tar import BookwyrmTarFile logger = logging.getLogger(__name__) @@ -49,21 +51,22 @@ def start_export_task(**kwargs): job.save(update_fields=["export_data"]) -def tar_export(json_data: str, user, f): - f.open("wb") - with BookwyrmTarFile.open(mode="w:gz", fileobj=f) as tar: +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, books = get_books_for_user(user) + editions = get_books_for_user(user) for book in editions: if getattr(book, "cover", False): tar.add_image(book.cover) - f.close() + file.close() def json_export(user): @@ -91,18 +94,19 @@ def json_export(user): # reading goals reading_goals = models.AnnualGoal.objects.filter(user=user).distinct() goals_list = [] + # TODO: either error checking should be more sophisticated or maybe we don't need this try/except try: for goal in reading_goals: goals_list.append( {"goal": goal.goal, "year": goal.year, "privacy": goal.privacy} ) - except Exception: + except Exception: # pylint: disable=broad-except pass try: readthroughs = models.ReadThrough.objects.filter(user=user).distinct().values() readthroughs = list(readthroughs) - except Exception as e: + except Exception: # pylint: disable=broad-except readthroughs = [] # books diff --git a/bookwyrm/models/bookwyrm_import_job.py b/bookwyrm/models/bookwyrm_import_job.py index 6e71aa4b5..8fe797ed7 100644 --- a/bookwyrm/models/bookwyrm_import_job.py +++ b/bookwyrm/models/bookwyrm_import_job.py @@ -1,3 +1,5 @@ +"""Import a user from another Bookwyrm instance""" + from functools import reduce import json import logging @@ -11,13 +13,7 @@ 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, - ChildJob, - SubTask, - create_child_job, -) +from bookwyrm.models.job import ParentJob, ParentTask, SubTask from bookwyrm.utils.tar import BookwyrmTarFile logger = logging.getLogger(__name__) @@ -161,8 +157,10 @@ def get_or_create_edition(book_data, tar): if cover_path: tar.write_image_to_file(cover_path, new_book.cover) - # NOTE: clean_values removes "last_edited_by" because it's a user ID from the old database - # if this is required, bookwyrm_export_job will need to bring in the user who edited it. + # NOTE: clean_values removes "last_edited_by" + # because it's a user ID from the old database + # if this is required, bookwyrm_export_job will + # need to bring in the user who edited it. # create parent work = models.Work.objects.create(title=book["title"]) @@ -197,7 +195,7 @@ def clean_values(data): return new_data -def find_existing(cls, data, user): +def find_existing(cls, data): """Given a book or author, find any existing model instances""" identifiers = [ @@ -248,27 +246,31 @@ def upsert_readthroughs(data, user, book_id): """Take a JSON string of readthroughs, find or create the instances in the database and return a list of saved instances""" - for rt in data: + for read_thru in data: start_date = ( - parse_datetime(rt["start_date"]) if rt["start_date"] is not None else None + parse_datetime(read_thru["start_date"]) + if read_thru["start_date"] is not None + else None ) finish_date = ( - parse_datetime(rt["finish_date"]) if rt["finish_date"] is not None else None + parse_datetime(read_thru["finish_date"]) + if read_thru["finish_date"] is not None + else None ) stopped_date = ( - parse_datetime(rt["stopped_date"]) - if rt["stopped_date"] is not None + parse_datetime(read_thru["stopped_date"]) + if read_thru["stopped_date"] is not None else None ) readthrough = { "user": user, "book": models.Edition.objects.get(id=book_id), - "progress": rt["progress"], - "progress_mode": rt["progress_mode"], + "progress": read_thru["progress"], + "progress_mode": read_thru["progress_mode"], "start_date": start_date, "finish_date": finish_date, "stopped_date": stopped_date, - "is_active": rt["is_active"], + "is_active": read_thru["is_active"], } existing = models.ReadThrough.objects.filter(**readthrough).exists() @@ -311,7 +313,8 @@ def get_or_create_statuses(user, cls, data, book_id): def upsert_lists(user, lists, items, book_id): - """Take a list and ListItems as JSON and create DB entries if they don't already exist""" + """Take a list and ListItems as JSON and + create DB entries if they don't already exist""" book = models.Edition.objects.get(id=book_id) @@ -408,7 +411,7 @@ def update_user_settings(user, data): @app.task(queue=IMPORTS, base=SubTask) -def update_user_settings_task(job_id, child_id): +def update_user_settings_task(job_id): """wrapper task for user's settings import""" parent_job = BookwyrmImportJob.objects.get(id=job_id) @@ -433,7 +436,7 @@ def update_goals(user, data): @app.task(queue=IMPORTS, base=SubTask) -def update_goals_task(job_id, child_id): +def update_goals_task(job_id): """wrapper task for user's goals import""" parent_job = BookwyrmImportJob.objects.get(id=job_id) @@ -450,7 +453,7 @@ def upsert_saved_lists(user, values): @app.task(queue=IMPORTS, base=SubTask) -def upsert_saved_lists_task(job_id, child_id): +def upsert_saved_lists_task(job_id): """wrapper task for user's saved lists import""" parent_job = BookwyrmImportJob.objects.get(id=job_id) @@ -477,7 +480,7 @@ def upsert_follows(user, values): @app.task(queue=IMPORTS, base=SubTask) -def upsert_follows_task(job_id, child_id): +def upsert_follows_task(job_id): """wrapper task for user's follows import""" parent_job = BookwyrmImportJob.objects.get(id=job_id) @@ -504,7 +507,7 @@ def upsert_user_blocks(user, user_ids): @app.task(queue=IMPORTS, base=SubTask) -def upsert_user_blocks_task(job_id, child_id): +def upsert_user_blocks_task(job_id): """wrapper task for user's blocks import""" parent_job = BookwyrmImportJob.objects.get(id=job_id) diff --git a/bookwyrm/models/job.py b/bookwyrm/models/job.py index 4ba4bc2d7..4f5cb2093 100644 --- a/bookwyrm/models/job.py +++ b/bookwyrm/models/job.py @@ -31,6 +31,8 @@ class Job(models.Model): ) class Meta: + """Make it abstract""" + abstract = True def complete_job(self): @@ -119,7 +121,7 @@ class ParentJob(Job): if not self.complete and self.has_completed: self.complete_job() - def __terminate_job(self): + def __terminate_job(self): # pylint: disable=unused-private-member """Tell workers to ignore and not execute this task & pending child tasks. Extend. """ @@ -183,7 +185,9 @@ class ParentTask(app.Task): Usage e.g. @app.task(base=ParentTask) """ - def before_start(self, task_id, args, kwargs): + 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. @@ -208,7 +212,9 @@ class ParentTask(app.Task): if kwargs["no_children"]: job.set_status(ChildJob.Status.ACTIVE) - def on_success(self, retval, task_id, args, kwargs): + 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. @@ -241,7 +247,9 @@ class SubTask(app.Task): Usage e.g. @app.task(base=SubTask) """ - def before_start(self, task_id, args, kwargs): + 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. @@ -263,7 +271,9 @@ class SubTask(app.Task): child_job.save(update_fields=["task_id"]) child_job.set_status(ChildJob.Status.ACTIVE) - def on_success(self, retval, task_id, args, kwargs): + 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. diff --git a/bookwyrm/models/notification.py b/bookwyrm/models/notification.py index c8140bce9..98d20a3cb 100644 --- a/bookwyrm/models/notification.py +++ b/bookwyrm/models/notification.py @@ -1,6 +1,7 @@ """ 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, @@ -10,7 +11,6 @@ from . import ( BookwyrmImportJob, LinkDomain, ) -from bookwyrm.models.bookwyrm_export_job import BookwyrmExportJob from . import ListItem, Report, Status, User, UserFollowRequest diff --git a/bookwyrm/templates/settings/imports/imports.html b/bookwyrm/templates/settings/imports/imports.html index 09d12b04a..0f4ae04fc 100644 --- a/bookwyrm/templates/settings/imports/imports.html +++ b/bookwyrm/templates/settings/imports/imports.html @@ -141,7 +141,7 @@
- {% url 'settings-imports' status as url %} + {% url 'settings-imports' status as url %} @@ -231,7 +231,7 @@
{% trans "ID" %}
- {% url 'settings-imports' status as url %} + {% url 'settings-imports' status as url %} @@ -299,5 +299,5 @@ {% include 'snippets/pagination.html' with page=user_imports path=request.path %} - {% endblock %} +{% endblock %} diff --git a/bookwyrm/tests/models/test_bookwyrm_export_job.py b/bookwyrm/tests/models/test_bookwyrm_export_job.py index bd314e60e..73b59a4cc 100644 --- a/bookwyrm/tests/models/test_bookwyrm_export_job.py +++ b/bookwyrm/tests/models/test_bookwyrm_export_job.py @@ -1,3 +1,4 @@ +"""test bookwyrm user export functions""" import datetime import time import json @@ -110,7 +111,7 @@ class BookwyrmExport(TestCase): ) # add to list - item = models.ListItem.objects.create( + models.ListItem.objects.create( book_list=self.list, user=self.local_user, book=self.edition, @@ -226,7 +227,7 @@ class BookwyrmExport(TestCase): json_data["books"][0]["quotes"][0]["quote"], "A rose by any other name" ) - def test_tar_export(self): + def test_tar_export(self): # pylint: disable=unnecessary-pass """test the tar export function""" # TODO diff --git a/bookwyrm/tests/models/test_bookwyrm_import_job.py b/bookwyrm/tests/models/test_bookwyrm_import_job.py index 61713cd17..c07772e16 100644 --- a/bookwyrm/tests/models/test_bookwyrm_import_job.py +++ b/bookwyrm/tests/models/test_bookwyrm_import_job.py @@ -5,14 +5,12 @@ import pathlib from unittest.mock import patch from django.db.models import Q -from django.utils import timezone from django.utils.dateparse import parse_datetime from django.test import TestCase from bookwyrm import models -from bookwyrm.settings import DOMAIN from bookwyrm.utils.tar import BookwyrmTarFile -import bookwyrm.models.bookwyrm_import_job as bookwyrm_import_job +from bookwyrm.models import bookwyrm_import_job class BookwyrmImport(TestCase): @@ -246,7 +244,9 @@ class BookwyrmImport(TestCase): self.assertEqual(author.name, "James C. Scott") def test_get_or_create_edition_existing(self): - """Test take a JSON string of books and editions, find or create the editions in the database and return a list of edition instances""" + """Test take a JSON string of books and editions, + find or create the editions in the database and + return a list of edition instances""" self.assertEqual(models.Edition.objects.count(), 1) self.assertEqual(models.Edition.objects.count(), 1) @@ -258,7 +258,9 @@ class BookwyrmImport(TestCase): self.assertEqual(models.Edition.objects.count(), 1) def test_get_or_create_edition_not_existing(self): - """Test take a JSON string of books and editions, find or create the editions in the database and return a list of edition instances""" + """Test take a JSON string of books and editions, + find or create the editions in the database and + return a list of edition instances""" self.assertEqual(models.Edition.objects.count(), 1) @@ -441,7 +443,8 @@ class BookwyrmImport(TestCase): ) def test_upsert_list_existing(self): - """Take a list and ListItems as JSON and create DB entries if they don't already exist""" + """Take a list and ListItems as JSON and create DB entries + if they don't already exist""" book_data = self.import_data["books"][0] @@ -456,7 +459,7 @@ class BookwyrmImport(TestCase): name="my list of books", user=self.local_user ) - list_item = models.ListItem.objects.create( + models.ListItem.objects.create( book=self.book, book_list=book_list, user=self.local_user, order=1 ) @@ -489,7 +492,8 @@ class BookwyrmImport(TestCase): ) def test_upsert_list_not_existing(self): - """Take a list and ListItems as JSON and create DB entries if they don't already exist""" + """Take a list and ListItems as JSON and create DB entries + if they don't already exist""" book_data = self.import_data["books"][0] diff --git a/bookwyrm/tests/views/preferences/test_export_user.py b/bookwyrm/tests/views/preferences/test_export_user.py index 1483fc4ec..654ed2a05 100644 --- a/bookwyrm/tests/views/preferences/test_export_user.py +++ b/bookwyrm/tests/views/preferences/test_export_user.py @@ -1,5 +1,4 @@ -""" test for app action functionality """ -from collections import namedtuple +""" test for user export app functionality """ from unittest.mock import patch from django.http import HttpResponse diff --git a/bookwyrm/utils/tar.py b/bookwyrm/utils/tar.py index 448df48d9..8f43b2c15 100644 --- a/bookwyrm/utils/tar.py +++ b/bookwyrm/utils/tar.py @@ -1,12 +1,15 @@ +"""manage tar files for user exports""" +import io +import tarfile from uuid import uuid4 from django.core.files import File -import tarfile -import io class BookwyrmTarFile(tarfile.TarFile): - def write_bytes(self, data: bytes, filename="archive.json"): - """Add a file containing :data: bytestring with name :filename: to the archive""" + """Create tar files for user exports""" + + def write_bytes(self, data: bytes): + """Add a file containing bytes to the archive""" buffer = io.BytesIO(data) info = tarfile.TarInfo("archive.json") info.size = len(data) @@ -30,10 +33,12 @@ class BookwyrmTarFile(tarfile.TarFile): self.addfile(info, fileobj=image) def read(self, filename): + """read data from the tar""" with self.extractfile(filename) as reader: return reader.read() def write_image_to_file(self, filename, file_field): + """add an image to the tar""" extension = filename.rsplit(".")[-1] with self.extractfile(filename) as reader: filename = f"{str(uuid4())}.{extension}" diff --git a/bookwyrm/views/preferences/export.py b/bookwyrm/views/preferences/export.py index 037b8dbdc..c55e12c86 100644 --- a/bookwyrm/views/preferences/export.py +++ b/bookwyrm/views/preferences/export.py @@ -13,7 +13,7 @@ from django.views import View from django.utils.decorators import method_decorator from django.shortcuts import redirect -from bookwyrm import models, settings +from bookwyrm import models from bookwyrm.models.bookwyrm_export_job import BookwyrmExportJob from bookwyrm.settings import PAGE_LENGTH @@ -135,10 +135,11 @@ class ExportUser(View): @method_decorator(login_required, name="dispatch") -class ExportArchive(View): +class ExportArchive(View): # pylint: disable=line-too-long """Serve the archive file""" def get(self, request, archive_id): + """download user export file""" export = BookwyrmExportJob.objects.get(task_id=archive_id, user=request.user) return HttpResponse( export.export_data, From 07ef12ce8e02d187e109c9efde2a8720526782b6 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sun, 22 Oct 2023 17:26:27 +1100 Subject: [PATCH 09/38] fix tests and linting --- bookwyrm/models/bookwyrm_export_job.py | 2 +- bookwyrm/models/bookwyrm_import_job.py | 4 ++-- .../templates/settings/imports/imports.html | 21 ++++++++++--------- .../tests/models/test_bookwyrm_export_job.py | 5 ++--- .../tests/models/test_bookwyrm_import_job.py | 2 +- bookwyrm/tests/utils/test_tar.py | 2 +- bookwyrm/views/preferences/export.py | 4 ++-- 7 files changed, 20 insertions(+), 20 deletions(-) diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index e3fb2a81f..1185c867a 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -69,7 +69,7 @@ def tar_export(json_data: str, user, file): file.close() -def json_export(user): +def json_export(user): # pylint: disable=too-many-locals, too-many-statements """Generate an export for a user""" # user exported_user = {} diff --git a/bookwyrm/models/bookwyrm_import_job.py b/bookwyrm/models/bookwyrm_import_job.py index 8fe797ed7..32c1a037a 100644 --- a/bookwyrm/models/bookwyrm_import_job.py +++ b/bookwyrm/models/bookwyrm_import_job.py @@ -124,7 +124,7 @@ def get_or_create_edition(book_data, tar): ): book[key] = edition[key] - existing = find_existing(models.Edition, book, None) + existing = find_existing(models.Edition, book) if existing: return existing @@ -233,7 +233,7 @@ def get_or_create_authors(data): authors = [] for author in data: clean = clean_values(author) - existing = find_existing(models.Author, clean, None) + existing = find_existing(models.Author, clean) if existing: authors.append(existing) else: diff --git a/bookwyrm/templates/settings/imports/imports.html b/bookwyrm/templates/settings/imports/imports.html index 0f4ae04fc..8898aab71 100644 --- a/bookwyrm/templates/settings/imports/imports.html +++ b/bookwyrm/templates/settings/imports/imports.html @@ -274,16 +274,17 @@ {% else %} + {% 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 }} + {% endif %} diff --git a/bookwyrm/tests/models/test_bookwyrm_export_job.py b/bookwyrm/tests/models/test_bookwyrm_export_job.py index 73b59a4cc..d3e81a161 100644 --- a/bookwyrm/tests/models/test_bookwyrm_export_job.py +++ b/bookwyrm/tests/models/test_bookwyrm_export_job.py @@ -1,6 +1,5 @@ """test bookwyrm user export functions""" import datetime -import time import json from unittest.mock import patch @@ -227,8 +226,8 @@ class BookwyrmExport(TestCase): json_data["books"][0]["quotes"][0]["quote"], "A rose by any other name" ) - def test_tar_export(self): # pylint: disable=unnecessary-pass + def test_tar_export(self): """test the tar export function""" # TODO - pass + pass # pylint: disable=unnecessary-pass diff --git a/bookwyrm/tests/models/test_bookwyrm_import_job.py b/bookwyrm/tests/models/test_bookwyrm_import_job.py index c07772e16..78a8ec160 100644 --- a/bookwyrm/tests/models/test_bookwyrm_import_job.py +++ b/bookwyrm/tests/models/test_bookwyrm_import_job.py @@ -306,7 +306,7 @@ class BookwyrmImport(TestCase): self.assertEqual(models.Edition.objects.first().openlibrary_key, "OL28216445M") existing = bookwyrm_import_job.find_existing( - models.Edition, {"openlibrary_key": "OL28216445M", "isbn_10": None}, None + models.Edition, {"openlibrary_key": "OL28216445M", "isbn_10": None} ) self.assertEqual(existing.title, "Test Book") diff --git a/bookwyrm/tests/utils/test_tar.py b/bookwyrm/tests/utils/test_tar.py index 5989d3bb9..d1945c735 100644 --- a/bookwyrm/tests/utils/test_tar.py +++ b/bookwyrm/tests/utils/test_tar.py @@ -10,7 +10,7 @@ def read_tar(): yield tar -def get_write_tar(): +def write_tar(): archive_path = "/tmp/test.tar.gz" with open(archive_path, "wb") as archive_file: with BookwyrmTarFile.open(mode="w:gz", fileobj=archive_file) as tar: diff --git a/bookwyrm/views/preferences/export.py b/bookwyrm/views/preferences/export.py index c55e12c86..f54d97ccb 100644 --- a/bookwyrm/views/preferences/export.py +++ b/bookwyrm/views/preferences/export.py @@ -135,7 +135,7 @@ class ExportUser(View): @method_decorator(login_required, name="dispatch") -class ExportArchive(View): # pylint: disable=line-too-long +class ExportArchive(View): """Serve the archive file""" def get(self, request, archive_id): @@ -145,6 +145,6 @@ class ExportArchive(View): # pylint: disable=line-too-long export.export_data, content_type="application/gzip", headers={ - "Content-Disposition": 'attachment; filename="bookwyrm-account-export.tar.gz"' + "Content-Disposition": 'attachment; filename="bookwyrm-account-export.tar.gz"' # pylint: disable=line-too-long }, ) From b6b55b2e657ba200d29b7e81f84c05c6040e1771 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sun, 22 Oct 2023 17:49:26 +1100 Subject: [PATCH 10/38] once more into the linting breach! --- bookwyrm/models/bookwyrm_export_job.py | 3 ++- bookwyrm/tests/utils/test_tar.py | 1 + bookwyrm/utils/tar.py | 8 ++++---- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index 1185c867a..e4a6e314f 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -94,7 +94,8 @@ def json_export(user): # pylint: disable=too-many-locals, too-many-statements # reading goals reading_goals = models.AnnualGoal.objects.filter(user=user).distinct() goals_list = [] - # TODO: either error checking should be more sophisticated or maybe we don't need this try/except + # TODO: either error checking should be more sophisticated + # or maybe we don't need this try/except try: for goal in reading_goals: goals_list.append( diff --git a/bookwyrm/tests/utils/test_tar.py b/bookwyrm/tests/utils/test_tar.py index d1945c735..cb4e738d7 100644 --- a/bookwyrm/tests/utils/test_tar.py +++ b/bookwyrm/tests/utils/test_tar.py @@ -10,6 +10,7 @@ def read_tar(): yield tar +@pytest.fixture def write_tar(): archive_path = "/tmp/test.tar.gz" with open(archive_path, "wb") as archive_file: diff --git a/bookwyrm/utils/tar.py b/bookwyrm/utils/tar.py index 8f43b2c15..61c1019ec 100644 --- a/bookwyrm/utils/tar.py +++ b/bookwyrm/utils/tar.py @@ -8,14 +8,14 @@ from django.core.files import File class BookwyrmTarFile(tarfile.TarFile): """Create tar files for user exports""" - def write_bytes(self, data: bytes): + def write_bytes(self, data: bytes) -> None: """Add a file containing bytes to the archive""" buffer = io.BytesIO(data) info = tarfile.TarInfo("archive.json") info.size = len(data) self.addfile(info, fileobj=buffer) - def add_image(self, image, filename=None, directory=""): + def add_image(self, image: Any, filename: str = None, directory: Any = "") -> None: """ Add an image to the tar archive :param str filename: overrides the file name set by image @@ -32,12 +32,12 @@ class BookwyrmTarFile(tarfile.TarFile): self.addfile(info, fileobj=image) - def read(self, filename): + def read(self, filename: str) -> Any: """read data from the tar""" with self.extractfile(filename) as reader: return reader.read() - def write_image_to_file(self, filename, file_field): + def write_image_to_file(self, filename: str, file_field: Any) -> None: """add an image to the tar""" extension = filename.rsplit(".")[-1] with self.extractfile(filename) as reader: From 2b6852e7a0b40ce4dd8db168ba77049333cac937 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sun, 22 Oct 2023 17:56:46 +1100 Subject: [PATCH 11/38] oops import Any --- bookwyrm/utils/tar.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bookwyrm/utils/tar.py b/bookwyrm/utils/tar.py index 61c1019ec..6aec88b42 100644 --- a/bookwyrm/utils/tar.py +++ b/bookwyrm/utils/tar.py @@ -1,6 +1,7 @@ """manage tar files for user exports""" import io import tarfile +from typing import Any from uuid import uuid4 from django.core.files import File From b8fc5c9b7a6ee796d8f7629270bd7a19b3da26c1 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Mon, 23 Oct 2023 20:42:56 +1100 Subject: [PATCH 12/38] fix tests --- .../tests/models/test_bookwyrm_import_job.py | 64 +++++++++++-------- bookwyrm/tests/utils/test_tar.py | 7 +- 2 files changed, 40 insertions(+), 31 deletions(-) diff --git a/bookwyrm/tests/models/test_bookwyrm_import_job.py b/bookwyrm/tests/models/test_bookwyrm_import_job.py index 78a8ec160..249160481 100644 --- a/bookwyrm/tests/models/test_bookwyrm_import_job.py +++ b/bookwyrm/tests/models/test_bookwyrm_import_job.py @@ -62,13 +62,12 @@ class BookwyrmImport(TestCase): parent_work=self.work, ) - archive_file = pathlib.Path(__file__).parent.joinpath( + self.archive_file = pathlib.Path(__file__).parent.joinpath( "../data/bookwyrm_account_export.tar.gz" ) - self.tarfile = BookwyrmTarFile.open( - mode="r:gz", fileobj=open(archive_file, "rb") - ) - self.import_data = json.loads(self.tarfile.read("archive.json").decode("utf-8")) + with open(self.archive_file, "rb") as fileobj: + tarfile = BookwyrmTarFile.open(mode="r:gz", fileobj=fileobj) + self.import_data = json.loads(tarfile.read("archive.json").decode("utf-8")) def test_update_user_profile(self): """Test update the user's profile from import data""" @@ -77,19 +76,22 @@ class BookwyrmImport(TestCase): "bookwyrm.models.activitypub_mixin.broadcast_task.apply_async" ): - models.bookwyrm_import_job.update_user_profile( - self.local_user, self.tarfile, self.import_data.get("user") - ) - self.local_user.refresh_from_db() + with open(self.archive_file, "rb") as fileobj: + tarfile = BookwyrmTarFile.open(mode="r:gz", fileobj=fileobj) - self.assertEqual( - self.local_user.username, "mouse" - ) # username should not change - self.assertEqual(self.local_user.name, "Rat") - self.assertEqual( - self.local_user.summary, - "I love to make soup in Paris and eat pizza in New York", - ) + models.bookwyrm_import_job.update_user_profile( + self.local_user, tarfile, self.import_data.get("user") + ) + self.local_user.refresh_from_db() + + self.assertEqual( + self.local_user.username, "mouse" + ) # username should not change + self.assertEqual(self.local_user.name, "Rat") + self.assertEqual( + self.local_user.summary, + "I love to make soup in Paris and eat pizza in New York", + ) def test_update_user_settings(self): """Test updating the user's settings from import data""" @@ -248,14 +250,16 @@ class BookwyrmImport(TestCase): find or create the editions in the database and return a list of edition instances""" - self.assertEqual(models.Edition.objects.count(), 1) self.assertEqual(models.Edition.objects.count(), 1) - bookwyrm_import_job.get_or_create_edition( - self.import_data["books"][1], self.tarfile - ) # Sand Talk + with open(self.archive_file, "rb") as fileobj: + tarfile = BookwyrmTarFile.open(mode="r:gz", fileobj=fileobj) - self.assertEqual(models.Edition.objects.count(), 1) + bookwyrm_import_job.get_or_create_edition( + self.import_data["books"][1], tarfile + ) # Sand Talk + + self.assertEqual(models.Edition.objects.count(), 1) def test_get_or_create_edition_not_existing(self): """Test take a JSON string of books and editions, @@ -264,12 +268,16 @@ class BookwyrmImport(TestCase): self.assertEqual(models.Edition.objects.count(), 1) - bookwyrm_import_job.get_or_create_edition( - self.import_data["books"][0], self.tarfile - ) # Seeing like a state + with open(self.archive_file, "rb") as fileobj: + tarfile = BookwyrmTarFile.open(mode="r:gz", fileobj=fileobj) + bookwyrm_import_job.get_or_create_edition( + self.import_data["books"][0], tarfile + ) # Seeing like a state - self.assertTrue(models.Edition.objects.filter(isbn_13="9780300070163").exists()) - self.assertEqual(models.Edition.objects.count(), 2) + self.assertTrue( + models.Edition.objects.filter(isbn_13="9780300070163").exists() + ) + self.assertEqual(models.Edition.objects.count(), 2) def test_clean_values(self): """test clean values we don't want when creating new instances""" @@ -373,7 +381,7 @@ class BookwyrmImport(TestCase): self.assertEqual( models.Review.objects.filter(book=self.book).first().name, "great book" ) - self.assertEqual( + self.assertAlmostEqual( models.Review.objects.filter(book=self.book).first().rating, 5.00 ) diff --git a/bookwyrm/tests/utils/test_tar.py b/bookwyrm/tests/utils/test_tar.py index cb4e738d7..be5257542 100644 --- a/bookwyrm/tests/utils/test_tar.py +++ b/bookwyrm/tests/utils/test_tar.py @@ -1,5 +1,6 @@ -from bookwyrm.utils.tar import BookwyrmTarFile +import os import pytest +from bookwyrm.utils.tar import BookwyrmTarFile @pytest.fixture @@ -15,10 +16,10 @@ def write_tar(): archive_path = "/tmp/test.tar.gz" with open(archive_path, "wb") as archive_file: with BookwyrmTarFile.open(mode="w:gz", fileobj=archive_file) as tar: - return tar + yield tar os.remove(archive_path) def test_write_bytes(write_tar): - write_tar.write_bytes(b"ABCDEF", filename="example.txt") + write_tar.write_bytes(b"ABCDEF") From ddec2dbaa98b6f3a9a8fedeacefeb40508aa5ab2 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Mon, 23 Oct 2023 20:43:49 +1100 Subject: [PATCH 13/38] fix tar types notification docstring --- bookwyrm/models/notification.py | 2 +- bookwyrm/utils/tar.py | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/bookwyrm/models/notification.py b/bookwyrm/models/notification.py index 98d20a3cb..d62043845 100644 --- a/bookwyrm/models/notification.py +++ b/bookwyrm/models/notification.py @@ -255,7 +255,7 @@ def notify_user_on_user_import_complete( def notify_user_on_user_export_complete( sender, instance, *args, update_fields=None, **kwargs ): - """we imported your user details! aren't you proud of us""" + """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: print("RETURNING", instance.status) diff --git a/bookwyrm/utils/tar.py b/bookwyrm/utils/tar.py index 6aec88b42..044a47404 100644 --- a/bookwyrm/utils/tar.py +++ b/bookwyrm/utils/tar.py @@ -1,7 +1,7 @@ """manage tar files for user exports""" import io import tarfile -from typing import Any +from typing import Any, Optional from uuid import uuid4 from django.core.files import File @@ -16,7 +16,9 @@ class BookwyrmTarFile(tarfile.TarFile): info.size = len(data) self.addfile(info, fileobj=buffer) - def add_image(self, image: Any, filename: str = None, directory: Any = "") -> None: + def add_image( + self, image: Any, filename: Optional[str] = None, directory: Any = "" + ) -> None: """ Add an image to the tar archive :param str filename: overrides the file name set by image @@ -35,12 +37,12 @@ class BookwyrmTarFile(tarfile.TarFile): def read(self, filename: str) -> Any: """read data from the tar""" - with self.extractfile(filename) as reader: + if reader := self.extractfile(filename): return reader.read() def write_image_to_file(self, filename: str, file_field: Any) -> None: """add an image to the tar""" extension = filename.rsplit(".")[-1] - with self.extractfile(filename) as reader: + if buf := self.extractfile(filename): filename = f"{str(uuid4())}.{extension}" - file_field.save(filename, File(reader)) + file_field.save(filename, File(buf)) From e29c93a1e90155bdc186c09502453d7b95f51240 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Mon, 23 Oct 2023 20:44:52 +1100 Subject: [PATCH 14/38] complete jobs more sensibly - fix tuple in tar export I accidentally broke by following pylint blindly - just use job.set_status to complete jobs since it does everything we need - fix/avoid Celery "not JSON deserializable" error by not saving whole job including user value --- bookwyrm/models/bookwyrm_export_job.py | 9 ++++----- bookwyrm/models/bookwyrm_import_job.py | 3 +-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index e4a6e314f..7c3d3ac2a 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -42,13 +42,12 @@ def start_export_task(**kwargs): 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" - ) # need to explicitly set this here to trigger notifications - job.save(update_fields=["export_data"]) + + job.set_status("complete") def tar_export(json_data: str, user, file): @@ -61,7 +60,7 @@ def tar_export(json_data: str, user, file): if getattr(user, "avatar", False): tar.add_image(user.avatar, filename="avatar") - editions = get_books_for_user(user) + editions, books = get_books_for_user(user) # pylint: disable=unused-argument for book in editions: if getattr(book, "cover", False): tar.add_image(book.cover) diff --git a/bookwyrm/models/bookwyrm_import_job.py b/bookwyrm/models/bookwyrm_import_job.py index 32c1a037a..16dad1bfc 100644 --- a/bookwyrm/models/bookwyrm_import_job.py +++ b/bookwyrm/models/bookwyrm_import_job.py @@ -61,8 +61,7 @@ def start_import_task(**kwargs): process_books(job, tar) - job.set_status("complete") # set here to trigger notifications - job.save() + job.set_status("complete") archive_file.close() except Exception as err: # pylint: disable=broad-except From f30555be0f2d71217724619b072e74e905456f4d Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Mon, 23 Oct 2023 21:30:17 +1100 Subject: [PATCH 15/38] minor pylint and mypy fixes --- bookwyrm/importers/bookwyrm_import.py | 9 ++- bookwyrm/models/bookwyrm_export_job.py | 2 +- .../tests/models/test_bookwyrm_import_job.py | 60 ++++++++++--------- bookwyrm/utils/tar.py | 1 + 4 files changed, 40 insertions(+), 32 deletions(-) diff --git a/bookwyrm/importers/bookwyrm_import.py b/bookwyrm/importers/bookwyrm_import.py index c8f4433ca..38fc6af61 100644 --- a/bookwyrm/importers/bookwyrm_import.py +++ b/bookwyrm/importers/bookwyrm_import.py @@ -1,4 +1,9 @@ """Import data from Bookwyrm export files""" +from typing import Any + +from django.http import QueryDict + +from bookwyrm.models import User from bookwyrm.models.bookwyrm_import_job import BookwyrmImportJob @@ -8,8 +13,8 @@ class BookwyrmImporter: """ def process_import( - self, user, archive_file, settings - ): # pylint: disable=no-self-use + self, user: User, archive_file: bytes, settings: QueryDict + ) -> BookwyrmImportJob: # pylint: disable=no-self-use """import user data from a Bookwyrm export file""" required = [k for k in settings if settings.get(k) == "on"] diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index 7c3d3ac2a..da1cab320 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -60,7 +60,7 @@ def tar_export(json_data: str, user, file): if getattr(user, "avatar", False): tar.add_image(user.avatar, filename="avatar") - editions, books = get_books_for_user(user) # pylint: disable=unused-argument + editions, books = get_books_for_user(user) # pylint: disable=unused-variable for book in editions: if getattr(book, "cover", False): tar.add_image(book.cover) diff --git a/bookwyrm/tests/models/test_bookwyrm_import_job.py b/bookwyrm/tests/models/test_bookwyrm_import_job.py index 249160481..5a41e5607 100644 --- a/bookwyrm/tests/models/test_bookwyrm_import_job.py +++ b/bookwyrm/tests/models/test_bookwyrm_import_job.py @@ -13,7 +13,7 @@ from bookwyrm.utils.tar import BookwyrmTarFile from bookwyrm.models import bookwyrm_import_job -class BookwyrmImport(TestCase): +class BookwyrmImport(TestCase): # pylint: disable=too-many-public-methods """testing user import functions""" def setUp(self): @@ -66,8 +66,10 @@ class BookwyrmImport(TestCase): "../data/bookwyrm_account_export.tar.gz" ) with open(self.archive_file, "rb") as fileobj: - tarfile = BookwyrmTarFile.open(mode="r:gz", fileobj=fileobj) - self.import_data = json.loads(tarfile.read("archive.json").decode("utf-8")) + with BookwyrmTarFile.open(mode="r:gz", fileobj=fileobj) as tarfile: + self.import_data = json.loads( + tarfile.read("archive.json").decode("utf-8") + ) def test_update_user_profile(self): """Test update the user's profile from import data""" @@ -77,21 +79,21 @@ class BookwyrmImport(TestCase): ): with open(self.archive_file, "rb") as fileobj: - tarfile = BookwyrmTarFile.open(mode="r:gz", fileobj=fileobj) + with BookwyrmTarFile.open(mode="r:gz", fileobj=fileobj) as tarfile: - models.bookwyrm_import_job.update_user_profile( - self.local_user, tarfile, self.import_data.get("user") - ) - self.local_user.refresh_from_db() + models.bookwyrm_import_job.update_user_profile( + self.local_user, tarfile, self.import_data.get("user") + ) + self.local_user.refresh_from_db() - self.assertEqual( - self.local_user.username, "mouse" - ) # username should not change - self.assertEqual(self.local_user.name, "Rat") - self.assertEqual( - self.local_user.summary, - "I love to make soup in Paris and eat pizza in New York", - ) + self.assertEqual( + self.local_user.username, "mouse" + ) # username should not change + self.assertEqual(self.local_user.name, "Rat") + self.assertEqual( + self.local_user.summary, + "I love to make soup in Paris and eat pizza in New York", + ) def test_update_user_settings(self): """Test updating the user's settings from import data""" @@ -253,13 +255,13 @@ class BookwyrmImport(TestCase): self.assertEqual(models.Edition.objects.count(), 1) with open(self.archive_file, "rb") as fileobj: - tarfile = BookwyrmTarFile.open(mode="r:gz", fileobj=fileobj) + with BookwyrmTarFile.open(mode="r:gz", fileobj=fileobj) as tarfile: - bookwyrm_import_job.get_or_create_edition( - self.import_data["books"][1], tarfile - ) # Sand Talk + bookwyrm_import_job.get_or_create_edition( + self.import_data["books"][1], tarfile + ) # Sand Talk - self.assertEqual(models.Edition.objects.count(), 1) + self.assertEqual(models.Edition.objects.count(), 1) def test_get_or_create_edition_not_existing(self): """Test take a JSON string of books and editions, @@ -269,15 +271,15 @@ class BookwyrmImport(TestCase): self.assertEqual(models.Edition.objects.count(), 1) with open(self.archive_file, "rb") as fileobj: - tarfile = BookwyrmTarFile.open(mode="r:gz", fileobj=fileobj) - bookwyrm_import_job.get_or_create_edition( - self.import_data["books"][0], tarfile - ) # Seeing like a state + with BookwyrmTarFile.open(mode="r:gz", fileobj=fileobj) as tarfile: + bookwyrm_import_job.get_or_create_edition( + self.import_data["books"][0], tarfile + ) # Seeing like a state - self.assertTrue( - models.Edition.objects.filter(isbn_13="9780300070163").exists() - ) - self.assertEqual(models.Edition.objects.count(), 2) + self.assertTrue( + models.Edition.objects.filter(isbn_13="9780300070163").exists() + ) + self.assertEqual(models.Edition.objects.count(), 2) def test_clean_values(self): """test clean values we don't want when creating new instances""" diff --git a/bookwyrm/utils/tar.py b/bookwyrm/utils/tar.py index 044a47404..bae3f7628 100644 --- a/bookwyrm/utils/tar.py +++ b/bookwyrm/utils/tar.py @@ -39,6 +39,7 @@ class BookwyrmTarFile(tarfile.TarFile): """read data from the tar""" if reader := self.extractfile(filename): return reader.read() + return None def write_image_to_file(self, filename: str, file_field: Any) -> None: """add an image to the tar""" From 25a2615d5f16afffbc58e2b85c409c5a485af5fe Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sat, 28 Oct 2023 06:51:26 +1100 Subject: [PATCH 16/38] stop pylint constantly whining --- bookwyrm/importers/bookwyrm_import.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bookwyrm/importers/bookwyrm_import.py b/bookwyrm/importers/bookwyrm_import.py index 38fc6af61..206cd6219 100644 --- a/bookwyrm/importers/bookwyrm_import.py +++ b/bookwyrm/importers/bookwyrm_import.py @@ -1,6 +1,4 @@ """Import data from Bookwyrm export files""" -from typing import Any - from django.http import QueryDict from bookwyrm.models import User @@ -12,9 +10,10 @@ class BookwyrmImporter: 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: # pylint: disable=no-self-use + ) -> BookwyrmImportJob: """import user data from a Bookwyrm export file""" required = [k for k in settings if settings.get(k) == "on"] From 89b87db1c87afe88ff5f65d543c19f71663d63ca Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sun, 5 Nov 2023 06:54:29 -0800 Subject: [PATCH 17/38] Adds merge migration --- bookwyrm/migrations/0185_merge_20231105_1453.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 bookwyrm/migrations/0185_merge_20231105_1453.py diff --git a/bookwyrm/migrations/0185_merge_20231105_1453.py b/bookwyrm/migrations/0185_merge_20231105_1453.py new file mode 100644 index 000000000..767fe4195 --- /dev/null +++ b/bookwyrm/migrations/0185_merge_20231105_1453.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.20 on 2023-11-05 14:53 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0182_auto_20231027_1122"), + ("bookwyrm", "0184_sitesettings_user_import_time_limit"), + ] + + operations = [] From ff2bb513ed09ba5816de1563bebf618c1f8e567c Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sun, 5 Nov 2023 06:56:10 -0800 Subject: [PATCH 18/38] Adds migration for notification types --- ...86_alter_notification_notification_type.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 bookwyrm/migrations/0186_alter_notification_notification_type.py diff --git a/bookwyrm/migrations/0186_alter_notification_notification_type.py b/bookwyrm/migrations/0186_alter_notification_notification_type.py new file mode 100644 index 000000000..3e4effdfa --- /dev/null +++ b/bookwyrm/migrations/0186_alter_notification_notification_type.py @@ -0,0 +1,43 @@ +# Generated by Django 3.2.20 on 2023-11-05 14:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("bookwyrm", "0185_merge_20231105_1453"), + ] + + operations = [ + 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"), + ("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, + ), + ), + ] From 9e9e9a9f8552b84e879079b547eeedd729e27a23 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sun, 5 Nov 2023 07:04:05 -0800 Subject: [PATCH 19/38] Uses explicit imports to avoid circular import in migrations code --- bookwyrm/models/__init__.py | 1 + bookwyrm/models/bookwyrm_export_job.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/bookwyrm/models/__init__.py b/bookwyrm/models/__init__.py index 7062fe390..4f86f2aa6 100644 --- a/bookwyrm/models/__init__.py +++ b/bookwyrm/models/__init__.py @@ -27,6 +27,7 @@ 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 diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index da1cab320..4b0abd73f 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -8,7 +8,10 @@ from django.db.models import Q from django.core.serializers.json import DjangoJSONEncoder from django.core.files.base import ContentFile -from bookwyrm import models +from bookwyrm.models import AnnualGoal, ReadThrough, ShelfBook, Shelf, List, ListItem +from bookwyrm.models import Review, Comment, Quotation +from bookwyrm.models import Edition, Book +from bookwyrm.models import UserFollows, User, UserBlocks from bookwyrm.models.job import ParentJob, ParentTask from bookwyrm.settings import DOMAIN from bookwyrm.tasks import app, IMPORTS From d2f06e804ff03561167b29de2befa0403ae33aa9 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Mon, 6 Nov 2023 12:07:40 +1100 Subject: [PATCH 20/38] update references to bookwyrm models in export job --- bookwyrm/models/bookwyrm_export_job.py | 36 +++++++++++++------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index 4b0abd73f..d91ef6257 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -94,7 +94,7 @@ def json_export(user): # pylint: disable=too-many-locals, too-many-statements exported_user["avatar"] = f'https://{DOMAIN}{getattr(user, "avatar").url}' # reading goals - reading_goals = models.AnnualGoal.objects.filter(user=user).distinct() + reading_goals = AnnualGoal.objects.filter(user=user).distinct() goals_list = [] # TODO: either error checking should be more sophisticated # or maybe we don't need this try/except @@ -107,7 +107,7 @@ def json_export(user): # pylint: disable=too-many-locals, too-many-statements pass try: - readthroughs = models.ReadThrough.objects.filter(user=user).distinct().values() + readthroughs = ReadThrough.objects.filter(user=user).distinct().values() readthroughs = list(readthroughs) except Exception: # pylint: disable=broad-except readthroughs = [] @@ -123,16 +123,16 @@ def json_export(user): # pylint: disable=too-many-locals, too-many-statements book["authors"] = list(edition.first().authors.all().values()) # readthroughs book_readthroughs = ( - models.ReadThrough.objects.filter(user=user, book=book["id"]) + ReadThrough.objects.filter(user=user, book=book["id"]) .distinct() .values() ) book["readthroughs"] = list(book_readthroughs) # shelves - shelf_books = models.ShelfBook.objects.filter( + shelf_books = ShelfBook.objects.filter( user=user, book=book["id"] ).distinct() - shelves_from_books = models.Shelf.objects.filter( + shelves_from_books = Shelf.objects.filter( shelfbook__in=shelf_books, user=user ) @@ -140,34 +140,34 @@ def json_export(user): # pylint: disable=too-many-locals, too-many-statements book["shelf_books"] = {} for shelf in shelves_from_books: - shelf_contents = models.ShelfBook.objects.filter( + shelf_contents = ShelfBook.objects.filter( user=user, shelf=shelf ).distinct() book["shelf_books"][shelf.identifier] = list(shelf_contents.values()) # book lists - book_lists = models.List.objects.filter( + book_lists = List.objects.filter( books__in=[book["id"]], user=user ).distinct() book["lists"] = list(book_lists.values()) book["list_items"] = {} for blist in book_lists: - list_items = models.ListItem.objects.filter(book_list=blist).distinct() + list_items = ListItem.objects.filter(book_list=blist).distinct() book["list_items"][blist.name] = list(list_items.values()) # reviews - reviews = models.Review.objects.filter(user=user, book=book["id"]).distinct() + reviews = Review.objects.filter(user=user, book=book["id"]).distinct() book["reviews"] = list(reviews.values()) # comments - comments = models.Comment.objects.filter(user=user, book=book["id"]).distinct() + comments = Comment.objects.filter(user=user, book=book["id"]).distinct() book["comments"] = list(comments.values()) # quotes - quotes = models.Quotation.objects.filter(user=user, book=book["id"]).distinct() + quotes = Quotation.objects.filter(user=user, book=book["id"]).distinct() book["quotes"] = list(quotes.values()) @@ -175,19 +175,19 @@ def json_export(user): # pylint: disable=too-many-locals, too-many-statements final_books.append(book) # saved book lists - saved_lists = models.List.objects.filter(id__in=user.saved_lists.all()).distinct() + saved_lists = List.objects.filter(id__in=user.saved_lists.all()).distinct() saved_lists = [l.remote_id for l in saved_lists] # follows - follows = models.UserFollows.objects.filter(user_subject=user).distinct() - following = models.User.objects.filter( + follows = UserFollows.objects.filter(user_subject=user).distinct() + following = User.objects.filter( userfollows_user_object__in=follows ).distinct() follows = [f.remote_id for f in following] # blocks - blocks = models.UserBlocks.objects.filter(user_subject=user).distinct() - blocking = models.User.objects.filter(userblocks_user_object__in=blocks).distinct() + blocks = UserBlocks.objects.filter(user_subject=user).distinct() + blocking = User.objects.filter(userblocks_user_object__in=blocks).distinct() blocks = [b.remote_id for b in blocking] @@ -207,7 +207,7 @@ def get_books_for_user(user): """Get all the books and editions related to a user :returns: tuple of editions, books """ - all_books = models.Edition.viewer_aware_objects(user) + all_books = Edition.viewer_aware_objects(user) editions = all_books.filter( Q(shelves__user=user) | Q(readthrough__user=user) @@ -216,5 +216,5 @@ def get_books_for_user(user): | Q(comment__user=user) | Q(quotation__user=user) ).distinct() - books = models.Book.objects.filter(id__in=editions).distinct() + books = Book.objects.filter(id__in=editions).distinct() return editions, books From 93a32f4e1551c3c8843ff3f8a306b1ba7ba5960c Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Mon, 6 Nov 2023 14:40:19 +1100 Subject: [PATCH 21/38] update import/export user templates - always explain what export file can be used for - provide more information about overwrite vs upsert when importing --- bookwyrm/templates/import/import_user.html | 29 +++++++++++++++++-- .../templates/preferences/export-user.html | 8 ++--- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/bookwyrm/templates/import/import_user.html b/bookwyrm/templates/import/import_user.html index 8e7bb1a09..1871da84f 100644 --- a/bookwyrm/templates/import/import_user.html +++ b/bookwyrm/templates/import/import_user.html @@ -38,9 +38,32 @@ {{ import_form.archive_file }} -
-

{% trans "Importing this file will overwrite any data you currently have saved." %}

-

{% trans "Deselect any data you do not wish to include in your import. Books will always be imported" %}

+
+ {% blocktrans trimmed %} +

Deselect any checkboxes for data you do not wish to include in your import.

+

Importing this file will not delete any data but will overwrite the following information:

+
    +
  • Profile
  • +
      +
    • name
    • +
    • summary
    • +
    • avatar
    • +
    +
  • Settings
  • +
      +
    • whether manual approval is required for other users to follow your account
    • +
    • whether following/followers are shown on your profile
    • +
    • whether your reading goal is shown on your profile
    • +
    • whether you see user follow suggestions
    • +
    • whether your account is suggested to others
    • +
    • your timezone
    • +
    • your default post privacy setting
    • +
    +
  • Reading goals for all years listed in the import file
  • +
+

All other imported data will be added if it does not already exist. 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.

+ + {% endblocktrans %}
diff --git a/bookwyrm/templates/preferences/export-user.html b/bookwyrm/templates/preferences/export-user.html index 2f63c9e1c..437b6c7be 100644 --- a/bookwyrm/templates/preferences/export-user.html +++ b/bookwyrm/templates/preferences/export-user.html @@ -9,16 +9,16 @@ {% block panel %}
+

+ {% trans "Your exported archive file will include all user data for import into another Bookwyrm server" %} +

{% if next_available %}

- {% blocktrans %} + {% blocktrans trimmed %} You will be able to create a new export file at {{ next_available }} {% endblocktrans %}

{% else %} -

- {% trans "Your exported archive file will include all user data for import into another Bookwyrm server" %} -

{% csrf_token %}
{% trans "ID" %} {{ import.status }}
- - @@ -56,13 +53,6 @@ {% endif %} {% for job in jobs %} - + {% endfor %} From 06d822d9e031afedd5e2b6bd2e73a63684afd712 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Mon, 6 Nov 2023 09:35:04 -0800 Subject: [PATCH 26/38] Alternative format for user import guide --- bookwyrm/templates/import/import_user.html | 179 +++++++++++++-------- 1 file changed, 109 insertions(+), 70 deletions(-) diff --git a/bookwyrm/templates/import/import_user.html b/bookwyrm/templates/import/import_user.html index 1871da84f..3f0f7453d 100644 --- a/bookwyrm/templates/import/import_user.html +++ b/bookwyrm/templates/import/import_user.html @@ -29,85 +29,124 @@

{% blocktrans %}You will next be able to import a user file at {{ next_available }}{% endblocktrans %}

{% else %} - + {% csrf_token %} -
-
-
+
+
+

{% trans "Step 1:" %}

+

+ {% blocktrans trimmed %} + Select an export file generated from another BookWyrm account. The file format should be .tar.gz. + {% endblocktrans %} +

+
+
{{ import_form.archive_file }}
-
- {% blocktrans trimmed %} -

Deselect any checkboxes for data you do not wish to include in your import.

-

Importing this file will not delete any data but will overwrite the following information:

-
    -
  • Profile
  • -
      -
    • name
    • -
    • summary
    • -
    • avatar
    • -
    -
  • Settings
  • -
      -
    • whether manual approval is required for other users to follow your account
    • -
    • whether following/followers are shown on your profile
    • -
    • whether your reading goal is shown on your profile
    • -
    • whether you see user follow suggestions
    • -
    • whether your account is suggested to others
    • -
    • your timezone
    • -
    • your default post privacy setting
    • -
    -
  • Reading goals for all years listed in the import file
  • -
-

All other imported data will be added if it does not already exist. 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.

+
- {% endblocktrans %} + + +
+
+

{% trans "Step 2:" %}

+

+ {% blocktrans trimmed %} + Deselect any checkboxes for data you do not wish to include in your import. + {% endblocktrans %} +

+

Unless specified below, importing will not delete any data. Imported data will be added if it does not already exist. 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.

+
+
+
+
+ +

+ {% trans "Overwrites display name, summary, and avatar" %} +

+
+
+ +
+ {% trans "Overwrites:" %} +
    +
  • + {% trans "Whether manual approval is required for other users to follow your account" %} +
  • +
  • + {% trans "Whether following/followers are shown on your profile" %} +
  • +
  • + {% trans "Whether your reading goal is shown on your profile" %} +
  • +
  • + {% trans "Whether you see user follow suggestions" %} +
  • +
  • + {% trans "Whether your account is suggested to others" %} +
  • +
  • + {% trans "Your timezone" %} +
  • +
  • + {% trans "Your default post privacy setting" %} +
  • +
+
+
+
+ +
+ +
+
+
+ +

+ {% trans "Reading goals for all years listed in the import file" %} +

+
+ + + + + + + +
-
-
- - - - - - - - - - - - -
-
-
{% if not import_limit_reset and not import_size_limit or allowed_imports > 0 %} {% else %} From 3f038b4d67c324a49c647e75d69f1f9ff5620c12 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Mon, 6 Nov 2023 09:42:58 -0800 Subject: [PATCH 27/38] Moves if to the right place --- bookwyrm/templates/preferences/export-user.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bookwyrm/templates/preferences/export-user.html b/bookwyrm/templates/preferences/export-user.html index b1e36f9d2..0a45b8b0a 100644 --- a/bookwyrm/templates/preferences/export-user.html +++ b/bookwyrm/templates/preferences/export-user.html @@ -75,9 +75,9 @@ {% trans "Active" %} {% endif %} - {% if job.complete and not job.status == "stopped" and not job.status == "failed" %}
- + {% if not jobs %} @@ -76,6 +80,9 @@ {% endif %} +
- {% trans "Date Created" %} + {% trans "Date" %} - {% trans "Last Updated" %} - + {% trans "Status" %}
- {% if job.complete and not job.status == "stopped" and not job.status == "failed" %} -

{{ job.created_date }}

- {% else %} -

{{ job.created_date }}

- {% endif %} -
{{ job.updated_date }} + {% if job.complete and not job.status == "stopped" and not job.status == "failed" %} + +

+ + + + {% trans "Download your export" %} + + +

+ {% endif %}
+ {% if job.complete and not job.status == "stopped" and not job.status == "failed" %}

From 282f7dd8d649dca3fbb7a5ac96e8850eada55e9f Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Tue, 7 Nov 2023 11:04:11 +1100 Subject: [PATCH 28/38] show filesize on user downloads page - add column to user download page to display filesize - adds a filter to display file sizes - don't download the user downloads page from notifications ;) --- .../notifications/items/user_export.html | 2 +- bookwyrm/templates/preferences/export-user.html | 9 ++++++++- bookwyrm/templatetags/utilities.py | 17 +++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/bookwyrm/templates/notifications/items/user_export.html b/bookwyrm/templates/notifications/items/user_export.html index d4ab04faa..1df40dbac 100644 --- a/bookwyrm/templates/notifications/items/user_export.html +++ b/bookwyrm/templates/notifications/items/user_export.html @@ -11,5 +11,5 @@ {% block description %} {% url 'prefs-user-export' as url %} - {% blocktrans %}Your user export is ready.{% endblocktrans %} + {% blocktrans %}Your user export is ready.{% endblocktrans %} {% endblock %} diff --git a/bookwyrm/templates/preferences/export-user.html b/bookwyrm/templates/preferences/export-user.html index 0a45b8b0a..da8f537a6 100644 --- a/bookwyrm/templates/preferences/export-user.html +++ b/bookwyrm/templates/preferences/export-user.html @@ -1,5 +1,6 @@ {% extends 'preferences/layout.html' %} {% load i18n %} +{% load utilities %} {% block title %}{% trans "User Export" %}{% endblock %} @@ -40,9 +41,12 @@

{% trans "Date" %} + {% trans "Status" %} + {% trans "Size" %} +
+ {{ job.export_data|get_file_size }} + {% if job.complete and not job.status == "stopped" and not job.status == "failed" %}

diff --git a/bookwyrm/templatetags/utilities.py b/bookwyrm/templatetags/utilities.py index 42e67990f..3ae14c17c 100644 --- a/bookwyrm/templatetags/utilities.py +++ b/bookwyrm/templatetags/utilities.py @@ -125,3 +125,20 @@ def id_to_username(user_id): value = f"{name}@{domain}" return value + + +@register.filter(name="get_file_size") +def get_file_size(file): + """display the size of a file in human readable terms""" + + try: + raw_size = os.stat(file.path).st_size + if raw_size < 1024: + return f"{raw_size} bytes" + if raw_size < 1024**2: + return f"{raw_size/1024:.2f} KB" + if raw_size < 1024**3: + return f"{raw_size/1024**2:.2f} MB" + return f"{raw_size/1024**3:.2f} GB" + except Exception: # pylint: disable=broad-except + return "" From 0a5e1048ce041643affe67bf45036da9decb8fce Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Tue, 7 Nov 2023 12:09:06 +1100 Subject: [PATCH 29/38] Add more info to user export page (#3093) - match page title to menu - change description on IMPORT page from 'readthroughs' to 'reading history' - provide more information on export page about what is and is not included. --- bookwyrm/templates/import/import_user.html | 4 +-- .../templates/preferences/export-user.html | 35 ++++++++++++++++--- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/bookwyrm/templates/import/import_user.html b/bookwyrm/templates/import/import_user.html index 3f0f7453d..29081df00 100644 --- a/bookwyrm/templates/import/import_user.html +++ b/bookwyrm/templates/import/import_user.html @@ -119,14 +119,14 @@ {% trans "Reading goals" %}

- {% trans "Reading goals for all years listed in the import file" %} + {% trans "Overwrites reading goals for all years listed in the import file" %}