Merge pull request #3037 from hughrun/user-migrate

complete most outstanding user migrate tasks
This commit is contained in:
Hugh Rundle 2023-10-22 15:40:22 +11:00 committed by GitHub
commit 11a726b40b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 875 additions and 246 deletions

View file

@ -0,0 +1,85 @@
# Generated by Django 3.2.20 on 2023-10-21 20:50
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0182_merge_20230905_2240"),
]
operations = [
migrations.AddField(
model_name="notification",
name="related_user_export",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="bookwyrm.bookwyrmexportjob",
),
),
migrations.AlterField(
model_name="childjob",
name="status",
field=models.CharField(
choices=[
("pending", "Pending"),
("active", "Active"),
("complete", "Complete"),
("stopped", "Stopped"),
("failed", "Failed"),
],
default="pending",
max_length=50,
null=True,
),
),
migrations.AlterField(
model_name="notification",
name="notification_type",
field=models.CharField(
choices=[
("FAVORITE", "Favorite"),
("REPLY", "Reply"),
("MENTION", "Mention"),
("TAG", "Tag"),
("FOLLOW", "Follow"),
("FOLLOW_REQUEST", "Follow Request"),
("BOOST", "Boost"),
("IMPORT", "Import"),
("USER_IMPORT", "User Import"),
("USER_EXPORT", "User Export"),
("ADD", "Add"),
("REPORT", "Report"),
("LINK_DOMAIN", "Link Domain"),
("INVITE", "Invite"),
("ACCEPT", "Accept"),
("JOIN", "Join"),
("LEAVE", "Leave"),
("REMOVE", "Remove"),
("GROUP_PRIVACY", "Group Privacy"),
("GROUP_NAME", "Group Name"),
("GROUP_DESCRIPTION", "Group Description"),
],
max_length=255,
),
),
migrations.AlterField(
model_name="parentjob",
name="status",
field=models.CharField(
choices=[
("pending", "Pending"),
("active", "Active"),
("complete", "Complete"),
("stopped", "Stopped"),
("failed", "Failed"),
],
default="pending",
max_length=50,
null=True,
),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.20 on 2023-10-22 02:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0183_auto_20231021_2050"),
]
operations = [
migrations.AddField(
model_name="sitesettings",
name="user_import_time_limit",
field=models.IntegerField(default=48),
),
]

View file

@ -35,13 +35,17 @@ def start_export_task(**kwargs):
# don't start the job if it was stopped from the UI
if job.complete:
return
# This is where ChildJobs get made
job.export_data = ContentFile(b"", str(uuid4()))
json_data = json_export(job.user)
tar_export(json_data, job.user, job.export_data)
try:
# This is where ChildJobs get made
job.export_data = ContentFile(b"", str(uuid4()))
json_data = json_export(job.user)
tar_export(json_data, job.user, job.export_data)
except Exception as err: # pylint: disable=broad-except
logger.exception("User Export Job %s Failed with error: %s", job.id, err)
job.set_status("failed")
job.set_status(
"complete"
) # need to explicitly set this here to trigger notifications
job.save(update_fields=["export_data"])
@ -56,7 +60,8 @@ def tar_export(json_data: str, user, f):
editions, books = get_books_for_user(user)
for book in editions:
tar.add_image(book.cover)
if getattr(book, "cover", False):
tar.add_image(book.cover)
f.close()
@ -153,20 +158,12 @@ def json_export(user):
comments = models.Comment.objects.filter(user=user, book=book["id"]).distinct()
book["comments"] = list(comments.values())
logger.error("FINAL COMMENTS")
logger.error(book["comments"])
# quotes
quotes = models.Quotation.objects.filter(user=user, book=book["id"]).distinct()
# quote_statuses = models.Status.objects.filter(
# id__in=quotes, user=kwargs["user"]
# ).distinct()
book["quotes"] = list(quotes.values())
logger.error("FINAL QUOTES")
logger.error(book["quotes"])
# append everything
final_books.append(book)

View file

