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 # don't start the job if it was stopped from the UI
if job.complete: if job.complete:
return return
try:
# This is where ChildJobs get made # This is where ChildJobs get made
job.export_data = ContentFile(b"", str(uuid4())) job.export_data = ContentFile(b"", str(uuid4()))
json_data = json_export(job.user)
json_data = json_export(job.user) tar_export(json_data, job.user, job.export_data)
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"]) 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) editions, books = get_books_for_user(user)
for book in editions: for book in editions:
tar.add_image(book.cover) if getattr(book, "cover", False):
tar.add_image(book.cover)
f.close() f.close()
@ -153,20 +158,12 @@ def json_export(user):
comments = models.Comment.objects.filter(user=user, book=book["id"]).distinct() comments = models.Comment.objects.filter(user=user, book=book["id"]).distinct()
book["comments"] = list(comments.values()) book["comments"] = list(comments.values())
logger.error("FINAL COMMENTS")
logger.error(book["comments"])
# quotes # quotes
quotes = models.Quotation.objects.filter(user=user, book=book["id"]).distinct() 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()) book["quotes"] = list(quotes.values())
logger.error("FINAL QUOTES")
logger.error(book["quotes"])
# append everything # append everything
final_books.append(book) final_books.append(book)

View file

@ -1,5 +1,6 @@
from functools import reduce from functools import reduce
import json import json
import logging
import operator import operator
from django.db.models import FileField, JSONField, CharField from django.db.models import FileField, JSONField, CharField
@ -18,7 +19,8 @@ from bookwyrm.models.job import (
create_child_job, create_child_job,
) )
from bookwyrm.utils.tar import BookwyrmTarFile from bookwyrm.utils.tar import BookwyrmTarFile
import json
logger = logging.getLogger(__name__)
class BookwyrmImportJob(ParentJob): class BookwyrmImportJob(ParentJob):
@ -43,27 +45,33 @@ def start_import_task(**kwargs):
if job.complete: if job.complete:
return return
archive_file.open("rb") try:
with BookwyrmTarFile.open(mode="r:gz", fileobj=archive_file) as tar: archive_file.open("rb")
job.import_data = json.loads(tar.read("archive.json").decode("utf-8")) 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: if "include_user_profile" in job.required:
update_user_profile(job.user, tar, job.import_data.get("user")) update_user_profile(job.user, tar, job.import_data.get("user"))
if "include_user_settings" in job.required: if "include_user_settings" in job.required:
update_user_settings(job.user, job.import_data.get("user")) update_user_settings(job.user, job.import_data.get("user"))
if "include_goals" in job.required: 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: 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: 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: if "include_blocks" in job.required:
upsert_user_blocks(job.user, job.import_data.get("blocked_users")) upsert_user_blocks(job.user, job.import_data.get("blocked_users"))
process_books(job, tar) process_books(job, tar)
job.save() job.set_status("complete") # set here to trigger notifications
archive_file.close() 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): def process_books(job, tar):

View file

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

View file

