From cbd08127efe8105576be779ad7f0fa938878a36a Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sun, 14 Jan 2024 12:14:44 +1100 Subject: [PATCH 01/41] initial work on fixing user exports with s3 - custom storages - tar.gz within bucket using s3_tar - slightly changes export directory structure - major problems still outstanding re delivering s3 files to end users --- .../migrations/0192_auto_20240114_0055.py | 53 ++ bookwyrm/models/bookwyrm_export_job.py | 460 ++++++++++++------ bookwyrm/models/job.py | 7 +- bookwyrm/settings.py | 1 + bookwyrm/storage_backends.py | 14 + bookwyrm/templatetags/utilities.py | 27 +- exports/6ee95f7f-58cd-4bff-9d41-1ac2b3db6187 | Bin 0 -> 3820 bytes exports/ba15a57f-e29e-4a29-aaf4-306b66960273 | Bin 0 -> 41614 bytes requirements.txt | 1 + 9 files changed, 408 insertions(+), 155 deletions(-) create mode 100644 bookwyrm/migrations/0192_auto_20240114_0055.py create mode 100644 exports/6ee95f7f-58cd-4bff-9d41-1ac2b3db6187 create mode 100644 exports/ba15a57f-e29e-4a29-aaf4-306b66960273 diff --git a/bookwyrm/migrations/0192_auto_20240114_0055.py b/bookwyrm/migrations/0192_auto_20240114_0055.py new file mode 100644 index 000000000..f4d324f7f --- /dev/null +++ b/bookwyrm/migrations/0192_auto_20240114_0055.py @@ -0,0 +1,53 @@ +# Generated by Django 3.2.23 on 2024-01-14 00:55 + +import bookwyrm.storage_backends +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0191_merge_20240102_0326'), + ] + + operations = [ + migrations.AddField( + model_name='bookwyrmexportjob', + name='export_json', + field=models.JSONField(encoder=django.core.serializers.json.DjangoJSONEncoder, null=True), + ), + migrations.AddField( + model_name='bookwyrmexportjob', + name='json_completed', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='bookwyrmexportjob', + name='export_data', + field=models.FileField(null=True, storage=bookwyrm.storage_backends.ExportsFileStorage, upload_to=''), + ), + migrations.CreateModel( + name='AddFileToTar', + fields=[ + ('childjob_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.childjob')), + ('parent_export_job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='child_edition_export_jobs', to='bookwyrm.bookwyrmexportjob')), + ], + options={ + 'abstract': False, + }, + bases=('bookwyrm.childjob',), + ), + migrations.CreateModel( + name='AddBookToUserExportJob', + fields=[ + ('childjob_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.childjob')), + ('edition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.edition')), + ], + options={ + 'abstract': False, + }, + bases=('bookwyrm.childjob',), + ), + ] diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index 1f6085e0c..12a9792e2 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -2,94 +2,347 @@ import dataclasses import logging +import boto3 +from s3_tar import S3Tar from uuid import uuid4 -from django.db.models import FileField +from django.db.models import CASCADE, BooleanField, FileField, ForeignKey, JSONField from django.db.models import Q from django.core.serializers.json import DjangoJSONEncoder -from django.core.files.base import ContentFile +from django.core.files.base import ContentFile, File +from django.utils import timezone + +from bookwyrm import settings, storage_backends from bookwyrm.models import AnnualGoal, ReadThrough, ShelfBook, List, ListItem from bookwyrm.models import Review, Comment, Quotation from bookwyrm.models import Edition from bookwyrm.models import UserFollows, User, UserBlocks -from bookwyrm.models.job import ParentJob, ParentTask +from bookwyrm.models.job import ParentJob, ChildJob, ParentTask, SubTask from bookwyrm.tasks import app, IMPORTS from bookwyrm.utils.tar import BookwyrmTarFile logger = logging.getLogger(__name__) - class BookwyrmExportJob(ParentJob): """entry for a specific request to export a bookwyrm user""" - export_data = FileField(null=True) + if settings.USE_S3: + storage = storage_backends.ExportsS3Storage + else: + storage = storage_backends.ExportsFileStorage + + export_data = FileField(null=True, storage=storage) # use custom storage backend here + export_json = JSONField(null=True, encoder=DjangoJSONEncoder) + json_completed = BooleanField(default=False) + def start_job(self): """Start the job""" - start_export_task.delay(job_id=self.id, no_children=True) - return self + task = start_export_task.delay(job_id=self.id, no_children=False) + self.task_id = task.id + self.save(update_fields=["task_id"]) + + + 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: + if not self.json_completed: + + try: + self.json_completed = True + self.save(update_fields=["json_completed"]) + + # add json file to tarfile + tar_job = AddFileToTar.objects.create( + parent_job=self, + parent_export_job=self + ) + tar_job.start_job() + + except Exception as err: # pylint: disable=broad-except + logger.exception("job %s failed with error: %s", self.id, err) + tar_job.set_status("failed") + self.stop_job(reason="failed") + + else: + self.complete_job() + + +class AddBookToUserExportJob(ChildJob): + """append book metadata for each book in an export""" + + edition = ForeignKey(Edition, on_delete=CASCADE) + + def start_job(self): + """Start the job""" + try: + + book = {} + book["work"] = self.edition.parent_work.to_activity() + book["edition"] = self.edition.to_activity() + + if book["edition"].get("cover"): + # change the URL to be relative to the JSON file + filename = book["edition"]["cover"]["url"].rsplit("/", maxsplit=1)[-1] + book["edition"]["cover"]["url"] = f"covers/{filename}" + + # authors + book["authors"] = [] + for author in self.edition.authors.all(): + book["authors"].append(author.to_activity()) + + # Shelves this book is on + # Every ShelfItem is this book so we don't other serializing + book["shelves"] = [] + shelf_books = ( + ShelfBook.objects.select_related("shelf") + .filter(user=self.parent_job.user, book=self.edition) + .distinct() + ) + + for shelfbook in shelf_books: + book["shelves"].append(shelfbook.shelf.to_activity()) + + # Lists and ListItems + # ListItems include "notes" and "approved" so we need them + # even though we know it's this book + book["lists"] = [] + list_items = ListItem.objects.filter(book=self.edition, user=self.parent_job.user).distinct() + + for item in list_items: + list_info = item.book_list.to_activity() + list_info[ + "privacy" + ] = item.book_list.privacy # this isn't serialized so we add it + list_info["list_item"] = item.to_activity() + book["lists"].append(list_info) + + # Statuses + # Can't use select_subclasses here because + # we need to filter on the "book" value, + # which is not available on an ordinary Status + for status in ["comments", "quotations", "reviews"]: + book[status] = [] + + + comments = Comment.objects.filter(user=self.parent_job.user, book=self.edition).all() + for status in comments: + obj = status.to_activity() + obj["progress"] = status.progress + obj["progress_mode"] = status.progress_mode + book["comments"].append(obj) + + + quotes = Quotation.objects.filter(user=self.parent_job.user, book=self.edition).all() + for status in quotes: + obj = status.to_activity() + obj["position"] = status.position + obj["endposition"] = status.endposition + obj["position_mode"] = status.position_mode + book["quotations"].append(obj) + + + reviews = Review.objects.filter(user=self.parent_job.user, book=self.edition).all() + for status in reviews: + obj = status.to_activity() + book["reviews"].append(obj) + + # readthroughs can't be serialized to activity + book_readthroughs = ( + ReadThrough.objects.filter(user=self.parent_job.user, book=self.edition).distinct().values() + ) + book["readthroughs"] = list(book_readthroughs) + + self.parent_job.export_json["books"].append(book) + self.parent_job.save(update_fields=["export_json"]) + self.complete_job() + + except Exception as err: # pylint: disable=broad-except + logger.exception("AddBookToUserExportJob %s Failed with error: %s", self.id, err) + self.set_status("failed") + + +class AddFileToTar(ChildJob): + """add files to export""" + + parent_export_job = ForeignKey( + BookwyrmExportJob, on_delete=CASCADE, related_name="child_edition_export_jobs" + ) # TODO: do we actually need this? Does self.parent_job.export_data work? + + + def start_job(self): + """Start the job""" + + # NOTE we are doing this all in one big job, which has the potential to block a thread + # This is because we need to refer to the same s3_job or BookwyrmTarFile whilst writing + # Alternatives using a series of jobs in a loop would be beter + # but Hugh couldn't make that work + + try: + task_id=self.parent_export_job.task_id + export_data = self.parent_export_job.export_data + export_json = self.parent_export_job.export_json + json_data = DjangoJSONEncoder().encode(export_json) + user = self.parent_export_job.user + editions = get_books_for_user(user) + + if settings.USE_S3: + s3_job = S3Tar( + settings.AWS_STORAGE_BUCKET_NAME, + f"exports/{str(self.parent_export_job.task_id)}.tar.gz" + ) + + # TODO: either encrypt the file or we will need to get it to the user + # from this secure part of the bucket + export_data.save("archive.json", ContentFile(json_data.encode("utf-8"))) + + s3_job.add_file( + f"exports/{export_data.name}" + ) + s3_job.add_file( + f"images/{user.avatar.name}", + folder="avatar" + ) + for book in editions: + if getattr(book, "cover", False): + cover_name = f"images/{book.cover.name}" + s3_job.add_file( + cover_name, + folder="covers" + ) + + s3_job.tar() + # delete export json as soon as it's tarred + # there is probably a better way to do this + # Currently this merely makes the file empty + export_data.delete(save=False) + + else: + # TODO: is the export_data file open to the world? + logger.info( "export file URL: %s",export_data.url) + + export_data.open("wb") + with BookwyrmTarFile.open(mode="w:gz", fileobj=export_data) 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", directory=f"avatar/") # TODO: does this work? + + for book in editions: + if getattr(book, "cover", False): + tar.add_image(book.cover) + + export_data.close() + + + self.complete_job() + + except Exception as err: # pylint: disable=broad-except + logger.exception("AddFileToTar %s Failed with error: %s", self.id, err) + self.stop_job(reason="failed") + self.parent_job.stop_job(reason="failed") @app.task(queue=IMPORTS, base=ParentTask) def start_export_task(**kwargs): - """trigger the child tasks for each row""" + """trigger the child tasks for user export""" + job = BookwyrmExportJob.objects.get(id=kwargs["job_id"]) # don't start the job if it was stopped from the UI if job.complete: return try: - # This is where ChildJobs get made + + # prepare the initial file and base json job.export_data = ContentFile(b"", str(uuid4())) - json_data = json_export(job.user) - tar_export(json_data, job.user, job.export_data) - job.save(update_fields=["export_data"]) + job.export_json = job.user.to_activity() + job.save(update_fields=["export_data", "export_json"]) + + # let's go + json_export.delay(job_id=job.id, job_user=job.user.id, no_children=False) + 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") +@app.task(queue=IMPORTS, base=ParentTask) +def export_saved_lists_task(**kwargs): + """add user saved lists to export JSON""" + + job = BookwyrmExportJob.objects.get(id=kwargs["job_id"]) + saved_lists = List.objects.filter(id__in=job.user.saved_lists.all()).distinct() + job.export_json["saved_lists"] = [l.remote_id for l in saved_lists] + job.save(update_fields=["export_json"]) -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")) +@app.task(queue=IMPORTS, base=ParentTask) +def export_follows_task(**kwargs): + """add user follows to export JSON""" - # Add avatar image if present - if getattr(user, "avatar", False): - tar.add_image(user.avatar, filename="avatar") - - editions = get_books_for_user(user) - for book in editions: - if getattr(book, "cover", False): - tar.add_image(book.cover) - - file.close() + job = BookwyrmExportJob.objects.get(id=kwargs["job_id"]) + follows = UserFollows.objects.filter(user_subject=job.user).distinct() + following = User.objects.filter(userfollows_user_object__in=follows).distinct() + job.export_json["follows"] = [f.remote_id for f in following] + job.save(update_fields=["export_json"]) -def json_export( - user, -): # pylint: disable=too-many-locals, too-many-statements, too-many-branches +@app.task(queue=IMPORTS, base=ParentTask) +def export_blocks_task(**kwargs): + """add user blocks to export JSON""" + + job = BookwyrmExportJob.objects.get(id=kwargs["job_id"]) + blocks = UserBlocks.objects.filter(user_subject=job.user).distinct() + blocking = User.objects.filter(userblocks_user_object__in=blocks).distinct() + job.export_json["blocks"] = [b.remote_id for b in blocking] + job.save(update_fields=["export_json"]) + + +@app.task(queue=IMPORTS, base=ParentTask) +def export_reading_goals_task(**kwargs): + """add user reading goals to export JSON""" + + job = BookwyrmExportJob.objects.get(id=kwargs["job_id"]) + reading_goals = AnnualGoal.objects.filter(user=job.user).distinct() + job.export_json["goals"] = [] + for goal in reading_goals: + exported_user["goals"].append( + {"goal": goal.goal, "year": goal.year, "privacy": goal.privacy} + ) + job.save(update_fields=["export_json"]) + + +@app.task(queue=IMPORTS, base=ParentTask) +def json_export(**kwargs): """Generate an export for a user""" - # User as AP object - exported_user = user.to_activity() + job = BookwyrmExportJob.objects.get(id=kwargs["job_id"]) + job.set_status("active") + job_id = kwargs["job_id"] + # I don't love this but it prevents a JSON encoding error # when there is no user image if isinstance( - exported_user["icon"], + job.export_json["icon"], dataclasses._MISSING_TYPE, # pylint: disable=protected-access ): - exported_user["icon"] = {} + job.export_json["icon"] = {} else: # change the URL to be relative to the JSON file - file_type = exported_user["icon"]["url"].rsplit(".", maxsplit=1)[-1] + file_type = job.export_json["icon"]["url"].rsplit(".", maxsplit=1)[-1] filename = f"avatar.{file_type}" - exported_user["icon"]["url"] = filename + job.export_json["icon"]["url"] = filename # Additional settings - can't be serialized as AP vals = [ @@ -98,120 +351,45 @@ def json_export( "default_post_privacy", "show_suggested_users", ] - exported_user["settings"] = {} + job.export_json["settings"] = {} for k in vals: - exported_user["settings"][k] = getattr(user, k) + job.export_json["settings"][k] = getattr(job.user, k) - # Reading goals - can't be serialized as AP - reading_goals = AnnualGoal.objects.filter(user=user).distinct() - exported_user["goals"] = [] - for goal in reading_goals: - exported_user["goals"].append( - {"goal": goal.goal, "year": goal.year, "privacy": goal.privacy} - ) + job.export_json["books"] = [] - # Reading history - can't be serialized as AP - readthroughs = ReadThrough.objects.filter(user=user).distinct().values() - readthroughs = list(readthroughs) + # save settings we just updated + job.save(update_fields=["export_json"]) - # Books - editions = get_books_for_user(user) - exported_user["books"] = [] + # trigger subtasks + export_saved_lists_task.delay(job_id=job_id, no_children=False) + export_follows_task.delay(job_id=job_id, no_children=False) + export_blocks_task.delay(job_id=job_id, no_children=False) + trigger_books_jobs.delay(job_id=job_id, no_children=False) - for edition in editions: - book = {} - book["work"] = edition.parent_work.to_activity() - book["edition"] = edition.to_activity() - if book["edition"].get("cover"): - # change the URL to be relative to the JSON file - filename = book["edition"]["cover"]["url"].rsplit("/", maxsplit=1)[-1] - book["edition"]["cover"]["url"] = f"covers/{filename}" +@app.task(queue=IMPORTS, base=ParentTask) +def trigger_books_jobs(**kwargs): + """trigger tasks to get data for each book""" - # authors - book["authors"] = [] - for author in edition.authors.all(): - book["authors"].append(author.to_activity()) + try: + job = BookwyrmExportJob.objects.get(id=kwargs["job_id"]) + editions = get_books_for_user(job.user) - # Shelves this book is on - # Every ShelfItem is this book so we don't other serializing - book["shelves"] = [] - shelf_books = ( - ShelfBook.objects.select_related("shelf") - .filter(user=user, book=edition) - .distinct() - ) + if len(editions) == 0: + job.notify_child_job_complete() + return - for shelfbook in shelf_books: - book["shelves"].append(shelfbook.shelf.to_activity()) - - # Lists and ListItems - # ListItems include "notes" and "approved" so we need them - # even though we know it's this book - book["lists"] = [] - list_items = ListItem.objects.filter(book=edition, user=user).distinct() - - for item in list_items: - list_info = item.book_list.to_activity() - list_info[ - "privacy" - ] = item.book_list.privacy # this isn't serialized so we add it - list_info["list_item"] = item.to_activity() - book["lists"].append(list_info) - - # Statuses - # Can't use select_subclasses here because - # we need to filter on the "book" value, - # which is not available on an ordinary Status - for status in ["comments", "quotations", "reviews"]: - book[status] = [] - - comments = Comment.objects.filter(user=user, book=edition).all() - for status in comments: - obj = status.to_activity() - obj["progress"] = status.progress - obj["progress_mode"] = status.progress_mode - book["comments"].append(obj) - - quotes = Quotation.objects.filter(user=user, book=edition).all() - for status in quotes: - obj = status.to_activity() - obj["position"] = status.position - obj["endposition"] = status.endposition - obj["position_mode"] = status.position_mode - book["quotations"].append(obj) - - reviews = Review.objects.filter(user=user, book=edition).all() - for status in reviews: - obj = status.to_activity() - book["reviews"].append(obj) - - # readthroughs can't be serialized to activity - book_readthroughs = ( - ReadThrough.objects.filter(user=user, book=edition).distinct().values() - ) - book["readthroughs"] = list(book_readthroughs) - - # append everything - exported_user["books"].append(book) - - # saved book lists - just the remote id - saved_lists = List.objects.filter(id__in=user.saved_lists.all()).distinct() - exported_user["saved_lists"] = [l.remote_id for l in saved_lists] - - # follows - just the remote id - follows = UserFollows.objects.filter(user_subject=user).distinct() - following = User.objects.filter(userfollows_user_object__in=follows).distinct() - exported_user["follows"] = [f.remote_id for f in following] - - # blocks - just the remote id - blocks = UserBlocks.objects.filter(user_subject=user).distinct() - blocking = User.objects.filter(userblocks_user_object__in=blocks).distinct() - - exported_user["blocks"] = [b.remote_id for b in blocking] - - return DjangoJSONEncoder().encode(exported_user) + for edition in editions: + try: + edition_job = AddBookToUserExportJob.objects.create(edition=edition, parent_job=job) + edition_job.start_job() + except Exception as err: # pylint: disable=broad-except + logger.exception("AddBookToUserExportJob %s Failed with error: %s", edition_job.id, err) + edition_job.set_status("failed") + except Exception as err: # pylint: disable=broad-except + logger.exception("trigger_books_jobs %s Failed with error: %s", job.id, err) + job.set_status("failed") def get_books_for_user(user): """Get all the books and editions related to a user""" diff --git a/bookwyrm/models/job.py b/bookwyrm/models/job.py index 4f5cb2093..5a2653571 100644 --- a/bookwyrm/models/job.py +++ b/bookwyrm/models/job.py @@ -135,8 +135,7 @@ class ParentJob(Job): ) app.control.revoke(list(tasks)) - for task in self.pending_child_jobs: - task.update(status=self.Status.STOPPED) + self.pending_child_jobs.update(status=self.Status.STOPPED) @property def has_completed(self): @@ -248,7 +247,7 @@ class SubTask(app.Task): """ def before_start( - self, task_id, args, kwargs + self, task_id, *args, **kwargs ): # pylint: disable=no-self-use, unused-argument """Handler called before the task starts. Override. @@ -272,7 +271,7 @@ class SubTask(app.Task): child_job.set_status(ChildJob.Status.ACTIVE) def on_success( - self, retval, task_id, args, kwargs + self, retval, task_id, *args, **kwargs ): # pylint: disable=no-self-use, unused-argument """Run by the worker if the task executes successfully. Override. diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index fcc91857a..7896850e3 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -442,3 +442,4 @@ 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" +DATA_UPLOAD_MAX_MEMORY_SIZE = (1024**2 * 20) # 20MB TEMPORARY FIX WHILST WORKING ON THIS \ No newline at end of file diff --git a/bookwyrm/storage_backends.py b/bookwyrm/storage_backends.py index 6dd9f522c..c97b4e848 100644 --- a/bookwyrm/storage_backends.py +++ b/bookwyrm/storage_backends.py @@ -1,6 +1,7 @@ """Handles backends for storages""" import os from tempfile import SpooledTemporaryFile +from django.core.files.storage import FileSystemStorage from storages.backends.s3boto3 import S3Boto3Storage from storages.backends.azure_storage import AzureStorage @@ -61,3 +62,16 @@ class AzureImagesStorage(AzureStorage): # pylint: disable=abstract-method location = "images" overwrite_files = False + +class ExportsFileStorage(FileSystemStorage): # pylint: disable=abstract-method + """Storage class for exports contents with local files""" + + location = "exports" + overwrite_files = False + +class ExportsS3Storage(S3Boto3Storage): # pylint: disable=abstract-method + """Storage class for exports contents with S3""" + + location = "exports" + default_acl = None + overwrite_files = False \ No newline at end of file diff --git a/bookwyrm/templatetags/utilities.py b/bookwyrm/templatetags/utilities.py index fca66688a..754db41dd 100644 --- a/bookwyrm/templatetags/utilities.py +++ b/bookwyrm/templatetags/utilities.py @@ -9,7 +9,7 @@ from django.utils.translation import gettext_lazy as _ from django.templatetags.static import static from bookwyrm.models import User -from bookwyrm.settings import INSTANCE_ACTOR_USERNAME +from bookwyrm.settings import INSTANCE_ACTOR_USERNAME, USE_S3 register = template.Library() @@ -133,15 +133,22 @@ 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 + # TODO: this obviously isn't a proper solution + # boto storages do not implement 'path' + if not USE_S3: + 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" + + return "" + + except Exception as e: # pylint: disable=broad-except + print(e) return "" diff --git a/exports/6ee95f7f-58cd-4bff-9d41-1ac2b3db6187 b/exports/6ee95f7f-58cd-4bff-9d41-1ac2b3db6187 new file mode 100644 index 0000000000000000000000000000000000000000..d7166b70306179d10b652499641665bc4e5a992a GIT binary patch literal 3820 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 literal 0 HcmV?d00001 diff --git a/exports/ba15a57f-e29e-4a29-aaf4-306b66960273 b/exports/ba15a57f-e29e-4a29-aaf4-306b66960273 new file mode 100644 index 0000000000000000000000000000000000000000..318069303d9f47f3296429b66dc293422a67d8fe GIT binary patch literal 41614 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 Date: Sun, 14 Jan 2024 12:19:59 +1100 Subject: [PATCH 02/41] oops - remove test export files - check in emblackened files --- .../migrations/0192_auto_20240114_0055.py | 77 ++++++++++++----- bookwyrm/models/bookwyrm_export_job.py | 79 ++++++++++-------- bookwyrm/settings.py | 4 +- bookwyrm/storage_backends.py | 4 +- exports/6ee95f7f-58cd-4bff-9d41-1ac2b3db6187 | Bin 3820 -> 0 bytes exports/ba15a57f-e29e-4a29-aaf4-306b66960273 | Bin 41614 -> 0 bytes 6 files changed, 108 insertions(+), 56 deletions(-) delete mode 100644 exports/6ee95f7f-58cd-4bff-9d41-1ac2b3db6187 delete mode 100644 exports/ba15a57f-e29e-4a29-aaf4-306b66960273 diff --git a/bookwyrm/migrations/0192_auto_20240114_0055.py b/bookwyrm/migrations/0192_auto_20240114_0055.py index f4d324f7f..824439728 100644 --- a/bookwyrm/migrations/0192_auto_20240114_0055.py +++ b/bookwyrm/migrations/0192_auto_20240114_0055.py @@ -9,45 +9,84 @@ import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ('bookwyrm', '0191_merge_20240102_0326'), + ("bookwyrm", "0191_merge_20240102_0326"), ] operations = [ migrations.AddField( - model_name='bookwyrmexportjob', - name='export_json', - field=models.JSONField(encoder=django.core.serializers.json.DjangoJSONEncoder, null=True), + model_name="bookwyrmexportjob", + name="export_json", + field=models.JSONField( + encoder=django.core.serializers.json.DjangoJSONEncoder, null=True + ), ), migrations.AddField( - model_name='bookwyrmexportjob', - name='json_completed', + model_name="bookwyrmexportjob", + name="json_completed", field=models.BooleanField(default=False), ), migrations.AlterField( - model_name='bookwyrmexportjob', - name='export_data', - field=models.FileField(null=True, storage=bookwyrm.storage_backends.ExportsFileStorage, upload_to=''), + model_name="bookwyrmexportjob", + name="export_data", + field=models.FileField( + null=True, + storage=bookwyrm.storage_backends.ExportsFileStorage, + upload_to="", + ), ), migrations.CreateModel( - name='AddFileToTar', + name="AddFileToTar", fields=[ - ('childjob_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.childjob')), - ('parent_export_job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='child_edition_export_jobs', to='bookwyrm.bookwyrmexportjob')), + ( + "childjob_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="bookwyrm.childjob", + ), + ), + ( + "parent_export_job", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="child_edition_export_jobs", + to="bookwyrm.bookwyrmexportjob", + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, - bases=('bookwyrm.childjob',), + bases=("bookwyrm.childjob",), ), migrations.CreateModel( - name='AddBookToUserExportJob', + name="AddBookToUserExportJob", fields=[ - ('childjob_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.childjob')), - ('edition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.edition')), + ( + "childjob_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="bookwyrm.childjob", + ), + ), + ( + "edition", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="bookwyrm.edition", + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, - bases=('bookwyrm.childjob',), + bases=("bookwyrm.childjob",), ), ] diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index 12a9792e2..2d1c0d94f 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -24,6 +24,7 @@ from bookwyrm.utils.tar import BookwyrmTarFile logger = logging.getLogger(__name__) + class BookwyrmExportJob(ParentJob): """entry for a specific request to export a bookwyrm user""" @@ -32,11 +33,12 @@ class BookwyrmExportJob(ParentJob): else: storage = storage_backends.ExportsFileStorage - export_data = FileField(null=True, storage=storage) # use custom storage backend here + export_data = FileField( + null=True, storage=storage + ) # use custom storage backend here export_json = JSONField(null=True, encoder=DjangoJSONEncoder) json_completed = BooleanField(default=False) - def start_job(self): """Start the job""" @@ -44,7 +46,6 @@ class BookwyrmExportJob(ParentJob): self.task_id = task.id self.save(update_fields=["task_id"]) - def notify_child_job_complete(self): """let the job know when the items get work done""" @@ -63,9 +64,8 @@ class BookwyrmExportJob(ParentJob): # add json file to tarfile tar_job = AddFileToTar.objects.create( - parent_job=self, - parent_export_job=self - ) + parent_job=self, parent_export_job=self + ) tar_job.start_job() except Exception as err: # pylint: disable=broad-except @@ -116,7 +116,9 @@ class AddBookToUserExportJob(ChildJob): # ListItems include "notes" and "approved" so we need them # even though we know it's this book book["lists"] = [] - list_items = ListItem.objects.filter(book=self.edition, user=self.parent_job.user).distinct() + list_items = ListItem.objects.filter( + book=self.edition, user=self.parent_job.user + ).distinct() for item in list_items: list_info = item.book_list.to_activity() @@ -133,16 +135,18 @@ class AddBookToUserExportJob(ChildJob): for status in ["comments", "quotations", "reviews"]: book[status] = [] - - comments = Comment.objects.filter(user=self.parent_job.user, book=self.edition).all() + comments = Comment.objects.filter( + user=self.parent_job.user, book=self.edition + ).all() for status in comments: obj = status.to_activity() obj["progress"] = status.progress obj["progress_mode"] = status.progress_mode book["comments"].append(obj) - - quotes = Quotation.objects.filter(user=self.parent_job.user, book=self.edition).all() + quotes = Quotation.objects.filter( + user=self.parent_job.user, book=self.edition + ).all() for status in quotes: obj = status.to_activity() obj["position"] = status.position @@ -150,15 +154,18 @@ class AddBookToUserExportJob(ChildJob): obj["position_mode"] = status.position_mode book["quotations"].append(obj) - - reviews = Review.objects.filter(user=self.parent_job.user, book=self.edition).all() + reviews = Review.objects.filter( + user=self.parent_job.user, book=self.edition + ).all() for status in reviews: obj = status.to_activity() book["reviews"].append(obj) # readthroughs can't be serialized to activity book_readthroughs = ( - ReadThrough.objects.filter(user=self.parent_job.user, book=self.edition).distinct().values() + ReadThrough.objects.filter(user=self.parent_job.user, book=self.edition) + .distinct() + .values() ) book["readthroughs"] = list(book_readthroughs) @@ -167,7 +174,9 @@ class AddBookToUserExportJob(ChildJob): self.complete_job() except Exception as err: # pylint: disable=broad-except - logger.exception("AddBookToUserExportJob %s Failed with error: %s", self.id, err) + logger.exception( + "AddBookToUserExportJob %s Failed with error: %s", self.id, err + ) self.set_status("failed") @@ -176,8 +185,7 @@ class AddFileToTar(ChildJob): parent_export_job = ForeignKey( BookwyrmExportJob, on_delete=CASCADE, related_name="child_edition_export_jobs" - ) # TODO: do we actually need this? Does self.parent_job.export_data work? - + ) # TODO: do we actually need this? Does self.parent_job.export_data work? def start_job(self): """Start the job""" @@ -188,7 +196,7 @@ class AddFileToTar(ChildJob): # but Hugh couldn't make that work try: - task_id=self.parent_export_job.task_id + task_id = self.parent_export_job.task_id export_data = self.parent_export_job.export_data export_json = self.parent_export_job.export_json json_data = DjangoJSONEncoder().encode(export_json) @@ -198,27 +206,19 @@ class AddFileToTar(ChildJob): if settings.USE_S3: s3_job = S3Tar( settings.AWS_STORAGE_BUCKET_NAME, - f"exports/{str(self.parent_export_job.task_id)}.tar.gz" + f"exports/{str(self.parent_export_job.task_id)}.tar.gz", ) # TODO: either encrypt the file or we will need to get it to the user # from this secure part of the bucket export_data.save("archive.json", ContentFile(json_data.encode("utf-8"))) - s3_job.add_file( - f"exports/{export_data.name}" - ) - s3_job.add_file( - f"images/{user.avatar.name}", - folder="avatar" - ) + s3_job.add_file(f"exports/{export_data.name}") + s3_job.add_file(f"images/{user.avatar.name}", folder="avatar") for book in editions: if getattr(book, "cover", False): cover_name = f"images/{book.cover.name}" - s3_job.add_file( - cover_name, - folder="covers" - ) + s3_job.add_file(cover_name, folder="covers") s3_job.tar() # delete export json as soon as it's tarred @@ -228,7 +228,7 @@ class AddFileToTar(ChildJob): else: # TODO: is the export_data file open to the world? - logger.info( "export file URL: %s",export_data.url) + logger.info("export file URL: %s", export_data.url) export_data.open("wb") with BookwyrmTarFile.open(mode="w:gz", fileobj=export_data) as tar: @@ -237,7 +237,9 @@ class AddFileToTar(ChildJob): # Add avatar image if present if getattr(user, "avatar", False): - tar.add_image(user.avatar, filename="avatar", directory=f"avatar/") # TODO: does this work? + tar.add_image( + user.avatar, filename="avatar", directory=f"avatar/" + ) # TODO: does this work? for book in editions: if getattr(book, "cover", False): @@ -245,7 +247,6 @@ class AddFileToTar(ChildJob): export_data.close() - self.complete_job() except Exception as err: # pylint: disable=broad-except @@ -277,6 +278,7 @@ def start_export_task(**kwargs): logger.exception("User Export Job %s Failed with error: %s", job.id, err) job.set_status("failed") + @app.task(queue=IMPORTS, base=ParentTask) def export_saved_lists_task(**kwargs): """add user saved lists to export JSON""" @@ -381,16 +383,23 @@ def trigger_books_jobs(**kwargs): for edition in editions: try: - edition_job = AddBookToUserExportJob.objects.create(edition=edition, parent_job=job) + edition_job = AddBookToUserExportJob.objects.create( + edition=edition, parent_job=job + ) edition_job.start_job() except Exception as err: # pylint: disable=broad-except - logger.exception("AddBookToUserExportJob %s Failed with error: %s", edition_job.id, err) + logger.exception( + "AddBookToUserExportJob %s Failed with error: %s", + edition_job.id, + err, + ) edition_job.set_status("failed") except Exception as err: # pylint: disable=broad-except logger.exception("trigger_books_jobs %s Failed with error: %s", job.id, err) job.set_status("failed") + def get_books_for_user(user): """Get all the books and editions related to a user""" diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index 7896850e3..7c8947521 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -442,4 +442,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" -DATA_UPLOAD_MAX_MEMORY_SIZE = (1024**2 * 20) # 20MB TEMPORARY FIX WHILST WORKING ON THIS \ No newline at end of file +DATA_UPLOAD_MAX_MEMORY_SIZE = ( + 1024**2 * 20 +) # 20MB TEMPORARY FIX WHILST WORKING ON THIS diff --git a/bookwyrm/storage_backends.py b/bookwyrm/storage_backends.py index c97b4e848..87c29ae70 100644 --- a/bookwyrm/storage_backends.py +++ b/bookwyrm/storage_backends.py @@ -63,15 +63,17 @@ class AzureImagesStorage(AzureStorage): # pylint: disable=abstract-method location = "images" overwrite_files = False + class ExportsFileStorage(FileSystemStorage): # pylint: disable=abstract-method """Storage class for exports contents with local files""" location = "exports" overwrite_files = False + class ExportsS3Storage(S3Boto3Storage): # pylint: disable=abstract-method """Storage class for exports contents with S3""" location = "exports" default_acl = None - overwrite_files = False \ No newline at end of file + overwrite_files = False diff --git a/exports/6ee95f7f-58cd-4bff-9d41-1ac2b3db6187 b/exports/6ee95f7f-58cd-4bff-9d41-1ac2b3db6187 deleted file mode 100644 index d7166b70306179d10b652499641665bc4e5a992a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3820 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 diff --git a/exports/ba15a57f-e29e-4a29-aaf4-306b66960273 b/exports/ba15a57f-e29e-4a29-aaf4-306b66960273 deleted file mode 100644 index 318069303d9f47f3296429b66dc293422a67d8fe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41614 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 Date: Sun, 14 Jan 2024 14:14:20 +1100 Subject: [PATCH 03/41] ignore exports dir --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ec2a08f80..6193f10c3 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ # BookWyrm .env /images/ +/exports/ bookwyrm/static/css/bookwyrm.css bookwyrm/static/css/themes/ !bookwyrm/static/css/themes/bookwyrm-*.scss From 469172947b7d38f3d43c165bd22f84ee0c7cf94f Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Thu, 18 Jan 2024 18:43:45 +1100 Subject: [PATCH 04/41] cleanup and linting --- bookwyrm/models/bookwyrm_export_job.py | 33 ++++++++++--------- bookwyrm/templatetags/utilities.py | 4 +-- .../tests/models/test_bookwyrm_export_job.py | 6 ++++ bookwyrm/views/preferences/export.py | 1 + 4 files changed, 26 insertions(+), 18 deletions(-) diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index 2d1c0d94f..bef5e1ea6 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -2,14 +2,14 @@ import dataclasses import logging -import boto3 -from s3_tar import S3Tar from uuid import uuid4 +from s3_tar import S3Tar + from django.db.models import CASCADE, BooleanField, FileField, ForeignKey, JSONField from django.db.models import Q from django.core.serializers.json import DjangoJSONEncoder -from django.core.files.base import ContentFile, File +from django.core.files.base import ContentFile from django.utils import timezone from bookwyrm import settings, storage_backends @@ -18,7 +18,7 @@ from bookwyrm.models import AnnualGoal, ReadThrough, ShelfBook, List, ListItem from bookwyrm.models import Review, Comment, Quotation from bookwyrm.models import Edition from bookwyrm.models import UserFollows, User, UserBlocks -from bookwyrm.models.job import ParentJob, ChildJob, ParentTask, SubTask +from bookwyrm.models.job import ParentJob, ChildJob, ParentTask from bookwyrm.tasks import app, IMPORTS from bookwyrm.utils.tar import BookwyrmTarFile @@ -82,6 +82,7 @@ class AddBookToUserExportJob(ChildJob): edition = ForeignKey(Edition, on_delete=CASCADE) + # pylint: disable=too-many-locals def start_job(self): """Start the job""" try: @@ -185,7 +186,7 @@ class AddFileToTar(ChildJob): parent_export_job = ForeignKey( BookwyrmExportJob, on_delete=CASCADE, related_name="child_edition_export_jobs" - ) # TODO: do we actually need this? Does self.parent_job.export_data work? + ) def start_job(self): """Start the job""" @@ -196,7 +197,6 @@ class AddFileToTar(ChildJob): # but Hugh couldn't make that work try: - task_id = self.parent_export_job.task_id export_data = self.parent_export_job.export_data export_json = self.parent_export_job.export_json json_data = DjangoJSONEncoder().encode(export_json) @@ -209,9 +209,12 @@ class AddFileToTar(ChildJob): f"exports/{str(self.parent_export_job.task_id)}.tar.gz", ) - # TODO: either encrypt the file or we will need to get it to the user + # TODO: will need to get it to the user # from this secure part of the bucket - export_data.save("archive.json", ContentFile(json_data.encode("utf-8"))) + export_data.save( + "archive.json", + ContentFile(json_data.encode("utf-8")) + ) s3_job.add_file(f"exports/{export_data.name}") s3_job.add_file(f"images/{user.avatar.name}", folder="avatar") @@ -222,14 +225,12 @@ class AddFileToTar(ChildJob): s3_job.tar() # delete export json as soon as it's tarred - # there is probably a better way to do this - # Currently this merely makes the file empty + # TODO: there is probably a better way to do this + # Currently this merely makes the file empty even though + # we're using save=False export_data.delete(save=False) else: - # TODO: is the export_data file open to the world? - logger.info("export file URL: %s", export_data.url) - export_data.open("wb") with BookwyrmTarFile.open(mode="w:gz", fileobj=export_data) as tar: @@ -238,8 +239,8 @@ class AddFileToTar(ChildJob): # Add avatar image if present if getattr(user, "avatar", False): tar.add_image( - user.avatar, filename="avatar", directory=f"avatar/" - ) # TODO: does this work? + user.avatar, filename="avatar", directory="avatar/" + ) for book in editions: if getattr(book, "cover", False): @@ -319,7 +320,7 @@ def export_reading_goals_task(**kwargs): reading_goals = AnnualGoal.objects.filter(user=job.user).distinct() job.export_json["goals"] = [] for goal in reading_goals: - exported_user["goals"].append( + job.export_json["goals"].append( {"goal": goal.goal, "year": goal.year, "privacy": goal.privacy} ) job.save(update_fields=["export_json"]) diff --git a/bookwyrm/templatetags/utilities.py b/bookwyrm/templatetags/utilities.py index a1b3d6cdf..225510085 100644 --- a/bookwyrm/templatetags/utilities.py +++ b/bookwyrm/templatetags/utilities.py @@ -148,8 +148,8 @@ def get_file_size(file): return "" - except Exception as e: # pylint: disable=broad-except - print(e) + except Exception as error: # pylint: disable=broad-except + print(error) return "" diff --git a/bookwyrm/tests/models/test_bookwyrm_export_job.py b/bookwyrm/tests/models/test_bookwyrm_export_job.py index b5f2520a9..1cf1f63d0 100644 --- a/bookwyrm/tests/models/test_bookwyrm_export_job.py +++ b/bookwyrm/tests/models/test_bookwyrm_export_job.py @@ -141,6 +141,8 @@ class BookwyrmExport(TestCase): book=self.edition, ) + + # pylint: disable=E1121 def test_json_export_user_settings(self): """Test the json export function for basic user info""" data = export_job.json_export(self.local_user) @@ -158,6 +160,8 @@ class BookwyrmExport(TestCase): ) self.assertEqual(user_data["settings"]["default_post_privacy"], "followers") + + # pylint: disable=E1121 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) @@ -180,6 +184,8 @@ class BookwyrmExport(TestCase): self.assertEqual(len(json_data["blocks"]), 1) self.assertEqual(json_data["blocks"][0], "https://your.domain.here/user/badger") + + # pylint: disable=E1121 def test_json_export_books(self): """Test the json export function for extended user info""" diff --git a/bookwyrm/views/preferences/export.py b/bookwyrm/views/preferences/export.py index d16f3aaa3..33c90291d 100644 --- a/bookwyrm/views/preferences/export.py +++ b/bookwyrm/views/preferences/export.py @@ -187,6 +187,7 @@ class ExportUser(View): class ExportArchive(View): """Serve the archive file""" + # TODO: how do we serve s3 files? def get(self, request, archive_id): """download user export file""" export = BookwyrmExportJob.objects.get(task_id=archive_id, user=request.user) From 26c37de2d4c84602d08f5cd434a09191be7e056d Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sat, 20 Jan 2024 07:16:42 +1100 Subject: [PATCH 05/41] linting --- bookwyrm/models/bookwyrm_export_job.py | 5 +---- bookwyrm/tests/models/test_bookwyrm_export_job.py | 3 --- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index bef5e1ea6..4b31b0ddf 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -211,10 +211,7 @@ class AddFileToTar(ChildJob): # TODO: will need to get it to the user # from this secure part of the bucket - export_data.save( - "archive.json", - ContentFile(json_data.encode("utf-8")) - ) + export_data.save("archive.json", ContentFile(json_data.encode("utf-8"))) s3_job.add_file(f"exports/{export_data.name}") s3_job.add_file(f"images/{user.avatar.name}", folder="avatar") diff --git a/bookwyrm/tests/models/test_bookwyrm_export_job.py b/bookwyrm/tests/models/test_bookwyrm_export_job.py index 1cf1f63d0..f0b9e445b 100644 --- a/bookwyrm/tests/models/test_bookwyrm_export_job.py +++ b/bookwyrm/tests/models/test_bookwyrm_export_job.py @@ -141,7 +141,6 @@ class BookwyrmExport(TestCase): book=self.edition, ) - # pylint: disable=E1121 def test_json_export_user_settings(self): """Test the json export function for basic user info""" @@ -160,7 +159,6 @@ class BookwyrmExport(TestCase): ) self.assertEqual(user_data["settings"]["default_post_privacy"], "followers") - # pylint: disable=E1121 def test_json_export_extended_user_data(self): """Test the json export function for other non-book user info""" @@ -184,7 +182,6 @@ class BookwyrmExport(TestCase): self.assertEqual(len(json_data["blocks"]), 1) self.assertEqual(json_data["blocks"][0], "https://your.domain.here/user/badger") - # pylint: disable=E1121 def test_json_export_books(self): """Test the json export function for extended user info""" From 2bb9a855917c3c2ecca9cc32efedf30edc87ea12 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sun, 28 Jan 2024 15:07:55 +1100 Subject: [PATCH 06/41] various fixes - use signed url for s3 downloads - re-arrange tar.gz file to match original - delete all working files after tarring - import from s3 export TODO - check local export and import - fix error when avatar missing - deal with multiple s3 storage options (e.g. Azure) --- .env.example | 1 + ...114_0055.py => 0193_auto_20240128_0249.py} | 4 +- bookwyrm/models/bookwyrm_export_job.py | 46 +++++++++++------ bookwyrm/models/bookwyrm_import_job.py | 13 +++-- .../templates/preferences/export-user.html | 50 ++++++++++++------- bookwyrm/templatetags/utilities.py | 22 +++----- bookwyrm/views/preferences/export.py | 34 ++++++++++++- 7 files changed, 114 insertions(+), 56 deletions(-) rename bookwyrm/migrations/{0192_auto_20240114_0055.py => 0193_auto_20240128_0249.py} (96%) diff --git a/.env.example b/.env.example index 20ce8240b..497d05779 100644 --- a/.env.example +++ b/.env.example @@ -81,6 +81,7 @@ AWS_SECRET_ACCESS_KEY= # AWS_S3_CUSTOM_DOMAIN=None # "example-bucket-name.s3.fr-par.scw.cloud" # AWS_S3_REGION_NAME=None # "fr-par" # AWS_S3_ENDPOINT_URL=None # "https://s3.fr-par.scw.cloud" +# S3_ENDPOINT_URL=None # same as AWS_S3_ENDPOINT_URL - needed for non-AWS for user exports # Commented are example values if you use Azure Blob Storage # USE_AZURE=true diff --git a/bookwyrm/migrations/0192_auto_20240114_0055.py b/bookwyrm/migrations/0193_auto_20240128_0249.py similarity index 96% rename from bookwyrm/migrations/0192_auto_20240114_0055.py rename to bookwyrm/migrations/0193_auto_20240128_0249.py index 824439728..c1c0527b9 100644 --- a/bookwyrm/migrations/0192_auto_20240114_0055.py +++ b/bookwyrm/migrations/0193_auto_20240128_0249.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.23 on 2024-01-14 00:55 +# Generated by Django 3.2.23 on 2024-01-28 02:49 import bookwyrm.storage_backends import django.core.serializers.json @@ -9,7 +9,7 @@ import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ("bookwyrm", "0191_merge_20240102_0326"), + ("bookwyrm", "0192_sitesettings_user_exports_enabled"), ] operations = [ diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index 4b31b0ddf..384e71701 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -5,6 +5,7 @@ import logging from uuid import uuid4 from s3_tar import S3Tar +from storages.backends.s3boto3 import S3Boto3Storage from django.db.models import CASCADE, BooleanField, FileField, ForeignKey, JSONField from django.db.models import Q @@ -57,7 +58,6 @@ class BookwyrmExportJob(ParentJob): if not self.complete and self.has_completed: if not self.json_completed: - try: self.json_completed = True self.save(update_fields=["json_completed"]) @@ -193,8 +193,7 @@ class AddFileToTar(ChildJob): # NOTE we are doing this all in one big job, which has the potential to block a thread # This is because we need to refer to the same s3_job or BookwyrmTarFile whilst writing - # Alternatives using a series of jobs in a loop would be beter - # but Hugh couldn't make that work + # Using a series of jobs in a loop would be better if possible try: export_data = self.parent_export_job.export_data @@ -203,29 +202,41 @@ class AddFileToTar(ChildJob): user = self.parent_export_job.user editions = get_books_for_user(user) + # filenames for later + export_data_original = str(export_data) + filename = str(self.parent_export_job.task_id) + if settings.USE_S3: s3_job = S3Tar( settings.AWS_STORAGE_BUCKET_NAME, - f"exports/{str(self.parent_export_job.task_id)}.tar.gz", + f"exports/{filename}.tar.gz", ) - # TODO: will need to get it to the user - # from this secure part of the bucket - export_data.save("archive.json", ContentFile(json_data.encode("utf-8"))) - + # save json file + export_data.save( + f"archive_{filename}.json", ContentFile(json_data.encode("utf-8")) + ) s3_job.add_file(f"exports/{export_data.name}") - s3_job.add_file(f"images/{user.avatar.name}", folder="avatar") + + # save image file + file_type = user.avatar.name.rsplit(".", maxsplit=1)[-1] + export_data.save(f"avatar_{filename}.{file_type}", user.avatar) + s3_job.add_file(f"exports/{export_data.name}") + for book in editions: if getattr(book, "cover", False): cover_name = f"images/{book.cover.name}" s3_job.add_file(cover_name, folder="covers") s3_job.tar() - # delete export json as soon as it's tarred - # TODO: there is probably a better way to do this - # Currently this merely makes the file empty even though - # we're using save=False - export_data.delete(save=False) + + # delete child files - we don't need them any more + s3_storage = S3Boto3Storage(querystring_auth=True, custom_domain=None) + S3Boto3Storage.delete(s3_storage, f"exports/{export_data_original}") + S3Boto3Storage.delete(s3_storage, f"exports/archive_{filename}.json") + S3Boto3Storage.delete( + s3_storage, f"exports/avatar_{filename}.{file_type}" + ) else: export_data.open("wb") @@ -266,7 +277,14 @@ def start_export_task(**kwargs): # prepare the initial file and base json job.export_data = ContentFile(b"", str(uuid4())) + # BUG: this throws a MISSING class error if there is no avatar + # #3096 may fix it + if not job.user.avatar: + job.user.avatar = "" + job.user.save() + job.export_json = job.user.to_activity() + logger.info(job.export_json) job.save(update_fields=["export_data", "export_json"]) # let's go diff --git a/bookwyrm/models/bookwyrm_import_job.py b/bookwyrm/models/bookwyrm_import_job.py index 9a11fd932..02af25d12 100644 --- a/bookwyrm/models/bookwyrm_import_job.py +++ b/bookwyrm/models/bookwyrm_import_job.py @@ -42,20 +42,23 @@ def start_import_task(**kwargs): try: archive_file.open("rb") with BookwyrmTarFile.open(mode="r:gz", fileobj=archive_file) as tar: - job.import_data = json.loads(tar.read("archive.json").decode("utf-8")) + json_filename = next( + filter(lambda n: n.startswith("archive"), tar.getnames()) + ) + job.import_data = json.loads(tar.read(json_filename).decode("utf-8")) if "include_user_profile" in job.required: update_user_profile(job.user, tar, job.import_data) if "include_user_settings" in job.required: update_user_settings(job.user, job.import_data) if "include_goals" in job.required: - update_goals(job.user, job.import_data.get("goals")) + update_goals(job.user, job.import_data.get("goals", [])) if "include_saved_lists" in job.required: - upsert_saved_lists(job.user, job.import_data.get("saved_lists")) + upsert_saved_lists(job.user, job.import_data.get("saved_lists", [])) if "include_follows" in job.required: - upsert_follows(job.user, job.import_data.get("follows")) + upsert_follows(job.user, job.import_data.get("follows", [])) if "include_blocks" in job.required: - upsert_user_blocks(job.user, job.import_data.get("blocks")) + upsert_user_blocks(job.user, job.import_data.get("blocks", [])) process_books(job, tar) diff --git a/bookwyrm/templates/preferences/export-user.html b/bookwyrm/templates/preferences/export-user.html index cd3119e3e..764d51db9 100644 --- a/bookwyrm/templates/preferences/export-user.html +++ b/bookwyrm/templates/preferences/export-user.html @@ -92,25 +92,25 @@ {% endif %} - {% for job in jobs %} + {% for export in jobs %} - {{ job.updated_date }} + {{ export.job.updated_date }} - {% if job.status %} - {{ job.status }} - {{ job.status_display }} - {% elif job.complete %} + {% if export.job.status %} + {{ export.job.status }} + {{ export.job.status_display }} + {% elif export.job.complete %} {% trans "Complete" %} {% else %} {% trans "Active" %} @@ -118,18 +118,30 @@ - {{ job.export_data|get_file_size }} + {{ export.size|get_file_size }} - {% if job.complete and not job.status == "stopped" and not job.status == "failed" %} -

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

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

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