@ -1,5 +1,6 @@
from functools import reduce
import json
import logging
import operator
from django.db.models import FileField, JSONField, CharField
@ -18,7 +19,8 @@ from bookwyrm.models.job import (
create_child_job,
)
from bookwyrm.utils.tar import BookwyrmTarFile
import json
logger = logging.getLogger(__name__)
class BookwyrmImportJob(ParentJob):
@ -43,27 +45,33 @@ def start_import_task(**kwargs):
if job.complete:
return
archive_file.open("rb")
with BookwyrmTarFile.open(mode="r:gz", fileobj=archive_file) as tar:
job.import_data = json.loads(tar.read("archive.json").decode("utf-8"))
try:
archive_file.open("rb")
with BookwyrmTarFile.open(mode="r:gz", fileobj=archive_file) as tar:
job.import_data = json.loads(tar.read("archive.json").decode("utf-8"))
if "include_user_profile" in job.required:
update_user_profile(job.user, tar, job.import_data.get("user"))
if "include_user_settings" in job.required:
update_user_settings(job.user, job.import_data.get("user"))
if "include_goals" in job.required:
update_goals(job.user, job.import_data.get("goals"))
if "include_saved_lists" in job.required:
upsert_saved_lists(job.user, job.import_data.get("saved_lists"))
if "include_follows" in job.required:
upsert_follows(job.user, job.import_data.get("follows"))
if "include_blocks" in job.required:
upsert_user_blocks(job.user, job.import_data.get("blocked_users"))
if "include_user_profile" in job.required:
update_user_profile(job.user, tar, job.import_data.get("user"))
if "include_user_settings" in job.required:
update_user_settings(job.user, job.import_data.get("user"))
if "include_goals" in job.required:
update_goals(job.user, job.import_data.get("goals"))
if "include_saved_lists" in job.required:
upsert_saved_lists(job.user, job.import_data.get("saved_lists"))
if "include_follows" in job.required:
upsert_follows(job.user, job.import_data.get("follows"))
if "include_blocks" in job.required:
upsert_user_blocks(job.user, job.import_data.get("blocked_users"))
process_books(job, tar)
process_books(job, tar)
job.save()
archive_file.close()
job.set_status("complete") # set here to trigger notifications
job.save()
archive_file.close()
except Exception as err: # pylint: disable=broad-except
logger.exception("User Import Job %s Failed with error: %s", job.id, err)
job.set_status("failed")
def process_books(job, tar):

View file

@ -19,6 +19,7 @@ class Job(models.Model):
ACTIVE = "active", _("Active")
COMPLETE = "complete", _("Complete")
STOPPED = "stopped", _("Stopped")
FAILED = "failed", _("Failed")
task_id = models.UUIDField(unique=True, null=True, blank=True)
@ -43,14 +44,17 @@ class Job(models.Model):
self.save(update_fields=["status", "complete", "updated_date"])
def stop_job(self):
def stop_job(self, reason=None):
"""Stop the job"""
if self.complete:
return
self.__terminate_job()
self.status = self.Status.STOPPED
if reason and reason == "failed":
self.status = self.Status.FAILED
else:
self.status = self.Status.STOPPED
self.complete = True
self.updated_date = timezone.now()
@ -72,6 +76,10 @@ class Job(models.Model):
self.stop_job()
return
if status == self.Status.FAILED:
self.stop_job(reason="failed")
return
self.updated_date = timezone.now()
self.status = status

View file