@ -2,7 +2,15 @@
from django.db import models, transaction from django.db import models, transaction
from django.dispatch import receiver from django.dispatch import receiver
from .base_model import BookWyrmModel 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 from . import ListItem, Report, Status, User, UserFollowRequest
@ -22,6 +30,8 @@ class Notification(BookWyrmModel):
# Imports # Imports
IMPORT = "IMPORT" IMPORT = "IMPORT"
USER_IMPORT = "USER_IMPORT"
USER_EXPORT = "USER_EXPORT"
# List activity # List activity
ADD = "ADD" ADD = "ADD"
@ -44,7 +54,7 @@ class Notification(BookWyrmModel):
NotificationType = models.TextChoices( NotificationType = models.TextChoices(
# there has got be a better way to do this # there has got be a better way to do this
"NotificationType", "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) 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_status = models.ForeignKey("Status", on_delete=models.CASCADE, null=True)
related_import = models.ForeignKey("ImportJob", 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( related_list_items = models.ManyToManyField(
"ListItem", symmetrical=False, related_name="notifications" "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) @receiver(models.signals.post_save, sender=Report)
@transaction.atomic @transaction.atomic
# pylint: disable=unused-argument # pylint: disable=unused-argument

View file

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

View file

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

View file

@ -13,6 +13,10 @@
{% include 'notifications/items/follow_request.html' %} {% include 'notifications/items/follow_request.html' %}
{% elif notification.notification_type == 'IMPORT' %} {% elif notification.notification_type == 'IMPORT' %}
{% include 'notifications/items/import.html' %} {% 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' %} {% elif notification.notification_type == 'ADD' %}
{% include 'notifications/items/add.html' %} {% include 'notifications/items/add.html' %}
{% elif notification.notification_type == 'REPORT' %} {% 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 %} {% block panel %}
<div class="block content"> <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"> <p class="notification">
{% trans "Your exported archive file will include all user data for import into another Bookwyrm server" %} {% trans "Your exported archive file will include all user data for import into another Bookwyrm server" %}
</p> </p>
<p> <form name="export" method="POST" href="{% url 'prefs-user-export' %}">
<form name="export" method="POST" href="{% url 'prefs-user-export' %}"> {% csrf_token %}
{% csrf_token %} <button type="submit" class="button">
<button type="submit" class="button"> <span class="icon icon-download" aria-hidden="true"></span>
<span class="icon icon-download" aria-hidden="true"></span> <span>{% trans "Create user export file" %}</span>
<span>{% trans "Create user export file" %}</span> </button>
</button> </form>
</form> {% endif %}
</p>
</div> </div>
<div class="content block"> <div class="content block">
<h2 class="title">{% trans "Recent Exports" %}</h2> <h2 class="title">{% trans "Recent Exports" %}</h2>
@ -50,8 +57,8 @@
{% for job in jobs %} {% for job in jobs %}
<tr> <tr>
<td> <td>
{% if job.complete %} {% if job.complete and not job.status == "stopped" and not job.status == "failed" %}
<p><a href="/preferences/user-export/{{ job.task_id }}">{{ job.created_date }}</a></p> <p><a download="" href="/preferences/user-export/{{ job.task_id }}">{{ job.created_date }}</a></p>
{% else %} {% else %}
<p>{{ job.created_date }}</p> <p>{{ job.created_date }}</p>
{% endif %} {% endif %}
@ -59,7 +66,7 @@
<td>{{ job.updated_date }}</td> <td>{{ job.updated_date }}</td>
<td> <td>
<span <span
{% if job.status == "stopped" %} {% if job.status == "stopped" or job.status == "failed" %}
class="tag is-danger" class="tag is-danger"
{% elif job.status == "pending" %} {% elif job.status == "pending" %}
class="tag is-warning" 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"> <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 "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 "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> </div>
{% csrf_token %} {% csrf_token %}
<div class="control"> <div class="control">
@ -89,91 +90,214 @@
</div> </div>
</form> </form>
</details> </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>
<div class="block"> <div class="block">
<div class="tabs"> <h4 class="title is-4">{% trans "Book Imports" %}</h4>
<ul> <div class="block">
{% url 'settings-imports' as url %} <div class="tabs">
<li {% if request.path in url %}class="is-active" aria-current="page"{% endif %}> <ul>
<a href="{{ url }}">{% trans "Active" %}</a> {% url 'settings-imports' as url %}
</li> <li {% if request.path in url %}class="is-active" aria-current="page"{% endif %}>
{% url 'settings-imports' status="complete" as url %} <a href="{{ url }}">{% trans "Active" %}</a>
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}> </li>
<a href="{{ url }}">{% trans "Completed" %}</a> {% url 'settings-imports' status="complete" as url %}
</li> <li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
</ul> <a href="{{ url }}">{% trans "Completed" %}</a>
</li>
</ul>
</div>
</div> </div>
<div class="table-container block content">
<table class="table is-striped is-fullwidth">
<tr>
{% url 'settings-imports' status as url %}
<th>
{% trans "ID" %}
</th>
<th>
{% trans "User" as text %}
{% include 'snippets/table-sort-header.html' with field="user" sort=sort text=text %}
</th>
<th>
{% trans "Date Created" as text %}
{% include 'snippets/table-sort-header.html' with field="created_date" sort=sort text=text %}
</th>
{% if status != "active" %}
<th>
{% trans "Date Updated" %}
</th>
{% endif %}
<th>
{% trans "Items" %}
</th>
<th>
{% trans "Pending items" %}
</th>
<th>
{% trans "Successful items" %}
</th>
<th>
{% trans "Failed items" %}
</th>
{% if status == "active" %}
<th>{% trans "Actions" %}</th>
{% endif %}
</tr>
{% for import in imports %}
<tr>
<td>{{ import.id }}</td>
<td class="overflow-wrap-anywhere">
<a href="{% url 'settings-user' import.user.id %}">{{ import.user|username }}</a>
</td>
<td>{{ import.created_date }}</td>
{% if status != "active" %}
<td>{{ import.updated_date }}</td>
{% 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>
<div class="table-container block content"> <div class="block">
<table class="table is-striped is-fullwidth"> <h4 class="title is-4">{% trans "User Imports" %}</h4>
<tr> <div class="block">
{% url 'settings-imports' status as url %} <div class="tabs">
<th> <ul>
{% trans "ID" %} {% url 'settings-imports' as url %}
</th> <li {% if request.path in url %}class="is-active" aria-current="page"{% endif %}>
<th> <a href="{{ url }}">{% trans "Active" %}</a>
{% trans "User" as text %} </li>
{% include 'snippets/table-sort-header.html' with field="user" sort=sort text=text %} {% url 'settings-imports' status="complete" as url %}
</th> <li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
<th> <a href="{{ url }}">{% trans "Completed" %}</a>
{% trans "Date Created" as text %} </li>
{% include 'snippets/table-sort-header.html' with field="created_date" sort=sort text=text %} </ul>
</th> </div>
{% if status != "active" %} </div>
<th>
{% trans "Date Updated" %} <div class="table-container block content">
</th> <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 %} {% endif %}
<th> </table>
{% trans "Items" %} </div>
</th>
<th> {% include 'snippets/pagination.html' with page=user_imports path=request.path %}
{% trans "Pending items" %} {% endblock %}
</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> </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( self.tarfile = BookwyrmTarFile.open(
mode="r:gz", fileobj=open(archive_file, "rb") mode="r:gz", fileobj=open(archive_file, "rb")
) )
self.import_data = json.loads( self.import_data = json.loads(self.tarfile.read("archive.json").decode("utf-8"))
self.tarfile.read("archive.json").decode("utf-8")
)
def test_update_user_profile(self): def test_update_user_profile(self):
"""Test update the user's profile from import data""" """Test update the user's profile from import data"""
# TODO once the tar is set up with patch("bookwyrm.suggested_users.remove_user_task.delay"), patch(
pass "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): def test_update_user_settings(self):
"""Test updating the user's settings from import data""" """Test updating the user's settings from import data"""
@ -543,6 +556,8 @@ class BookwyrmImport(TestCase):
self.assertEqual( self.assertEqual(
models.ShelfBook.objects.filter(user=self.local_user.id).count(), 2 models.ShelfBook.objects.filter(user=self.local_user.id).count(), 2
) )
# check we didn't create an extra shelf
self.assertEqual( 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() jobs = models.bookwyrm_export_job.BookwyrmExportJob.objects.count()
self.assertEqual(jobs, 1) 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(), views.ImportList.as_view(),
name="settings-imports-complete", 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( re_path(
r"^settings/imports/disable/?$", r"^settings/imports/disable/?$",
views.disable_imports, views.disable_imports,
@ -332,6 +337,11 @@ urlpatterns = [
views.set_import_size_limit, views.set_import_size_limit,
name="settings-imports-set-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( re_path(
r"^settings/celery/?$", views.CeleryStatus.as_view(), name="settings-celery" r"^settings/celery/?$", views.CeleryStatus.as_view(), name="settings-celery"
), ),

View file

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

View file

@ -40,9 +40,17 @@ class ImportList(View):
paginated = Paginator(imports, PAGE_LENGTH) paginated = Paginator(imports, PAGE_LENGTH)
page = paginated.get_page(request.GET.get("page")) 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() site_settings = models.SiteSettings.objects.get()
data = { data = {
"imports": page, "imports": page,
"user_imports": user_page,
"page_range": paginated.get_elided_page_range( "page_range": paginated.get_elided_page_range(
page.number, on_each_side=2, on_ends=1 page.number, on_each_side=2, on_ends=1
), ),
@ -50,6 +58,7 @@ class ImportList(View):
"sort": sort, "sort": sort,
"import_size_limit": site_settings.import_size_limit, "import_size_limit": site_settings.import_size_limit,
"import_limit_reset": site_settings.import_limit_reset, "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) 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.import_limit_reset = import_limit_reset
site.save(update_fields=["import_size_limit", "import_limit_reset"]) site.save(update_fields=["import_size_limit", "import_limit_reset"])
return redirect("settings-imports") 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( jobs = BookwyrmImportJob.objects.filter(user=request.user).order_by(
"-created_date" "-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) paginated = Paginator(jobs, PAGE_LENGTH)
page = paginated.get_page(request.GET.get("page")) page = paginated.get_page(request.GET.get("page"))
data = { data = {
"import_form": forms.ImportUserForm(), "import_form": forms.ImportUserForm(),
"jobs": page, "jobs": page,
"user_import_hours": hours,
"next_available": next_available,
"page_range": paginated.get_elided_page_range( "page_range": paginated.get_elided_page_range(
page.number, on_each_side=2, on_ends=1 page.number, on_each_side=2, on_ends=1
), ),

View file

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