+ {% else %} +

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

+ {% endif %} + {% endif %} diff --git a/bookwyrm/templatetags/utilities.py b/bookwyrm/templatetags/utilities.py index 225510085..e04c9f33a 100644 --- a/bookwyrm/templatetags/utilities.py +++ b/bookwyrm/templatetags/utilities.py @@ -130,23 +130,17 @@ def id_to_username(user_id): @register.filter(name="get_file_size") -def get_file_size(file): +def get_file_size(raw_size): """display the size of a file in human readable terms""" try: - # TODO: this obviously isn't a proper solution - # boto storages do not implement 'path' - if not USE_S3: - 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" - - return "" + 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 as error: # pylint: disable=broad-except print(error) diff --git a/bookwyrm/views/preferences/export.py b/bookwyrm/views/preferences/export.py index 33c90291d..bd32d45ad 100644 --- a/bookwyrm/views/preferences/export.py +++ b/bookwyrm/views/preferences/export.py @@ -13,9 +13,11 @@ from django.views import View from django.utils.decorators import method_decorator from django.shortcuts import redirect +from storages.backends.s3boto3 import S3Boto3Storage + from bookwyrm import models from bookwyrm.models.bookwyrm_export_job import BookwyrmExportJob -from bookwyrm.settings import PAGE_LENGTH +from bookwyrm import settings # pylint: disable=no-self-use,too-many-locals @@ -152,6 +154,34 @@ class ExportUser(View): jobs = BookwyrmExportJob.objects.filter(user=request.user).order_by( "-created_date" ) + + exports = [] + for job in jobs: + export = {"job": job} + + if settings.USE_S3: + # make custom_domain None so we can sign the url (https://github.com/jschneier/django-storages/issues/944) + storage = S3Boto3Storage(querystring_auth=True, custom_domain=None) + + # for s3 we download directly from s3, so we need a signed url + export["url"] = S3Boto3Storage.url( + storage, f"/exports/{job.task_id}.tar.gz", expire=900 + ) # temporarily downloadable file, expires after 5 minutes + + # for s3 we create a new tar file in s3, so we need to check the size of _that_ file + try: + export["size"] = S3Boto3Storage.size( + storage, f"exports/{job.task_id}.tar.gz" + ) + except Exception: + export["size"] = 0 + + else: + # for local storage export_data is the tar file + export["size"] = job.export_data.size if job.export_data else 0 + + exports.append(export) + site = models.SiteSettings.objects.get() hours = site.user_import_time_limit allowed = ( @@ -162,7 +192,7 @@ class ExportUser(View): next_available = ( jobs.first().created_date + timedelta(hours=hours) if not allowed else False ) - paginated = Paginator(jobs, PAGE_LENGTH) + paginated = Paginator(exports, settings.PAGE_LENGTH) page = paginated.get_page(request.GET.get("page")) data = { "jobs": page, From a3e05254b56cd51574bf1d25c9ecba6c1f3f8862 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sun, 28 Jan 2024 15:56:44 +1100 Subject: [PATCH 07/41] fix avatar import path --- bookwyrm/models/bookwyrm_export_job.py | 74 +++++++++++++------------- bookwyrm/models/bookwyrm_import_job.py | 2 +- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index 384e71701..a611ba4b1 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -246,9 +246,7 @@ class AddFileToTar(ChildJob): # Add avatar image if present if getattr(user, "avatar", False): - tar.add_image( - user.avatar, filename="avatar", directory="avatar/" - ) + tar.add_image(user.avatar, filename="avatar") for book in editions: if getattr(book, "cover", False): @@ -284,7 +282,6 @@ def start_export_task(**kwargs): job.user.save() job.export_json = job.user.to_activity() - logger.info(job.export_json) job.save(update_fields=["export_data", "export_json"]) # let's go @@ -345,45 +342,48 @@ def export_reading_goals_task(**kwargs): def json_export(**kwargs): """Generate an export for a user""" - job = BookwyrmExportJob.objects.get(id=kwargs["job_id"]) - job.set_status("active") - job_id = kwargs["job_id"] + try: + job = BookwyrmExportJob.objects.get(id=kwargs["job_id"]) + job.set_status("active") + job_id = kwargs["job_id"] - # I don't love this but it prevents a JSON encoding error - # when there is no user image - if isinstance( - job.export_json["icon"], - dataclasses._MISSING_TYPE, # pylint: disable=protected-access - ): - job.export_json["icon"] = {} - else: - # change the URL to be relative to the JSON file - file_type = job.export_json["icon"]["url"].rsplit(".", maxsplit=1)[-1] - filename = f"avatar.{file_type}" - job.export_json["icon"]["url"] = filename + if not job.export_json.get("icon"): + job.export_json["icon"] = {} + else: + # change the URL to be relative to the JSON file + file_type = job.export_json["icon"]["url"].rsplit(".", maxsplit=1)[-1] + filename = f"avatar.{file_type}" + job.export_json["icon"]["url"] = filename - # Additional settings - can't be serialized as AP - vals = [ - "show_goal", - "preferred_timezone", - "default_post_privacy", - "show_suggested_users", - ] - job.export_json["settings"] = {} - for k in vals: - job.export_json["settings"][k] = getattr(job.user, k) + # Additional settings - can't be serialized as AP + vals = [ + "show_goal", + "preferred_timezone", + "default_post_privacy", + "show_suggested_users", + ] + job.export_json["settings"] = {} + for k in vals: + job.export_json["settings"][k] = getattr(job.user, k) - job.export_json["books"] = [] + job.export_json["books"] = [] - # save settings we just updated - job.save(update_fields=["export_json"]) + # save settings we just updated + job.save(update_fields=["export_json"]) - # trigger subtasks - export_saved_lists_task.delay(job_id=job_id, no_children=False) - export_follows_task.delay(job_id=job_id, no_children=False) - export_blocks_task.delay(job_id=job_id, no_children=False) - trigger_books_jobs.delay(job_id=job_id, no_children=False) + # trigger subtasks + export_saved_lists_task.delay(job_id=job_id, no_children=False) + export_follows_task.delay(job_id=job_id, no_children=False) + export_blocks_task.delay(job_id=job_id, no_children=False) + trigger_books_jobs.delay(job_id=job_id, no_children=False) + except Exception as err: # pylint: disable=broad-except + logger.exception( + "json_export task in job %s Failed with error: %s", + job.id, + err, + ) + job.set_status("failed") @app.task(queue=IMPORTS, base=ParentTask) def trigger_books_jobs(**kwargs): diff --git a/bookwyrm/models/bookwyrm_import_job.py b/bookwyrm/models/bookwyrm_import_job.py index 02af25d12..5229430eb 100644 --- a/bookwyrm/models/bookwyrm_import_job.py +++ b/bookwyrm/models/bookwyrm_import_job.py @@ -215,7 +215,7 @@ def upsert_statuses(user, cls, data, book_remote_id): instance.save() # save and broadcast else: - logger.info("User does not have permission to import statuses") + logger.warning("User does not have permission to import statuses") def upsert_lists(user, lists, book_id): From 2c231acebe3aeccbd11e255865b281579d1767e7 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sun, 28 Jan 2024 20:35:47 +1100 Subject: [PATCH 08/41] linting and tests --- bookwyrm/models/bookwyrm_export_job.py | 16 +-- bookwyrm/templatetags/utilities.py | 2 +- .../tests/models/test_bookwyrm_export_job.py | 105 +++--------------- .../views/preferences/test_export_user.py | 3 +- bookwyrm/views/preferences/export.py | 8 +- 5 files changed, 28 insertions(+), 106 deletions(-) diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index a611ba4b1..2d87b203f 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -1,6 +1,5 @@ """Export user account to tar.gz file for import into another Bookwyrm instance""" -import dataclasses import logging from uuid import uuid4 @@ -191,9 +190,11 @@ class AddFileToTar(ChildJob): def start_job(self): """Start the job""" - # NOTE we are doing this all in one big job, which has the potential to block a thread - # This is because we need to refer to the same s3_job or BookwyrmTarFile whilst writing - # Using a series of jobs in a loop would be better if possible + # NOTE we are doing this all in one big job, + # which has the potential to block a thread + # This is because we need to refer to the same s3_job + # or BookwyrmTarFile whilst writing + # Using a series of jobs in a loop would be better try: export_data = self.parent_export_job.export_data @@ -275,12 +276,6 @@ def start_export_task(**kwargs): # prepare the initial file and base json job.export_data = ContentFile(b"", str(uuid4())) - # BUG: this throws a MISSING class error if there is no avatar - # #3096 may fix it - if not job.user.avatar: - job.user.avatar = "" - job.user.save() - job.export_json = job.user.to_activity() job.save(update_fields=["export_data", "export_json"]) @@ -385,6 +380,7 @@ def json_export(**kwargs): ) job.set_status("failed") + @app.task(queue=IMPORTS, base=ParentTask) def trigger_books_jobs(**kwargs): """trigger tasks to get data for each book""" diff --git a/bookwyrm/templatetags/utilities.py b/bookwyrm/templatetags/utilities.py index e04c9f33a..e4ddbb47c 100644 --- a/bookwyrm/templatetags/utilities.py +++ b/bookwyrm/templatetags/utilities.py @@ -9,7 +9,7 @@ from django.utils.translation import gettext_lazy as _ from django.templatetags.static import static from bookwyrm.models import User -from bookwyrm.settings import INSTANCE_ACTOR_USERNAME, USE_S3 +from bookwyrm.settings import INSTANCE_ACTOR_USERNAME register = template.Library() diff --git a/bookwyrm/tests/models/test_bookwyrm_export_job.py b/bookwyrm/tests/models/test_bookwyrm_export_job.py index f0b9e445b..cf3ba0688 100644 --- a/bookwyrm/tests/models/test_bookwyrm_export_job.py +++ b/bookwyrm/tests/models/test_bookwyrm_export_job.py @@ -5,13 +5,15 @@ from unittest.mock import patch from django.core.serializers.json import DjangoJSONEncoder from django.test import TestCase +from django.test.utils import override_settings + from django.utils import timezone from bookwyrm import models import bookwyrm.models.bookwyrm_export_job as export_job -class BookwyrmExport(TestCase): +class BookwyrmExportJob(TestCase): """testing user export functions""" def setUp(self): @@ -141,94 +143,17 @@ class BookwyrmExport(TestCase): book=self.edition, ) - # pylint: disable=E1121 - 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) - self.assertEqual(user_data["preferredUsername"], "mouse") - self.assertEqual(user_data["name"], "Mouse") - self.assertEqual(user_data["summary"], "