@ -2,7 +2,15 @@
from django.db import models, transaction
from django.dispatch import receiver
from .base_model import BookWyrmModel
from . import Boost, Favorite, GroupMemberInvitation, ImportJob, LinkDomain
from . import (
Boost,
Favorite,
GroupMemberInvitation,
ImportJob,
BookwyrmImportJob,
LinkDomain,
)
from bookwyrm.models.bookwyrm_export_job import BookwyrmExportJob
from . import ListItem, Report, Status, User, UserFollowRequest
@ -22,6 +30,8 @@ class Notification(BookWyrmModel):
# Imports
IMPORT = "IMPORT"
USER_IMPORT = "USER_IMPORT"
USER_EXPORT = "USER_EXPORT"
# List activity
ADD = "ADD"
@ -44,7 +54,7 @@ class Notification(BookWyrmModel):
NotificationType = models.TextChoices(
# there has got be a better way to do this
"NotificationType",
f"{FAVORITE} {REPLY} {MENTION} {TAG} {FOLLOW} {FOLLOW_REQUEST} {BOOST} {IMPORT} {ADD} {REPORT} {LINK_DOMAIN} {INVITE} {ACCEPT} {JOIN} {LEAVE} {REMOVE} {GROUP_PRIVACY} {GROUP_NAME} {GROUP_DESCRIPTION}",
f"{FAVORITE} {REPLY} {MENTION} {TAG} {FOLLOW} {FOLLOW_REQUEST} {BOOST} {IMPORT} {USER_IMPORT} {USER_EXPORT} {ADD} {REPORT} {LINK_DOMAIN} {INVITE} {ACCEPT} {JOIN} {LEAVE} {REMOVE} {GROUP_PRIVACY} {GROUP_NAME} {GROUP_DESCRIPTION}",
)
user = models.ForeignKey("User", on_delete=models.CASCADE)
@ -61,6 +71,9 @@ class Notification(BookWyrmModel):
)
related_status = models.ForeignKey("Status", on_delete=models.CASCADE, null=True)
related_import = models.ForeignKey("ImportJob", on_delete=models.CASCADE, null=True)
related_user_export = models.ForeignKey(
"BookwyrmExportJob", on_delete=models.CASCADE, null=True
)
related_list_items = models.ManyToManyField(
"ListItem", symmetrical=False, related_name="notifications"
)
@ -223,6 +236,38 @@ def notify_user_on_import_complete(
)
@receiver(models.signals.post_save, sender=BookwyrmImportJob)
# pylint: disable=unused-argument
def notify_user_on_user_import_complete(
sender, instance, *args, update_fields=None, **kwargs
):
"""we imported your user details! aren't you proud of us"""
update_fields = update_fields or []
if not instance.complete or "complete" not in update_fields:
return
Notification.objects.create(
user=instance.user, notification_type=Notification.USER_IMPORT
)
@receiver(models.signals.post_save, sender=BookwyrmExportJob)
# pylint: disable=unused-argument
def notify_user_on_user_export_complete(
sender, instance, *args, update_fields=None, **kwargs
):
"""we imported your user details! aren't you proud of us"""
update_fields = update_fields or []
if not instance.complete or "complete" not in update_fields:
print("RETURNING", instance.status)
return
print("NOTIFYING")
Notification.objects.create(
user=instance.user,
notification_type=Notification.USER_EXPORT,
related_user_export=instance,
)
@receiver(models.signals.post_save, sender=Report)
@transaction.atomic
# pylint: disable=unused-argument

View file

@ -96,6 +96,7 @@ class SiteSettings(SiteModel):
imports_enabled = models.BooleanField(default=True)
import_size_limit = models.IntegerField(default=0)
import_limit_reset = models.IntegerField(default=0)
user_import_time_limit = models.IntegerField(default=48)
field_tracker = FieldTracker(fields=["name", "instance_tagline", "logo"])

View file

@ -10,96 +10,89 @@
{% if invalid %}
<div class="notification is-danger">
{% trans "Not a valid JSON file" %}
{% trans "Not a valid import file" %}
</div>
{% endif %}
{% if import_size_limit and import_limit_reset %}
<div class="notification">
<p>{% blocktrans %}Currently you are allowed to import one user every {{ user_import_limit_reset }} days.{% endblocktrans %}</p>
<p>{% blocktrans %}You have {{ allowed_imports }} left.{% endblocktrans %}</p>
</div>
{% endif %}
{% if recent_avg_hours or recent_avg_minutes %}
<div class="notification">
<p>
{% if recent_avg_hours %}
{% blocktrans trimmed with hours=recent_avg_hours|floatformat:0|intcomma %}
On average, recent imports have taken {{ hours }} hours.
{% endblocktrans %}
{% else %}
{% blocktrans trimmed with minutes=recent_avg_minutes|floatformat:0|intcomma %}
On average, recent imports have taken {{ minutes }} minutes.
{% endblocktrans %}
{% endif %}
</p>
{% if not site.imports_enabled %}
<div class="box notification has-text-centered is-warning m-6 content">
<p class="mt-5">
<span class="icon icon-warning is-size-2" aria-hidden="true"></span>
</p>
<p class="mb-5">
{% trans "Imports are temporarily disabled; thank you for your patience." %}
</p>
</div>
{% elif next_available %}
<div class="notification is-warning">
<p>{% blocktrans %}Currently you are allowed to import one user every {{ user_import_hours }} hours.{% endblocktrans %}</p>
<p>{% blocktrans %}You will next be able to import a user file at {{ next_available }}{% endblocktrans %}</p>
</div>
{% endif %}
{% else %}
<form class="box" name="import-user" action="/user-import" method="post" enctype="multipart/form-data">
{% csrf_token %}
<form class="box" name="import-user" action="/user-import" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="columns">
<div class="column is-half">
<div class="field">
<label class="label" for="id_archive_file">{% trans "Data file:" %}</label>
{{ import_form.archive_file }}
</div>
<div>
<p class="block"> {% trans "Importing this file will overwrite any data you currently have saved." %}</p>
<p class="block">{% trans "Deselect any data you do not wish to include in your import. Books will always be imported" %}</p>
</div>
</div>
<div class="columns">
<div class="column is-half">
<div class="field">
<label class="label" for="id_archive_file">{% trans "Data file:" %}</label>
{{ import_form.archive_file }}
</div>
<div>
<p class="block"> {% trans "Importing this file will overwrite any data you currently have saved." %}</p>
<p class="block">{% trans "Deselect any data you do not wish to include in your import. Books will always be imported" %}</p>
</div>
<div class="column is-half">
<div class="field">
<label class="label">
<input type="checkbox" name="include_user_profile" checked> {% trans "Include user profile" %}
</label>
<label class="label">
<input type="checkbox" name="include_user_settings" checked> {% trans "Include user settings" %}
</label>
<label class="label">
<input type="checkbox" name="include_goals" checked> {% trans "Include reading goals" %}
</label>
<label class="label">
<input type="checkbox" name="include_shelves" checked> {% trans "Include shelves" %}
</label>
<label class="label">
<input type="checkbox" name="include_readthroughs" checked> {% trans "Include 'readthroughs'" %}
</label>
<label class="label">
<input type="checkbox" name="include_reviews" checked> {% trans "Include book reviews" %}
</label>
<label class="label">
<input type="checkbox" name="include_quotes" checked> {% trans "Include quotations" %}
</label>
<label class="label">
<input type="checkbox" name="include_comments" checked> {% trans "Include comments about books" %}
</label>
<label class="label">
<input type="checkbox" name="include_lists" checked> {% trans "Include book lists" %}
</label>
<label class="label">
<input type="checkbox" name="include_saved_lists" checked> {% trans "Include saved lists" %}
</label>
<label class="label">
<input type="checkbox" name="include_follows" checked> {% trans "Include follows" %}
</label>
<label class="label">
<input type="checkbox" name="include_blocks" checked> {% trans "Include user blocks" %}
</label>
</div>
<div class="column is-half">
<div class="field">
<label class="label">
<input type="checkbox" name="include_user_profile" checked> {% trans "Include user profile" %}
</label>
<label class="label">
<input type="checkbox" name="include_user_settings" checked> {% trans "Include user settings" %}
</label>
<label class="label">
<input type="checkbox" name="include_goals" checked> {% trans "Include reading goals" %}
</label>
<label class="label">
<input type="checkbox" name="include_shelves" checked> {% trans "Include shelves" %}
</label>
<label class="label">
<input type="checkbox" name="include_readthroughs" checked> {% trans "Include 'readthroughs'" %}
</label>
<label class="label">
<input type="checkbox" name="include_reviews" checked> {% trans "Include book reviews" %}
</label>
<label class="label">
<input type="checkbox" name="include_quotes" checked> {% trans "Include quotations" %}
</label>
<label class="label">
<input type="checkbox" name="include_comments" checked> {% trans "Include comments about books" %}
</label>
<label class="label">
<input type="checkbox" name="include_lists" checked> {% trans "Include book lists" %}
</label>
<label class="label">
<input type="checkbox" name="include_saved_lists" checked> {% trans "Include saved lists" %}
</label>
<label class="label">
<input type="checkbox" name="include_follows" checked> {% trans "Include follows" %}
</label>
<label class="label">
<input type="checkbox" name="include_blocks" checked> {% trans "Include user blocks" %}
</label>
</div>
</div>
</div>
{% if not import_limit_reset and not import_size_limit or allowed_imports > 0 %}
<button class="button is-primary" type="submit">{% trans "Import" %}</button>
{% else %}
<button class="button is-primary is-disabled" type="submit">{% trans "Import" %}</button>
<p>{% trans "You've reached the import limit." %}</p>
{% endif%}
</form>
</div>
</div>
{% if not import_limit_reset and not import_size_limit or allowed_imports > 0 %}
<button class="button is-primary" type="submit">{% trans "Import" %}</button>
{% else %}
<button class="button is-primary is-disabled" type="submit">{% trans "Import" %}</button>
<p>{% trans "You've reached the import limit." %}</p>
{% endif%}
</form>
{% endif %}
</div>
@ -133,7 +126,7 @@
<td>{{ job.updated_date }}</td>
<td>
<span
{% if job.status == "stopped" %}
{% if job.status == "stopped" or job.status == "failed" %}
class="tag is-danger"
{% elif job.status == "pending" %}
class="tag is-warning"