I'm a real bookmouse

") - self.assertEqual(user_data["manuallyApprovesFollowers"], False) - self.assertEqual(user_data["hideFollows"], False) - self.assertEqual(user_data["discoverable"], True) - self.assertEqual(user_data["settings"]["show_goal"], False) - self.assertEqual(user_data["settings"]["show_suggested_users"], False) - self.assertEqual( - user_data["settings"]["preferred_timezone"], "America/Los Angeles" - ) - self.assertEqual(user_data["settings"]["default_post_privacy"], "followers") + self.job = models.BookwyrmExportJob.objects.create(user=self.local_user) - # pylint: disable=E1121 - 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) + def test_export_saved_lists_task(self): + """test saved list task""" - # 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["blocks"]), 1) - self.assertEqual(json_data["blocks"][0], "https://your.domain.here/user/badger") - - # pylint: disable=E1121 - 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]["edition"]["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]["name"], "Read") - - self.assertEqual(len(json_data["books"][0]["lists"]), 1) - self.assertEqual(json_data["books"][0]["lists"][0]["name"], "My excellent list") - self.assertEqual( - json_data["books"][0]["lists"][0]["list_item"]["book"], - self.edition.remote_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]["quotations"]), 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.0) - - 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]["quotations"][0]["content"], "

check this out

" - ) - self.assertEqual( - json_data["books"][0]["quotations"][0]["quote"], - "

A rose by any other name

", - ) + with patch("bookwyrm.models.bookwyrm_export_job.json_export.delay"): + models.bookwyrm_export_job.start_export_task( + job_id=self.job.id, no_children=False + ) + print(self.job.user) + print(self.job.export_data) + print(self.job.export_json) + # IDK how to test this... + pass diff --git a/bookwyrm/tests/views/preferences/test_export_user.py b/bookwyrm/tests/views/preferences/test_export_user.py index 654ed2a05..e40081eb1 100644 --- a/bookwyrm/tests/views/preferences/test_export_user.py +++ b/bookwyrm/tests/views/preferences/test_export_user.py @@ -41,8 +41,7 @@ class ExportUserViews(TestCase): 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) + export = views.ExportUser.as_view()(request) self.assertIsInstance(export, HttpResponse) self.assertEqual(export.status_code, 302) diff --git a/bookwyrm/views/preferences/export.py b/bookwyrm/views/preferences/export.py index bd32d45ad..54d6df261 100644 --- a/bookwyrm/views/preferences/export.py +++ b/bookwyrm/views/preferences/export.py @@ -160,7 +160,8 @@ class ExportUser(View): export = {"job": job} if settings.USE_S3: - # make custom_domain None so we can sign the url (https://github.com/jschneier/django-storages/issues/944) + # make custom_domain None so we can sign the url + # see https://github.com/jschneier/django-storages/issues/944 storage = S3Boto3Storage(querystring_auth=True, custom_domain=None) # for s3 we download directly from s3, so we need a signed url @@ -168,12 +169,13 @@ class ExportUser(View): storage, f"/exports/{job.task_id}.tar.gz", expire=900 ) # temporarily downloadable file, expires after 5 minutes - # for s3 we create a new tar file in s3, so we need to check the size of _that_ file + # for s3 we create a new tar file in s3, + # so we need to check the size of _that_ file try: export["size"] = S3Boto3Storage.size( storage, f"exports/{job.task_id}.tar.gz" ) - except Exception: + except Exception: # pylint: disable=broad-except export["size"] = 0 else: From c106b2a988296c4bc4ad65d82a2676b8fd796d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adeodato=20Sim=C3=B3?= Date: Sun, 28 Jan 2024 22:00:40 -0300 Subject: [PATCH 09/41] Subclass boto3.Session to use AWS_S3_ENDPOINT_URL As of 0.1.13, the s3-tar library uses an environment variable (`S3_ENDPOINT_URL`) to determine the AWS endpoint. See: https://github.com/xtream1101/s3-tar/blob/0.1.13/s3_tar/utils.py#L25-L29. To save BookWyrm admins from having to set it (e.g., through `.env`) when they are already setting `AWS_S3_ENDPOINT_URL`, we create a Session class that unconditionally uses that URL, and feed it to S3Tar. --- bookwyrm/models/bookwyrm_export_job.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index 2d87b203f..610ec13d8 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -3,6 +3,7 @@ import logging from uuid import uuid4 +from boto3.session import Session as BotoSession from s3_tar import S3Tar from storages.backends.s3boto3 import S3Boto3Storage @@ -25,6 +26,14 @@ from bookwyrm.utils.tar import BookwyrmTarFile logger = logging.getLogger(__name__) +class BookwyrmAwsSession(BotoSession): + """a boto session that always uses settings.AWS_S3_ENDPOINT_URL""" + + def client(service_name, **kwargs): + kwargs["endpoint_url"] = settings.AWS_S3_ENDPOINT_URL + return super().client(service_name, **kwargs) + + class BookwyrmExportJob(ParentJob): """entry for a specific request to export a bookwyrm user""" @@ -211,6 +220,7 @@ class AddFileToTar(ChildJob): s3_job = S3Tar( settings.AWS_STORAGE_BUCKET_NAME, f"exports/{filename}.tar.gz", + session=BookwyrmAwsSession(), ) # save json file From 765fc1e43d19e9c8cdf91a6f72a47eb2ab18721a Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Mon, 29 Jan 2024 12:28:37 +1100 Subject: [PATCH 10/41] fix tests --- .../tests/models/test_bookwyrm_export_job.py | 142 ++++++++++++++++-- .../views/preferences/test_export_user.py | 3 +- 2 files changed, 130 insertions(+), 15 deletions(-) diff --git a/bookwyrm/tests/models/test_bookwyrm_export_job.py b/bookwyrm/tests/models/test_bookwyrm_export_job.py index cf3ba0688..267d30217 100644 --- a/bookwyrm/tests/models/test_bookwyrm_export_job.py +++ b/bookwyrm/tests/models/test_bookwyrm_export_job.py @@ -1,16 +1,11 @@ """test bookwyrm user export functions""" import datetime -import json from unittest.mock import patch -from django.core.serializers.json import DjangoJSONEncoder -from django.test import TestCase -from django.test.utils import override_settings - from django.utils import timezone +from django.test import TestCase from bookwyrm import models -import bookwyrm.models.bookwyrm_export_job as export_job class BookwyrmExportJob(TestCase): @@ -143,17 +138,136 @@ class BookwyrmExportJob(TestCase): book=self.edition, ) - self.job = models.BookwyrmExportJob.objects.create(user=self.local_user) + self.job = models.BookwyrmExportJob.objects.create( + user=self.local_user, export_json={} + ) - def test_export_saved_lists_task(self): - """test saved list task""" + def test_add_book_to_user_export_job(self): + """does AddBookToUserExportJob ...add the book to the export?""" + + self.job.export_json["books"] = [] + self.job.save() + + with patch("bookwyrm.models.bookwyrm_export_job.AddFileToTar.start_job"): + model = models.bookwyrm_export_job + edition_job = model.AddBookToUserExportJob.objects.create( + edition=self.edition, parent_job=self.job + ) + + edition_job.start_job() + + self.job.refresh_from_db() + self.assertIsNotNone(self.job.export_json["books"]) + self.assertEqual(len(self.job.export_json["books"]), 1) + book = self.job.export_json["books"][0] + self.assertEqual(book["work"]["id"], self.work.remote_id) + self.assertEqual(len(book["authors"]), 1) + self.assertEqual(len(book["shelves"]), 1) + self.assertEqual(len(book["lists"]), 1) + self.assertEqual(len(book["comments"]), 1) + self.assertEqual(len(book["reviews"]), 1) + self.assertEqual(len(book["quotations"]), 1) + self.assertEqual(len(book["readthroughs"]), 1) + + def test_start_export_task(self): + """test saved list task saves initial json and data""" with patch("bookwyrm.models.bookwyrm_export_job.json_export.delay"): models.bookwyrm_export_job.start_export_task( job_id=self.job.id, no_children=False ) - print(self.job.user) - print(self.job.export_data) - print(self.job.export_json) - # IDK how to test this... - pass + + self.job.refresh_from_db() + + self.assertIsNotNone(self.job.export_data) + self.assertIsNotNone(self.job.export_json) + self.assertEqual(self.job.export_json["name"], self.local_user.name) + + def test_export_saved_lists_task(self): + """test export_saved_lists_task adds the saved lists""" + + models.bookwyrm_export_job.export_saved_lists_task( + job_id=self.job.id, no_children=False + ) + + self.job.refresh_from_db() + + self.assertIsNotNone(self.job.export_json["saved_lists"]) + self.assertEqual( + self.job.export_json["saved_lists"][0], self.saved_list.remote_id + ) + + def test_export_follows_task(self): + """test export_follows_task adds the follows""" + + models.bookwyrm_export_job.export_follows_task( + job_id=self.job.id, no_children=False + ) + + self.job.refresh_from_db() + + self.assertIsNotNone(self.job.export_json["follows"]) + self.assertEqual(self.job.export_json["follows"][0], self.rat_user.remote_id) + + def test_export_blocks_task(self): + + """test export_blocks_task adds the blocks""" + + models.bookwyrm_export_job.export_blocks_task( + job_id=self.job.id, no_children=False + ) + + self.job.refresh_from_db() + + self.assertIsNotNone(self.job.export_json["blocks"]) + self.assertEqual(self.job.export_json["blocks"][0], self.badger_user.remote_id) + + def test_export_reading_goals_task(self): + """test export_reading_goals_task adds the goals""" + + models.bookwyrm_export_job.export_reading_goals_task( + job_id=self.job.id, no_children=False + ) + + self.job.refresh_from_db() + + self.assertIsNotNone(self.job.export_json["goals"]) + self.assertEqual(self.job.export_json["goals"][0]["goal"], 128937123) + + def test_json_export(self): + """test json_export job adds settings""" + + with patch( + "bookwyrm.models.bookwyrm_export_job.export_saved_lists_task.delay" + ), patch( + "bookwyrm.models.bookwyrm_export_job.export_follows_task.delay" + ), patch( + "bookwyrm.models.bookwyrm_export_job.export_blocks_task.delay" + ), patch( + "bookwyrm.models.bookwyrm_export_job.trigger_books_jobs.delay" + ): + + models.bookwyrm_export_job.json_export( + job_id=self.job.id, no_children=False + ) + + self.job.refresh_from_db() + + self.assertIsNotNone(self.job.export_json["settings"]) + self.assertFalse(self.job.export_json["settings"]["show_goal"]) + self.assertEqual( + self.job.export_json["settings"]["preferred_timezone"], + "America/Los Angeles", + ) + self.assertEqual( + self.job.export_json["settings"]["default_post_privacy"], "followers" + ) + self.assertFalse(self.job.export_json["settings"]["show_suggested_users"]) + + def test_get_books_for_user(self): + """does get_books_for_user get all the books""" + + data = models.bookwyrm_export_job.get_books_for_user(self.local_user) + + self.assertEqual(len(data), 1) + self.assertEqual(data[0].title, "Example Edition") diff --git a/bookwyrm/tests/views/preferences/test_export_user.py b/bookwyrm/tests/views/preferences/test_export_user.py index e40081eb1..98892f6b8 100644 --- a/bookwyrm/tests/views/preferences/test_export_user.py +++ b/bookwyrm/tests/views/preferences/test_export_user.py @@ -41,7 +41,8 @@ class ExportUserViews(TestCase): request = self.factory.post("") request.user = self.local_user - export = views.ExportUser.as_view()(request) + with patch("bookwyrm.models.bookwyrm_export_job.BookwyrmExportJob.start_job"): + export = views.ExportUser.as_view()(request) self.assertIsInstance(export, HttpResponse) self.assertEqual(export.status_code, 302) From adff3c425153262811bd8930d0c2a2d842edaed9 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Mon, 29 Jan 2024 13:45:35 +1100 Subject: [PATCH 11/41] allow user exports with s3 also undoes a line space change in settings.py to make the PR cleaner --- bookwyrm/settings.py | 1 + bookwyrm/templates/settings/imports/imports.html | 5 +---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index 3f9665baf..cc941da84 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -442,4 +442,5 @@ 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" + DATA_UPLOAD_MAX_MEMORY_SIZE = env.int("DATA_UPLOAD_MAX_MEMORY_SIZE", (1024**2 * 100)) diff --git a/bookwyrm/templates/settings/imports/imports.html b/bookwyrm/templates/settings/imports/imports.html index 11b3c7e03..ca53dd410 100644 --- a/bookwyrm/templates/settings/imports/imports.html +++ b/bookwyrm/templates/settings/imports/imports.html @@ -157,13 +157,10 @@ >

{% trans "Users are currently unable to start new user exports. This is the default setting." %}

- {% if use_s3 %} -

{% trans "It is not currently possible to provide user exports when using s3 storage. The BookWyrm development team are working on a fix for this." %}

- {% endif %}
{% csrf_token %}
-
From 5f7be848fc3f3ccea46c434fd6e9aae68bb04035 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Mon, 29 Jan 2024 14:10:36 +1100 Subject: [PATCH 12/41] subclass boto3 session instead of adding new env value Thanks Dato! --- .env.example | 1 - bookwyrm/models/bookwyrm_export_job.py | 9 +++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 497d05779..20ce8240b 100644 --- a/.env.example +++ b/.env.example @@ -81,7 +81,6 @@ AWS_SECRET_ACCESS_KEY= # AWS_S3_CUSTOM_DOMAIN=None # "example-bucket-name.s3.fr-par.scw.cloud" # AWS_S3_REGION_NAME=None # "fr-par" # AWS_S3_ENDPOINT_URL=None # "https://s3.fr-par.scw.cloud" -# S3_ENDPOINT_URL=None # same as AWS_S3_ENDPOINT_URL - needed for non-AWS for user exports # Commented are example values if you use Azure Blob Storage # USE_AZURE=true diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index 610ec13d8..0cb726aa1 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -29,9 +29,9 @@ logger = logging.getLogger(__name__) class BookwyrmAwsSession(BotoSession): """a boto session that always uses settings.AWS_S3_ENDPOINT_URL""" - def client(service_name, **kwargs): + def client(self, *args, **kwargs): # pylint: disable=arguments-differ kwargs["endpoint_url"] = settings.AWS_S3_ENDPOINT_URL - return super().client(service_name, **kwargs) + return super().client("s3", *args, **kwargs) class BookwyrmExportJob(ParentJob): @@ -42,9 +42,7 @@ class BookwyrmExportJob(ParentJob): else: storage = storage_backends.ExportsFileStorage - export_data = FileField( - null=True, storage=storage - ) # use custom storage backend here + export_data = FileField(null=True, storage=storage) export_json = JSONField(null=True, encoder=DjangoJSONEncoder) json_completed = BooleanField(default=False) @@ -70,7 +68,6 @@ class BookwyrmExportJob(ParentJob): self.json_completed = True self.save(update_fields=["json_completed"]) - # add json file to tarfile tar_job = AddFileToTar.objects.create( parent_job=self, parent_export_job=self ) From 3675a4cf3f0076cb1885715ad7d6f034308936ec Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Mon, 29 Jan 2024 14:28:30 +1100 Subject: [PATCH 13/41] disable user exports if using azure --- bookwyrm/templates/settings/imports/imports.html | 5 ++++- bookwyrm/views/admin/imports.py | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/bookwyrm/templates/settings/imports/imports.html b/bookwyrm/templates/settings/imports/imports.html index ca53dd410..8693f7b68 100644 --- a/bookwyrm/templates/settings/imports/imports.html +++ b/bookwyrm/templates/settings/imports/imports.html @@ -157,10 +157,13 @@ >

{% trans "Users are currently unable to start new user exports. This is the default setting." %}

+ {% if use_azure %} +

{% trans "It is not currently possible to provide user exports when using Azure storage." %}

+ {% endif %}
{% csrf_token %}
-
diff --git a/bookwyrm/views/admin/imports.py b/bookwyrm/views/admin/imports.py index 0924536bf..1009f4149 100644 --- a/bookwyrm/views/admin/imports.py +++ b/bookwyrm/views/admin/imports.py @@ -9,7 +9,7 @@ from django.views.decorators.http import require_POST from bookwyrm import models from bookwyrm.views.helpers import redirect_to_referer -from bookwyrm.settings import PAGE_LENGTH, USE_S3 +from bookwyrm.settings import PAGE_LENGTH, USE_AZURE # pylint: disable=no-self-use @@ -59,7 +59,7 @@ class ImportList(View): "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, - "use_s3": USE_S3, + "use_azure": USE_AZURE, } return TemplateResponse(request, "settings/imports/imports.html", data) From a6dc5bd13fff79f7ba55a170f784c047e15c09d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adeodato=20Sim=C3=B3?= Date: Mon, 18 Mar 2024 14:56:29 -0300 Subject: [PATCH 14/41] Make `get_file_size` robust against typing errors --- bookwyrm/templatetags/utilities.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bookwyrm/templatetags/utilities.py b/bookwyrm/templatetags/utilities.py index df5b5ab30..fb2113de4 100644 --- a/bookwyrm/templatetags/utilities.py +++ b/bookwyrm/templatetags/utilities.py @@ -130,10 +130,14 @@ def id_to_username(user_id): @register.filter(name="get_file_size") -def get_file_size(raw_size): +def get_file_size(nbytes): """display the size of a file in human readable terms""" try: + raw_size = float(nbytes) + except (ValueError, TypeError): + return repr(nbytes) + else: if raw_size < 1024: return f"{raw_size} bytes" if raw_size < 1024**2: @@ -142,10 +146,6 @@ def get_file_size(raw_size): return f"{raw_size/1024**2:.2f} MB" return f"{raw_size/1024**3:.2f} GB" - except Exception as error: # pylint: disable=broad-except - print(error) - return "" - @register.filter(name="get_user_permission") def get_user_permission(user): From dd27684d4bc876a8de9360cdb5ac10054ccf427b Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sun, 24 Mar 2024 20:53:49 +1100 Subject: [PATCH 15/41] set signed s3 url expiry with env value Adds S3_SIGNED_URL_EXPIRY val to .env and settings (defaults to 15 mins) Note that this is reset every time the user loads the exports page and is independent of the _creation_ of export files. --- .env.example | 3 +++ bookwyrm/settings.py | 1 + bookwyrm/views/preferences/export.py | 15 +++++++++++---- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 1bf6d5406..ee2ccd45a 100644 --- a/.env.example +++ b/.env.example @@ -71,6 +71,9 @@ ENABLE_THUMBNAIL_GENERATION=true USE_S3=false AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= +# seconds for signed S3 urls to expire +# this is currently only used for user export files +S3_SIGNED_URL_EXPIRY=900 # Commented are example values if you use a non-AWS, S3-compatible service # AWS S3 should work with only AWS_STORAGE_BUCKET_NAME and AWS_S3_REGION_NAME diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index 77bec0d8e..d2ba490b7 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -375,6 +375,7 @@ if USE_HTTPS: USE_S3 = env.bool("USE_S3", False) USE_AZURE = env.bool("USE_AZURE", False) +S3_SIGNED_URL_EXPIRY = env.int("S3_SIGNED_URL_EXPIRY", 900) if USE_S3: # AWS settings diff --git a/bookwyrm/views/preferences/export.py b/bookwyrm/views/preferences/export.py index 54d6df261..09b43155b 100644 --- a/bookwyrm/views/preferences/export.py +++ b/bookwyrm/views/preferences/export.py @@ -146,7 +146,12 @@ class Export(View): # 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""" + """ + Let users export user data to import into another Bookwyrm instance + This view creates signed URLs to pre-processed export files in + s3 storage on load (if they exist) and allows the user to create + a new file. + """ def get(self, request): """Request tar file""" @@ -166,8 +171,10 @@ class ExportUser(View): # for s3 we download directly from s3, so we need a signed url export["url"] = S3Boto3Storage.url( - storage, f"/exports/{job.task_id}.tar.gz", expire=900 - ) # temporarily downloadable file, expires after 5 minutes + storage, + f"/exports/{job.task_id}.tar.gz", + expire=settings.S3_SIGNED_URL_EXPIRY, + ) # for s3 we create a new tar file in s3, # so we need to check the size of _that_ file @@ -207,7 +214,7 @@ class ExportUser(View): return TemplateResponse(request, "preferences/export-user.html", data) def post(self, request): - """Download the json file of a user's data""" + """Trigger processing of a new user export file""" job = BookwyrmExportJob.objects.create(user=request.user) job.start_job() From 03587dfdc7ec1113151d8d7049e460e5c8ae6722 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sun, 24 Mar 2024 20:56:20 +1100 Subject: [PATCH 16/41] migrations --- .../migrations/0197_merge_20240324_0235.py | 13 +++++++++++ ...198_alter_bookwyrmexportjob_export_data.py | 23 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 bookwyrm/migrations/0197_merge_20240324_0235.py create mode 100644 bookwyrm/migrations/0198_alter_bookwyrmexportjob_export_data.py diff --git a/bookwyrm/migrations/0197_merge_20240324_0235.py b/bookwyrm/migrations/0197_merge_20240324_0235.py new file mode 100644 index 000000000..a7c01a955 --- /dev/null +++ b/bookwyrm/migrations/0197_merge_20240324_0235.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.25 on 2024-03-24 02:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0196_merge_20240318_1737"), + ("bookwyrm", "0196_merge_pr3134_into_main"), + ] + + operations = [] diff --git a/bookwyrm/migrations/0198_alter_bookwyrmexportjob_export_data.py b/bookwyrm/migrations/0198_alter_bookwyrmexportjob_export_data.py new file mode 100644 index 000000000..95eddb278 --- /dev/null +++ b/bookwyrm/migrations/0198_alter_bookwyrmexportjob_export_data.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.25 on 2024-03-24 08:53 + +import bookwyrm.storage_backends +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0197_merge_20240324_0235"), + ] + + operations = [ + migrations.AlterField( + model_name="bookwyrmexportjob", + name="export_data", + field=models.FileField( + null=True, + storage=bookwyrm.storage_backends.ExportsS3Storage, + upload_to="", + ), + ), + ] From 69f464418d9eecb83823a1f2bb88a6254515abf2 Mon Sep 17 00:00:00 2001 From: Bart Schuurmans Date: Sun, 24 Mar 2024 12:06:42 +0100 Subject: [PATCH 17/41] Remove problematic migration This migration is dependent on the runtime configuration (.env); a structural fix will follow. --- ...198_alter_bookwyrmexportjob_export_data.py | 23 ------------------- 1 file changed, 23 deletions(-) delete mode 100644 bookwyrm/migrations/0198_alter_bookwyrmexportjob_export_data.py diff --git a/bookwyrm/migrations/0198_alter_bookwyrmexportjob_export_data.py b/bookwyrm/migrations/0198_alter_bookwyrmexportjob_export_data.py deleted file mode 100644 index 95eddb278..000000000 --- a/bookwyrm/migrations/0198_alter_bookwyrmexportjob_export_data.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.2.25 on 2024-03-24 08:53 - -import bookwyrm.storage_backends -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("bookwyrm", "0197_merge_20240324_0235"), - ] - - operations = [ - migrations.AlterField( - model_name="bookwyrmexportjob", - name="export_data", - field=models.FileField( - null=True, - storage=bookwyrm.storage_backends.ExportsS3Storage, - upload_to="", - ), - ), - ] From 073f62d5bb449cf5a4cbb2c85de320fc8e9dc382 Mon Sep 17 00:00:00 2001 From: Bart Schuurmans Date: Sun, 24 Mar 2024 11:51:37 +0100 Subject: [PATCH 18/41] Add exports_volume to docker-compose.yml Exports should be written to a Docker volume instead of to the bind mount (= source directory). This way they are shared between different containers even when they run on different machines. --- docker-compose.yml | 4 ++++ exports/.gitkeep | 0 2 files changed, 4 insertions(+) create mode 100644 exports/.gitkeep diff --git a/docker-compose.yml b/docker-compose.yml index 71a844ba2..634c021b6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,7 @@ services: - .:/app - static_volume:/app/static - media_volume:/app/images + - exports_volume:/app/exports depends_on: - db - celery_worker @@ -67,6 +68,7 @@ services: - .:/app - static_volume:/app/static - media_volume:/app/images + - exports_volume:/app/exports depends_on: - db - redis_broker @@ -81,6 +83,7 @@ services: - .:/app - static_volume:/app/static - media_volume:/app/images + - exports_volume:/app/exports depends_on: - celery_worker restart: on-failure @@ -109,6 +112,7 @@ volumes: pgdata: static_volume: media_volume: + exports_volume: redis_broker_data: redis_activity_data: networks: diff --git a/exports/.gitkeep b/exports/.gitkeep new file mode 100644 index 000000000..e69de29bb From 471233c1dc6aa6aa3539a53a3f03641c103e5ed0 Mon Sep 17 00:00:00 2001 From: Bart Schuurmans Date: Sun, 24 Mar 2024 12:22:17 +0100 Subject: [PATCH 19/41] Use different export job fields for the different storage backends This way, the database definition is not depdendent on the runtime configuration. --- .../0198_export_job_separate_file_fields.py | 28 +++++++++++++++++++ bookwyrm/models/bookwyrm_export_job.py | 27 ++++++++++++++---- 2 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 bookwyrm/migrations/0198_export_job_separate_file_fields.py diff --git a/bookwyrm/migrations/0198_export_job_separate_file_fields.py b/bookwyrm/migrations/0198_export_job_separate_file_fields.py new file mode 100644 index 000000000..d9dd87eee --- /dev/null +++ b/bookwyrm/migrations/0198_export_job_separate_file_fields.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.25 on 2024-03-24 11:20 + +import bookwyrm.storage_backends +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0197_merge_20240324_0235"), + ] + + operations = [ + migrations.RenameField( + model_name="bookwyrmexportjob", + old_name="export_data", + new_name="export_data_file", + ), + migrations.AddField( + model_name="bookwyrmexportjob", + name="export_data_s3", + field=models.FileField( + null=True, + storage=bookwyrm.storage_backends.ExportsS3Storage, + upload_to="", + ), + ), + ] diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index 0cb726aa1..1e64b389f 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -37,15 +37,30 @@ class BookwyrmAwsSession(BotoSession): class BookwyrmExportJob(ParentJob): """entry for a specific request to export a bookwyrm user""" - if settings.USE_S3: - storage = storage_backends.ExportsS3Storage - else: - storage = storage_backends.ExportsFileStorage + # Only one of these fields is used, dependent on the configuration. + export_data_file = FileField(null=True, storage=storage_backends.ExportsFileStorage) + export_data_s3 = FileField(null=True, storage=storage_backends.ExportsS3Storage) - export_data = FileField(null=True, storage=storage) export_json = JSONField(null=True, encoder=DjangoJSONEncoder) json_completed = BooleanField(default=False) + @property + def export_data(self): + """returns the file field of the configured storage backend""" + # TODO: We could check whether a field for a different backend is + # filled, to support migrating to a different backend. + if settings.USE_S3: + return self.export_data_s3 + return self.export_data_file + + @export_data.setter + def export_data(self, value): + """sets the file field of the configured storage backend""" + if settings.USE_S3: + self.export_data_s3 = value + else: + self.export_data_file = value + def start_job(self): """Start the job""" @@ -284,7 +299,7 @@ def start_export_task(**kwargs): # prepare the initial file and base json job.export_data = ContentFile(b"", str(uuid4())) job.export_json = job.user.to_activity() - job.save(update_fields=["export_data", "export_json"]) + job.save(update_fields=["export_data_file", "export_data_s3", "export_json"]) # let's go json_export.delay(job_id=job.id, job_user=job.user.id, no_children=False) From ab7b0893e0106a7a01cb727e35d31cd8faaf8fe6 Mon Sep 17 00:00:00 2001 From: Bart Schuurmans Date: Sun, 24 Mar 2024 12:47:26 +0100 Subject: [PATCH 20/41] User exports: handle files that no longer exist on file storage --- bookwyrm/views/preferences/export.py | 30 ++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/bookwyrm/views/preferences/export.py b/bookwyrm/views/preferences/export.py index 09b43155b..5ff0d8616 100644 --- a/bookwyrm/views/preferences/export.py +++ b/bookwyrm/views/preferences/export.py @@ -6,16 +6,17 @@ 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.http import HttpResponse, HttpResponseServerError, Http404 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.utils.translation import gettext_lazy as _ from django.shortcuts import redirect from storages.backends.s3boto3 import S3Boto3Storage -from bookwyrm import models +from bookwyrm import models, storage_backends from bookwyrm.models.bookwyrm_export_job import BookwyrmExportJob from bookwyrm import settings @@ -187,7 +188,11 @@ class ExportUser(View): else: # for local storage export_data is the tar file - export["size"] = job.export_data.size if job.export_data else 0 + try: + export["size"] = job.export_data.size if job.export_data else 0 + except FileNotFoundError: + # file no longer exists + export["size"] = 0 exports.append(export) @@ -230,10 +235,15 @@ class ExportArchive(View): 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, - content_type="application/gzip", - headers={ - "Content-Disposition": 'attachment; filename="bookwyrm-account-export.tar.gz"' # pylint: disable=line-too-long - }, - ) + if isinstance(export.export_data.storage, storage_backends.ExportsFileStorage): + try: + return HttpResponse( + export.export_data, + content_type="application/gzip", + headers={ + "Content-Disposition": 'attachment; filename="bookwyrm-account-export.tar.gz"' # pylint: disable=line-too-long + }, + ) + except FileNotFoundError: + raise Http404() + return HttpResponseServerError() From 5bd66cb3f7a08669c9608a0e15afc18ed5cb7d43 Mon Sep 17 00:00:00 2001 From: Bart Schuurmans Date: Sun, 24 Mar 2024 13:03:47 +0100 Subject: [PATCH 21/41] Only generate signed S3 link to user export when user clicks download --- .../templates/preferences/export-user.html | 30 ++++++------------- bookwyrm/views/preferences/export.py | 29 ++++++++++++------ 2 files changed, 29 insertions(+), 30 deletions(-) diff --git a/bookwyrm/templates/preferences/export-user.html b/bookwyrm/templates/preferences/export-user.html index 26cd292e8..13fe16a77 100644 --- a/bookwyrm/templates/preferences/export-user.html +++ b/bookwyrm/templates/preferences/export-user.html @@ -126,27 +126,15 @@ {{ export.size|get_file_size }} - {% if export.job.complete and not export.job.status == "stopped" and not export.job.status == "failed" %} - {% if export.url%} -

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