View file

@ -13,6 +13,10 @@
{% include 'notifications/items/follow_request.html' %}
{% elif notification.notification_type == 'IMPORT' %}
{% include 'notifications/items/import.html' %}
{% elif notification.notification_type == 'USER_IMPORT' %}
{% include 'notifications/items/user_import.html' %}
{% elif notification.notification_type == 'USER_EXPORT' %}
{% include 'notifications/items/user_export.html' %}
{% elif notification.notification_type == 'ADD' %}
{% include 'notifications/items/add.html' %}
{% elif notification.notification_type == 'REPORT' %}

View file

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

View file

@ -0,0 +1,10 @@
{% extends 'notifications/items/layout.html' %}
{% load i18n %}
{% block icon %}
<span class="icon icon-list"></span>
{% endblock %}
{% block description %}
{% blocktrans %}Your user import is complete.{% endblocktrans %}
{% endblock %}

View file

@ -9,18 +9,25 @@
{% block panel %}
<div class="block content">
{% if next_available %}
<p class="notification is-warning">
{% blocktrans %}
You will be able to create a new export file at {{ next_available }}
{% endblocktrans %}
</p>
{% else %}
<p class="notification">
{% trans "Your exported archive file will include all user data for import into another Bookwyrm server" %}
</p>
<p>
<form name="export" method="POST" href="{% url 'prefs-user-export' %}">
{% csrf_token %}
<button type="submit" class="button">
<span class="icon icon-download" aria-hidden="true"></span>
<span>{% trans "Create user export file" %}</span>
</button>
</form>
</p>
<form name="export" method="POST" href="{% url 'prefs-user-export' %}">
{% csrf_token %}
<button type="submit" class="button">
<span class="icon icon-download" aria-hidden="true"></span>
<span>{% trans "Create user export file" %}</span>
</button>
</form>
{% endif %}
</div>
<div class="content block">
<h2 class="title">{% trans "Recent Exports" %}</h2>
@ -50,8 +57,8 @@
{% for job in jobs %}
<tr>
<td>
{% if job.complete %}
<p><a href="/preferences/user-export/{{ job.task_id }}">{{ job.created_date }}</a></p>
{% if job.complete and not job.status == "stopped" and not job.status == "failed" %}
<p><a download="" href="/preferences/user-export/{{ job.task_id }}">{{ job.created_date }}</a></p>
{% else %}
<p>{{ job.created_date }}</p>
{% endif %}
@ -59,7 +66,7 @@
<td>{{ job.updated_date }}</td>
<td>
<span
{% if job.status == "stopped" %}
{% if job.status == "stopped" or job.status == "failed" %}
class="tag is-danger"
{% elif job.status == "pending" %}
class="tag is-warning"

View file

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

View file

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

View file

@ -0,0 +1,233 @@
import datetime
import time
import json
from unittest.mock import patch
from django.core.serializers.json import DjangoJSONEncoder
from django.test import TestCase
from django.utils import timezone
from bookwyrm import models
import bookwyrm.models.bookwyrm_export_job as export_job
class BookwyrmExport(TestCase):
"""testing user export functions"""
def setUp(self):
"""lots of stuff to set up for a user export"""
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
"bookwyrm.activitystreams.populate_stream_task.delay"
), patch("bookwyrm.lists_stream.populate_lists_task.delay"), patch(
"bookwyrm.suggested_users.rerank_user_task.delay"
), patch(
"bookwyrm.lists_stream.remove_list_task.delay"
), patch(
"bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"
), patch(
"bookwyrm.activitystreams.add_book_statuses_task"
):
self.local_user = models.User.objects.create_user(
"mouse",
"mouse@mouse.mouse",
"password",
local=True,
localname="mouse",
name="Mouse",
summary="I'm a real bookmouse",
manually_approves_followers=False,
hide_follows=False,
show_goal=False,
show_suggested_users=False,
discoverable=True,
preferred_timezone="America/Los Angeles",
default_post_privacy="followers",
)
self.rat_user = models.User.objects.create_user(
"rat", "rat@rat.rat", "ratword", local=True, localname="rat"
)
self.badger_user = models.User.objects.create_user(
"badger",
"badger@badger.badger",
"badgerword",
local=True,
localname="badger",
)
models.AnnualGoal.objects.create(
user=self.local_user,
year=timezone.now().year,
goal=128937123,
privacy="followers",
)
self.list = models.List.objects.create(
name="My excellent list",
user=self.local_user,
remote_id="https://local.lists/1111",
)
self.saved_list = models.List.objects.create(
name="My cool list",
user=self.rat_user,
remote_id="https://local.lists/9999",
)
self.local_user.saved_lists.add(self.saved_list)
self.local_user.blocks.add(self.badger_user)
self.rat_user.followers.add(self.local_user)
# book, edition, author
self.author = models.Author.objects.create(name="Sam Zhu")
self.work = models.Work.objects.create(
title="Example Work", remote_id="https://example.com/book/1"
)
self.edition = models.Edition.objects.create(
title="Example Edition", parent_work=self.work
)
self.edition.authors.add(self.author)
# readthrough
self.readthrough_start = timezone.now()
finish = self.readthrough_start + datetime.timedelta(days=1)
models.ReadThrough.objects.create(
user=self.local_user,
book=self.edition,
start_date=self.readthrough_start,
finish_date=finish,
)
# shelve
read_shelf = models.Shelf.objects.get(
user=self.local_user, identifier="read"
)
models.ShelfBook.objects.create(
book=self.edition, shelf=read_shelf, user=self.local_user
)
# add to list
item = models.ListItem.objects.create(
book_list=self.list,
user=self.local_user,
book=self.edition,
approved=True,
order=1,
)
# review
models.Review.objects.create(
content="awesome",
name="my review",
rating=5,
user=self.local_user,
book=self.edition,
)
# comment
models.Comment.objects.create(
content="ok so far",
user=self.local_user,
book=self.edition,
progress=15,
)
# quote
models.Quotation.objects.create(
content="check this out",
quote="A rose by any other name",
user=self.local_user,
book=self.edition,
)
def test_json_export_user_settings(self):
"""Test the json export function for basic user info"""
data = export_job.json_export(self.local_user)
user_data = json.loads(data)["user"]
self.assertEqual(user_data["username"], "mouse")
self.assertEqual(user_data["name"], "Mouse")
self.assertEqual(user_data["summary"], "I'm a real bookmouse")
self.assertEqual(user_data["manually_approves_followers"], False)
self.assertEqual(user_data["hide_follows"], False)
self.assertEqual(user_data["show_goal"], False)
self.assertEqual(user_data["show_suggested_users"], False)
self.assertEqual(user_data["discoverable"], True)
self.assertEqual(user_data["preferred_timezone"], "America/Los Angeles")
self.assertEqual(user_data["default_post_privacy"], "followers")
def test_json_export_extended_user_data(self):
"""Test the json export function for other non-book user info"""
data = export_job.json_export(self.local_user)
json_data = json.loads(data)
# goal
self.assertEqual(len(json_data["goals"]), 1)
self.assertEqual(json_data["goals"][0]["goal"], 128937123)
self.assertEqual(json_data["goals"][0]["year"], timezone.now().year)
self.assertEqual(json_data["goals"][0]["privacy"], "followers")
# saved lists
self.assertEqual(len(json_data["saved_lists"]), 1)
self.assertEqual(json_data["saved_lists"][0], "https://local.lists/9999")
# follows
self.assertEqual(len(json_data["follows"]), 1)
self.assertEqual(json_data["follows"][0], "https://your.domain.here/user/rat")
# blocked users
self.assertEqual(len(json_data["blocked_users"]), 1)
self.assertEqual(
json_data["blocked_users"][0], "https://your.domain.here/user/badger"
)
def test_json_export_books(self):
"""Test the json export function for extended user info"""
data = export_job.json_export(self.local_user)
json_data = json.loads(data)
start_date = json_data["books"][0]["readthroughs"][0]["start_date"]
self.assertEqual(len(json_data["books"]), 1)
self.assertEqual(json_data["books"][0]["title"], "Example Edition")
self.assertEqual(len(json_data["books"][0]["authors"]), 1)
self.assertEqual(json_data["books"][0]["authors"][0]["name"], "Sam Zhu")
self.assertEqual(
f'"{start_date}"', DjangoJSONEncoder().encode(self.readthrough_start)
)
self.assertEqual(json_data["books"][0]["shelves"][0]["identifier"], "read")
self.assertEqual(
json_data["books"][0]["shelf_books"]["read"][0]["book_id"], self.edition.id
)
self.assertEqual(len(json_data["books"][0]["lists"]), 1)
self.assertEqual(json_data["books"][0]["lists"][0]["name"], "My excellent list")
self.assertEqual(len(json_data["books"][0]["list_items"]), 1)
self.assertEqual(
json_data["books"][0]["list_items"]["My excellent list"][0]["book_id"],
self.edition.id,
)
self.assertEqual(len(json_data["books"][0]["reviews"]), 1)
self.assertEqual(len(json_data["books"][0]["comments"]), 1)
self.assertEqual(len(json_data["books"][0]["quotes"]), 1)
self.assertEqual(json_data["books"][0]["reviews"][0]["name"], "my review")
self.assertEqual(json_data["books"][0]["reviews"][0]["content"], "awesome")
self.assertEqual(json_data["books"][0]["reviews"][0]["rating"], "5.00")
self.assertEqual(json_data["books"][0]["comments"][0]["content"], "ok so far")
self.assertEqual(json_data["books"][0]["comments"][0]["progress"], 15)
self.assertEqual(json_data["books"][0]["comments"][0]["progress_mode"], "PG")
self.assertEqual(
json_data["books"][0]["quotes"][0]["content"], "check this out"
)
self.assertEqual(
json_data["books"][0]["quotes"][0]["quote"], "A rose by any other name"
)
def test_tar_export(self):
"""test the tar export function"""
# TODO
pass