- {% else %} -

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

- {% endif %} - + {% if export.url %} + + + + {% trans "Download your export" %} + + + {% elif export.job.complete and not export.job.status == "stopped" and not export.job.status == "failed" %} + {% trans "Archive is no longer available" %} {% endif %} diff --git a/bookwyrm/views/preferences/export.py b/bookwyrm/views/preferences/export.py index 5ff0d8616..50641e86e 100644 --- a/bookwyrm/views/preferences/export.py +++ b/bookwyrm/views/preferences/export.py @@ -10,6 +10,7 @@ from django.http import HttpResponse, HttpResponseServerError, Http404 from django.template.response import TemplateResponse from django.utils import timezone from django.views import View +from django.urls import reverse from django.utils.decorators import method_decorator from django.utils.translation import gettext_lazy as _ from django.shortcuts import redirect @@ -166,17 +167,8 @@ class ExportUser(View): export = {"job": job} if settings.USE_S3: - # make custom_domain None so we can sign the url - # see https://github.com/jschneier/django-storages/issues/944 storage = S3Boto3Storage(querystring_auth=True, custom_domain=None) - # for s3 we download directly from s3, so we need a signed url - export["url"] = S3Boto3Storage.url( - storage, - f"/exports/{job.task_id}.tar.gz", - expire=settings.S3_SIGNED_URL_EXPIRY, - ) - # for s3 we create a new tar file in s3, # so we need to check the size of _that_ file try: @@ -194,6 +186,9 @@ class ExportUser(View): # file no longer exists export["size"] = 0 + if export["size"] > 0: + export["url"] = reverse("prefs-export-file", args=[job.task_id]) + exports.append(export) site = models.SiteSettings.objects.get() @@ -235,6 +230,21 @@ class ExportArchive(View): def get(self, request, archive_id): """download user export file""" export = BookwyrmExportJob.objects.get(task_id=archive_id, user=request.user) + + if isinstance(export.export_data.storage, storage_backends.ExportsS3Storage): + # make custom_domain None so we can sign the url + # see https://github.com/jschneier/django-storages/issues/944 + storage = S3Boto3Storage(querystring_auth=True, custom_domain=None) + try: + url = S3Boto3Storage.url( + storage, + f"/exports/{export.task_id}.tar.gz", + expire=settings.S3_SIGNED_URL_EXPIRY, + ) + except Exception: + raise Http404() + return redirect(url) + if isinstance(export.export_data.storage, storage_backends.ExportsFileStorage): try: return HttpResponse( @@ -246,4 +256,5 @@ class ExportArchive(View): ) except FileNotFoundError: raise Http404() + return HttpResponseServerError() From aee8dc16af13e0ba421e746f355da740bf778f6d Mon Sep 17 00:00:00 2001 From: Bart Schuurmans Date: Sun, 24 Mar 2024 13:27:01 +0100 Subject: [PATCH 22/41] Fix pylint warning --- bookwyrm/views/preferences/export.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bookwyrm/views/preferences/export.py b/bookwyrm/views/preferences/export.py index 50641e86e..1d77e1200 100644 --- a/bookwyrm/views/preferences/export.py +++ b/bookwyrm/views/preferences/export.py @@ -12,7 +12,6 @@ from django.utils import timezone from django.views import View from django.urls import reverse from django.utils.decorators import method_decorator -from django.utils.translation import gettext_lazy as _ from django.shortcuts import redirect from storages.backends.s3boto3 import S3Boto3Storage From e0decbfd1d2a325354d5d93ad643d015f7003f59 Mon Sep 17 00:00:00 2001 From: Bart Schuurmans Date: Mon, 25 Mar 2024 17:59:39 +0100 Subject: [PATCH 23/41] Fix urlescaped relative path to cover image in export Fixes #3292 --- bookwyrm/models/bookwyrm_export_job.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index 1e64b389f..de96fb421 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -2,6 +2,7 @@ import logging from uuid import uuid4 +from urllib.parse import urlparse, unquote from boto3.session import Session as BotoSession from s3_tar import S3Tar @@ -97,6 +98,12 @@ class BookwyrmExportJob(ParentJob): self.complete_job() +def url2relativepath(url: str) -> str: + """turn an absolute URL into a relative filesystem path""" + parsed = urlparse(url) + return unquote(parsed.path[1:]) + + class AddBookToUserExportJob(ChildJob): """append book metadata for each book in an export""" @@ -112,9 +119,9 @@ class AddBookToUserExportJob(ChildJob): book["edition"] = self.edition.to_activity() if book["edition"].get("cover"): - # change the URL to be relative to the JSON file - filename = book["edition"]["cover"]["url"].rsplit("/", maxsplit=1)[-1] - book["edition"]["cover"]["url"] = f"covers/{filename}" + book["edition"]["cover"]["url"] = url2relativepath( + book["edition"]["cover"]["url"] + ) # authors book["authors"] = [] From a51402241babf51ac213a6582a819e1022143983 Mon Sep 17 00:00:00 2001 From: Bart Schuurmans Date: Mon, 25 Mar 2024 18:14:00 +0100 Subject: [PATCH 24/41] Refactor creation of user export archive --- bookwyrm/models/bookwyrm_export_job.py | 99 +++++++++++++------------- 1 file changed, 50 insertions(+), 49 deletions(-) diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index de96fb421..8e3927b73 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -1,7 +1,6 @@ """Export user account to tar.gz file for import into another Bookwyrm instance""" import logging -from uuid import uuid4 from urllib.parse import urlparse, unquote from boto3.session import Session as BotoSession @@ -225,64 +224,68 @@ class AddFileToTar(ChildJob): # Using a series of jobs in a loop would be better try: - export_data = self.parent_export_job.export_data - export_json = self.parent_export_job.export_json - json_data = DjangoJSONEncoder().encode(export_json) - user = self.parent_export_job.user + export_job = self.parent_export_job + export_task_id = str(export_job.task_id) + + export_json_bytes = ( + DjangoJSONEncoder().encode(export_job.export_json).encode("utf-8") + ) + + user = export_job.user editions = get_books_for_user(user) - # filenames for later - export_data_original = str(export_data) - filename = str(self.parent_export_job.task_id) - if settings.USE_S3: - s3_job = S3Tar( + # Connection for writing temporary files + s3 = S3Boto3Storage() + + # Handle for creating the final archive + s3_archive_path = f"exports/{export_task_id}.tar.gz" + s3_tar = S3Tar( settings.AWS_STORAGE_BUCKET_NAME, - f"exports/{filename}.tar.gz", + s3_archive_path, session=BookwyrmAwsSession(), ) - # save json file - export_data.save( - f"archive_{filename}.json", ContentFile(json_data.encode("utf-8")) + # Save JSON file to a temporary location + export_json_tmp_file = f"exports/{export_task_id}/archive.json" + S3Boto3Storage.save( + s3, + export_json_tmp_file, + ContentFile(export_json_bytes), ) - s3_job.add_file(f"exports/{export_data.name}") + s3_tar.add_file(export_json_tmp_file) - # save image file - file_type = user.avatar.name.rsplit(".", maxsplit=1)[-1] - export_data.save(f"avatar_{filename}.{file_type}", user.avatar) - s3_job.add_file(f"exports/{export_data.name}") + # Add avatar image if present + if user.avatar: + s3_tar.add_file(f"images/{user.avatar.name}") - for book in editions: - if getattr(book, "cover", False): - cover_name = f"images/{book.cover.name}" - s3_job.add_file(cover_name, folder="covers") + for edition in editions: + if edition.cover: + s3_tar.add_file(f"images/{edition.cover.name}") - s3_job.tar() + # Create archive and store file name + s3_tar.tar() + export_job.export_data_s3 = s3_archive_path + export_job.save() - # delete child files - we don't need them any more - s3_storage = S3Boto3Storage(querystring_auth=True, custom_domain=None) - S3Boto3Storage.delete(s3_storage, f"exports/{export_data_original}") - S3Boto3Storage.delete(s3_storage, f"exports/archive_{filename}.json") - S3Boto3Storage.delete( - s3_storage, f"exports/avatar_{filename}.{file_type}" - ) + # Delete temporary files + S3Boto3Storage.delete(s3, export_json_tmp_file) else: - export_data.open("wb") - with BookwyrmTarFile.open(mode="w:gz", fileobj=export_data) as tar: + export_job.export_data_file = f"{export_task_id}.tar.gz" + with export_job.export_data_file.open("wb") as f: + with BookwyrmTarFile.open(mode="w:gz", fileobj=f) as tar: + # save json file + tar.write_bytes(export_json_bytes) - tar.write_bytes(json_data.encode("utf-8")) + # Add avatar image if present + if user.avatar: + tar.add_image(user.avatar, directory="images/") - # Add avatar image if present - if getattr(user, "avatar", False): - tar.add_image(user.avatar, filename="avatar") - - for book in editions: - if getattr(book, "cover", False): - tar.add_image(book.cover) - - export_data.close() + for edition in editions: + if edition.cover: + tar.add_image(edition.cover, directory="images/") + export_job.save() self.complete_job() @@ -304,9 +307,8 @@ def start_export_task(**kwargs): try: # prepare the initial file and base json - job.export_data = ContentFile(b"", str(uuid4())) job.export_json = job.user.to_activity() - job.save(update_fields=["export_data_file", "export_data_s3", "export_json"]) + job.save(update_fields=["export_json"]) # let's go json_export.delay(job_id=job.id, job_user=job.user.id, no_children=False) @@ -374,10 +376,9 @@ def json_export(**kwargs): if not job.export_json.get("icon"): job.export_json["icon"] = {} else: - # change the URL to be relative to the JSON file - file_type = job.export_json["icon"]["url"].rsplit(".", maxsplit=1)[-1] - filename = f"avatar.{file_type}" - job.export_json["icon"]["url"] = filename + job.export_json["icon"]["url"] = url2relativepath( + job.export_json["icon"]["url"] + ) # Additional settings - can't be serialized as AP vals = [ From f721289b1da1499db0c8f2c13fab9faba41c5fc8 Mon Sep 17 00:00:00 2001 From: Bart Schuurmans Date: Mon, 25 Mar 2024 18:13:09 +0100 Subject: [PATCH 25/41] Simplify logic for rendering user exports --- .../templates/preferences/export-user.html | 4 ++- bookwyrm/views/preferences/export.py | 28 ++++++------------- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/bookwyrm/templates/preferences/export-user.html b/bookwyrm/templates/preferences/export-user.html index 13fe16a77..bd675afaa 100644 --- a/bookwyrm/templates/preferences/export-user.html +++ b/bookwyrm/templates/preferences/export-user.html @@ -123,7 +123,9 @@ + {% if export.size %} {{ export.size|get_file_size }} + {% endif %} {% if export.url %} @@ -133,7 +135,7 @@ {% trans "Download your export" %} - {% elif export.job.complete and not export.job.status == "stopped" and not export.job.status == "failed" %} + {% elif export.unavailable %} {% trans "Archive is no longer available" %} {% endif %} diff --git a/bookwyrm/views/preferences/export.py b/bookwyrm/views/preferences/export.py index 1d77e1200..f501f331b 100644 --- a/bookwyrm/views/preferences/export.py +++ b/bookwyrm/views/preferences/export.py @@ -165,28 +165,16 @@ class ExportUser(View): for job in jobs: export = {"job": job} - if settings.USE_S3: - storage = S3Boto3Storage(querystring_auth=True, custom_domain=None) - - # for s3 we create a new tar file in s3, - # so we need to check the size of _that_ file + if job.export_data: try: - export["size"] = S3Boto3Storage.size( - storage, f"exports/{job.task_id}.tar.gz" - ) - except Exception: # pylint: disable=broad-except - export["size"] = 0 - - else: - # for local storage export_data is the tar file - try: - export["size"] = job.export_data.size if job.export_data else 0 + export["size"] = job.export_data.size + export["url"] = reverse("prefs-export-file", args=[job.task_id]) except FileNotFoundError: - # file no longer exists - export["size"] = 0 - - if export["size"] > 0: - export["url"] = reverse("prefs-export-file", args=[job.task_id]) + # file no longer exists locally + export["unavailable"] = True + except Exception: # pylint: disable=broad-except + # file no longer exists on storage backend + export["unavailable"] = True exports.append(export) From bd95bcd50b4822fc0fe196253a993a9fca52315c Mon Sep 17 00:00:00 2001 From: Bart Schuurmans Date: Mon, 25 Mar 2024 18:14:14 +0100 Subject: [PATCH 26/41] Add test for special character in cover filename --- .../tests/models/test_bookwyrm_export_job.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/bookwyrm/tests/models/test_bookwyrm_export_job.py b/bookwyrm/tests/models/test_bookwyrm_export_job.py index 267d30217..1e0f6a39f 100644 --- a/bookwyrm/tests/models/test_bookwyrm_export_job.py +++ b/bookwyrm/tests/models/test_bookwyrm_export_job.py @@ -1,7 +1,13 @@ """test bookwyrm user export functions""" import datetime +from io import BytesIO +import pathlib + from unittest.mock import patch +from PIL import Image + +from django.core.files.base import ContentFile from django.utils import timezone from django.test import TestCase @@ -86,6 +92,15 @@ class BookwyrmExportJob(TestCase): title="Example Edition", parent_work=self.work ) + # edition cover + image_file = pathlib.Path(__file__).parent.joinpath( + "../../static/images/default_avi.jpg" + ) + image = Image.open(image_file) + output = BytesIO() + image.save(output, format=image.format) + self.edition.cover.save("tèst.jpg", ContentFile(output.getvalue())) + self.edition.authors.add(self.author) # readthrough @@ -160,6 +175,7 @@ class BookwyrmExportJob(TestCase): self.assertIsNotNone(self.job.export_json["books"]) self.assertEqual(len(self.job.export_json["books"]), 1) book = self.job.export_json["books"][0] + self.assertEqual(book["work"]["id"], self.work.remote_id) self.assertEqual(len(book["authors"]), 1) self.assertEqual(len(book["shelves"]), 1) @@ -169,6 +185,11 @@ class BookwyrmExportJob(TestCase): self.assertEqual(len(book["quotations"]), 1) self.assertEqual(len(book["readthroughs"]), 1) + self.assertEqual(book["edition"]["id"], self.edition.remote_id) + self.assertEqual( + book["edition"]["cover"]["url"], f"images/{self.edition.cover.name}" + ) + def test_start_export_task(self): """test saved list task saves initial json and data""" From d9bf848cfab311788fbe12392243776bbb07cff0 Mon Sep 17 00:00:00 2001 From: Bart Schuurmans Date: Mon, 25 Mar 2024 18:25:43 +0100 Subject: [PATCH 27/41] Fix pylint warnings --- bookwyrm/models/bookwyrm_export_job.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index 8e3927b73..c94c6bec0 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -236,7 +236,7 @@ class AddFileToTar(ChildJob): if settings.USE_S3: # Connection for writing temporary files - s3 = S3Boto3Storage() + storage = S3Boto3Storage() # Handle for creating the final archive s3_archive_path = f"exports/{export_task_id}.tar.gz" @@ -249,7 +249,7 @@ class AddFileToTar(ChildJob): # Save JSON file to a temporary location export_json_tmp_file = f"exports/{export_task_id}/archive.json" S3Boto3Storage.save( - s3, + storage, export_json_tmp_file, ContentFile(export_json_bytes), ) @@ -269,12 +269,12 @@ class AddFileToTar(ChildJob): export_job.save() # Delete temporary files - S3Boto3Storage.delete(s3, export_json_tmp_file) + S3Boto3Storage.delete(storage, export_json_tmp_file) else: export_job.export_data_file = f"{export_task_id}.tar.gz" - with export_job.export_data_file.open("wb") as f: - with BookwyrmTarFile.open(mode="w:gz", fileobj=f) as tar: + with export_job.export_data_file.open("wb") as tar_file: + with BookwyrmTarFile.open(mode="w:gz", fileobj=tar_file) as tar: # save json file tar.write_bytes(export_json_bytes) From 145c67dd214f9df9d97ca12dad0b4f4b88125ef6 Mon Sep 17 00:00:00 2001 From: Bart Schuurmans Date: Tue, 26 Mar 2024 12:41:04 +0100 Subject: [PATCH 28/41] Merge BookwyrmExportJob export_data field back into one with dynamic storage backend --- ...198_alter_bookwyrmexportjob_export_data.py | 23 +++++++++++ .../0198_export_job_separate_file_fields.py | 28 ------------- bookwyrm/models/bookwyrm_export_job.py | 41 +++++++------------ bookwyrm/settings.py | 20 +++++++-- 4 files changed, 54 insertions(+), 58 deletions(-) create mode 100644 bookwyrm/migrations/0198_alter_bookwyrmexportjob_export_data.py delete mode 100644 bookwyrm/migrations/0198_export_job_separate_file_fields.py diff --git a/bookwyrm/migrations/0198_alter_bookwyrmexportjob_export_data.py b/bookwyrm/migrations/0198_alter_bookwyrmexportjob_export_data.py new file mode 100644 index 000000000..552584d2b --- /dev/null +++ b/bookwyrm/migrations/0198_alter_bookwyrmexportjob_export_data.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.25 on 2024-03-26 11:37 + +import bookwyrm.models.bookwyrm_export_job +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0197_merge_20240324_0235"), + ] + + operations = [ + migrations.AlterField( + model_name="bookwyrmexportjob", + name="export_data", + field=models.FileField( + null=True, + storage=bookwyrm.models.bookwyrm_export_job.select_exports_storage, + upload_to="", + ), + ), + ] diff --git a/bookwyrm/migrations/0198_export_job_separate_file_fields.py b/bookwyrm/migrations/0198_export_job_separate_file_fields.py deleted file mode 100644 index d9dd87eee..000000000 --- a/bookwyrm/migrations/0198_export_job_separate_file_fields.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 3.2.25 on 2024-03-24 11:20 - -import bookwyrm.storage_backends -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("bookwyrm", "0197_merge_20240324_0235"), - ] - - operations = [ - migrations.RenameField( - model_name="bookwyrmexportjob", - old_name="export_data", - new_name="export_data_file", - ), - migrations.AddField( - model_name="bookwyrmexportjob", - name="export_data_s3", - field=models.FileField( - null=True, - storage=bookwyrm.storage_backends.ExportsS3Storage, - upload_to="", - ), - ), - ] diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index c94c6bec0..8fd108014 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -12,8 +12,9 @@ from django.db.models import Q from django.core.serializers.json import DjangoJSONEncoder from django.core.files.base import ContentFile from django.utils import timezone +from django.utils.module_loading import import_string -from bookwyrm import settings, storage_backends +from bookwyrm import settings from bookwyrm.models import AnnualGoal, ReadThrough, ShelfBook, List, ListItem from bookwyrm.models import Review, Comment, Quotation @@ -34,33 +35,19 @@ class BookwyrmAwsSession(BotoSession): return super().client("s3", *args, **kwargs) +def select_exports_storage(): + """callable to allow for dependency on runtime configuration""" + cls = import_string(settings.EXPORTS_STORAGE) + return cls() + + class BookwyrmExportJob(ParentJob): """entry for a specific request to export a bookwyrm user""" - # Only one of these fields is used, dependent on the configuration. - export_data_file = FileField(null=True, storage=storage_backends.ExportsFileStorage) - export_data_s3 = FileField(null=True, storage=storage_backends.ExportsS3Storage) - + export_data = FileField(null=True, storage=select_exports_storage) export_json = JSONField(null=True, encoder=DjangoJSONEncoder) json_completed = BooleanField(default=False) - @property - def export_data(self): - """returns the file field of the configured storage backend""" - # TODO: We could check whether a field for a different backend is - # filled, to support migrating to a different backend. - if settings.USE_S3: - return self.export_data_s3 - return self.export_data_file - - @export_data.setter - def export_data(self, value): - """sets the file field of the configured storage backend""" - if settings.USE_S3: - self.export_data_s3 = value - else: - self.export_data_file = value - def start_job(self): """Start the job""" @@ -265,15 +252,15 @@ class AddFileToTar(ChildJob): # Create archive and store file name s3_tar.tar() - export_job.export_data_s3 = s3_archive_path - export_job.save() + export_job.export_data = s3_archive_path + export_job.save(update_fields=["export_data"]) # Delete temporary files S3Boto3Storage.delete(storage, export_json_tmp_file) else: - export_job.export_data_file = f"{export_task_id}.tar.gz" - with export_job.export_data_file.open("wb") as tar_file: + export_job.export_data = f"{export_task_id}.tar.gz" + with export_job.export_data.open("wb") as tar_file: with BookwyrmTarFile.open(mode="w:gz", fileobj=tar_file) as tar: # save json file tar.write_bytes(export_json_bytes) @@ -285,7 +272,7 @@ class AddFileToTar(ChildJob): for edition in editions: if edition.cover: tar.add_image(edition.cover, directory="images/") - export_job.save() + export_job.save(update_fields=["export_data"]) self.complete_job() diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index d2ba490b7..1e778ad15 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -390,16 +390,20 @@ if USE_S3: # S3 Static settings STATIC_LOCATION = "static" STATIC_URL = f"{PROTOCOL}://{AWS_S3_CUSTOM_DOMAIN}/{STATIC_LOCATION}/" + STATIC_FULL_URL = STATIC_URL STATICFILES_STORAGE = "bookwyrm.storage_backends.StaticStorage" # S3 Media settings MEDIA_LOCATION = "images" MEDIA_URL = f"{PROTOCOL}://{AWS_S3_CUSTOM_DOMAIN}/{MEDIA_LOCATION}/" MEDIA_FULL_URL = MEDIA_URL - STATIC_FULL_URL = STATIC_URL DEFAULT_FILE_STORAGE = "bookwyrm.storage_backends.ImagesStorage" + # S3 Exports settings + EXPORTS_STORAGE = "bookwyrm.storage_backends.ExportsS3Storage" + # Content Security Policy CSP_DEFAULT_SRC = ["'self'", AWS_S3_CUSTOM_DOMAIN] + CSP_ADDITIONAL_HOSTS CSP_SCRIPT_SRC = ["'self'", AWS_S3_CUSTOM_DOMAIN] + CSP_ADDITIONAL_HOSTS elif USE_AZURE: + # Azure settings AZURE_ACCOUNT_NAME = env("AZURE_ACCOUNT_NAME") AZURE_ACCOUNT_KEY = env("AZURE_ACCOUNT_KEY") AZURE_CONTAINER = env("AZURE_CONTAINER") @@ -409,6 +413,7 @@ elif USE_AZURE: STATIC_URL = ( f"{PROTOCOL}://{AZURE_CUSTOM_DOMAIN}/{AZURE_CONTAINER}/{STATIC_LOCATION}/" ) + STATIC_FULL_URL = STATIC_URL STATICFILES_STORAGE = "bookwyrm.storage_backends.AzureStaticStorage" # Azure Media settings MEDIA_LOCATION = "images" @@ -416,15 +421,24 @@ elif USE_AZURE: f"{PROTOCOL}://{AZURE_CUSTOM_DOMAIN}/{AZURE_CONTAINER}/{MEDIA_LOCATION}/" ) MEDIA_FULL_URL = MEDIA_URL - STATIC_FULL_URL = STATIC_URL DEFAULT_FILE_STORAGE = "bookwyrm.storage_backends.AzureImagesStorage" + # Azure Exports settings + EXPORTS_STORAGE = None # not implemented yet + # Content Security Policy CSP_DEFAULT_SRC = ["'self'", AZURE_CUSTOM_DOMAIN] + CSP_ADDITIONAL_HOSTS CSP_SCRIPT_SRC = ["'self'", AZURE_CUSTOM_DOMAIN] + CSP_ADDITIONAL_HOSTS else: + # Static settings STATIC_URL = "/static/" + STATIC_FULL_URL = f"{PROTOCOL}://{DOMAIN}{STATIC_URL}" + STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage" + # Media settings MEDIA_URL = "/images/" MEDIA_FULL_URL = f"{PROTOCOL}://{DOMAIN}{MEDIA_URL}" - STATIC_FULL_URL = f"{PROTOCOL}://{DOMAIN}{STATIC_URL}" + DEFAULT_FILE_STORAGE = "django.core.files.storage.FileSystemStorage" + # Exports settings + EXPORTS_STORAGE = "bookwyrm.storage_backends.ExportsFileStorage" + # Content Security Policy CSP_DEFAULT_SRC = ["'self'"] + CSP_ADDITIONAL_HOSTS CSP_SCRIPT_SRC = ["'self'"] + CSP_ADDITIONAL_HOSTS From ef57c0bc8b23bf4bb1dff7e4fc9ba3cb95db035d Mon Sep 17 00:00:00 2001 From: Bart Schuurmans Date: Tue, 26 Mar 2024 13:16:08 +0100 Subject: [PATCH 29/41] Check last user export too in post handler --- bookwyrm/views/preferences/export.py | 45 ++++++++++++++++------------ 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/bookwyrm/views/preferences/export.py b/bookwyrm/views/preferences/export.py index f501f331b..de243586d 100644 --- a/bookwyrm/views/preferences/export.py +++ b/bookwyrm/views/preferences/export.py @@ -148,21 +148,35 @@ class Export(View): @method_decorator(login_required, name="dispatch") class ExportUser(View): """ - Let users export user data to import into another Bookwyrm instance - This view creates signed URLs to pre-processed export files in - s3 storage on load (if they exist) and allows the user to create - a new file. + Let users request and download an archive of user data to import into + another Bookwyrm instance. """ + user_jobs = None + + def setup(self, request, *args, **kwargs): + super().setup(request, *args, **kwargs) + + self.user_jobs = BookwyrmExportJob.objects.filter(user=request.user).order_by( + "-created_date" + ) + + def new_export_blocked_until(self): + """whether the user is allowed to request a new export""" + last_job = self.user_jobs.first() + if not last_job: + return None + site = models.SiteSettings.objects.get() + blocked_until = last_job.created_date + timedelta( + hours=site.user_import_time_limit + ) + return blocked_until if blocked_until > timezone.now() else None + def get(self, request): """Request tar file""" - jobs = BookwyrmExportJob.objects.filter(user=request.user).order_by( - "-created_date" - ) - exports = [] - for job in jobs: + for job in self.user_jobs: export = {"job": job} if job.export_data: @@ -178,16 +192,7 @@ class ExportUser(View): exports.append(export) - 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 - ) + next_available = self.new_export_blocked_until() paginated = Paginator(exports, settings.PAGE_LENGTH) page = paginated.get_page(request.GET.get("page")) data = { @@ -202,6 +207,8 @@ class ExportUser(View): def post(self, request): """Trigger processing of a new user export file""" + if self.new_export_blocked_until() is not None: + return HttpResponse(status=429) # Too Many Requests job = BookwyrmExportJob.objects.create(user=request.user) job.start_job() From ed2e9e5ea87746bb50f2602a40c81bd648564f55 Mon Sep 17 00:00:00 2001 From: Bart Schuurmans Date: Tue, 26 Mar 2024 13:18:13 +0100 Subject: [PATCH 30/41] Merge migration --- bookwyrm/migrations/0199_merge_20240326_1217.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 bookwyrm/migrations/0199_merge_20240326_1217.py diff --git a/bookwyrm/migrations/0199_merge_20240326_1217.py b/bookwyrm/migrations/0199_merge_20240326_1217.py new file mode 100644 index 000000000..7794af54a --- /dev/null +++ b/bookwyrm/migrations/0199_merge_20240326_1217.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.25 on 2024-03-26 12:17 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0198_alter_bookwyrmexportjob_export_data"), + ("bookwyrm", "0198_book_search_vector_author_aliases"), + ] + + operations = [] From 9685ae5a0a7f7ae073915554498c2f2bda546df5 Mon Sep 17 00:00:00 2001 From: Bart Schuurmans Date: Tue, 26 Mar 2024 16:18:30 +0100 Subject: [PATCH 31/41] Consolidate BookwyrmExportJob into two tasks Creating the export JSON and export TAR are now the only two tasks. --- bookwyrm/models/bookwyrm_export_job.py | 565 ++++++++++--------------- 1 file changed, 218 insertions(+), 347 deletions(-) diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index 8fd108014..8c3eeb41f 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -7,20 +7,19 @@ from boto3.session import Session as BotoSession from s3_tar import S3Tar from storages.backends.s3boto3 import S3Boto3Storage -from django.db.models import CASCADE, BooleanField, FileField, ForeignKey, JSONField +from django.db.models import BooleanField, FileField, JSONField from django.db.models import Q from django.core.serializers.json import DjangoJSONEncoder from django.core.files.base import ContentFile -from django.utils import timezone from django.utils.module_loading import import_string from bookwyrm import settings -from bookwyrm.models import AnnualGoal, ReadThrough, ShelfBook, List, ListItem +from bookwyrm.models import AnnualGoal, ReadThrough, ShelfBook, ListItem from bookwyrm.models import Review, Comment, Quotation from bookwyrm.models import Edition from bookwyrm.models import UserFollows, User, UserBlocks -from bookwyrm.models.job import ParentJob, ChildJob, ParentTask +from bookwyrm.models.job import ParentJob from bookwyrm.tasks import app, IMPORTS from bookwyrm.utils.tar import BookwyrmTarFile @@ -49,40 +48,12 @@ class BookwyrmExportJob(ParentJob): json_completed = BooleanField(default=False) def start_job(self): - """Start the job""" + """schedule the first task""" - task = start_export_task.delay(job_id=self.id, no_children=False) + task = create_export_json_task.delay(job_id=self.id) self.task_id = task.id self.save(update_fields=["task_id"]) - 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: - if not self.json_completed: - try: - self.json_completed = True - self.save(update_fields=["json_completed"]) - - tar_job = AddFileToTar.objects.create( - parent_job=self, parent_export_job=self - ) - tar_job.start_job() - - except Exception as err: # pylint: disable=broad-except - logger.exception("job %s failed with error: %s", self.id, err) - tar_job.set_status("failed") - self.stop_job(reason="failed") - - else: - self.complete_job() - def url2relativepath(url: str) -> str: """turn an absolute URL into a relative filesystem path""" @@ -90,345 +61,245 @@ def url2relativepath(url: str) -> str: return unquote(parsed.path[1:]) -class AddBookToUserExportJob(ChildJob): - """append book metadata for each book in an export""" +@app.task(queue=IMPORTS) +def create_export_json_task(job_id): + """create the JSON data for the export""" - edition = ForeignKey(Edition, on_delete=CASCADE) - - # pylint: disable=too-many-locals - def start_job(self): - """Start the job""" - try: - - book = {} - book["work"] = self.edition.parent_work.to_activity() - book["edition"] = self.edition.to_activity() - - if book["edition"].get("cover"): - book["edition"]["cover"]["url"] = url2relativepath( - book["edition"]["cover"]["url"] - ) - - # authors - book["authors"] = [] - for author in self.edition.authors.all(): - book["authors"].append(author.to_activity()) - - # Shelves this book is on - # Every ShelfItem is this book so we don't other serializing - book["shelves"] = [] - shelf_books = ( - ShelfBook.objects.select_related("shelf") - .filter(user=self.parent_job.user, book=self.edition) - .distinct() - ) - - for shelfbook in shelf_books: - book["shelves"].append(shelfbook.shelf.to_activity()) - - # Lists and ListItems - # ListItems include "notes" and "approved" so we need them - # even though we know it's this book - book["lists"] = [] - list_items = ListItem.objects.filter( - book=self.edition, user=self.parent_job.user - ).distinct() - - for item in list_items: - list_info = item.book_list.to_activity() - list_info[ - "privacy" - ] = item.book_list.privacy # this isn't serialized so we add it - list_info["list_item"] = item.to_activity() - book["lists"].append(list_info) - - # Statuses - # Can't use select_subclasses here because - # we need to filter on the "book" value, - # which is not available on an ordinary Status - for status in ["comments", "quotations", "reviews"]: - book[status] = [] - - comments = Comment.objects.filter( - user=self.parent_job.user, book=self.edition - ).all() - for status in comments: - obj = status.to_activity() - obj["progress"] = status.progress - obj["progress_mode"] = status.progress_mode - book["comments"].append(obj) - - quotes = Quotation.objects.filter( - user=self.parent_job.user, book=self.edition - ).all() - for status in quotes: - obj = status.to_activity() - obj["position"] = status.position - obj["endposition"] = status.endposition - obj["position_mode"] = status.position_mode - book["quotations"].append(obj) - - reviews = Review.objects.filter( - user=self.parent_job.user, book=self.edition - ).all() - for status in reviews: - obj = status.to_activity() - book["reviews"].append(obj) - - # readthroughs can't be serialized to activity - book_readthroughs = ( - ReadThrough.objects.filter(user=self.parent_job.user, book=self.edition) - .distinct() - .values() - ) - book["readthroughs"] = list(book_readthroughs) - - self.parent_job.export_json["books"].append(book) - self.parent_job.save(update_fields=["export_json"]) - self.complete_job() - - except Exception as err: # pylint: disable=broad-except - logger.exception( - "AddBookToUserExportJob %s Failed with error: %s", self.id, err - ) - self.set_status("failed") - - -class AddFileToTar(ChildJob): - """add files to export""" - - parent_export_job = ForeignKey( - BookwyrmExportJob, on_delete=CASCADE, related_name="child_edition_export_jobs" - ) - - def start_job(self): - """Start the job""" - - # NOTE we are doing this all in one big job, - # which has the potential to block a thread - # This is because we need to refer to the same s3_job - # or BookwyrmTarFile whilst writing - # Using a series of jobs in a loop would be better - - try: - export_job = self.parent_export_job - export_task_id = str(export_job.task_id) - - export_json_bytes = ( - DjangoJSONEncoder().encode(export_job.export_json).encode("utf-8") - ) - - user = export_job.user - editions = get_books_for_user(user) - - if settings.USE_S3: - # Connection for writing temporary files - storage = S3Boto3Storage() - - # Handle for creating the final archive - s3_archive_path = f"exports/{export_task_id}.tar.gz" - s3_tar = S3Tar( - settings.AWS_STORAGE_BUCKET_NAME, - s3_archive_path, - session=BookwyrmAwsSession(), - ) - - # Save JSON file to a temporary location - export_json_tmp_file = f"exports/{export_task_id}/archive.json" - S3Boto3Storage.save( - storage, - export_json_tmp_file, - ContentFile(export_json_bytes), - ) - s3_tar.add_file(export_json_tmp_file) - - # Add avatar image if present - if user.avatar: - s3_tar.add_file(f"images/{user.avatar.name}") - - for edition in editions: - if edition.cover: - s3_tar.add_file(f"images/{edition.cover.name}") - - # Create archive and store file name - s3_tar.tar() - export_job.export_data = s3_archive_path - export_job.save(update_fields=["export_data"]) - - # Delete temporary files - S3Boto3Storage.delete(storage, export_json_tmp_file) - - else: - export_job.export_data = f"{export_task_id}.tar.gz" - with export_job.export_data.open("wb") as tar_file: - with BookwyrmTarFile.open(mode="w:gz", fileobj=tar_file) as tar: - # save json file - tar.write_bytes(export_json_bytes) - - # Add avatar image if present - if user.avatar: - tar.add_image(user.avatar, directory="images/") - - for edition in editions: - if edition.cover: - tar.add_image(edition.cover, directory="images/") - export_job.save(update_fields=["export_data"]) - - self.complete_job() - - except Exception as err: # pylint: disable=broad-except - logger.exception("AddFileToTar %s Failed with error: %s", self.id, err) - self.stop_job(reason="failed") - self.parent_job.stop_job(reason="failed") - - -@app.task(queue=IMPORTS, base=ParentTask) -def start_export_task(**kwargs): - """trigger the child tasks for user export""" - - job = BookwyrmExportJob.objects.get(id=kwargs["job_id"]) + job = BookwyrmExportJob.objects.get(id=job_id) # don't start the job if it was stopped from the UI if job.complete: return - try: - - # prepare the initial file and base json - job.export_json = job.user.to_activity() - job.save(update_fields=["export_json"]) - - # let's go - json_export.delay(job_id=job.id, job_user=job.user.id, no_children=False) - - 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") - - -@app.task(queue=IMPORTS, base=ParentTask) -def export_saved_lists_task(**kwargs): - """add user saved lists to export JSON""" - - job = BookwyrmExportJob.objects.get(id=kwargs["job_id"]) - saved_lists = List.objects.filter(id__in=job.user.saved_lists.all()).distinct() - job.export_json["saved_lists"] = [l.remote_id for l in saved_lists] - job.save(update_fields=["export_json"]) - - -@app.task(queue=IMPORTS, base=ParentTask) -def export_follows_task(**kwargs): - """add user follows to export JSON""" - - job = BookwyrmExportJob.objects.get(id=kwargs["job_id"]) - follows = UserFollows.objects.filter(user_subject=job.user).distinct() - following = User.objects.filter(userfollows_user_object__in=follows).distinct() - job.export_json["follows"] = [f.remote_id for f in following] - job.save(update_fields=["export_json"]) - - -@app.task(queue=IMPORTS, base=ParentTask) -def export_blocks_task(**kwargs): - """add user blocks to export JSON""" - - job = BookwyrmExportJob.objects.get(id=kwargs["job_id"]) - blocks = UserBlocks.objects.filter(user_subject=job.user).distinct() - blocking = User.objects.filter(userblocks_user_object__in=blocks).distinct() - job.export_json["blocks"] = [b.remote_id for b in blocking] - job.save(update_fields=["export_json"]) - - -@app.task(queue=IMPORTS, base=ParentTask) -def export_reading_goals_task(**kwargs): - """add user reading goals to export JSON""" - - job = BookwyrmExportJob.objects.get(id=kwargs["job_id"]) - reading_goals = AnnualGoal.objects.filter(user=job.user).distinct() - job.export_json["goals"] = [] - for goal in reading_goals: - job.export_json["goals"].append( - {"goal": goal.goal, "year": goal.year, "privacy": goal.privacy} - ) - job.save(update_fields=["export_json"]) - - -@app.task(queue=IMPORTS, base=ParentTask) -def json_export(**kwargs): - """Generate an export for a user""" try: - job = BookwyrmExportJob.objects.get(id=kwargs["job_id"]) job.set_status("active") - job_id = kwargs["job_id"] - if not job.export_json.get("icon"): - job.export_json["icon"] = {} - else: - job.export_json["icon"]["url"] = url2relativepath( - job.export_json["icon"]["url"] - ) - - # Additional settings - can't be serialized as AP - vals = [ - "show_goal", - "preferred_timezone", - "default_post_privacy", - "show_suggested_users", - ] - job.export_json["settings"] = {} - for k in vals: - job.export_json["settings"][k] = getattr(job.user, k) - - job.export_json["books"] = [] - - # save settings we just updated + # generate JSON structure + job.export_json = export_json(job.user) job.save(update_fields=["export_json"]) - # trigger subtasks - export_saved_lists_task.delay(job_id=job_id, no_children=False) - export_follows_task.delay(job_id=job_id, no_children=False) - export_blocks_task.delay(job_id=job_id, no_children=False) - trigger_books_jobs.delay(job_id=job_id, no_children=False) - + # create archive in separate task + create_archive_task.delay(job_id=job.id) except Exception as err: # pylint: disable=broad-except logger.exception( - "json_export task in job %s Failed with error: %s", - job.id, - err, + "create_export_json_task for %s failed with error: %s", job, err ) job.set_status("failed") -@app.task(queue=IMPORTS, base=ParentTask) -def trigger_books_jobs(**kwargs): - """trigger tasks to get data for each book""" +@app.task(queue=IMPORTS) +def create_archive_task(job_id): + """create the archive containing the JSON file and additional files""" + + job = BookwyrmExportJob.objects.get(id=job_id) + + # don't start the job if it was stopped from the UI + if job.complete: + return try: - job = BookwyrmExportJob.objects.get(id=kwargs["job_id"]) - editions = get_books_for_user(job.user) + export_task_id = job.task_id + export_json_bytes = DjangoJSONEncoder().encode(job.export_json).encode("utf-8") - if len(editions) == 0: - job.notify_child_job_complete() - return + user = job.user + editions = get_books_for_user(user) - for edition in editions: - try: - edition_job = AddBookToUserExportJob.objects.create( - edition=edition, parent_job=job - ) - edition_job.start_job() - except Exception as err: # pylint: disable=broad-except - logger.exception( - "AddBookToUserExportJob %s Failed with error: %s", - edition_job.id, - err, - ) - edition_job.set_status("failed") + if settings.USE_S3: + # Connection for writing temporary files + storage = S3Boto3Storage() + + # Handle for creating the final archive + s3_archive_path = f"exports/{export_task_id}.tar.gz" + s3_tar = S3Tar( + settings.AWS_STORAGE_BUCKET_NAME, + s3_archive_path, + session=BookwyrmAwsSession(), + ) + + # Save JSON file to a temporary location + export_json_tmp_file = f"exports/{export_task_id}/archive.json" + S3Boto3Storage.save( + storage, + export_json_tmp_file, + ContentFile(export_json_bytes), + ) + s3_tar.add_file(export_json_tmp_file) + + # Add avatar image if present + if user.avatar: + s3_tar.add_file(f"images/{user.avatar.name}") + + for edition in editions: + if edition.cover: + s3_tar.add_file(f"images/{edition.cover.name}") + + # Create archive and store file name + s3_tar.tar() + job.export_data = s3_archive_path + job.save(update_fields=["export_data"]) + + # Delete temporary files + S3Boto3Storage.delete(storage, export_json_tmp_file) + + else: + job.export_data = f"{export_task_id}.tar.gz" + with job.export_data.open("wb") as tar_file: + with BookwyrmTarFile.open(mode="w:gz", fileobj=tar_file) as tar: + # save json file + tar.write_bytes(export_json_bytes) + + # Add avatar image if present + if user.avatar: + tar.add_image(user.avatar, directory="images/") + + for edition in editions: + if edition.cover: + tar.add_image(edition.cover, directory="images/") + job.save(update_fields=["export_data"]) + + job.set_status("completed") except Exception as err: # pylint: disable=broad-except - logger.exception("trigger_books_jobs %s Failed with error: %s", job.id, err) + logger.exception("create_archive_task for %s failed with error: %s", job, err) job.set_status("failed") +def export_json(user: User): + """create export JSON""" + data = export_user(user) # in the root of the JSON structure + data["settings"] = export_settings(user) + data["goals"] = export_goals(user) + data["books"] = export_books(user) + data["saved_lists"] = export_saved_lists(user) + data["follows"] = export_follows(user) + data["blocks"] = export_blocks(user) + return data + + +def export_user(user: User): + """export user data""" + data = user.to_activity() + data["icon"]["url"] = ( + url2relativepath(data["icon"]["url"]) if data.get("icon", False) else {} + ) + return data + + +def export_settings(user: User): + """Additional settings - can't be serialized as AP""" + vals = [ + "show_goal", + "preferred_timezone", + "default_post_privacy", + "show_suggested_users", + ] + return {k: getattr(user, k) for k in vals} + + +def export_saved_lists(user: User): + """add user saved lists to export JSON""" + return [l.remote_id for l in user.saved_lists.all()] + + +def export_follows(user: User): + """add user follows to export JSON""" + follows = UserFollows.objects.filter(user_subject=user).distinct() + following = User.objects.filter(userfollows_user_object__in=follows).distinct() + return [f.remote_id for f in following] + + +def export_blocks(user: User): + """add user blocks to export JSON""" + blocks = UserBlocks.objects.filter(user_subject=user).distinct() + blocking = User.objects.filter(userblocks_user_object__in=blocks).distinct() + return [b.remote_id for b in blocking] + + +def export_goals(user: User): + """add user reading goals to export JSON""" + reading_goals = AnnualGoal.objects.filter(user=user).distinct() + return [ + {"goal": goal.goal, "year": goal.year, "privacy": goal.privacy} + for goal in reading_goals + ] + + +def export_books(user: User): + """add books to export JSON""" + editions = get_books_for_user(user) + return [export_book(user, edition) for edition in editions] + + +def export_book(user: User, edition: Edition): + """add book to export JSON""" + data = {} + data["work"] = edition.parent_work.to_activity() + data["edition"] = edition.to_activity() + + if data["edition"].get("cover"): + data["edition"]["cover"]["url"] = url2relativepath( + data["edition"]["cover"]["url"] + ) + + # authors + data["authors"] = [author.to_activity() for author in edition.authors.all()] + + # Shelves this book is on + # Every ShelfItem is this book so we don't other serializing + shelf_books = ( + ShelfBook.objects.select_related("shelf") + .filter(user=user, book=edition) + .distinct() + ) + data["shelves"] = [shelfbook.shelf.to_activity() for shelfbook in shelf_books] + + # Lists and ListItems + # ListItems include "notes" and "approved" so we need them + # even though we know it's this book + list_items = ListItem.objects.filter(book=edition, user=user).distinct() + + data["lists"] = [] + for item in list_items: + list_info = item.book_list.to_activity() + list_info[ + "privacy" + ] = item.book_list.privacy # this isn't serialized so we add it + list_info["list_item"] = item.to_activity() + data["lists"].append(list_info) + + # Statuses + # Can't use select_subclasses here because + # we need to filter on the "book" value, + # which is not available on an ordinary Status + for status in ["comments", "quotations", "reviews"]: + data[status] = [] + + comments = Comment.objects.filter(user=user, book=edition).all() + for status in comments: + obj = status.to_activity() + obj["progress"] = status.progress + obj["progress_mode"] = status.progress_mode + data["comments"].append(obj) + + quotes = Quotation.objects.filter(user=user, book=edition).all() + for status in quotes: + obj = status.to_activity() + obj["position"] = status.position + obj["endposition"] = status.endposition + obj["position_mode"] = status.position_mode + data["quotations"].append(obj) + + reviews = Review.objects.filter(user=user, book=edition).all() + data["reviews"] = [status.to_activity() for status in reviews] + + # readthroughs can't be serialized to activity + book_readthroughs = ( + ReadThrough.objects.filter(user=user, book=edition).distinct().values() + ) + data["readthroughs"] = list(book_readthroughs) + return data + + def get_books_for_user(user): """Get all the books and editions related to a user""" From 9afd0ebb54d67f5cc1ed4f7d894479e713e44470 Mon Sep 17 00:00:00 2001 From: Bart Schuurmans Date: Wed, 27 Mar 2024 20:15:06 +0100 Subject: [PATCH 32/41] Update migrations --- .../migrations/0200_auto_20240327_1914.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 bookwyrm/migrations/0200_auto_20240327_1914.py diff --git a/bookwyrm/migrations/0200_auto_20240327_1914.py b/bookwyrm/migrations/0200_auto_20240327_1914.py new file mode 100644 index 000000000..44d84a13e --- /dev/null +++ b/bookwyrm/migrations/0200_auto_20240327_1914.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.25 on 2024-03-27 19:14 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0199_merge_20240326_1217'), + ] + + operations = [ + migrations.RemoveField( + model_name='addfiletotar', + name='childjob_ptr', + ), + migrations.RemoveField( + model_name='addfiletotar', + name='parent_export_job', + ), + migrations.DeleteModel( + name='AddBookToUserExportJob', + ), + migrations.DeleteModel( + name='AddFileToTar', + ), + ] From 797d5cb508555283dd7807883866cb8bc5eb6508 Mon Sep 17 00:00:00 2001 From: Bart Schuurmans Date: Wed, 27 Mar 2024 20:39:57 +0100 Subject: [PATCH 33/41] Update BookwyrmExportJob tests --- bookwyrm/models/bookwyrm_export_job.py | 9 ++- .../tests/models/test_bookwyrm_export_job.py | 76 ++----------------- 2 files changed, 11 insertions(+), 74 deletions(-) diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index 8c3eeb41f..7a0c1100c 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -179,9 +179,10 @@ def export_json(user: User): def export_user(user: User): """export user data""" data = user.to_activity() - data["icon"]["url"] = ( - url2relativepath(data["icon"]["url"]) if data.get("icon", False) else {} - ) + if data.get("icon", False): + data["icon"]["url"] = url2relativepath(data["icon"]["url"]) + else: + data["icon"] = {} return data @@ -236,7 +237,7 @@ def export_book(user: User, edition: Edition): data["work"] = edition.parent_work.to_activity() data["edition"] = edition.to_activity() - if data["edition"].get("cover"): + if data["edition"].get("cover", False): data["edition"]["cover"]["url"] = url2relativepath( data["edition"]["cover"]["url"] ) diff --git a/bookwyrm/tests/models/test_bookwyrm_export_job.py b/bookwyrm/tests/models/test_bookwyrm_export_job.py index 1e0f6a39f..654ecec9e 100644 --- a/bookwyrm/tests/models/test_bookwyrm_export_job.py +++ b/bookwyrm/tests/models/test_bookwyrm_export_job.py @@ -153,25 +153,15 @@ class BookwyrmExportJob(TestCase): book=self.edition, ) - self.job = models.BookwyrmExportJob.objects.create( - user=self.local_user, export_json={} - ) + self.job = models.BookwyrmExportJob.objects.create(user=self.local_user) + + # run the first stage of the export + with patch("bookwyrm.models.bookwyrm_export_job.create_archive_task.delay"): + models.bookwyrm_export_job.create_export_json_task(job_id=self.job.id) + self.job.refresh_from_db() def test_add_book_to_user_export_job(self): """does AddBookToUserExportJob ...add the book to the export?""" - - self.job.export_json["books"] = [] - self.job.save() - - with patch("bookwyrm.models.bookwyrm_export_job.AddFileToTar.start_job"): - model = models.bookwyrm_export_job - edition_job = model.AddBookToUserExportJob.objects.create( - edition=self.edition, parent_job=self.job - ) - - edition_job.start_job() - - self.job.refresh_from_db() self.assertIsNotNone(self.job.export_json["books"]) self.assertEqual(len(self.job.export_json["books"]), 1) book = self.job.export_json["books"][0] @@ -192,27 +182,12 @@ class BookwyrmExportJob(TestCase): def test_start_export_task(self): """test saved list task saves initial json and data""" - - with patch("bookwyrm.models.bookwyrm_export_job.json_export.delay"): - models.bookwyrm_export_job.start_export_task( - job_id=self.job.id, no_children=False - ) - - self.job.refresh_from_db() - self.assertIsNotNone(self.job.export_data) self.assertIsNotNone(self.job.export_json) self.assertEqual(self.job.export_json["name"], self.local_user.name) def test_export_saved_lists_task(self): """test export_saved_lists_task adds the saved lists""" - - models.bookwyrm_export_job.export_saved_lists_task( - job_id=self.job.id, no_children=False - ) - - self.job.refresh_from_db() - self.assertIsNotNone(self.job.export_json["saved_lists"]) self.assertEqual( self.job.export_json["saved_lists"][0], self.saved_list.remote_id @@ -220,60 +195,21 @@ class BookwyrmExportJob(TestCase): def test_export_follows_task(self): """test export_follows_task adds the follows""" - - models.bookwyrm_export_job.export_follows_task( - job_id=self.job.id, no_children=False - ) - - self.job.refresh_from_db() - self.assertIsNotNone(self.job.export_json["follows"]) self.assertEqual(self.job.export_json["follows"][0], self.rat_user.remote_id) def test_export_blocks_task(self): - """test export_blocks_task adds the blocks""" - - models.bookwyrm_export_job.export_blocks_task( - job_id=self.job.id, no_children=False - ) - - self.job.refresh_from_db() - self.assertIsNotNone(self.job.export_json["blocks"]) self.assertEqual(self.job.export_json["blocks"][0], self.badger_user.remote_id) def test_export_reading_goals_task(self): """test export_reading_goals_task adds the goals""" - - models.bookwyrm_export_job.export_reading_goals_task( - job_id=self.job.id, no_children=False - ) - - self.job.refresh_from_db() - self.assertIsNotNone(self.job.export_json["goals"]) self.assertEqual(self.job.export_json["goals"][0]["goal"], 128937123) def test_json_export(self): """test json_export job adds settings""" - - with patch( - "bookwyrm.models.bookwyrm_export_job.export_saved_lists_task.delay" - ), patch( - "bookwyrm.models.bookwyrm_export_job.export_follows_task.delay" - ), patch( - "bookwyrm.models.bookwyrm_export_job.export_blocks_task.delay" - ), patch( - "bookwyrm.models.bookwyrm_export_job.trigger_books_jobs.delay" - ): - - models.bookwyrm_export_job.json_export( - job_id=self.job.id, no_children=False - ) - - self.job.refresh_from_db() - self.assertIsNotNone(self.job.export_json["settings"]) self.assertFalse(self.job.export_json["settings"]["show_goal"]) self.assertEqual( From c6ca547d58c1c0bd1d2f378495507a068eba66ea Mon Sep 17 00:00:00 2001 From: Bart Schuurmans Date: Wed, 27 Mar 2024 20:41:59 +0100 Subject: [PATCH 34/41] Fix migration formatting --- bookwyrm/migrations/0200_auto_20240327_1914.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bookwyrm/migrations/0200_auto_20240327_1914.py b/bookwyrm/migrations/0200_auto_20240327_1914.py index 44d84a13e..38180b3f9 100644 --- a/bookwyrm/migrations/0200_auto_20240327_1914.py +++ b/bookwyrm/migrations/0200_auto_20240327_1914.py @@ -6,22 +6,22 @@ from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ('bookwyrm', '0199_merge_20240326_1217'), + ("bookwyrm", "0199_merge_20240326_1217"), ] operations = [ migrations.RemoveField( - model_name='addfiletotar', - name='childjob_ptr', + model_name="addfiletotar", + name="childjob_ptr", ), migrations.RemoveField( - model_name='addfiletotar', - name='parent_export_job', + model_name="addfiletotar", + name="parent_export_job", ), migrations.DeleteModel( - name='AddBookToUserExportJob', + name="AddBookToUserExportJob", ), migrations.DeleteModel( - name='AddFileToTar', + name="AddFileToTar", ), ] From cdbc1d172c00c30a4579c98bd38d51e0690330c9 Mon Sep 17 00:00:00 2001 From: Bart Schuurmans Date: Wed, 27 Mar 2024 23:27:19 +0100 Subject: [PATCH 35/41] Fix double exports subdir in S3 user export --- bookwyrm/models/bookwyrm_export_job.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index 7a0c1100c..35226c6a4 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -99,6 +99,7 @@ def create_archive_task(job_id): try: export_task_id = job.task_id + archive_filename = f"{export_task_id}.tar.gz" export_json_bytes = DjangoJSONEncoder().encode(job.export_json).encode("utf-8") user = job.user @@ -109,10 +110,9 @@ def create_archive_task(job_id): storage = S3Boto3Storage() # Handle for creating the final archive - s3_archive_path = f"exports/{export_task_id}.tar.gz" s3_tar = S3Tar( settings.AWS_STORAGE_BUCKET_NAME, - s3_archive_path, + f"exports/{archive_filename}", session=BookwyrmAwsSession(), ) @@ -135,14 +135,14 @@ def create_archive_task(job_id): # Create archive and store file name s3_tar.tar() - job.export_data = s3_archive_path + job.export_data = archive_filename job.save(update_fields=["export_data"]) # Delete temporary files S3Boto3Storage.delete(storage, export_json_tmp_file) else: - job.export_data = f"{export_task_id}.tar.gz" + job.export_data = archive_filename with job.export_data.open("wb") as tar_file: with BookwyrmTarFile.open(mode="w:gz", fileobj=tar_file) as tar: # save json file From dabf7c6e10084181765cc1356e8b099d29ee0742 Mon Sep 17 00:00:00 2001 From: Bart Schuurmans Date: Thu, 28 Mar 2024 13:09:21 +0100 Subject: [PATCH 36/41] User export testing fixes --- bookwyrm/models/bookwyrm_export_job.py | 74 ++++++++++++++++---------- bookwyrm/utils/tar.py | 15 +++--- 2 files changed, 53 insertions(+), 36 deletions(-) diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index 35226c6a4..09f064ea2 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -1,11 +1,10 @@ """Export user account to tar.gz file for import into another Bookwyrm instance""" import logging -from urllib.parse import urlparse, unquote +import os from boto3.session import Session as BotoSession from s3_tar import S3Tar -from storages.backends.s3boto3 import S3Boto3Storage from django.db.models import BooleanField, FileField, JSONField from django.db.models import Q @@ -13,7 +12,7 @@ from django.core.serializers.json import DjangoJSONEncoder from django.core.files.base import ContentFile from django.utils.module_loading import import_string -from bookwyrm import settings +from bookwyrm import settings, storage_backends from bookwyrm.models import AnnualGoal, ReadThrough, ShelfBook, ListItem from bookwyrm.models import Review, Comment, Quotation @@ -55,12 +54,6 @@ class BookwyrmExportJob(ParentJob): self.save(update_fields=["task_id"]) -def url2relativepath(url: str) -> str: - """turn an absolute URL into a relative filesystem path""" - parsed = urlparse(url) - return unquote(parsed.path[1:]) - - @app.task(queue=IMPORTS) def create_export_json_task(job_id): """create the JSON data for the export""" @@ -87,6 +80,22 @@ def create_export_json_task(job_id): job.set_status("failed") +def archive_file_location(file, directory="") -> str: + """get the relative location of a file inside the archive""" + return os.path.join(directory, file.name) + + +def add_file_to_s3_tar(s3_tar: S3Tar, storage, file, directory=""): + """ + add file to S3Tar inside directory, keeping any directories under its + storage location + """ + s3_tar.add_file( + os.path.join(storage.location, file.name), + folder=os.path.dirname(archive_file_location(file, directory=directory)), + ) + + @app.task(queue=IMPORTS) def create_archive_task(job_id): """create the archive containing the JSON file and additional files""" @@ -98,7 +107,7 @@ def create_archive_task(job_id): return try: - export_task_id = job.task_id + export_task_id = str(job.task_id) archive_filename = f"{export_task_id}.tar.gz" export_json_bytes = DjangoJSONEncoder().encode(job.export_json).encode("utf-8") @@ -106,32 +115,39 @@ def create_archive_task(job_id): editions = get_books_for_user(user) if settings.USE_S3: - # Connection for writing temporary files - storage = S3Boto3Storage() + # Storage for writing temporary files + exports_storage = storage_backends.ExportsS3Storage() # Handle for creating the final archive s3_tar = S3Tar( - settings.AWS_STORAGE_BUCKET_NAME, - f"exports/{archive_filename}", + exports_storage.bucket_name, + os.path.join(exports_storage.location, archive_filename), session=BookwyrmAwsSession(), ) # Save JSON file to a temporary location - export_json_tmp_file = f"exports/{export_task_id}/archive.json" - S3Boto3Storage.save( - storage, + export_json_tmp_file = os.path.join(export_task_id, "archive.json") + exports_storage.save( export_json_tmp_file, ContentFile(export_json_bytes), ) - s3_tar.add_file(export_json_tmp_file) + s3_tar.add_file( + os.path.join(exports_storage.location, export_json_tmp_file) + ) + + # Add images to TAR + images_storage = storage_backends.ImagesStorage() - # Add avatar image if present if user.avatar: - s3_tar.add_file(f"images/{user.avatar.name}") + add_file_to_s3_tar( + s3_tar, images_storage, user.avatar, directory="images" + ) for edition in editions: if edition.cover: - s3_tar.add_file(f"images/{edition.cover.name}") + add_file_to_s3_tar( + s3_tar, images_storage, edition.cover, directory="images" + ) # Create archive and store file name s3_tar.tar() @@ -139,7 +155,7 @@ def create_archive_task(job_id): job.save(update_fields=["export_data"]) # Delete temporary files - S3Boto3Storage.delete(storage, export_json_tmp_file) + exports_storage.delete(export_json_tmp_file) else: job.export_data = archive_filename @@ -150,11 +166,11 @@ def create_archive_task(job_id): # Add avatar image if present if user.avatar: - tar.add_image(user.avatar, directory="images/") + tar.add_image(user.avatar, directory="images") for edition in editions: if edition.cover: - tar.add_image(edition.cover, directory="images/") + tar.add_image(edition.cover, directory="images") job.save(update_fields=["export_data"]) job.set_status("completed") @@ -179,8 +195,8 @@ def export_json(user: User): def export_user(user: User): """export user data""" data = user.to_activity() - if data.get("icon", False): - data["icon"]["url"] = url2relativepath(data["icon"]["url"]) + if user.avatar: + data["icon"]["url"] = archive_file_location(user.avatar, directory="images") else: data["icon"] = {} return data @@ -237,9 +253,9 @@ def export_book(user: User, edition: Edition): data["work"] = edition.parent_work.to_activity() data["edition"] = edition.to_activity() - if data["edition"].get("cover", False): - data["edition"]["cover"]["url"] = url2relativepath( - data["edition"]["cover"]["url"] + if edition.cover: + data["edition"]["cover"]["url"] = archive_file_location( + edition.cover, directory="images" ) # authors diff --git a/bookwyrm/utils/tar.py b/bookwyrm/utils/tar.py index bae3f7628..6b78b1a99 100644 --- a/bookwyrm/utils/tar.py +++ b/bookwyrm/utils/tar.py @@ -1,5 +1,6 @@ """manage tar files for user exports""" import io +import os import tarfile from typing import Any, Optional from uuid import uuid4 @@ -24,13 +25,13 @@ class BookwyrmTarFile(tarfile.TarFile): :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}" + if filename is None: + filename = image.name else: - filename = f"{directory}{image.name}" + filename += os.path.splitext(image.name)[1] + path = os.path.join(directory, filename) - info = tarfile.TarInfo(name=filename) + info = tarfile.TarInfo(name=path) info.size = image.size self.addfile(info, fileobj=image) @@ -43,7 +44,7 @@ class BookwyrmTarFile(tarfile.TarFile): def write_image_to_file(self, filename: str, file_field: Any) -> None: """add an image to the tar""" - extension = filename.rsplit(".")[-1] + extension = os.path.splitext(filename)[1] if buf := self.extractfile(filename): - filename = f"{str(uuid4())}.{extension}" + filename = str(uuid4()) + extension file_field.save(filename, File(buf)) From bb5d8152f154e5d600ef381bd07a73423addd258 Mon Sep 17 00:00:00 2001 From: Bart Schuurmans Date: Thu, 28 Mar 2024 13:21:30 +0100 Subject: [PATCH 37/41] Fix mypy error --- bookwyrm/utils/tar.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bookwyrm/utils/tar.py b/bookwyrm/utils/tar.py index 6b78b1a99..70fdc38f1 100644 --- a/bookwyrm/utils/tar.py +++ b/bookwyrm/utils/tar.py @@ -18,7 +18,7 @@ class BookwyrmTarFile(tarfile.TarFile): self.addfile(info, fileobj=buffer) def add_image( - self, image: Any, filename: Optional[str] = None, directory: Any = "" + self, image: Any, filename: Optional[str] = None, directory: str = "" ) -> None: """ Add an image to the tar archive @@ -26,12 +26,12 @@ class BookwyrmTarFile(tarfile.TarFile): :param str directory: the directory in the archive to put the image """ if filename is None: - filename = image.name + dst_filename = image.name else: - filename += os.path.splitext(image.name)[1] - path = os.path.join(directory, filename) + dst_filename = filename + os.path.splitext(image.name)[1] + dst_path = os.path.join(directory, dst_filename) - info = tarfile.TarInfo(name=path) + info = tarfile.TarInfo(name=dst_path) info.size = image.size self.addfile(info, fileobj=image) From 2bbe3d4c325ef7ace4353940a12b676da07a2f2f Mon Sep 17 00:00:00 2001 From: Bart Schuurmans Date: Thu, 28 Mar 2024 13:50:55 +0100 Subject: [PATCH 38/41] Test user export archive contents --- .../tests/models/test_bookwyrm_export_job.py | 44 +++++++++++++++---- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/bookwyrm/tests/models/test_bookwyrm_export_job.py b/bookwyrm/tests/models/test_bookwyrm_export_job.py index 654ecec9e..46c9bff56 100644 --- a/bookwyrm/tests/models/test_bookwyrm_export_job.py +++ b/bookwyrm/tests/models/test_bookwyrm_export_job.py @@ -1,17 +1,15 @@ """test bookwyrm user export functions""" import datetime -from io import BytesIO +import json import pathlib from unittest.mock import patch -from PIL import Image - -from django.core.files.base import ContentFile from django.utils import timezone from django.test import TestCase from bookwyrm import models +from bookwyrm.utils.tar import BookwyrmTarFile class BookwyrmExportJob(TestCase): @@ -47,6 +45,11 @@ class BookwyrmExportJob(TestCase): preferred_timezone="America/Los Angeles", default_post_privacy="followers", ) + avatar_path = pathlib.Path(__file__).parent.joinpath( + "../../static/images/default_avi.jpg" + ) + with open(avatar_path, "rb") as avatar_file: + self.local_user.avatar.save("mouse-avatar.jpg", avatar_file) self.rat_user = models.User.objects.create_user( "rat", "rat@rat.rat", "ratword", local=True, localname="rat" @@ -93,13 +96,11 @@ class BookwyrmExportJob(TestCase): ) # edition cover - image_file = pathlib.Path(__file__).parent.joinpath( + cover_path = pathlib.Path(__file__).parent.joinpath( "../../static/images/default_avi.jpg" ) - image = Image.open(image_file) - output = BytesIO() - image.save(output, format=image.format) - self.edition.cover.save("tèst.jpg", ContentFile(output.getvalue())) + with open(cover_path, "rb") as cover_file: + self.edition.cover.save("tèst.jpg", cover_file) self.edition.authors.add(self.author) @@ -228,3 +229,28 @@ class BookwyrmExportJob(TestCase): self.assertEqual(len(data), 1) self.assertEqual(data[0].title, "Example Edition") + + def test_archive(self): + """actually create the TAR file""" + models.bookwyrm_export_job.create_archive_task(job_id=self.job.id) + self.job.refresh_from_db() + + with self.job.export_data.open("rb") as tar_file: + with BookwyrmTarFile.open(mode="r", fileobj=tar_file) as tar: + archive_json_file = tar.extractfile("archive.json") + data = json.load(archive_json_file) + + # JSON from the archive should be what we want it to be + self.assertEqual(data, self.job.export_json) + + # User avatar should be present in archive + with self.local_user.avatar.open() as expected_avatar: + archive_avatar = tar.extractfile(data["icon"]["url"]) + self.assertEqual(expected_avatar.read(), archive_avatar.read()) + + # Edition cover should be present in archive + with self.edition.cover.open() as expected_cover: + archive_cover = tar.extractfile( + data["books"][0]["edition"]["cover"]["url"] + ) + self.assertEqual(expected_cover.read(), archive_cover.read()) From 5d597f1ca96659418f9fc4e68a81773cad7e0517 Mon Sep 17 00:00:00 2001 From: Bart Schuurmans Date: Fri, 29 Mar 2024 14:25:08 +0100 Subject: [PATCH 39/41] Use new "with ()" style --- .../tests/models/test_bookwyrm_export_job.py | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/bookwyrm/tests/models/test_bookwyrm_export_job.py b/bookwyrm/tests/models/test_bookwyrm_export_job.py index a02cfe052..29a2a07c1 100644 --- a/bookwyrm/tests/models/test_bookwyrm_export_job.py +++ b/bookwyrm/tests/models/test_bookwyrm_export_job.py @@ -233,22 +233,24 @@ class BookwyrmExportJob(TestCase): models.bookwyrm_export_job.create_archive_task(job_id=self.job.id) self.job.refresh_from_db() - with self.job.export_data.open("rb") as tar_file: - with BookwyrmTarFile.open(mode="r", fileobj=tar_file) as tar: - archive_json_file = tar.extractfile("archive.json") - data = json.load(archive_json_file) + with ( + self.job.export_data.open("rb") as tar_file, + BookwyrmTarFile.open(mode="r", fileobj=tar_file) as tar, + ): + archive_json_file = tar.extractfile("archive.json") + data = json.load(archive_json_file) - # JSON from the archive should be what we want it to be - self.assertEqual(data, self.job.export_json) + # JSON from the archive should be what we want it to be + self.assertEqual(data, self.job.export_json) - # User avatar should be present in archive - with self.local_user.avatar.open() as expected_avatar: - archive_avatar = tar.extractfile(data["icon"]["url"]) - self.assertEqual(expected_avatar.read(), archive_avatar.read()) + # User avatar should be present in archive + with self.local_user.avatar.open() as expected_avatar: + archive_avatar = tar.extractfile(data["icon"]["url"]) + self.assertEqual(expected_avatar.read(), archive_avatar.read()) - # Edition cover should be present in archive - with self.edition.cover.open() as expected_cover: - archive_cover = tar.extractfile( - data["books"][0]["edition"]["cover"]["url"] - ) - self.assertEqual(expected_cover.read(), archive_cover.read()) + # Edition cover should be present in archive + with self.edition.cover.open() as expected_cover: + archive_cover = tar.extractfile( + data["books"][0]["edition"]["cover"]["url"] + ) + self.assertEqual(expected_cover.read(), archive_cover.read()) From 501fb4552890c7336f45392047bb879c519b91c4 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sat, 13 Apr 2024 12:03:35 +1000 Subject: [PATCH 40/41] export avatars to own directory Saving avatars to /images is problematic because it changes the original filepath from avatars/filename to images/avatars/filename. In this PR prior to this commit, imports failed as they are looking for a file path beginning with "avatar" --- bookwyrm/models/bookwyrm_export_job.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py index 09f064ea2..da79de6a8 100644 --- a/bookwyrm/models/bookwyrm_export_job.py +++ b/bookwyrm/models/bookwyrm_export_job.py @@ -139,9 +139,7 @@ def create_archive_task(job_id): images_storage = storage_backends.ImagesStorage() if user.avatar: - add_file_to_s3_tar( - s3_tar, images_storage, user.avatar, directory="images" - ) + add_file_to_s3_tar(s3_tar, images_storage, user.avatar) for edition in editions: if edition.cover: @@ -166,7 +164,7 @@ def create_archive_task(job_id): # Add avatar image if present if user.avatar: - tar.add_image(user.avatar, directory="images") + tar.add_image(user.avatar) for edition in editions: if edition.cover: @@ -196,7 +194,7 @@ def export_user(user: User): """export user data""" data = user.to_activity() if user.avatar: - data["icon"]["url"] = archive_file_location(user.avatar, directory="images") + data["icon"]["url"] = archive_file_location(user.avatar) else: data["icon"] = {} return data From c3c46144fe3aa61eefdb285d19d005705b95f6e6 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sat, 13 Apr 2024 12:39:40 +1000 Subject: [PATCH 41/41] add merge migration --- bookwyrm/migrations/0205_merge_20240413_0232.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 bookwyrm/migrations/0205_merge_20240413_0232.py diff --git a/bookwyrm/migrations/0205_merge_20240413_0232.py b/bookwyrm/migrations/0205_merge_20240413_0232.py new file mode 100644 index 000000000..9cca29c45 --- /dev/null +++ b/bookwyrm/migrations/0205_merge_20240413_0232.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.25 on 2024-04-13 02:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0200_auto_20240327_1914"), + ("bookwyrm", "0204_merge_20240409_1042"), + ] + + operations = []