View file

@ -70,15 +70,28 @@ class BookwyrmImport(TestCase):
self.tarfile = BookwyrmTarFile.open(
mode="r:gz", fileobj=open(archive_file, "rb")
)
self.import_data = json.loads(
self.tarfile.read("archive.json").decode("utf-8")
)
self.import_data = json.loads(self.tarfile.read("archive.json").decode("utf-8"))
def test_update_user_profile(self):
"""Test update the user's profile from import data"""
# TODO once the tar is set up
pass
with patch("bookwyrm.suggested_users.remove_user_task.delay"), patch(
"bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"
):
models.bookwyrm_import_job.update_user_profile(
self.local_user, self.tarfile, self.import_data.get("user")
)
self.local_user.refresh_from_db()
self.assertEqual(
self.local_user.username, "mouse"
) # username should not change
self.assertEqual(self.local_user.name, "Rat")
self.assertEqual(
self.local_user.summary,
"I love to make soup in Paris and eat pizza in New York",
)
def test_update_user_settings(self):
"""Test updating the user's settings from import data"""
@ -543,6 +556,8 @@ class BookwyrmImport(TestCase):
self.assertEqual(
models.ShelfBook.objects.filter(user=self.local_user.id).count(), 2
)
# check we didn't create an extra shelf
self.assertEqual(
models.Shelf.objects.filter(user=self.local_user.id).count(), 2
models.Shelf.objects.filter(user=self.local_user.id).count(), 4
)

View file

@ -49,26 +49,3 @@ class ExportUserViews(TestCase):
jobs = models.bookwyrm_export_job.BookwyrmExportJob.objects.count()
self.assertEqual(jobs, 1)
def test_download_export_user_file(self, *_):
"""simple user export"""
# TODO: need some help with this one
job = models.bookwyrm_export_job.BookwyrmExportJob.objects.create(
user=self.local_user
)
MockTask = namedtuple("Task", ("id"))
with patch(
"bookwyrm.models.bookwyrm_export_job.start_export_task.delay"
) as mock:
mock.return_value = MockTask(b'{"name": "mouse"}')
job.start_job()
request = self.factory.get("")
request.user = self.local_user
job.refresh_from_db()
export = views.ExportArchive.as_view()(request, job.id)
self.assertIsInstance(export, HttpResponse)
self.assertEqual(export.status_code, 200)
# pylint: disable=line-too-long
self.assertEqual(export.content, b'{"name": "mouse"}')

View file

@ -317,6 +317,11 @@ urlpatterns = [
views.ImportList.as_view(),
name="settings-imports-complete",
),
re_path(
r"^settings/user-imports/(?P<import_id>\d+)/complete/?$",
views.set_user_import_completed,
name="settings-user-import-complete",
),
re_path(
r"^settings/imports/disable/?$",
views.disable_imports,
@ -332,6 +337,11 @@ urlpatterns = [
views.set_import_size_limit,
name="settings-imports-set-limit",
),
re_path(
r"^settings/user-imports/set-limit/?$",
views.set_user_import_limit,
name="settings-user-imports-set-limit",
),
re_path(
r"^settings/celery/?$", views.CeleryStatus.as_view(), name="settings-celery"
),

View file

@ -16,6 +16,8 @@ from .admin.imports import (
disable_imports,
enable_imports,
set_import_size_limit,
set_user_import_completed,
set_user_import_limit,
)
from .admin.ip_blocklist import IPBlocklist
from .admin.invite import ManageInvites, Invite, InviteRequest

View file

@ -40,9 +40,17 @@ class ImportList(View):
paginated = Paginator(imports, PAGE_LENGTH)
page = paginated.get_page(request.GET.get("page"))
user_imports = models.BookwyrmImportJob.objects.filter(
complete=complete
).order_by("created_date")
user_paginated = Paginator(user_imports, PAGE_LENGTH)
user_page = user_paginated.get_page(request.GET.get("page"))
site_settings = models.SiteSettings.objects.get()
data = {
"imports": page,
"user_imports": user_page,
"page_range": paginated.get_elided_page_range(
page.number, on_each_side=2, on_ends=1
),
@ -50,6 +58,7 @@ class ImportList(View):
"sort": sort,
"import_size_limit": site_settings.import_size_limit,
"import_limit_reset": site_settings.import_limit_reset,
"user_import_time_limit": site_settings.user_import_time_limit,
}
return TemplateResponse(request, "settings/imports/imports.html", data)
@ -95,3 +104,25 @@ def set_import_size_limit(request):
site.import_limit_reset = import_limit_reset
site.save(update_fields=["import_size_limit", "import_limit_reset"])
return redirect("settings-imports")
@require_POST
@login_required
@permission_required("bookwyrm.moderate_user", raise_exception=True)
# pylint: disable=unused-argument
def set_user_import_completed(request, import_id):
"""Mark a user import as complete"""
import_job = get_object_or_404(models.BookwyrmImportJob, id=import_id)
import_job.stop_job()
return redirect("settings-imports")
@require_POST
@permission_required("bookwyrm.edit_instance_settings", raise_exception=True)
# pylint: disable=unused-argument
def set_user_import_limit(request):
"""Limit how ofter users can import and export their account"""
site = models.SiteSettings.objects.get()
site.user_import_time_limit = int(request.POST.get("limit"))
site.save(update_fields=["user_import_time_limit"])
return redirect("settings-imports")

View file

@ -142,11 +142,25 @@ class UserImport(View):
jobs = BookwyrmImportJob.objects.filter(user=request.user).order_by(
"-created_date"
)
site = models.SiteSettings.objects.get()
hours = site.user_import_time_limit
allowed = (
jobs.first().created_date < timezone.now() - datetime.timedelta(hours=hours)
if jobs.first()
else True
)
next_available = (
jobs.first().created_date + datetime.timedelta(hours=hours)
if not allowed
else False
)
paginated = Paginator(jobs, PAGE_LENGTH)
page = paginated.get_page(request.GET.get("page"))
data = {
"import_form": forms.ImportUserForm(),
"jobs": page,
"user_import_hours": hours,
"next_available": next_available,
"page_range": paginated.get_elided_page_range(
page.number, on_each_side=2, on_ends=1
),

View file

@ -1,4 +1,5 @@
""" Let users export their book data """
from datetime import timedelta
import csv
import io
@ -7,11 +8,12 @@ from django.core.paginator import Paginator
from django.db.models import Q
from django.http import HttpResponse
from django.template.response import TemplateResponse
from django.utils import timezone
from django.views import View
from django.utils.decorators import method_decorator
from django.shortcuts import redirect
from bookwyrm import models
from bookwyrm import models, settings
from bookwyrm.models.bookwyrm_export_job import BookwyrmExportJob
from bookwyrm.settings import PAGE_LENGTH
@ -101,10 +103,21 @@ class ExportUser(View):
jobs = BookwyrmExportJob.objects.filter(user=request.user).order_by(
"-created_date"
)
site = models.SiteSettings.objects.get()
hours = site.user_import_time_limit
allowed = (
jobs.first().created_date < timezone.now() - timedelta(hours=hours)
if jobs.first()
else True
)
next_available = (
jobs.first().created_date + timedelta(hours=hours) if not allowed else False
)
paginated = Paginator(jobs, PAGE_LENGTH)
page = paginated.get_page(request.GET.get("page"))
data = {
"jobs": page,
"next_available": next_available,
"page_range": paginated.get_elided_page_range(
page.number, on_each_side=2, on_ends=1
),