mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-11-25 11:01:12 +00:00
Merge from main to avoid conflicts
This commit is contained in:
commit
500e4eb4f5
305 changed files with 22708 additions and 5724 deletions
|
@ -137,3 +137,6 @@ TWO_FACTOR_LOGIN_MAX_SECONDS=60
|
|||
# and AWS_S3_CUSTOM_DOMAIN (if used) are added by default.
|
||||
# Value should be a comma-separated list of host names.
|
||||
CSP_ADDITIONAL_HOSTS=
|
||||
# The last number here means "megabytes"
|
||||
# Increase if users are having trouble uploading BookWyrm export files.
|
||||
DATA_UPLOAD_MAX_MEMORY_SIZE = (1024**2 * 100)
|
9
.github/workflows/django-tests.yml
vendored
9
.github/workflows/django-tests.yml
vendored
|
@ -32,6 +32,15 @@ jobs:
|
|||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
- name: Check migrations up-to-date
|
||||
run: |
|
||||
python ./manage.py makemigrations --check
|
||||
env:
|
||||
SECRET_KEY: beepbeep
|
||||
DOMAIN: your.domain.here
|
||||
EMAIL_HOST: ""
|
||||
EMAIL_HOST_USER: ""
|
||||
EMAIL_HOST_PASSWORD: ""
|
||||
- name: Run Tests
|
||||
env:
|
||||
SECRET_KEY: beepbeep
|
||||
|
|
|
@ -13,14 +13,15 @@ User relationship interactions follow the standard ActivityPub spec.
|
|||
- `Block`: prevent users from seeing one another's statuses, and prevents the blocked user from viewing the actor's profile
|
||||
- `Update`: updates a user's profile and settings
|
||||
- `Delete`: deactivates a user
|
||||
- `Undo`: reverses a `Follow` or `Block`
|
||||
- `Undo`: reverses a `Block` or `Follow`
|
||||
|
||||
### Activities
|
||||
- `Create/Status`: saves a new status in the database.
|
||||
- `Delete/Status`: Removes a status
|
||||
- `Like/Status`: Creates a favorite on the status
|
||||
- `Announce/Status`: Boosts the status into the actor's timeline
|
||||
- `Undo/*`,: Reverses a `Like` or `Announce`
|
||||
- `Undo/*`,: Reverses an `Announce`, `Like`, or `Move`
|
||||
- `Move/User`: Moves a user from one ActivityPub id to another.
|
||||
|
||||
### Collections
|
||||
User's books and lists are represented by [`OrderedCollection`](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollection)
|
||||
|
|
2
VERSION
2
VERSION
|
@ -1 +1 @@
|
|||
0.7.0
|
||||
0.7.1
|
||||
|
|
|
@ -23,6 +23,7 @@ from .verbs import Create, Delete, Undo, Update
|
|||
from .verbs import Follow, Accept, Reject, Block
|
||||
from .verbs import Add, Remove
|
||||
from .verbs import Announce, Like
|
||||
from .verbs import Move
|
||||
|
||||
# this creates a list of all the Activity types that we can serialize,
|
||||
# so when an Activity comes in from outside, we can check if it's known
|
||||
|
|
|
@ -236,7 +236,7 @@ class ActivityObject:
|
|||
omit = kwargs.get("omit", ())
|
||||
data = self.__dict__.copy()
|
||||
# recursively serialize
|
||||
for (k, v) in data.items():
|
||||
for k, v in data.items():
|
||||
try:
|
||||
if issubclass(type(v), ActivityObject):
|
||||
data[k] = v.serialize()
|
||||
|
@ -396,19 +396,15 @@ def resolve_remote_id(
|
|||
|
||||
def get_representative():
|
||||
"""Get or create an actor representing the instance
|
||||
to sign requests to 'secure mastodon' servers"""
|
||||
username = f"{INSTANCE_ACTOR_USERNAME}@{DOMAIN}"
|
||||
email = "bookwyrm@localhost"
|
||||
try:
|
||||
user = models.User.objects.get(username=username)
|
||||
except models.User.DoesNotExist:
|
||||
user = models.User.objects.create_user(
|
||||
username=username,
|
||||
email=email,
|
||||
to sign outgoing HTTP GET requests"""
|
||||
return models.User.objects.get_or_create(
|
||||
username=f"{INSTANCE_ACTOR_USERNAME}@{DOMAIN}",
|
||||
defaults=dict(
|
||||
email="bookwyrm@localhost",
|
||||
local=True,
|
||||
localname=INSTANCE_ACTOR_USERNAME,
|
||||
)
|
||||
return user
|
||||
),
|
||||
)[0]
|
||||
|
||||
|
||||
def get_activitypub_data(url):
|
||||
|
|
|
@ -22,8 +22,6 @@ class BookData(ActivityObject):
|
|||
aasin: Optional[str] = None
|
||||
isfdb: Optional[str] = None
|
||||
lastEditedBy: Optional[str] = None
|
||||
links: list[str] = field(default_factory=list)
|
||||
fileLinks: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
|
@ -45,6 +43,8 @@ class Book(BookData):
|
|||
firstPublishedDate: str = ""
|
||||
publishedDate: str = ""
|
||||
|
||||
fileLinks: list[str] = field(default_factory=list)
|
||||
|
||||
cover: Optional[Document] = None
|
||||
type: str = "Book"
|
||||
|
||||
|
|
|
@ -40,4 +40,6 @@ class Person(ActivityObject):
|
|||
manuallyApprovesFollowers: str = False
|
||||
discoverable: str = False
|
||||
hideFollows: str = False
|
||||
movedTo: str = None
|
||||
alsoKnownAs: dict[str] = None
|
||||
type: str = "Person"
|
||||
|
|
|
@ -171,9 +171,19 @@ class Reject(Verb):
|
|||
type: str = "Reject"
|
||||
|
||||
def action(self, allow_external_connections=True):
|
||||
"""reject a follow request"""
|
||||
obj = self.object.to_model(save=False, allow_create=False)
|
||||
obj.reject()
|
||||
"""reject a follow or follow request"""
|
||||
|
||||
for model_name in ["UserFollowRequest", "UserFollows", None]:
|
||||
model = apps.get_model(f"bookwyrm.{model_name}") if model_name else None
|
||||
if obj := self.object.to_model(
|
||||
model=model,
|
||||
save=False,
|
||||
allow_create=False,
|
||||
allow_external_connections=allow_external_connections,
|
||||
):
|
||||
# Reject the first model that can be built.
|
||||
obj.reject()
|
||||
break
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
|
@ -231,3 +241,30 @@ class Announce(Verb):
|
|||
def action(self, allow_external_connections=True):
|
||||
"""boost"""
|
||||
self.to_model(allow_external_connections=allow_external_connections)
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Move(Verb):
|
||||
"""a user moving an object"""
|
||||
|
||||
object: str
|
||||
type: str = "Move"
|
||||
origin: str = None
|
||||
target: str = None
|
||||
|
||||
def action(self, allow_external_connections=True):
|
||||
"""move"""
|
||||
|
||||
object_is_user = resolve_remote_id(remote_id=self.object, model="User")
|
||||
|
||||
if object_is_user:
|
||||
model = apps.get_model("bookwyrm.MoveUser")
|
||||
|
||||
self.to_model(
|
||||
model=model,
|
||||
save=True,
|
||||
allow_external_connections=allow_external_connections,
|
||||
)
|
||||
else:
|
||||
# we might do something with this to move other objects at some point
|
||||
pass
|
||||
|
|
|
@ -43,6 +43,7 @@ def search(
|
|||
min_confidence: float = 0,
|
||||
filters: Optional[list[Any]] = None,
|
||||
return_first: bool = False,
|
||||
books: Optional[QuerySet[models.Edition]] = None,
|
||||
) -> Union[Optional[models.Edition], QuerySet[models.Edition]]:
|
||||
"""search your local database"""
|
||||
filters = filters or []
|
||||
|
@ -54,13 +55,15 @@ def search(
|
|||
# first, try searching unique identifiers
|
||||
# unique identifiers never have spaces, title/author usually do
|
||||
if not " " in query:
|
||||
results = search_identifiers(query, *filters, return_first=return_first)
|
||||
results = search_identifiers(
|
||||
query, *filters, return_first=return_first, books=books
|
||||
)
|
||||
|
||||
# if there were no identifier results...
|
||||
if not results:
|
||||
# then try searching title/author
|
||||
results = search_title_author(
|
||||
query, min_confidence, *filters, return_first=return_first
|
||||
query, min_confidence, *filters, return_first=return_first, books=books
|
||||
)
|
||||
return results
|
||||
|
||||
|
@ -98,9 +101,17 @@ def format_search_result(search_result):
|
|||
|
||||
|
||||
def search_identifiers(
|
||||
query, *filters, return_first=False
|
||||
query,
|
||||
*filters,
|
||||
return_first=False,
|
||||
books=None,
|
||||
) -> Union[Optional[models.Edition], QuerySet[models.Edition]]:
|
||||
"""tries remote_id, isbn; defined as dedupe fields on the model"""
|
||||
"""search Editions by deduplication fields
|
||||
|
||||
Best for cases when we can assume someone is searching for an exact match on
|
||||
commonly unique data identifiers like isbn or specific library ids.
|
||||
"""
|
||||
books = books or models.Edition.objects
|
||||
if connectors.maybe_isbn(query):
|
||||
# Oh did you think the 'S' in ISBN stood for 'standard'?
|
||||
normalized_isbn = query.strip().upper().rjust(10, "0")
|
||||
|
@ -111,7 +122,7 @@ def search_identifiers(
|
|||
for f in models.Edition._meta.get_fields()
|
||||
if hasattr(f, "deduplication_field") and f.deduplication_field
|
||||
]
|
||||
results = models.Edition.objects.filter(
|
||||
results = books.filter(
|
||||
*filters, reduce(operator.or_, (Q(**f) for f in or_filters))
|
||||
).distinct()
|
||||
|
||||
|
@ -121,12 +132,17 @@ def search_identifiers(
|
|||
|
||||
|
||||
def search_title_author(
|
||||
query, min_confidence, *filters, return_first=False
|
||||
query,
|
||||
min_confidence,
|
||||
*filters,
|
||||
return_first=False,
|
||||
books=None,
|
||||
) -> QuerySet[models.Edition]:
|
||||
"""searches for title and author"""
|
||||
books = books or models.Edition.objects
|
||||
query = SearchQuery(query, config="simple") | SearchQuery(query, config="english")
|
||||
results = (
|
||||
models.Edition.objects.filter(*filters, search_vector=query)
|
||||
books.filter(*filters, search_vector=query)
|
||||
.annotate(rank=SearchRank(F("search_vector"), query))
|
||||
.filter(rank__gt=min_confidence)
|
||||
.order_by("-rank")
|
||||
|
@ -137,7 +153,7 @@ def search_title_author(
|
|||
|
||||
# filter out multiple editions of the same work
|
||||
list_results = []
|
||||
for work_id in set(editions_of_work[:30]):
|
||||
for work_id in editions_of_work[:30]:
|
||||
result = (
|
||||
results.filter(parent_work=work_id)
|
||||
.order_by("-rank", "-edition_rank")
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
""" using django model forms """
|
||||
from django import forms
|
||||
|
||||
from file_resubmit.widgets import ResubmitImageWidget
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.models.fields import ClearableFileInputWithWarning
|
||||
from .custom_form import CustomForm
|
||||
from .widgets import ArrayWidget, SelectDateWidget, Select
|
||||
|
||||
|
@ -70,9 +71,7 @@ class EditionForm(CustomForm):
|
|||
"published_date": SelectDateWidget(
|
||||
attrs={"aria-describedby": "desc_published_date"}
|
||||
),
|
||||
"cover": ClearableFileInputWithWarning(
|
||||
attrs={"aria-describedby": "desc_cover"}
|
||||
),
|
||||
"cover": ResubmitImageWidget(attrs={"aria-describedby": "desc_cover"}),
|
||||
"physical_format": Select(
|
||||
attrs={"aria-describedby": "desc_physical_format"}
|
||||
),
|
||||
|
|
|
@ -70,6 +70,22 @@ class DeleteUserForm(CustomForm):
|
|||
fields = ["password"]
|
||||
|
||||
|
||||
class MoveUserForm(CustomForm):
|
||||
target = forms.CharField(widget=forms.TextInput)
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["password"]
|
||||
|
||||
|
||||
class AliasUserForm(CustomForm):
|
||||
username = forms.CharField(widget=forms.TextInput)
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["password"]
|
||||
|
||||
|
||||
class ChangePasswordForm(CustomForm):
|
||||
current_password = forms.CharField(widget=forms.PasswordInput)
|
||||
confirm_password = forms.CharField(widget=forms.PasswordInput)
|
||||
|
|
|
@ -25,6 +25,10 @@ class ImportForm(forms.Form):
|
|||
csv_file = forms.FileField()
|
||||
|
||||
|
||||
class ImportUserForm(forms.Form):
|
||||
archive_file = forms.FileField()
|
||||
|
||||
|
||||
class ShelfForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Shelf
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
""" import classes """
|
||||
|
||||
from .importer import Importer
|
||||
from .bookwyrm_import import BookwyrmImporter
|
||||
from .calibre_import import CalibreImporter
|
||||
from .goodreads_import import GoodreadsImporter
|
||||
from .librarything_import import LibrarythingImporter
|
||||
|
|
24
bookwyrm/importers/bookwyrm_import.py
Normal file
24
bookwyrm/importers/bookwyrm_import.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
"""Import data from Bookwyrm export files"""
|
||||
from django.http import QueryDict
|
||||
|
||||
from bookwyrm.models import User
|
||||
from bookwyrm.models.bookwyrm_import_job import BookwyrmImportJob
|
||||
|
||||
|
||||
class BookwyrmImporter:
|
||||
"""Import a Bookwyrm User export file.
|
||||
This is kind of a combination of an importer and a connector.
|
||||
"""
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
def process_import(
|
||||
self, user: User, archive_file: bytes, settings: QueryDict
|
||||
) -> BookwyrmImportJob:
|
||||
"""import user data from a Bookwyrm export file"""
|
||||
|
||||
required = [k for k in settings if settings.get(k) == "on"]
|
||||
|
||||
job = BookwyrmImportJob.objects.create(
|
||||
user=user, archive_file=archive_file, required=required
|
||||
)
|
||||
return job
|
|
@ -40,7 +40,12 @@ class IsbnHyphenator:
|
|||
self.__element_tree = ElementTree.parse(self.__range_file_path)
|
||||
|
||||
gs1_prefix = isbn_13[:3]
|
||||
reg_group = self.__find_reg_group(isbn_13, gs1_prefix)
|
||||
try:
|
||||
reg_group = self.__find_reg_group(isbn_13, gs1_prefix)
|
||||
except ValueError:
|
||||
# if the reg groups are invalid, just return the original isbn
|
||||
return isbn_13
|
||||
|
||||
if reg_group is None:
|
||||
return isbn_13 # failed to hyphenate
|
||||
|
||||
|
|
43
bookwyrm/management/commands/erase_deleted_user_data.py
Normal file
43
bookwyrm/management/commands/erase_deleted_user_data.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
""" Erase any data stored about deleted users """
|
||||
import sys
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from bookwyrm import models
|
||||
from bookwyrm.models.user import erase_user_data
|
||||
|
||||
# pylint: disable=missing-function-docstring
|
||||
class Command(BaseCommand):
|
||||
"""command-line options"""
|
||||
|
||||
help = "Remove Two Factor Authorisation from user"
|
||||
|
||||
def add_arguments(self, parser): # pylint: disable=no-self-use
|
||||
parser.add_argument(
|
||||
"--dryrun",
|
||||
action="store_true",
|
||||
help="Preview users to be cleared without altering the database",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options): # pylint: disable=unused-argument
|
||||
|
||||
# Check for anything fishy
|
||||
bad_state = models.User.objects.filter(is_deleted=True, is_active=True)
|
||||
if bad_state.exists():
|
||||
raise CommandError(
|
||||
f"{bad_state.count()} user(s) marked as both active and deleted"
|
||||
)
|
||||
|
||||
deleted_users = models.User.objects.filter(is_deleted=True)
|
||||
self.stdout.write(f"Found {deleted_users.count()} deleted users")
|
||||
if options["dryrun"]:
|
||||
self.stdout.write("\n".join(u.username for u in deleted_users[:5]))
|
||||
if deleted_users.count() > 5:
|
||||
self.stdout.write("... and more")
|
||||
sys.exit()
|
||||
|
||||
self.stdout.write("Erasing user data:")
|
||||
for user_id in deleted_users.values_list("id", flat=True):
|
||||
erase_user_data.delay(user_id)
|
||||
self.stdout.write(".", ending="")
|
||||
|
||||
self.stdout.write("")
|
||||
self.stdout.write("Tasks created successfully")
|
|
@ -1,3 +1,4 @@
|
|||
""" look at all this nice middleware! """
|
||||
from .timezone_middleware import TimezoneMiddleware
|
||||
from .ip_middleware import IPBlocklistMiddleware
|
||||
from .file_too_big import FileTooBig
|
||||
|
|
30
bookwyrm/middleware/file_too_big.py
Normal file
30
bookwyrm/middleware/file_too_big.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
"""Middleware to display a custom 413 error page"""
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render
|
||||
from django.core.exceptions import RequestDataTooBig
|
||||
|
||||
|
||||
class FileTooBig:
|
||||
"""Middleware to display a custom page when a
|
||||
RequestDataTooBig exception is thrown"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
"""boilerplate __init__ from Django docs"""
|
||||
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
"""If RequestDataTooBig is thrown, render the 413 error page"""
|
||||
|
||||
try:
|
||||
body = request.body # pylint: disable=unused-variable
|
||||
|
||||
except RequestDataTooBig:
|
||||
|
||||
rendered = render(request, "413.html")
|
||||
response = HttpResponse(rendered)
|
||||
return response
|
||||
|
||||
response = self.get_response(request)
|
||||
return response
|
|
@ -45,5 +45,7 @@ class Migration(migrations.Migration):
|
|||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(populate_sort_title),
|
||||
migrations.RunPython(
|
||||
populate_sort_title, reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
|
|
130
bookwyrm/migrations/0182_auto_20231027_1122.py
Normal file
130
bookwyrm/migrations/0182_auto_20231027_1122.py
Normal file
|
@ -0,0 +1,130 @@
|
|||
# Generated by Django 3.2.20 on 2023-10-27 11:22
|
||||
|
||||
import bookwyrm.models.activitypub_mixin
|
||||
import bookwyrm.models.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0181_merge_20230806_2302"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="also_known_as",
|
||||
field=bookwyrm.models.fields.ManyToManyField(to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="moved_to",
|
||||
field=bookwyrm.models.fields.RemoteIdField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
validators=[bookwyrm.models.fields.validate_remote_id],
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="notification",
|
||||
name="notification_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("FAVORITE", "Favorite"),
|
||||
("REPLY", "Reply"),
|
||||
("MENTION", "Mention"),
|
||||
("TAG", "Tag"),
|
||||
("FOLLOW", "Follow"),
|
||||
("FOLLOW_REQUEST", "Follow Request"),
|
||||
("BOOST", "Boost"),
|
||||
("IMPORT", "Import"),
|
||||
("ADD", "Add"),
|
||||
("REPORT", "Report"),
|
||||
("LINK_DOMAIN", "Link Domain"),
|
||||
("INVITE", "Invite"),
|
||||
("ACCEPT", "Accept"),
|
||||
("JOIN", "Join"),
|
||||
("LEAVE", "Leave"),
|
||||
("REMOVE", "Remove"),
|
||||
("GROUP_PRIVACY", "Group Privacy"),
|
||||
("GROUP_NAME", "Group Name"),
|
||||
("GROUP_DESCRIPTION", "Group Description"),
|
||||
("MOVE", "Move"),
|
||||
],
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Move",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_date", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_date", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"remote_id",
|
||||
bookwyrm.models.fields.RemoteIdField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
validators=[bookwyrm.models.fields.validate_remote_id],
|
||||
),
|
||||
),
|
||||
("object", bookwyrm.models.fields.CharField(max_length=255)),
|
||||
(
|
||||
"origin",
|
||||
bookwyrm.models.fields.CharField(
|
||||
blank=True, default="", max_length=255, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
bookwyrm.models.fields.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
bases=(bookwyrm.models.activitypub_mixin.ActivityMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="MoveUser",
|
||||
fields=[
|
||||
(
|
||||
"move_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="bookwyrm.move",
|
||||
),
|
||||
),
|
||||
(
|
||||
"target",
|
||||
bookwyrm.models.fields.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="move_target",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
bases=("bookwyrm.move",),
|
||||
),
|
||||
]
|
18
bookwyrm/migrations/0183_auto_20231105_1607.py
Normal file
18
bookwyrm/migrations/0183_auto_20231105_1607.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.2.20 on 2023-11-05 16:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0182_auto_20231027_1122"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="is_deleted",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
35
bookwyrm/migrations/0184_auto_20231106_0421.py
Normal file
35
bookwyrm/migrations/0184_auto_20231106_0421.py
Normal file
|
@ -0,0 +1,35 @@
|
|||
# Generated by Django 3.2.20 on 2023-11-06 04:21
|
||||
|
||||
from django.db import migrations
|
||||
from bookwyrm.models import User
|
||||
|
||||
|
||||
def update_deleted_users(apps, schema_editor):
|
||||
"""Find all the users who are deleted, not just inactive, and set deleted"""
|
||||
users = apps.get_model("bookwyrm", "User")
|
||||
db_alias = schema_editor.connection.alias
|
||||
users.objects.using(db_alias).filter(
|
||||
is_active=False,
|
||||
deactivation_reason__in=[
|
||||
"self_deletion",
|
||||
"moderator_deletion",
|
||||
],
|
||||
).update(is_deleted=True)
|
||||
|
||||
# differente rules for remote users
|
||||
users.objects.using(db_alias).filter(is_active=False, local=False,).exclude(
|
||||
deactivation_reason="moderator_deactivation",
|
||||
).update(is_deleted=True)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0183_auto_20231105_1607"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
update_deleted_users, reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
|
@ -0,0 +1,42 @@
|
|||
# Generated by Django 3.2.20 on 2023-11-13 22:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0184_auto_20231106_0421"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="notification",
|
||||
name="notification_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("FAVORITE", "Favorite"),
|
||||
("BOOST", "Boost"),
|
||||
("REPLY", "Reply"),
|
||||
("MENTION", "Mention"),
|
||||
("TAG", "Tag"),
|
||||
("FOLLOW", "Follow"),
|
||||
("FOLLOW_REQUEST", "Follow Request"),
|
||||
("IMPORT", "Import"),
|
||||
("ADD", "Add"),
|
||||
("REPORT", "Report"),
|
||||
("LINK_DOMAIN", "Link Domain"),
|
||||
("INVITE", "Invite"),
|
||||
("ACCEPT", "Accept"),
|
||||
("JOIN", "Join"),
|
||||
("LEAVE", "Leave"),
|
||||
("REMOVE", "Remove"),
|
||||
("GROUP_PRIVACY", "Group Privacy"),
|
||||
("GROUP_NAME", "Group Name"),
|
||||
("GROUP_DESCRIPTION", "Group Description"),
|
||||
("MOVE", "Move"),
|
||||
],
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
]
|
212
bookwyrm/migrations/0186_auto_20231116_0048.py
Normal file
212
bookwyrm/migrations/0186_auto_20231116_0048.py
Normal file
|
@ -0,0 +1,212 @@
|
|||
# Generated by Django 3.2.20 on 2023-11-16 00:48
|
||||
|
||||
from django.conf import settings
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0185_alter_notification_notification_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ParentJob",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("task_id", models.UUIDField(blank=True, null=True, unique=True)),
|
||||
(
|
||||
"created_date",
|
||||
models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
(
|
||||
"updated_date",
|
||||
models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
("complete", models.BooleanField(default=False)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("active", "Active"),
|
||||
("complete", "Complete"),
|
||||
("stopped", "Stopped"),
|
||||
("failed", "Failed"),
|
||||
],
|
||||
default="pending",
|
||||
max_length=50,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="sitesettings",
|
||||
name="user_import_time_limit",
|
||||
field=models.IntegerField(default=48),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="notification",
|
||||
name="notification_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("FAVORITE", "Favorite"),
|
||||
("BOOST", "Boost"),
|
||||
("REPLY", "Reply"),
|
||||
("MENTION", "Mention"),
|
||||
("TAG", "Tag"),
|
||||
("FOLLOW", "Follow"),
|
||||
("FOLLOW_REQUEST", "Follow Request"),
|
||||
("IMPORT", "Import"),
|
||||
("USER_IMPORT", "User Import"),
|
||||
("USER_EXPORT", "User Export"),
|
||||
("ADD", "Add"),
|
||||
("REPORT", "Report"),
|
||||
("LINK_DOMAIN", "Link Domain"),
|
||||
("INVITE", "Invite"),
|
||||
("ACCEPT", "Accept"),
|
||||
("JOIN", "Join"),
|
||||
("LEAVE", "Leave"),
|
||||
("REMOVE", "Remove"),
|
||||
("GROUP_PRIVACY", "Group Privacy"),
|
||||
("GROUP_NAME", "Group Name"),
|
||||
("GROUP_DESCRIPTION", "Group Description"),
|
||||
("MOVE", "Move"),
|
||||
],
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BookwyrmExportJob",
|
||||
fields=[
|
||||
(
|
||||
"parentjob_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="bookwyrm.parentjob",
|
||||
),
|
||||
),
|
||||
("export_data", models.FileField(null=True, upload_to="")),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
bases=("bookwyrm.parentjob",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BookwyrmImportJob",
|
||||
fields=[
|
||||
(
|
||||
"parentjob_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="bookwyrm.parentjob",
|
||||
),
|
||||
),
|
||||
("archive_file", models.FileField(blank=True, null=True, upload_to="")),
|
||||
("import_data", models.JSONField(null=True)),
|
||||
(
|
||||
"required",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(blank=True, max_length=50),
|
||||
blank=True,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
bases=("bookwyrm.parentjob",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ChildJob",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("task_id", models.UUIDField(blank=True, null=True, unique=True)),
|
||||
(
|
||||
"created_date",
|
||||
models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
(
|
||||
"updated_date",
|
||||
models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
("complete", models.BooleanField(default=False)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("active", "Active"),
|
||||
("complete", "Complete"),
|
||||
("stopped", "Stopped"),
|
||||
("failed", "Failed"),
|
||||
],
|
||||
default="pending",
|
||||
max_length=50,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"parent_job",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="child_jobs",
|
||||
to="bookwyrm.parentjob",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="notification",
|
||||
name="related_user_export",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="bookwyrm.bookwyrmexportjob",
|
||||
),
|
||||
),
|
||||
]
|
48
bookwyrm/migrations/0186_invite_request_notification.py
Normal file
48
bookwyrm/migrations/0186_invite_request_notification.py
Normal file
|
@ -0,0 +1,48 @@
|
|||
# Generated by Django 3.2.20 on 2023-11-14 10:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0185_alter_notification_notification_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="notification",
|
||||
name="related_invite_requests",
|
||||
field=models.ManyToManyField(to="bookwyrm.InviteRequest"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="notification",
|
||||
name="notification_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("FAVORITE", "Favorite"),
|
||||
("BOOST", "Boost"),
|
||||
("REPLY", "Reply"),
|
||||
("MENTION", "Mention"),
|
||||
("TAG", "Tag"),
|
||||
("FOLLOW", "Follow"),
|
||||
("FOLLOW_REQUEST", "Follow Request"),
|
||||
("IMPORT", "Import"),
|
||||
("ADD", "Add"),
|
||||
("REPORT", "Report"),
|
||||
("LINK_DOMAIN", "Link Domain"),
|
||||
("INVITE_REQUEST", "Invite Request"),
|
||||
("INVITE", "Invite"),
|
||||
("ACCEPT", "Accept"),
|
||||
("JOIN", "Join"),
|
||||
("LEAVE", "Leave"),
|
||||
("REMOVE", "Remove"),
|
||||
("GROUP_PRIVACY", "Group Privacy"),
|
||||
("GROUP_NAME", "Group Name"),
|
||||
("GROUP_DESCRIPTION", "Group Description"),
|
||||
("MOVE", "Move"),
|
||||
],
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
]
|
54
bookwyrm/migrations/0187_partial_publication_dates.py
Normal file
54
bookwyrm/migrations/0187_partial_publication_dates.py
Normal file
|
@ -0,0 +1,54 @@
|
|||
# Generated by Django 3.2.20 on 2023-11-09 16:57
|
||||
|
||||
import bookwyrm.models.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0186_invite_request_notification"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="book",
|
||||
name="first_published_date_precision",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("DAY", "Day prec."),
|
||||
("MONTH", "Month prec."),
|
||||
("YEAR", "Year prec."),
|
||||
],
|
||||
editable=False,
|
||||
max_length=10,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="book",
|
||||
name="published_date_precision",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("DAY", "Day prec."),
|
||||
("MONTH", "Month prec."),
|
||||
("YEAR", "Year prec."),
|
||||
],
|
||||
editable=False,
|
||||
max_length=10,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="book",
|
||||
name="first_published_date",
|
||||
field=bookwyrm.models.fields.PartialDateField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="book",
|
||||
name="published_date",
|
||||
field=bookwyrm.models.fields.PartialDateField(blank=True, null=True),
|
||||
),
|
||||
]
|
18
bookwyrm/migrations/0188_theme_loads.py
Normal file
18
bookwyrm/migrations/0188_theme_loads.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.2.23 on 2023-11-20 18:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0187_partial_publication_dates"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="theme",
|
||||
name="loads",
|
||||
field=models.BooleanField(blank=True, null=True),
|
||||
),
|
||||
]
|
45
bookwyrm/migrations/0189_alter_user_preferred_language.py
Normal file
45
bookwyrm/migrations/0189_alter_user_preferred_language.py
Normal file
|
@ -0,0 +1,45 @@
|
|||
# Generated by Django 3.2.23 on 2023-12-12 23:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0188_theme_loads"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="preferred_language",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("en-us", "English"),
|
||||
("ca-es", "Català (Catalan)"),
|
||||
("de-de", "Deutsch (German)"),
|
||||
("eo-uy", "Esperanto (Esperanto)"),
|
||||
("es-es", "Español (Spanish)"),
|
||||
("eu-es", "Euskara (Basque)"),
|
||||
("gl-es", "Galego (Galician)"),
|
||||
("it-it", "Italiano (Italian)"),
|
||||
("fi-fi", "Suomi (Finnish)"),
|
||||
("fr-fr", "Français (French)"),
|
||||
("lt-lt", "Lietuvių (Lithuanian)"),
|
||||
("nl-nl", "Nederlands (Dutch)"),
|
||||
("no-no", "Norsk (Norwegian)"),
|
||||
("pl-pl", "Polski (Polish)"),
|
||||
("pt-br", "Português do Brasil (Brazilian Portuguese)"),
|
||||
("pt-pt", "Português Europeu (European Portuguese)"),
|
||||
("ro-ro", "Română (Romanian)"),
|
||||
("sv-se", "Svenska (Swedish)"),
|
||||
("uk-ua", "Українська (Ukrainian)"),
|
||||
("zh-hans", "简体中文 (Simplified Chinese)"),
|
||||
("zh-hant", "繁體中文 (Traditional Chinese)"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,13 @@
|
|||
# Generated by Django 3.2.23 on 2023-11-22 10:16
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0186_auto_20231116_0048"),
|
||||
("bookwyrm", "0188_theme_loads"),
|
||||
]
|
||||
|
||||
operations = []
|
|
@ -0,0 +1,45 @@
|
|||
# Generated by Django 3.2.23 on 2023-11-23 19:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0189_merge_0186_auto_20231116_0048_0188_theme_loads"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="notification",
|
||||
name="notification_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("FAVORITE", "Favorite"),
|
||||
("BOOST", "Boost"),
|
||||
("REPLY", "Reply"),
|
||||
("MENTION", "Mention"),
|
||||
("TAG", "Tag"),
|
||||
("FOLLOW", "Follow"),
|
||||
("FOLLOW_REQUEST", "Follow Request"),
|
||||
("IMPORT", "Import"),
|
||||
("USER_IMPORT", "User Import"),
|
||||
("USER_EXPORT", "User Export"),
|
||||
("ADD", "Add"),
|
||||
("REPORT", "Report"),
|
||||
("LINK_DOMAIN", "Link Domain"),
|
||||
("INVITE_REQUEST", "Invite Request"),
|
||||
("INVITE", "Invite"),
|
||||
("ACCEPT", "Accept"),
|
||||
("JOIN", "Join"),
|
||||
("LEAVE", "Leave"),
|
||||
("REMOVE", "Remove"),
|
||||
("GROUP_PRIVACY", "Group Privacy"),
|
||||
("GROUP_NAME", "Group Name"),
|
||||
("GROUP_DESCRIPTION", "Group Description"),
|
||||
("MOVE", "Move"),
|
||||
],
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
]
|
13
bookwyrm/migrations/0191_merge_20240102_0326.py
Normal file
13
bookwyrm/migrations/0191_merge_20240102_0326.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
# Generated by Django 3.2.23 on 2024-01-02 03:26
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0189_alter_user_preferred_language"),
|
||||
("bookwyrm", "0190_alter_notification_notification_type"),
|
||||
]
|
||||
|
||||
operations = []
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.2.23 on 2024-01-16 10:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0191_merge_20240102_0326"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="sitesettings",
|
||||
name="user_exports_enabled",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
|
@ -26,13 +26,17 @@ from .federated_server import FederatedServer
|
|||
from .group import Group, GroupMember, GroupMemberInvitation
|
||||
|
||||
from .import_job import ImportJob, ImportItem
|
||||
from .bookwyrm_import_job import BookwyrmImportJob
|
||||
from .bookwyrm_export_job import BookwyrmExportJob
|
||||
|
||||
from .move import MoveUser
|
||||
|
||||
from .site import SiteSettings, Theme, SiteInvite
|
||||
from .site import PasswordReset, InviteRequest
|
||||
from .announcement import Announcement
|
||||
from .antispam import EmailBlocklist, IPBlocklist, AutoMod, automod_task
|
||||
|
||||
from .notification import Notification
|
||||
from .notification import Notification, NotificationType
|
||||
|
||||
from .hashtag import Hashtag
|
||||
|
||||
|
|
|
@ -602,7 +602,7 @@ def to_ordered_collection_page(
|
|||
if activity_page.has_next():
|
||||
next_page = f"{remote_id}?page={activity_page.next_page_number()}"
|
||||
if activity_page.has_previous():
|
||||
prev_page = f"{remote_id}?page=%d{activity_page.previous_page_number()}"
|
||||
prev_page = f"{remote_id}?page={activity_page.previous_page_number()}"
|
||||
return activitypub.OrderedCollectionPage(
|
||||
id=f"{remote_id}?page={page}",
|
||||
partOf=remote_id,
|
||||
|
|
|
@ -10,6 +10,7 @@ from django.utils.translation import gettext_lazy as _
|
|||
|
||||
from bookwyrm.tasks import app, MISC
|
||||
from .base_model import BookWyrmModel
|
||||
from .notification import NotificationType
|
||||
from .user import User
|
||||
|
||||
|
||||
|
@ -80,7 +81,7 @@ def automod_task():
|
|||
with transaction.atomic():
|
||||
for admin in admins:
|
||||
notification, _ = notification_model.objects.get_or_create(
|
||||
user=admin, notification_type=notification_model.REPORT, read=False
|
||||
user=admin, notification_type=NotificationType.REPORT, read=False
|
||||
)
|
||||
notification.related_reports.set(reports)
|
||||
|
||||
|
|
|
@ -135,8 +135,8 @@ class Book(BookDataModel):
|
|||
preview_image = models.ImageField(
|
||||
upload_to="previews/covers/", blank=True, null=True
|
||||
)
|
||||
first_published_date = fields.DateTimeField(blank=True, null=True)
|
||||
published_date = fields.DateTimeField(blank=True, null=True)
|
||||
first_published_date = fields.PartialDateField(blank=True, null=True)
|
||||
published_date = fields.PartialDateField(blank=True, null=True)
|
||||
|
||||
objects = InheritanceManager()
|
||||
field_tracker = FieldTracker(fields=["authors", "title", "subtitle", "cover"])
|
||||
|
@ -366,9 +366,9 @@ class Edition(Book):
|
|||
|
||||
# normalize isbn format
|
||||
if self.isbn_10:
|
||||
self.isbn_10 = re.sub(r"[^0-9X]", "", self.isbn_10)
|
||||
self.isbn_10 = normalize_isbn(self.isbn_10)
|
||||
if self.isbn_13:
|
||||
self.isbn_13 = re.sub(r"[^0-9X]", "", self.isbn_13)
|
||||
self.isbn_13 = normalize_isbn(self.isbn_13)
|
||||
|
||||
# set rank
|
||||
self.edition_rank = self.get_rank()
|
||||
|
@ -463,6 +463,11 @@ def isbn_13_to_10(isbn_13):
|
|||
return converted + str(checkdigit)
|
||||
|
||||
|
||||
def normalize_isbn(isbn):
|
||||
"""Remove unexpected characters from ISBN 10 or 13"""
|
||||
return re.sub(r"[^0-9X]", "", isbn)
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@receiver(models.signals.post_save, sender=Edition)
|
||||
def preview_image(instance, *args, **kwargs):
|
||||
|
|
232
bookwyrm/models/bookwyrm_export_job.py
Normal file
232
bookwyrm/models/bookwyrm_export_job.py
Normal file
|
@ -0,0 +1,232 @@
|
|||
"""Export user account to tar.gz file for import into another Bookwyrm instance"""
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
from django.db.models import FileField
|
||||
from django.db.models import Q
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
from bookwyrm.models import AnnualGoal, ReadThrough, ShelfBook, List, ListItem
|
||||
from bookwyrm.models import Review, Comment, Quotation
|
||||
from bookwyrm.models import Edition
|
||||
from bookwyrm.models import UserFollows, User, UserBlocks
|
||||
from bookwyrm.models.job import ParentJob, ParentTask
|
||||
from bookwyrm.tasks import app, IMPORTS
|
||||
from bookwyrm.utils.tar import BookwyrmTarFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BookwyrmExportJob(ParentJob):
|
||||
"""entry for a specific request to export a bookwyrm user"""
|
||||
|
||||
export_data = FileField(null=True)
|
||||
|
||||
def start_job(self):
|
||||
"""Start the job"""
|
||||
start_export_task.delay(job_id=self.id, no_children=True)
|
||||
|
||||
return self
|
||||
|
||||
|
||||
@app.task(queue=IMPORTS, base=ParentTask)
|
||||
def start_export_task(**kwargs):
|
||||
"""trigger the child tasks for each row"""
|
||||
job = BookwyrmExportJob.objects.get(id=kwargs["job_id"])
|
||||
|
||||
# don't start the job if it was stopped from the UI
|
||||
if job.complete:
|
||||
return
|
||||
try:
|
||||
# This is where ChildJobs get made
|
||||
job.export_data = ContentFile(b"", str(uuid4()))
|
||||
json_data = json_export(job.user)
|
||||
tar_export(json_data, job.user, job.export_data)
|
||||
job.save(update_fields=["export_data"])
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
logger.exception("User Export Job %s Failed with error: %s", job.id, err)
|
||||
job.set_status("failed")
|
||||
|
||||
job.set_status("complete")
|
||||
|
||||
|
||||
def tar_export(json_data: str, user, file):
|
||||
"""wrap the export information in a tar file"""
|
||||
file.open("wb")
|
||||
with BookwyrmTarFile.open(mode="w:gz", fileobj=file) as tar:
|
||||
tar.write_bytes(json_data.encode("utf-8"))
|
||||
|
||||
# Add avatar image if present
|
||||
if getattr(user, "avatar", False):
|
||||
tar.add_image(user.avatar, filename="avatar")
|
||||
|
||||
editions = get_books_for_user(user)
|
||||
for book in editions:
|
||||
if getattr(book, "cover", False):
|
||||
tar.add_image(book.cover)
|
||||
|
||||
file.close()
|
||||
|
||||
|
||||
def json_export(
|
||||
user,
|
||||
): # pylint: disable=too-many-locals, too-many-statements, too-many-branches
|
||||
"""Generate an export for a user"""
|
||||
|
||||
# User as AP object
|
||||
exported_user = user.to_activity()
|
||||
# I don't love this but it prevents a JSON encoding error
|
||||
# when there is no user image
|
||||
if isinstance(
|
||||
exported_user["icon"],
|
||||
dataclasses._MISSING_TYPE, # pylint: disable=protected-access
|
||||
):
|
||||
exported_user["icon"] = {}
|
||||
else:
|
||||
# change the URL to be relative to the JSON file
|
||||
file_type = exported_user["icon"]["url"].rsplit(".", maxsplit=1)[-1]
|
||||
filename = f"avatar.{file_type}"
|
||||
exported_user["icon"]["url"] = filename
|
||||
|
||||
# Additional settings - can't be serialized as AP
|
||||
vals = [
|
||||
"show_goal",
|
||||
"preferred_timezone",
|
||||
"default_post_privacy",
|
||||
"show_suggested_users",
|
||||
]
|
||||
exported_user["settings"] = {}
|
||||
for k in vals:
|
||||
exported_user["settings"][k] = getattr(user, k)
|
||||
|
||||
# Reading goals - can't be serialized as AP
|
||||
reading_goals = AnnualGoal.objects.filter(user=user).distinct()
|
||||
exported_user["goals"] = []
|
||||
for goal in reading_goals:
|
||||
exported_user["goals"].append(
|
||||
{"goal": goal.goal, "year": goal.year, "privacy": goal.privacy}
|
||||
)
|
||||
|
||||
# Reading history - can't be serialized as AP
|
||||
readthroughs = ReadThrough.objects.filter(user=user).distinct().values()
|
||||
readthroughs = list(readthroughs)
|
||||
|
||||
# Books
|
||||
editions = get_books_for_user(user)
|
||||
exported_user["books"] = []
|
||||
|
||||
for edition in editions:
|
||||
book = {}
|
||||
book["work"] = edition.parent_work.to_activity()
|
||||
book["edition"] = edition.to_activity()
|
||||
|
||||
if book["edition"].get("cover"):
|
||||
# change the URL to be relative to the JSON file
|
||||
filename = book["edition"]["cover"]["url"].rsplit("/", maxsplit=1)[-1]
|
||||
book["edition"]["cover"]["url"] = f"covers/{filename}"
|
||||
|
||||
# authors
|
||||
book["authors"] = []
|
||||
for author in edition.authors.all():
|
||||
book["authors"].append(author.to_activity())
|
||||
|
||||
# Shelves this book is on
|
||||
# Every ShelfItem is this book so we don't other serializing
|
||||
book["shelves"] = []
|
||||
shelf_books = (
|
||||
ShelfBook.objects.select_related("shelf")
|
||||
.filter(user=user, book=edition)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
for shelfbook in shelf_books:
|
||||
book["shelves"].append(shelfbook.shelf.to_activity())
|
||||
|
||||
# Lists and ListItems
|
||||
# ListItems include "notes" and "approved" so we need them
|
||||
# even though we know it's this book
|
||||
book["lists"] = []
|
||||
list_items = ListItem.objects.filter(book=edition, user=user).distinct()
|
||||
|
||||
for item in list_items:
|
||||
list_info = item.book_list.to_activity()
|
||||
list_info[
|
||||
"privacy"
|
||||
] = item.book_list.privacy # this isn't serialized so we add it
|
||||
list_info["list_item"] = item.to_activity()
|
||||
book["lists"].append(list_info)
|
||||
|
||||
# Statuses
|
||||
# Can't use select_subclasses here because
|
||||
# we need to filter on the "book" value,
|
||||
# which is not available on an ordinary Status
|
||||
for status in ["comments", "quotations", "reviews"]:
|
||||
book[status] = []
|
||||
|
||||
comments = Comment.objects.filter(user=user, book=edition).all()
|
||||
for status in comments:
|
||||
obj = status.to_activity()
|
||||
obj["progress"] = status.progress
|
||||
obj["progress_mode"] = status.progress_mode
|
||||
book["comments"].append(obj)
|
||||
|
||||
quotes = Quotation.objects.filter(user=user, book=edition).all()
|
||||
for status in quotes:
|
||||
obj = status.to_activity()
|
||||
obj["position"] = status.position
|
||||
obj["endposition"] = status.endposition
|
||||
obj["position_mode"] = status.position_mode
|
||||
book["quotations"].append(obj)
|
||||
|
||||
reviews = Review.objects.filter(user=user, book=edition).all()
|
||||
for status in reviews:
|
||||
obj = status.to_activity()
|
||||
book["reviews"].append(obj)
|
||||
|
||||
# readthroughs can't be serialized to activity
|
||||
book_readthroughs = (
|
||||
ReadThrough.objects.filter(user=user, book=edition).distinct().values()
|
||||
)
|
||||
book["readthroughs"] = list(book_readthroughs)
|
||||
|
||||
# append everything
|
||||
exported_user["books"].append(book)
|
||||
|
||||
# saved book lists - just the remote id
|
||||
saved_lists = List.objects.filter(id__in=user.saved_lists.all()).distinct()
|
||||
exported_user["saved_lists"] = [l.remote_id for l in saved_lists]
|
||||
|
||||
# follows - just the remote id
|
||||
follows = UserFollows.objects.filter(user_subject=user).distinct()
|
||||
following = User.objects.filter(userfollows_user_object__in=follows).distinct()
|
||||
exported_user["follows"] = [f.remote_id for f in following]
|
||||
|
||||
# blocks - just the remote id
|
||||
blocks = UserBlocks.objects.filter(user_subject=user).distinct()
|
||||
blocking = User.objects.filter(userblocks_user_object__in=blocks).distinct()
|
||||
|
||||
exported_user["blocks"] = [b.remote_id for b in blocking]
|
||||
|
||||
return DjangoJSONEncoder().encode(exported_user)
|
||||
|
||||
|
||||
def get_books_for_user(user):
|
||||
"""Get all the books and editions related to a user"""
|
||||
|
||||
editions = (
|
||||
Edition.objects.select_related("parent_work")
|
||||
.filter(
|
||||
Q(shelves__user=user)
|
||||
| Q(readthrough__user=user)
|
||||
| Q(review__user=user)
|
||||
| Q(list__user=user)
|
||||
| Q(comment__user=user)
|
||||
| Q(quotation__user=user)
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
return editions
|
459
bookwyrm/models/bookwyrm_import_job.py
Normal file
459
bookwyrm/models/bookwyrm_import_job.py
Normal file
|
@ -0,0 +1,459 @@
|
|||
"""Import a user from another Bookwyrm instance"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.db.models import FileField, JSONField, CharField
|
||||
from django.utils import timezone
|
||||
from django.utils.html import strip_tags
|
||||
from django.contrib.postgres.fields import ArrayField as DjangoArrayField
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm import models
|
||||
from bookwyrm.tasks import app, IMPORTS
|
||||
from bookwyrm.models.job import ParentJob, ParentTask, SubTask
|
||||
from bookwyrm.utils.tar import BookwyrmTarFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BookwyrmImportJob(ParentJob):
|
||||
"""entry for a specific request for importing a bookwyrm user backup"""
|
||||
|
||||
archive_file = FileField(null=True, blank=True)
|
||||
import_data = JSONField(null=True)
|
||||
required = DjangoArrayField(CharField(max_length=50, blank=True), blank=True)
|
||||
|
||||
def start_job(self):
|
||||
"""Start the job"""
|
||||
start_import_task.delay(job_id=self.id, no_children=True)
|
||||
|
||||
|
||||
@app.task(queue=IMPORTS, base=ParentTask)
|
||||
def start_import_task(**kwargs):
|
||||
"""trigger the child import tasks for each user data"""
|
||||
job = BookwyrmImportJob.objects.get(id=kwargs["job_id"])
|
||||
archive_file = job.archive_file
|
||||
|
||||
# don't start the job if it was stopped from the UI
|
||||
if job.complete:
|
||||
return
|
||||
|
||||
try:
|
||||
archive_file.open("rb")
|
||||
with BookwyrmTarFile.open(mode="r:gz", fileobj=archive_file) as tar:
|
||||
job.import_data = json.loads(tar.read("archive.json").decode("utf-8"))
|
||||
|
||||
if "include_user_profile" in job.required:
|
||||
update_user_profile(job.user, tar, job.import_data)
|
||||
if "include_user_settings" in job.required:
|
||||
update_user_settings(job.user, job.import_data)
|
||||
if "include_goals" in job.required:
|
||||
update_goals(job.user, job.import_data.get("goals"))
|
||||
if "include_saved_lists" in job.required:
|
||||
upsert_saved_lists(job.user, job.import_data.get("saved_lists"))
|
||||
if "include_follows" in job.required:
|
||||
upsert_follows(job.user, job.import_data.get("follows"))
|
||||
if "include_blocks" in job.required:
|
||||
upsert_user_blocks(job.user, job.import_data.get("blocks"))
|
||||
|
||||
process_books(job, tar)
|
||||
|
||||
job.set_status("complete")
|
||||
archive_file.close()
|
||||
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
logger.exception("User Import Job %s Failed with error: %s", job.id, err)
|
||||
job.set_status("failed")
|
||||
|
||||
|
||||
def process_books(job, tar):
|
||||
"""
|
||||
Process user import data related to books
|
||||
We always import the books even if not assigning
|
||||
them to shelves, lists etc
|
||||
"""
|
||||
|
||||
books = job.import_data.get("books")
|
||||
|
||||
for data in books:
|
||||
book = get_or_create_edition(data, tar)
|
||||
|
||||
if "include_shelves" in job.required:
|
||||
upsert_shelves(book, job.user, data)
|
||||
|
||||
if "include_readthroughs" in job.required:
|
||||
upsert_readthroughs(data.get("readthroughs"), job.user, book.id)
|
||||
|
||||
if "include_comments" in job.required:
|
||||
upsert_statuses(
|
||||
job.user, models.Comment, data.get("comments"), book.remote_id
|
||||
)
|
||||
if "include_quotations" in job.required:
|
||||
upsert_statuses(
|
||||
job.user, models.Quotation, data.get("quotations"), book.remote_id
|
||||
)
|
||||
|
||||
if "include_reviews" in job.required:
|
||||
upsert_statuses(
|
||||
job.user, models.Review, data.get("reviews"), book.remote_id
|
||||
)
|
||||
|
||||
if "include_lists" in job.required:
|
||||
upsert_lists(job.user, data.get("lists"), book.id)
|
||||
|
||||
|
||||
def get_or_create_edition(book_data, tar):
|
||||
"""Take a JSON string of work and edition data,
|
||||
find or create the edition and work in the database and
|
||||
return an edition instance"""
|
||||
|
||||
edition = book_data.get("edition")
|
||||
existing = models.Edition.find_existing(edition)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
# make sure we have the authors in the local DB
|
||||
# replace the old author ids in the edition JSON
|
||||
edition["authors"] = []
|
||||
for author in book_data.get("authors"):
|
||||
parsed_author = activitypub.parse(author)
|
||||
instance = parsed_author.to_model(
|
||||
model=models.Author, save=True, overwrite=True
|
||||
)
|
||||
|
||||
edition["authors"].append(instance.remote_id)
|
||||
|
||||
# we will add the cover later from the tar
|
||||
# don't try to load it from the old server
|
||||
cover = edition.get("cover", {})
|
||||
cover_path = cover.get("url", None)
|
||||
edition["cover"] = {}
|
||||
|
||||
# first we need the parent work to exist
|
||||
work = book_data.get("work")
|
||||
work["editions"] = []
|
||||
parsed_work = activitypub.parse(work)
|
||||
work_instance = parsed_work.to_model(model=models.Work, save=True, overwrite=True)
|
||||
|
||||
# now we have a work we can add it to the edition
|
||||
# and create the edition model instance
|
||||
edition["work"] = work_instance.remote_id
|
||||
parsed_edition = activitypub.parse(edition)
|
||||
book = parsed_edition.to_model(model=models.Edition, save=True, overwrite=True)
|
||||
|
||||
# set the cover image from the tar
|
||||
if cover_path:
|
||||
tar.write_image_to_file(cover_path, book.cover)
|
||||
|
||||
return book
|
||||
|
||||
|
||||
def upsert_readthroughs(data, user, book_id):
|
||||
"""Take a JSON string of readthroughs and
|
||||
find or create the instances in the database"""
|
||||
|
||||
for read_through in data:
|
||||
|
||||
obj = {}
|
||||
keys = [
|
||||
"progress_mode",
|
||||
"start_date",
|
||||
"finish_date",
|
||||
"stopped_date",
|
||||
"is_active",
|
||||
]
|
||||
for key in keys:
|
||||
obj[key] = read_through[key]
|
||||
obj["user_id"] = user.id
|
||||
obj["book_id"] = book_id
|
||||
|
||||
existing = models.ReadThrough.objects.filter(**obj).first()
|
||||
if not existing:
|
||||
models.ReadThrough.objects.create(**obj)
|
||||
|
||||
|
||||
def upsert_statuses(user, cls, data, book_remote_id):
|
||||
"""Take a JSON string of a status and
|
||||
find or create the instances in the database"""
|
||||
|
||||
for status in data:
|
||||
if is_alias(
|
||||
user, status["attributedTo"]
|
||||
): # don't let l33t hax0rs steal other people's posts
|
||||
# update ids and remove replies
|
||||
status["attributedTo"] = user.remote_id
|
||||
status["to"] = update_followers_address(user, status["to"])
|
||||
status["cc"] = update_followers_address(user, status["cc"])
|
||||
status[
|
||||
"replies"
|
||||
] = (
|
||||
{}
|
||||
) # this parses incorrectly but we can't set it without knowing the new id
|
||||
status["inReplyToBook"] = book_remote_id
|
||||
parsed = activitypub.parse(status)
|
||||
if not status_already_exists(
|
||||
user, parsed
|
||||
): # don't duplicate posts on multiple import
|
||||
|
||||
instance = parsed.to_model(model=cls, save=True, overwrite=True)
|
||||
|
||||
for val in [
|
||||
"progress",
|
||||
"progress_mode",
|
||||
"position",
|
||||
"endposition",
|
||||
"position_mode",
|
||||
]:
|
||||
if status.get(val):
|
||||
instance.val = status[val]
|
||||
|
||||
instance.remote_id = instance.get_remote_id() # update the remote_id
|
||||
instance.save() # save and broadcast
|
||||
|
||||
else:
|
||||
logger.info("User does not have permission to import statuses")
|
||||
|
||||
|
||||
def upsert_lists(user, lists, book_id):
|
||||
"""Take a list of objects each containing
|
||||
a list and list item as AP objects
|
||||
|
||||
Because we are creating new IDs we can't assume the id
|
||||
will exist or be accurate, so we only use to_model for
|
||||
adding new items after checking whether they exist .
|
||||
|
||||
"""
|
||||
|
||||
book = models.Edition.objects.get(id=book_id)
|
||||
|
||||
for blist in lists:
|
||||
booklist = models.List.objects.filter(name=blist["name"], user=user).first()
|
||||
if not booklist:
|
||||
|
||||
blist["owner"] = user.remote_id
|
||||
parsed = activitypub.parse(blist)
|
||||
booklist = parsed.to_model(model=models.List, save=True, overwrite=True)
|
||||
|
||||
booklist.privacy = blist["privacy"]
|
||||
booklist.save()
|
||||
|
||||
item = models.ListItem.objects.filter(book=book, book_list=booklist).exists()
|
||||
if not item:
|
||||
count = booklist.books.count()
|
||||
models.ListItem.objects.create(
|
||||
book=book,
|
||||
book_list=booklist,
|
||||
user=user,
|
||||
notes=blist["list_item"]["notes"],
|
||||
approved=blist["list_item"]["approved"],
|
||||
order=count + 1,
|
||||
)
|
||||
|
||||
|
||||
def upsert_shelves(book, user, book_data):
|
||||
"""Take shelf JSON objects and create
|
||||
DB entries if they don't already exist"""
|
||||
|
||||
shelves = book_data["shelves"]
|
||||
for shelf in shelves:
|
||||
|
||||
book_shelf = models.Shelf.objects.filter(name=shelf["name"], user=user).first()
|
||||
|
||||
if not book_shelf:
|
||||
book_shelf = models.Shelf.objects.create(name=shelf["name"], user=user)
|
||||
|
||||
# add the book as a ShelfBook if needed
|
||||
if not models.ShelfBook.objects.filter(
|
||||
book=book, shelf=book_shelf, user=user
|
||||
).exists():
|
||||
models.ShelfBook.objects.create(
|
||||
book=book, shelf=book_shelf, user=user, shelved_date=timezone.now()
|
||||
)
|
||||
|
||||
|
||||
def update_user_profile(user, tar, data):
|
||||
"""update the user's profile from import data"""
|
||||
name = data.get("name", None)
|
||||
username = data.get("preferredUsername")
|
||||
user.name = name if name else username
|
||||
user.summary = strip_tags(data.get("summary", None))
|
||||
user.save(update_fields=["name", "summary"])
|
||||
if data["icon"].get("url"):
|
||||
avatar_filename = next(filter(lambda n: n.startswith("avatar"), tar.getnames()))
|
||||
tar.write_image_to_file(avatar_filename, user.avatar)
|
||||
|
||||
|
||||
def update_user_settings(user, data):
|
||||
"""update the user's settings from import data"""
|
||||
|
||||
update_fields = ["manually_approves_followers", "hide_follows", "discoverable"]
|
||||
|
||||
ap_fields = [
|
||||
("manuallyApprovesFollowers", "manually_approves_followers"),
|
||||
("hideFollows", "hide_follows"),
|
||||
("discoverable", "discoverable"),
|
||||
]
|
||||
|
||||
for (ap_field, bw_field) in ap_fields:
|
||||
setattr(user, bw_field, data[ap_field])
|
||||
|
||||
bw_fields = [
|
||||
"show_goal",
|
||||
"show_suggested_users",
|
||||
"default_post_privacy",
|
||||
"preferred_timezone",
|
||||
]
|
||||
|
||||
for field in bw_fields:
|
||||
update_fields.append(field)
|
||||
setattr(user, field, data["settings"][field])
|
||||
|
||||
user.save(update_fields=update_fields)
|
||||
|
||||
|
||||
@app.task(queue=IMPORTS, base=SubTask)
|
||||
def update_user_settings_task(job_id):
|
||||
"""wrapper task for user's settings import"""
|
||||
parent_job = BookwyrmImportJob.objects.get(id=job_id)
|
||||
|
||||
return update_user_settings(parent_job.user, parent_job.import_data.get("user"))
|
||||
|
||||
|
||||
def update_goals(user, data):
|
||||
"""update the user's goals from import data"""
|
||||
|
||||
for goal in data:
|
||||
# edit the existing goal if there is one
|
||||
existing = models.AnnualGoal.objects.filter(
|
||||
year=goal["year"], user=user
|
||||
).first()
|
||||
if existing:
|
||||
for k in goal.keys():
|
||||
setattr(existing, k, goal[k])
|
||||
existing.save()
|
||||
else:
|
||||
goal["user"] = user
|
||||
models.AnnualGoal.objects.create(**goal)
|
||||
|
||||
|
||||
@app.task(queue=IMPORTS, base=SubTask)
|
||||
def update_goals_task(job_id):
|
||||
"""wrapper task for user's goals import"""
|
||||
parent_job = BookwyrmImportJob.objects.get(id=job_id)
|
||||
|
||||
return update_goals(parent_job.user, parent_job.import_data.get("goals"))
|
||||
|
||||
|
||||
def upsert_saved_lists(user, values):
|
||||
"""Take a list of remote ids and add as saved lists"""
|
||||
|
||||
for remote_id in values:
|
||||
book_list = activitypub.resolve_remote_id(remote_id, models.List)
|
||||
if book_list:
|
||||
user.saved_lists.add(book_list)
|
||||
|
||||
|
||||
@app.task(queue=IMPORTS, base=SubTask)
|
||||
def upsert_saved_lists_task(job_id):
|
||||
"""wrapper task for user's saved lists import"""
|
||||
parent_job = BookwyrmImportJob.objects.get(id=job_id)
|
||||
|
||||
return upsert_saved_lists(
|
||||
parent_job.user, parent_job.import_data.get("saved_lists")
|
||||
)
|
||||
|
||||
|
||||
def upsert_follows(user, values):
|
||||
"""Take a list of remote ids and add as follows"""
|
||||
|
||||
for remote_id in values:
|
||||
followee = activitypub.resolve_remote_id(remote_id, models.User)
|
||||
if followee:
|
||||
(follow_request, created,) = models.UserFollowRequest.objects.get_or_create(
|
||||
user_subject=user,
|
||||
user_object=followee,
|
||||
)
|
||||
|
||||
if not created:
|
||||
# this request probably failed to connect with the remote
|
||||
# and should save to trigger a re-broadcast
|
||||
follow_request.save()
|
||||
|
||||
|
||||
@app.task(queue=IMPORTS, base=SubTask)
|
||||
def upsert_follows_task(job_id):
|
||||
"""wrapper task for user's follows import"""
|
||||
parent_job = BookwyrmImportJob.objects.get(id=job_id)
|
||||
|
||||
return upsert_follows(parent_job.user, parent_job.import_data.get("follows"))
|
||||
|
||||
|
||||
def upsert_user_blocks(user, user_ids):
|
||||
"""block users"""
|
||||
|
||||
for user_id in user_ids:
|
||||
user_object = activitypub.resolve_remote_id(user_id, models.User)
|
||||
if user_object:
|
||||
exists = models.UserBlocks.objects.filter(
|
||||
user_subject=user, user_object=user_object
|
||||
).exists()
|
||||
if not exists:
|
||||
models.UserBlocks.objects.create(
|
||||
user_subject=user, user_object=user_object
|
||||
)
|
||||
# remove the blocked users's lists from the groups
|
||||
models.List.remove_from_group(user, user_object)
|
||||
# remove the blocked user from all blocker's owned groups
|
||||
models.GroupMember.remove(user, user_object)
|
||||
|
||||
|
||||
@app.task(queue=IMPORTS, base=SubTask)
|
||||
def upsert_user_blocks_task(job_id):
|
||||
"""wrapper task for user's blocks import"""
|
||||
parent_job = BookwyrmImportJob.objects.get(id=job_id)
|
||||
|
||||
return upsert_user_blocks(
|
||||
parent_job.user, parent_job.import_data.get("blocked_users")
|
||||
)
|
||||
|
||||
|
||||
def update_followers_address(user, field):
|
||||
"""statuses to or cc followers need to have the followers
|
||||
address updated to the new local user"""
|
||||
|
||||
for i, audience in enumerate(field):
|
||||
if audience.rsplit("/")[-1] == "followers":
|
||||
field[i] = user.followers_url
|
||||
|
||||
return field
|
||||
|
||||
|
||||
def is_alias(user, remote_id):
|
||||
"""check that the user is listed as movedTo or also_known_as
|
||||
in the remote user's profile"""
|
||||
|
||||
remote_user = activitypub.resolve_remote_id(
|
||||
remote_id=remote_id, model=models.User, save=False
|
||||
)
|
||||
|
||||
if remote_user:
|
||||
|
||||
if remote_user.moved_to:
|
||||
return user.remote_id == remote_user.moved_to
|
||||
|
||||
if remote_user.also_known_as:
|
||||
return user in remote_user.also_known_as.all()
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def status_already_exists(user, status):
|
||||
"""check whether this status has already been published
|
||||
by this user. We can't rely on to_model() because it
|
||||
only matches on remote_id, which we have to change
|
||||
*after* saving because it needs the primary key (id)"""
|
||||
|
||||
return models.Status.objects.filter(
|
||||
user=user, content=status.content, published_date=status.published
|
||||
).exists()
|
|
@ -20,6 +20,11 @@ from markdown import markdown
|
|||
from bookwyrm import activitypub
|
||||
from bookwyrm.connectors import get_image
|
||||
from bookwyrm.utils.sanitizer import clean
|
||||
from bookwyrm.utils.partial_date import (
|
||||
PartialDate,
|
||||
PartialDateModel,
|
||||
from_partial_isoformat,
|
||||
)
|
||||
from bookwyrm.settings import MEDIA_FULL_URL
|
||||
|
||||
|
||||
|
@ -483,10 +488,12 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
|
|||
image_slug = value
|
||||
# when it's an inline image (User avatar/icon, Book cover), it's a json
|
||||
# blob, but when it's an attached image, it's just a url
|
||||
if hasattr(image_slug, "url"):
|
||||
url = image_slug.url
|
||||
elif isinstance(image_slug, str):
|
||||
if isinstance(image_slug, str):
|
||||
url = image_slug
|
||||
elif isinstance(image_slug, dict):
|
||||
url = image_slug.get("url")
|
||||
elif hasattr(image_slug, "url"): # Serialized to Image/Document object?
|
||||
url = image_slug.url
|
||||
else:
|
||||
return None
|
||||
|
||||
|
@ -537,7 +544,6 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
|
|||
def field_from_activity(self, value, allow_external_connections=True):
|
||||
missing_fields = datetime(1970, 1, 1) # "2022-10" => "2022-10-01"
|
||||
try:
|
||||
# TODO(dato): investigate `ignoretz=True` wrt bookwyrm#3028.
|
||||
date_value = dateutil.parser.parse(value, default=missing_fields)
|
||||
try:
|
||||
return timezone.make_aware(date_value)
|
||||
|
@ -547,6 +553,37 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
|
|||
return None
|
||||
|
||||
|
||||
class PartialDateField(ActivitypubFieldMixin, PartialDateModel):
|
||||
"""activitypub-aware partial date field"""
|
||||
|
||||
def field_to_activity(self, value) -> str:
|
||||
return value.partial_isoformat() if value else None
|
||||
|
||||
def field_from_activity(self, value, allow_external_connections=True):
|
||||
# pylint: disable=no-else-return
|
||||
try:
|
||||
return from_partial_isoformat(value)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# fallback to full ISO-8601 parsing
|
||||
try:
|
||||
parsed = dateutil.parser.isoparse(value)
|
||||
except (ValueError, ParserError):
|
||||
return None
|
||||
|
||||
if timezone.is_aware(parsed):
|
||||
return PartialDate.from_datetime(parsed)
|
||||
else:
|
||||
# Should not happen on the wire, but truncate down to date parts.
|
||||
return PartialDate.from_date_parts(parsed.year, parsed.month, parsed.day)
|
||||
|
||||
# FIXME: decide whether to fix timestamps like "2023-09-30T21:00:00-03":
|
||||
# clearly Oct 1st, not Sep 30th (an unwanted side-effect of USE_TZ). It's
|
||||
# basically the remnants of #3028; there is a data migration pending (see …)
|
||||
# but over the wire we might get these for an indeterminate amount of time.
|
||||
|
||||
|
||||
class HtmlField(ActivitypubFieldMixin, models.TextField):
|
||||
"""a text field for storing html"""
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
""" do book related things with other users """
|
||||
from django.apps import apps
|
||||
from django.db import models, IntegrityError, transaction
|
||||
from django.db.models import Q
|
||||
from bookwyrm.settings import DOMAIN
|
||||
|
@ -143,26 +142,28 @@ class GroupMemberInvitation(models.Model):
|
|||
@transaction.atomic
|
||||
def accept(self):
|
||||
"""turn this request into the real deal"""
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from .notification import Notification, NotificationType # circular dependency
|
||||
|
||||
GroupMember.from_request(self)
|
||||
|
||||
model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
# tell the group owner
|
||||
model.notify(
|
||||
Notification.notify(
|
||||
self.group.user,
|
||||
self.user,
|
||||
related_group=self.group,
|
||||
notification_type=model.ACCEPT,
|
||||
notification_type=NotificationType.ACCEPT,
|
||||
)
|
||||
|
||||
# let the other members know about it
|
||||
for membership in self.group.memberships.all():
|
||||
member = membership.user
|
||||
if member not in (self.user, self.group.user):
|
||||
model.notify(
|
||||
Notification.notify(
|
||||
member,
|
||||
self.user,
|
||||
related_group=self.group,
|
||||
notification_type=model.JOIN,
|
||||
notification_type=NotificationType.JOIN,
|
||||
)
|
||||
|
||||
def reject(self):
|
||||
|
|
308
bookwyrm/models/job.py
Normal file
308
bookwyrm/models/job.py
Normal file
|
@ -0,0 +1,308 @@
|
|||
"""Everything needed for Celery to multi-thread complex tasks."""
|
||||
|
||||
from django.db import models
|
||||
from django.db import transaction
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils import timezone
|
||||
from bookwyrm.models.user import User
|
||||
|
||||
from bookwyrm.tasks import app
|
||||
|
||||
|
||||
class Job(models.Model):
|
||||
"""Abstract model to store the state of a Task."""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
"""Possible job states."""
|
||||
|
||||
PENDING = "pending", _("Pending")
|
||||
ACTIVE = "active", _("Active")
|
||||
COMPLETE = "complete", _("Complete")
|
||||
STOPPED = "stopped", _("Stopped")
|
||||
FAILED = "failed", _("Failed")
|
||||
|
||||
task_id = models.UUIDField(unique=True, null=True, blank=True)
|
||||
|
||||
created_date = models.DateTimeField(default=timezone.now)
|
||||
updated_date = models.DateTimeField(default=timezone.now)
|
||||
complete = models.BooleanField(default=False)
|
||||
status = models.CharField(
|
||||
max_length=50, choices=Status.choices, default=Status.PENDING, null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Make it abstract"""
|
||||
|
||||
abstract = True
|
||||
|
||||
def complete_job(self):
|
||||
"""Report that the job has completed"""
|
||||
if self.complete:
|
||||
return
|
||||
|
||||
self.status = self.Status.COMPLETE
|
||||
self.complete = True
|
||||
self.updated_date = timezone.now()
|
||||
|
||||
self.save(update_fields=["status", "complete", "updated_date"])
|
||||
|
||||
def stop_job(self, reason=None):
|
||||
"""Stop the job"""
|
||||
if self.complete:
|
||||
return
|
||||
|
||||
self.__terminate_job()
|
||||
|
||||
if reason and reason == "failed":
|
||||
self.status = self.Status.FAILED
|
||||
else:
|
||||
self.status = self.Status.STOPPED
|
||||
self.complete = True
|
||||
self.updated_date = timezone.now()
|
||||
|
||||
self.save(update_fields=["status", "complete", "updated_date"])
|
||||
|
||||
def set_status(self, status):
|
||||
"""Set job status"""
|
||||
if self.complete:
|
||||
return
|
||||
|
||||
if self.status == status:
|
||||
return
|
||||
|
||||
if status == self.Status.COMPLETE:
|
||||
self.complete_job()
|
||||
return
|
||||
|
||||
if status == self.Status.STOPPED:
|
||||
self.stop_job()
|
||||
return
|
||||
|
||||
if status == self.Status.FAILED:
|
||||
self.stop_job(reason="failed")
|
||||
return
|
||||
|
||||
self.updated_date = timezone.now()
|
||||
self.status = status
|
||||
|
||||
self.save(update_fields=["status", "updated_date"])
|
||||
|
||||
def __terminate_job(self):
|
||||
"""Tell workers to ignore and not execute this task."""
|
||||
app.control.revoke(self.task_id, terminate=True)
|
||||
|
||||
|
||||
class ParentJob(Job):
|
||||
"""Store the state of a Task which can spawn many :model:`ChildJob`s to spread
|
||||
resource load.
|
||||
|
||||
Intended to be sub-classed if necessary via proxy or
|
||||
multi-table inheritance.
|
||||
Extends :model:`Job`.
|
||||
"""
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
def complete_job(self):
|
||||
"""Report that the job has completed and stop pending
|
||||
children. Extend.
|
||||
"""
|
||||
super().complete_job()
|
||||
self.__terminate_pending_child_jobs()
|
||||
|
||||
def notify_child_job_complete(self):
|
||||
"""let the job know when the items get work done"""
|
||||
if self.complete:
|
||||
return
|
||||
|
||||
self.updated_date = timezone.now()
|
||||
self.save(update_fields=["updated_date"])
|
||||
|
||||
if not self.complete and self.has_completed:
|
||||
self.complete_job()
|
||||
|
||||
def __terminate_job(self): # pylint: disable=unused-private-member
|
||||
"""Tell workers to ignore and not execute this task
|
||||
& pending child tasks. Extend.
|
||||
"""
|
||||
super().__terminate_job()
|
||||
self.__terminate_pending_child_jobs()
|
||||
|
||||
def __terminate_pending_child_jobs(self):
|
||||
"""Tell workers to ignore and not execute any pending child tasks."""
|
||||
tasks = self.pending_child_jobs.filter(task_id__isnull=False).values_list(
|
||||
"task_id", flat=True
|
||||
)
|
||||
app.control.revoke(list(tasks))
|
||||
|
||||
for task in self.pending_child_jobs:
|
||||
task.update(status=self.Status.STOPPED)
|
||||
|
||||
@property
|
||||
def has_completed(self):
|
||||
"""has this job finished"""
|
||||
return not self.pending_child_jobs.exists()
|
||||
|
||||
@property
|
||||
def pending_child_jobs(self):
|
||||
"""items that haven't been processed yet"""
|
||||
return self.child_jobs.filter(complete=False)
|
||||
|
||||
|
||||
class ChildJob(Job):
|
||||
"""Stores the state of a Task for the related :model:`ParentJob`.
|
||||
|
||||
Intended to be sub-classed if necessary via proxy or
|
||||
multi-table inheritance.
|
||||
Extends :model:`Job`.
|
||||
"""
|
||||
|
||||
parent_job = models.ForeignKey(
|
||||
ParentJob, on_delete=models.CASCADE, related_name="child_jobs"
|
||||
)
|
||||
|
||||
def set_status(self, status):
|
||||
"""Set job and parent_job status. Extend."""
|
||||
super().set_status(status)
|
||||
|
||||
if (
|
||||
status == self.Status.ACTIVE
|
||||
and self.parent_job.status == self.Status.PENDING
|
||||
):
|
||||
self.parent_job.set_status(self.Status.ACTIVE)
|
||||
|
||||
def complete_job(self):
|
||||
"""Report to parent_job that the job has completed. Extend."""
|
||||
super().complete_job()
|
||||
self.parent_job.notify_child_job_complete()
|
||||
|
||||
|
||||
class ParentTask(app.Task):
|
||||
"""Used with ParentJob, Abstract Tasks execute code at specific points in
|
||||
a Task's lifecycle, applying to all Tasks with the same 'base'.
|
||||
|
||||
All status & ParentJob.task_id assignment is managed here for you.
|
||||
Usage e.g. @app.task(base=ParentTask)
|
||||
"""
|
||||
|
||||
def before_start(
|
||||
self, task_id, args, kwargs
|
||||
): # pylint: disable=no-self-use, unused-argument
|
||||
"""Handler called before the task starts. Override.
|
||||
|
||||
Prepare ParentJob before the task starts.
|
||||
|
||||
Arguments:
|
||||
task_id (str): Unique id of the task to execute.
|
||||
args (Tuple): Original arguments for the task to execute.
|
||||
kwargs (Dict): Original keyword arguments for the task to execute.
|
||||
|
||||
Keyword Arguments:
|
||||
job_id (int): Unique 'id' of the ParentJob.
|
||||
no_children (bool): If 'True' this is the only Task expected to run
|
||||
for the given ParentJob.
|
||||
|
||||
Returns:
|
||||
None: The return value of this handler is ignored.
|
||||
"""
|
||||
job = ParentJob.objects.get(id=kwargs["job_id"])
|
||||
job.task_id = task_id
|
||||
job.save(update_fields=["task_id"])
|
||||
|
||||
if kwargs["no_children"]:
|
||||
job.set_status(ChildJob.Status.ACTIVE)
|
||||
|
||||
def on_success(
|
||||
self, retval, task_id, args, kwargs
|
||||
): # pylint: disable=no-self-use, unused-argument
|
||||
"""Run by the worker if the task executes successfully. Override.
|
||||
|
||||
Update ParentJob on Task complete.
|
||||
|
||||
Arguments:
|
||||
retval (Any): The return value of the task.
|
||||
task_id (str): Unique id of the executed task.
|
||||
args (Tuple): Original arguments for the executed task.
|
||||
kwargs (Dict): Original keyword arguments for the executed task.
|
||||
|
||||
Keyword Arguments:
|
||||
job_id (int): Unique 'id' of the ParentJob.
|
||||
no_children (bool): If 'True' this is the only Task expected to run
|
||||
for the given ParentJob.
|
||||
|
||||
Returns:
|
||||
None: The return value of this handler is ignored.
|
||||
"""
|
||||
|
||||
if kwargs["no_children"]:
|
||||
job = ParentJob.objects.get(id=kwargs["job_id"])
|
||||
job.complete_job()
|
||||
|
||||
|
||||
class SubTask(app.Task):
|
||||
"""Used with ChildJob, Abstract Tasks execute code at specific points in
|
||||
a Task's lifecycle, applying to all Tasks with the same 'base'.
|
||||
|
||||
All status & ChildJob.task_id assignment is managed here for you.
|
||||
Usage e.g. @app.task(base=SubTask)
|
||||
"""
|
||||
|
||||
def before_start(
|
||||
self, task_id, args, kwargs
|
||||
): # pylint: disable=no-self-use, unused-argument
|
||||
"""Handler called before the task starts. Override.
|
||||
|
||||
Prepare ChildJob before the task starts.
|
||||
|
||||
Arguments:
|
||||
task_id (str): Unique id of the task to execute.
|
||||
args (Tuple): Original arguments for the task to execute.
|
||||
kwargs (Dict): Original keyword arguments for the task to execute.
|
||||
|
||||
Keyword Arguments:
|
||||
job_id (int): Unique 'id' of the ParentJob.
|
||||
child_id (int): Unique 'id' of the ChildJob.
|
||||
|
||||
Returns:
|
||||
None: The return value of this handler is ignored.
|
||||
"""
|
||||
child_job = ChildJob.objects.get(id=kwargs["child_id"])
|
||||
child_job.task_id = task_id
|
||||
child_job.save(update_fields=["task_id"])
|
||||
child_job.set_status(ChildJob.Status.ACTIVE)
|
||||
|
||||
def on_success(
|
||||
self, retval, task_id, args, kwargs
|
||||
): # pylint: disable=no-self-use, unused-argument
|
||||
"""Run by the worker if the task executes successfully. Override.
|
||||
|
||||
Notify ChildJob of task completion.
|
||||
|
||||
Arguments:
|
||||
retval (Any): The return value of the task.
|
||||
task_id (str): Unique id of the executed task.
|
||||
args (Tuple): Original arguments for the executed task.
|
||||
kwargs (Dict): Original keyword arguments for the executed task.
|
||||
|
||||
Keyword Arguments:
|
||||
job_id (int): Unique 'id' of the ParentJob.
|
||||
child_id (int): Unique 'id' of the ChildJob.
|
||||
|
||||
Returns:
|
||||
None: The return value of this handler is ignored.
|
||||
"""
|
||||
subtask = ChildJob.objects.get(id=kwargs["child_id"])
|
||||
subtask.complete_job()
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_child_job(parent_job, task_callback):
|
||||
"""Utility method for creating a ChildJob
|
||||
and running a task to avoid DB race conditions
|
||||
"""
|
||||
child_job = ChildJob.objects.create(parent_job=parent_job)
|
||||
transaction.on_commit(
|
||||
lambda: task_callback.delay(job_id=parent_job.id, child_id=child_job.id)
|
||||
)
|
||||
|
||||
return child_job
|
71
bookwyrm/models/move.py
Normal file
71
bookwyrm/models/move.py
Normal file
|
@ -0,0 +1,71 @@
|
|||
""" move an object including migrating a user account """
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import models
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from .activitypub_mixin import ActivityMixin
|
||||
from .base_model import BookWyrmModel
|
||||
from . import fields
|
||||
from .notification import Notification, NotificationType
|
||||
|
||||
|
||||
class Move(ActivityMixin, BookWyrmModel):
|
||||
"""migrating an activitypub user account"""
|
||||
|
||||
user = fields.ForeignKey(
|
||||
"User", on_delete=models.PROTECT, activitypub_field="actor"
|
||||
)
|
||||
|
||||
object = fields.CharField(
|
||||
max_length=255,
|
||||
blank=False,
|
||||
null=False,
|
||||
activitypub_field="object",
|
||||
)
|
||||
|
||||
origin = fields.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
default="",
|
||||
activitypub_field="origin",
|
||||
)
|
||||
|
||||
activity_serializer = activitypub.Move
|
||||
|
||||
|
||||
class MoveUser(Move):
|
||||
"""migrating an activitypub user account"""
|
||||
|
||||
target = fields.ForeignKey(
|
||||
"User",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="move_target",
|
||||
activitypub_field="target",
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""update user info and broadcast it"""
|
||||
|
||||
# only allow if the source is listed in the target's alsoKnownAs
|
||||
if self.user in self.target.also_known_as.all():
|
||||
self.user.also_known_as.add(self.target.id)
|
||||
self.user.update_active_date()
|
||||
self.user.moved_to = self.target.remote_id
|
||||
self.user.save(update_fields=["moved_to"])
|
||||
|
||||
if self.user.local:
|
||||
kwargs[
|
||||
"broadcast"
|
||||
] = True # Only broadcast if we are initiating the Move
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
for follower in self.user.followers.all():
|
||||
if follower.local:
|
||||
Notification.notify(
|
||||
follower, self.user, notification_type=NotificationType.MOVE
|
||||
)
|
||||
|
||||
else:
|
||||
raise PermissionDenied()
|
|
@ -1,12 +1,21 @@
|
|||
""" alert a user to activity """
|
||||
from django.db import models, transaction
|
||||
from django.dispatch import receiver
|
||||
from bookwyrm.models.bookwyrm_export_job import BookwyrmExportJob
|
||||
from .base_model import BookWyrmModel
|
||||
from . import Boost, Favorite, GroupMemberInvitation, ImportJob, LinkDomain
|
||||
from . import (
|
||||
Boost,
|
||||
Favorite,
|
||||
GroupMemberInvitation,
|
||||
ImportJob,
|
||||
BookwyrmImportJob,
|
||||
LinkDomain,
|
||||
)
|
||||
from . import ListItem, Report, Status, User, UserFollowRequest
|
||||
from .site import InviteRequest
|
||||
|
||||
|
||||
class Notification(BookWyrmModel):
|
||||
class NotificationType(models.TextChoices):
|
||||
"""you've been tagged, liked, followed, etc"""
|
||||
|
||||
# Status interactions
|
||||
|
@ -22,6 +31,8 @@ class Notification(BookWyrmModel):
|
|||
|
||||
# Imports
|
||||
IMPORT = "IMPORT"
|
||||
USER_IMPORT = "USER_IMPORT"
|
||||
USER_EXPORT = "USER_EXPORT"
|
||||
|
||||
# List activity
|
||||
ADD = "ADD"
|
||||
|
@ -29,6 +40,7 @@ class Notification(BookWyrmModel):
|
|||
# Admin
|
||||
REPORT = "REPORT"
|
||||
LINK_DOMAIN = "LINK_DOMAIN"
|
||||
INVITE_REQUEST = "INVITE_REQUEST"
|
||||
|
||||
# Groups
|
||||
INVITE = "INVITE"
|
||||
|
@ -40,12 +52,12 @@ class Notification(BookWyrmModel):
|
|||
GROUP_NAME = "GROUP_NAME"
|
||||
GROUP_DESCRIPTION = "GROUP_DESCRIPTION"
|
||||
|
||||
# pylint: disable=line-too-long
|
||||
NotificationType = models.TextChoices(
|
||||
# there has got be a better way to do this
|
||||
"NotificationType",
|
||||
f"{FAVORITE} {REPLY} {MENTION} {TAG} {FOLLOW} {FOLLOW_REQUEST} {BOOST} {IMPORT} {ADD} {REPORT} {LINK_DOMAIN} {INVITE} {ACCEPT} {JOIN} {LEAVE} {REMOVE} {GROUP_PRIVACY} {GROUP_NAME} {GROUP_DESCRIPTION}",
|
||||
)
|
||||
# Migrations
|
||||
MOVE = "MOVE"
|
||||
|
||||
|
||||
class Notification(BookWyrmModel):
|
||||
"""a notification object"""
|
||||
|
||||
user = models.ForeignKey("User", on_delete=models.CASCADE)
|
||||
read = models.BooleanField(default=False)
|
||||
|
@ -61,11 +73,15 @@ class Notification(BookWyrmModel):
|
|||
)
|
||||
related_status = models.ForeignKey("Status", on_delete=models.CASCADE, null=True)
|
||||
related_import = models.ForeignKey("ImportJob", on_delete=models.CASCADE, null=True)
|
||||
related_user_export = models.ForeignKey(
|
||||
"BookwyrmExportJob", on_delete=models.CASCADE, null=True
|
||||
)
|
||||
related_list_items = models.ManyToManyField(
|
||||
"ListItem", symmetrical=False, related_name="notifications"
|
||||
)
|
||||
related_reports = models.ManyToManyField("Report", symmetrical=False)
|
||||
related_link_domains = models.ManyToManyField("LinkDomain", symmetrical=False)
|
||||
related_reports = models.ManyToManyField("Report")
|
||||
related_link_domains = models.ManyToManyField("LinkDomain")
|
||||
related_invite_requests = models.ManyToManyField("InviteRequest")
|
||||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
|
@ -90,11 +106,11 @@ class Notification(BookWyrmModel):
|
|||
user=user,
|
||||
related_users=related_user,
|
||||
related_list_items__book_list=list_item.book_list,
|
||||
notification_type=Notification.ADD,
|
||||
notification_type=NotificationType.ADD,
|
||||
).first()
|
||||
if not notification:
|
||||
notification = cls.objects.create(
|
||||
user=user, notification_type=Notification.ADD
|
||||
user=user, notification_type=NotificationType.ADD
|
||||
)
|
||||
notification.related_users.add(related_user)
|
||||
notification.related_list_items.add(list_item)
|
||||
|
@ -121,7 +137,7 @@ def notify_on_fav(sender, instance, *args, **kwargs):
|
|||
instance.status.user,
|
||||
instance.user,
|
||||
related_status=instance.status,
|
||||
notification_type=Notification.FAVORITE,
|
||||
notification_type=NotificationType.FAVORITE,
|
||||
)
|
||||
|
||||
|
||||
|
@ -135,7 +151,7 @@ def notify_on_unfav(sender, instance, *args, **kwargs):
|
|||
instance.status.user,
|
||||
instance.user,
|
||||
related_status=instance.status,
|
||||
notification_type=Notification.FAVORITE,
|
||||
notification_type=NotificationType.FAVORITE,
|
||||
)
|
||||
|
||||
|
||||
|
@ -160,7 +176,7 @@ def notify_user_on_mention(sender, instance, *args, **kwargs):
|
|||
instance.reply_parent.user,
|
||||
instance.user,
|
||||
related_status=instance,
|
||||
notification_type=Notification.REPLY,
|
||||
notification_type=NotificationType.REPLY,
|
||||
)
|
||||
|
||||
for mention_user in instance.mention_users.all():
|
||||
|
@ -172,7 +188,7 @@ def notify_user_on_mention(sender, instance, *args, **kwargs):
|
|||
Notification.notify(
|
||||
mention_user,
|
||||
instance.user,
|
||||
notification_type=Notification.MENTION,
|
||||
notification_type=NotificationType.MENTION,
|
||||
related_status=instance,
|
||||
)
|
||||
|
||||
|
@ -191,7 +207,7 @@ def notify_user_on_boost(sender, instance, *args, **kwargs):
|
|||
instance.boosted_status.user,
|
||||
instance.user,
|
||||
related_status=instance.boosted_status,
|
||||
notification_type=Notification.BOOST,
|
||||
notification_type=NotificationType.BOOST,
|
||||
)
|
||||
|
||||
|
||||
|
@ -203,7 +219,7 @@ def notify_user_on_unboost(sender, instance, *args, **kwargs):
|
|||
instance.boosted_status.user,
|
||||
instance.user,
|
||||
related_status=instance.boosted_status,
|
||||
notification_type=Notification.BOOST,
|
||||
notification_type=NotificationType.BOOST,
|
||||
)
|
||||
|
||||
|
||||
|
@ -218,11 +234,41 @@ def notify_user_on_import_complete(
|
|||
return
|
||||
Notification.objects.get_or_create(
|
||||
user=instance.user,
|
||||
notification_type=Notification.IMPORT,
|
||||
notification_type=NotificationType.IMPORT,
|
||||
related_import=instance,
|
||||
)
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=BookwyrmImportJob)
|
||||
# pylint: disable=unused-argument
|
||||
def notify_user_on_user_import_complete(
|
||||
sender, instance, *args, update_fields=None, **kwargs
|
||||
):
|
||||
"""we imported your user details! aren't you proud of us"""
|
||||
update_fields = update_fields or []
|
||||
if not instance.complete or "complete" not in update_fields:
|
||||
return
|
||||
Notification.objects.create(
|
||||
user=instance.user, notification_type=NotificationType.USER_IMPORT
|
||||
)
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=BookwyrmExportJob)
|
||||
# pylint: disable=unused-argument
|
||||
def notify_user_on_user_export_complete(
|
||||
sender, instance, *args, update_fields=None, **kwargs
|
||||
):
|
||||
"""we exported your user details! aren't you proud of us"""
|
||||
update_fields = update_fields or []
|
||||
if not instance.complete or "complete" not in update_fields:
|
||||
return
|
||||
Notification.objects.create(
|
||||
user=instance.user,
|
||||
notification_type=NotificationType.USER_EXPORT,
|
||||
related_user_export=instance,
|
||||
)
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=Report)
|
||||
@transaction.atomic
|
||||
# pylint: disable=unused-argument
|
||||
|
@ -233,11 +279,10 @@ def notify_admins_on_report(sender, instance, created, *args, **kwargs):
|
|||
return
|
||||
|
||||
# moderators and superusers should be notified
|
||||
admins = User.admins()
|
||||
for admin in admins:
|
||||
for admin in User.admins():
|
||||
notification, _ = Notification.objects.get_or_create(
|
||||
user=admin,
|
||||
notification_type=Notification.REPORT,
|
||||
notification_type=NotificationType.REPORT,
|
||||
read=False,
|
||||
)
|
||||
notification.related_reports.add(instance)
|
||||
|
@ -253,16 +298,33 @@ def notify_admins_on_link_domain(sender, instance, created, *args, **kwargs):
|
|||
return
|
||||
|
||||
# moderators and superusers should be notified
|
||||
admins = User.admins()
|
||||
for admin in admins:
|
||||
for admin in User.admins():
|
||||
notification, _ = Notification.objects.get_or_create(
|
||||
user=admin,
|
||||
notification_type=Notification.LINK_DOMAIN,
|
||||
notification_type=NotificationType.LINK_DOMAIN,
|
||||
read=False,
|
||||
)
|
||||
notification.related_link_domains.add(instance)
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=InviteRequest)
|
||||
@transaction.atomic
|
||||
# pylint: disable=unused-argument
|
||||
def notify_admins_on_invite_request(sender, instance, created, *args, **kwargs):
|
||||
"""need to handle a new invite request"""
|
||||
if not created:
|
||||
return
|
||||
|
||||
# moderators and superusers should be notified
|
||||
for admin in User.admins():
|
||||
notification, _ = Notification.objects.get_or_create(
|
||||
user=admin,
|
||||
notification_type=NotificationType.INVITE_REQUEST,
|
||||
read=False,
|
||||
)
|
||||
notification.related_invite_requests.add(instance)
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=GroupMemberInvitation)
|
||||
# pylint: disable=unused-argument
|
||||
def notify_user_on_group_invite(sender, instance, *args, **kwargs):
|
||||
|
@ -271,7 +333,7 @@ def notify_user_on_group_invite(sender, instance, *args, **kwargs):
|
|||
instance.user,
|
||||
instance.group.user,
|
||||
related_group=instance.group,
|
||||
notification_type=Notification.INVITE,
|
||||
notification_type=NotificationType.INVITE,
|
||||
)
|
||||
|
||||
|
||||
|
@ -309,11 +371,12 @@ def notify_user_on_follow(sender, instance, created, *args, **kwargs):
|
|||
notification = Notification.objects.filter(
|
||||
user=instance.user_object,
|
||||
related_users=instance.user_subject,
|
||||
notification_type=Notification.FOLLOW_REQUEST,
|
||||
notification_type=NotificationType.FOLLOW_REQUEST,
|
||||
).first()
|
||||
if not notification:
|
||||
notification = Notification.objects.create(
|
||||
user=instance.user_object, notification_type=Notification.FOLLOW_REQUEST
|
||||
user=instance.user_object,
|
||||
notification_type=NotificationType.FOLLOW_REQUEST,
|
||||
)
|
||||
notification.related_users.set([instance.user_subject])
|
||||
notification.read = False
|
||||
|
@ -323,6 +386,6 @@ def notify_user_on_follow(sender, instance, created, *args, **kwargs):
|
|||
Notification.notify(
|
||||
instance.user_object,
|
||||
instance.user_subject,
|
||||
notification_type=Notification.FOLLOW,
|
||||
notification_type=NotificationType.FOLLOW,
|
||||
read=False,
|
||||
)
|
||||
|
|
|
@ -65,6 +65,13 @@ class UserRelationship(BookWyrmModel):
|
|||
base_path = self.user_subject.remote_id
|
||||
return f"{base_path}#follows/{self.id}"
|
||||
|
||||
def get_accept_reject_id(self, status):
|
||||
"""get id for sending an accept or reject of a local user"""
|
||||
|
||||
base_path = self.user_object.remote_id
|
||||
status_id = self.id or 0
|
||||
return f"{base_path}#{status}/{status_id}"
|
||||
|
||||
|
||||
class UserFollows(ActivityMixin, UserRelationship):
|
||||
"""Following a user"""
|
||||
|
@ -105,6 +112,20 @@ class UserFollows(ActivityMixin, UserRelationship):
|
|||
)
|
||||
return obj
|
||||
|
||||
def reject(self):
|
||||
"""generate a Reject for this follow. This would normally happen
|
||||
when a user deletes a follow they previously accepted"""
|
||||
|
||||
if self.user_object.local:
|
||||
activity = activitypub.Reject(
|
||||
id=self.get_accept_reject_id(status="rejects"),
|
||||
actor=self.user_object.remote_id,
|
||||
object=self.to_activity(),
|
||||
).serialize()
|
||||
self.broadcast(activity, self.user_object)
|
||||
|
||||
self.delete()
|
||||
|
||||
|
||||
class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
||||
"""following a user requires manual or automatic confirmation"""
|
||||
|
@ -148,13 +169,6 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
|||
if not manually_approves:
|
||||
self.accept()
|
||||
|
||||
def get_accept_reject_id(self, status):
|
||||
"""get id for sending an accept or reject of a local user"""
|
||||
|
||||
base_path = self.user_object.remote_id
|
||||
status_id = self.id or 0
|
||||
return f"{base_path}#{status}/{status_id}"
|
||||
|
||||
def accept(self, broadcast_only=False):
|
||||
"""turn this request into the real deal"""
|
||||
user = self.user_object
|
||||
|
|
|
@ -96,6 +96,8 @@ class SiteSettings(SiteModel):
|
|||
imports_enabled = models.BooleanField(default=True)
|
||||
import_size_limit = models.IntegerField(default=0)
|
||||
import_limit_reset = models.IntegerField(default=0)
|
||||
user_exports_enabled = models.BooleanField(default=False)
|
||||
user_import_time_limit = models.IntegerField(default=48)
|
||||
|
||||
field_tracker = FieldTracker(fields=["name", "instance_tagline", "logo"])
|
||||
|
||||
|
@ -149,6 +151,7 @@ class Theme(SiteModel):
|
|||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
path = models.CharField(max_length=50, unique=True)
|
||||
loads = models.BooleanField(null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
# pylint: disable=invalid-str-returned
|
||||
|
|
|
@ -102,7 +102,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
if hasattr(self, "quotation"):
|
||||
self.quotation = None # pylint: disable=attribute-defined-outside-init
|
||||
self.deleted_date = timezone.now()
|
||||
self.save()
|
||||
self.save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def recipients(self):
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
""" database schema for user data """
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
from django.apps import apps
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.postgres.fields import ArrayField, CICharField
|
||||
from django.core.exceptions import PermissionDenied, ObjectDoesNotExist
|
||||
from django.dispatch import receiver
|
||||
from django.db import models, transaction
|
||||
from django.db import models, transaction, IntegrityError
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from model_utils import FieldTracker
|
||||
|
@ -53,6 +54,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
|
||||
username = fields.UsernameField()
|
||||
email = models.EmailField(unique=True, null=True)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
|
||||
key_pair = fields.OneToOneField(
|
||||
"KeyPair",
|
||||
|
@ -140,6 +142,19 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
theme = models.ForeignKey("Theme", null=True, blank=True, on_delete=models.SET_NULL)
|
||||
hide_follows = fields.BooleanField(default=False)
|
||||
|
||||
# migration fields
|
||||
|
||||
moved_to = fields.RemoteIdField(
|
||||
null=True, unique=False, activitypub_field="movedTo", deduplication_field=False
|
||||
)
|
||||
also_known_as = fields.ManyToManyField(
|
||||
"self",
|
||||
symmetrical=False,
|
||||
unique=False,
|
||||
activitypub_field="alsoKnownAs",
|
||||
deduplication_field=False,
|
||||
)
|
||||
|
||||
# options to turn features on and off
|
||||
show_goal = models.BooleanField(default=True)
|
||||
show_suggested_users = models.BooleanField(default=True)
|
||||
|
@ -314,6 +329,8 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
"schema": "http://schema.org#",
|
||||
"PropertyValue": "schema:PropertyValue",
|
||||
"value": "schema:value",
|
||||
"alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"},
|
||||
"movedTo": {"@id": "as:movedTo", "@type": "@id"},
|
||||
},
|
||||
]
|
||||
return activity_object
|
||||
|
@ -379,9 +396,44 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
"""We don't actually delete the database entry"""
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
self.is_active = False
|
||||
self.avatar = ""
|
||||
self.allow_reactivation = False
|
||||
self.is_deleted = True
|
||||
|
||||
self.erase_user_data()
|
||||
self.erase_user_statuses()
|
||||
|
||||
# skip the logic in this class's save()
|
||||
super().save(*args, **kwargs)
|
||||
super().save(
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def erase_user_data(self):
|
||||
"""Wipe a user's custom data"""
|
||||
if not self.is_deleted:
|
||||
raise IntegrityError(
|
||||
"Trying to erase user data on user that is not deleted"
|
||||
)
|
||||
|
||||
# mangle email address
|
||||
self.email = f"{uuid4()}@deleted.user"
|
||||
|
||||
# erase data fields
|
||||
self.avatar = ""
|
||||
self.preview_image = ""
|
||||
self.summary = None
|
||||
self.name = None
|
||||
self.favorites.set([])
|
||||
|
||||
def erase_user_statuses(self, broadcast=True):
|
||||
"""Wipe the data on all the user's statuses"""
|
||||
if not self.is_deleted:
|
||||
raise IntegrityError(
|
||||
"Trying to erase user data on user that is not deleted"
|
||||
)
|
||||
|
||||
for status in self.status_set.all():
|
||||
status.delete(broadcast=broadcast)
|
||||
|
||||
def deactivate(self):
|
||||
"""Disable the user but allow them to reactivate"""
|
||||
|
@ -471,6 +523,20 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
|
|||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@app.task(queue=MISC)
|
||||
def erase_user_data(user_id):
|
||||
"""Erase any custom data about this user asynchronously
|
||||
This is for deleted historical user data that pre-dates data
|
||||
being cleared automatically"""
|
||||
user = User.objects.get(id=user_id)
|
||||
user.erase_user_data()
|
||||
user.save(
|
||||
broadcast=False,
|
||||
update_fields=["email", "avatar", "preview_image", "summary", "name"],
|
||||
)
|
||||
user.erase_user_statuses(broadcast=False)
|
||||
|
||||
|
||||
@app.task(queue=MISC)
|
||||
def set_remote_server(user_id, allow_external_connections=False):
|
||||
"""figure out the user's remote server in the background"""
|
||||
|
|
|
@ -34,7 +34,7 @@ DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
|
|||
# is implemented (see bookwyrm-social#2278, bookwyrm-social#3082).
|
||||
SESSION_COOKIE_AGE = env.int("SESSION_COOKIE_AGE", 3600 * 24 * 30) # 1 month
|
||||
|
||||
JS_CACHE = "ac315a3b"
|
||||
JS_CACHE = "8a89cad7"
|
||||
|
||||
# email
|
||||
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
|
||||
|
@ -102,6 +102,7 @@ INSTALLED_APPS = [
|
|||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django.contrib.humanize",
|
||||
"file_resubmit",
|
||||
"sass_processor",
|
||||
"bookwyrm",
|
||||
"celery",
|
||||
|
@ -122,6 +123,7 @@ MIDDLEWARE = [
|
|||
"bookwyrm.middleware.IPBlocklistMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"bookwyrm.middleware.FileTooBig",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "bookwyrm.urls"
|
||||
|
@ -245,7 +247,11 @@ if env.bool("USE_DUMMY_CACHE", False):
|
|||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.dummy.DummyCache",
|
||||
}
|
||||
},
|
||||
"file_resubmit": {
|
||||
"BACKEND": "django.core.cache.backends.dummy.DummyCache",
|
||||
"LOCATION": "/tmp/file_resubmit_tests/",
|
||||
},
|
||||
}
|
||||
else:
|
||||
CACHES = {
|
||||
|
@ -255,7 +261,11 @@ else:
|
|||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
},
|
||||
}
|
||||
},
|
||||
"file_resubmit": {
|
||||
"BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
|
||||
"LOCATION": "/tmp/file_resubmit/",
|
||||
},
|
||||
}
|
||||
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
||||
|
@ -321,6 +331,7 @@ LANGUAGES = [
|
|||
("pt-pt", _("Português Europeu (European Portuguese)")),
|
||||
("ro-ro", _("Română (Romanian)")),
|
||||
("sv-se", _("Svenska (Swedish)")),
|
||||
("uk-ua", _("Українська (Ukrainian)")),
|
||||
("zh-hans", _("简体中文 (Simplified Chinese)")),
|
||||
("zh-hant", _("繁體中文 (Traditional Chinese)")),
|
||||
]
|
||||
|
@ -369,9 +380,9 @@ if USE_S3:
|
|||
AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID")
|
||||
AWS_SECRET_ACCESS_KEY = env("AWS_SECRET_ACCESS_KEY")
|
||||
AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME")
|
||||
AWS_S3_CUSTOM_DOMAIN = env("AWS_S3_CUSTOM_DOMAIN")
|
||||
AWS_S3_CUSTOM_DOMAIN = env("AWS_S3_CUSTOM_DOMAIN", None)
|
||||
AWS_S3_REGION_NAME = env("AWS_S3_REGION_NAME", "")
|
||||
AWS_S3_ENDPOINT_URL = env("AWS_S3_ENDPOINT_URL")
|
||||
AWS_S3_ENDPOINT_URL = env("AWS_S3_ENDPOINT_URL", None)
|
||||
AWS_DEFAULT_ACL = "public-read"
|
||||
AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"}
|
||||
# S3 Static settings
|
||||
|
@ -434,3 +445,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))
|
||||
|
|
|
@ -8,6 +8,7 @@ from opentelemetry import trace
|
|||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.redis_store import RedisStore, r
|
||||
from bookwyrm.settings import INSTANCE_ACTOR_USERNAME
|
||||
from bookwyrm.tasks import app, SUGGESTED_USERS
|
||||
from bookwyrm.telemetry import open_telemetry
|
||||
|
||||
|
@ -98,9 +99,15 @@ class SuggestedUsers(RedisStore):
|
|||
for (pk, score) in values
|
||||
]
|
||||
# annotate users with mutuals and shared book counts
|
||||
users = models.User.objects.filter(
|
||||
is_active=True, bookwyrm_user=True, id__in=[pk for (pk, _) in values]
|
||||
).annotate(mutuals=Case(*annotations, output_field=IntegerField(), default=0))
|
||||
users = (
|
||||
models.User.objects.filter(
|
||||
is_active=True, bookwyrm_user=True, id__in=[pk for (pk, _) in values]
|
||||
)
|
||||
.annotate(
|
||||
mutuals=Case(*annotations, output_field=IntegerField(), default=0)
|
||||
)
|
||||
.exclude(localname=INSTANCE_ACTOR_USERNAME)
|
||||
)
|
||||
if local:
|
||||
users = users.filter(local=True)
|
||||
return users.order_by("-mutuals")[:5]
|
||||
|
|
20
bookwyrm/templates/403.html
Normal file
20
bookwyrm/templates/403.html
Normal file
|
@ -0,0 +1,20 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load utilities %}
|
||||
|
||||
{% block title %}{% trans "Oh no!" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="block">
|
||||
<h1 class="title">{% trans "Permission Denied" %}</h1>
|
||||
<p class="content">
|
||||
{% blocktrans trimmed with level=request.user|get_user_permission %}
|
||||
You do not have permission to view this page or perform this action. Your user permission level is <code>{{ level }}</code>.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p class="content">{% trans "If you think you should have access, please speak to your BookWyrm server administrator." %}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
16
bookwyrm/templates/413.html
Normal file
16
bookwyrm/templates/413.html
Normal file
|
@ -0,0 +1,16 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "File too large" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="block">
|
||||
<h1 class="title">{% trans "File too large" %}</h1>
|
||||
<p class="content">{% trans "The file you are uploading is too large." %}</p>
|
||||
<p class="content">
|
||||
{% blocktrans %}
|
||||
You you can try using a smaller file, or ask your BookWyrm server administrator to increase the <code>DATA_UPLOAD_MAX_MEMORY_SIZE</code> setting.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -31,10 +31,10 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="columns is-multiline">
|
||||
{% if superlatives.top_rated %}
|
||||
{% with book=superlatives.top_rated.default_edition rating=superlatives.top_rated.rating %}
|
||||
<div class="column is-one-third is-flex">
|
||||
<div class="column is-half-tablet is-one-third-desktop is-flex-grow-1 is-flex">
|
||||
<div class="media notification is-clipped">
|
||||
<div class="media-left">
|
||||
<a href="{{ book.local_path }}">
|
||||
|
@ -53,7 +53,7 @@
|
|||
|
||||
{% if superlatives.wanted %}
|
||||
{% with book=superlatives.wanted.default_edition %}
|
||||
<div class="column is-one-third is-flex">
|
||||
<div class="column is-half-tablet is-one-third-desktop is-flex-grow-1 is-flex">
|
||||
<div class="media notification is-clipped">
|
||||
<div class="media-left">
|
||||
<a href="{{ book.local_path }}">
|
||||
|
@ -72,7 +72,7 @@
|
|||
|
||||
{% if superlatives.controversial %}
|
||||
{% with book=superlatives.controversial.default_edition %}
|
||||
<div class="column is-one-third is-flex">
|
||||
<div class="column is-half-tablet is-one-third-desktop is-flex-grow-1 is-flex">
|
||||
<div class="media notification is-clipped">
|
||||
<div class="media-left">
|
||||
<a href="{{ book.local_path }}">
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
</div>
|
||||
<div class="column">
|
||||
<label class="label" for="id_cover_url">
|
||||
{% trans "Load cover from url:" %}
|
||||
{% trans "Load cover from URL:" %}
|
||||
</label>
|
||||
<input class="input" name="cover-url" id="id_cover_url">
|
||||
</div>
|
||||
|
|
|
@ -247,7 +247,7 @@
|
|||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="id_cover_url">
|
||||
{% trans "Load cover from url:" %}
|
||||
{% trans "Load cover from URL:" %}
|
||||
</label>
|
||||
<input class="input" name="cover-url" id="id_cover_url" type="url" value="{{ cover_url|default:'' }}" aria-describedby="desc_cover">
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{% spaceless %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load humanize %}
|
||||
{% load date_ext %}
|
||||
|
||||
{% firstof book.physical_format_detail book.get_physical_format_display as format %}
|
||||
{% firstof book.physical_format book.physical_format_detail as format_property %}
|
||||
|
@ -57,7 +57,7 @@
|
|||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% with date=book.published_date|default:book.first_published_date|naturalday publisher=book.publishers|join:', ' %}
|
||||
{% with date=book.published_date|default:book.first_published_date|naturalday_partial publisher=book.publishers|join:', ' %}
|
||||
{% if book.published_date and publisher %}
|
||||
{% blocktrans %}Published {{ date }} by {{ publisher }}.{% endblocktrans %}
|
||||
{% elif publisher %}
|
||||
|
|
|
@ -99,7 +99,7 @@ homeTour.addSteps([
|
|||
],
|
||||
},
|
||||
{
|
||||
text: "{% trans 'Use the <strong>Feed</strong>, <strong>Lists</strong> and <strong>Discover</strong> links to discover the latest news from your feed, lists of books by topic, and the latest happenings on this Bookwyrm server!' %}",
|
||||
text: "{% trans 'Use the <strong>Lists</strong>, <strong>Discover</strong>, and <strong>Your Books</strong> links to discover reading suggestions and the latest happenings on this server, or to see your catalogued books!' %}",
|
||||
title: "{% trans 'Navigation Bar' %}",
|
||||
attachTo: {
|
||||
element: checkResponsiveState('#tour-navbar-start'),
|
||||
|
@ -197,7 +197,7 @@ homeTour.addSteps([
|
|||
],
|
||||
},
|
||||
{
|
||||
text: `{% trans "Your profile, books, direct messages, and settings can be accessed by clicking on your name in the menu here." %} <p class="notification is-warning is-light mt-3">{% trans "Try selecting <strong>Profile</strong> from the drop down menu to continue the tour." %}</p>`,
|
||||
text: `{% trans "Your profile, user directory, direct messages, and settings can be accessed by clicking on your name in the menu here." %} <p class="notification is-warning is-light mt-3">{% trans "Try selecting <strong>Profile</strong> from the drop down menu to continue the tour." %}</p>`,
|
||||
title: "{% trans 'Profile and settings menu' %}",
|
||||
attachTo: {
|
||||
element: checkResponsiveState('#navbar-dropdown'),
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% extends 'preferences/layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load humanize %}
|
||||
|
||||
{% block title %}{% trans "Import Books" %}{% endblock %}
|
||||
{% block title %}{% trans "Import Book List" %}{% endblock %}
|
||||
{% block header %}{% trans "Import Book List" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% block panel %}
|
||||
<div class="block">
|
||||
<h1 class="title">{% trans "Import Books" %}</h1>
|
||||
|
||||
{% if invalid %}
|
||||
<div class="notification is-danger">
|
||||
{% trans "Not a valid CSV file" %}
|
||||
|
@ -21,7 +20,7 @@
|
|||
{% blocktrans trimmed count days=import_limit_reset with display_size=import_size_limit|intcomma %}
|
||||
Currently, you are allowed to import {{ display_size }} books every {{ import_limit_reset }} day.
|
||||
{% plural %}
|
||||
Currently, you are allowed to import {{ import_size_limit }} books every {{ import_limit_reset }} days.
|
||||
Currently, you are allowed to import {{ display_size }} books every {{ import_limit_reset }} days.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p>{% blocktrans with display_left=allowed_imports|intcomma %}You have {{ display_left }} left.{% endblocktrans %}</p>
|
||||
|
|
222
bookwyrm/templates/import/import_user.html
Normal file
222
bookwyrm/templates/import/import_user.html
Normal file
|
@ -0,0 +1,222 @@
|
|||
{% extends 'preferences/layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load humanize %}
|
||||
|
||||
{% block title %}{% trans "Import BookWyrm Account" %}{% endblock %}
|
||||
{% block header %}{% trans "Import BookWyrm Account" %}{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
<div class="block">
|
||||
|
||||
{% if invalid %}
|
||||
<div class="notification is-danger">
|
||||
{% trans "Not a valid import file" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="notification is-warning">
|
||||
{% spaceless %}
|
||||
{% trans "If you wish to migrate any statuses (comments, reviews, or quotes) you must either set this account as an <strong>alias</strong> of the one you are migrating from, or <strong>move</strong> that account to this one, before you import your user data." %}
|
||||
{% endspaceless %}
|
||||
</p>
|
||||
{% if not site.imports_enabled %}
|
||||
<div class="box notification has-text-centered is-warning m-6 content">
|
||||
<p class="mt-5">
|
||||
<span class="icon icon-warning is-size-2" aria-hidden="true"></span>
|
||||
</p>
|
||||
<p class="mb-5">
|
||||
{% trans "Imports are temporarily disabled; thank you for your patience." %}
|
||||
</p>
|
||||
</div>
|
||||
{% elif next_available %}
|
||||
<div class="notification is-warning">
|
||||
<p>{% blocktrans %}Currently you are allowed to import one user every {{ user_import_hours }} hours.{% endblocktrans %}</p>
|
||||
<p>{% blocktrans %}You will next be able to import a user file at {{ next_available }}{% endblocktrans %}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<form class="box content" name="import-user" action="/user-import" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="block">
|
||||
<div class="notification">
|
||||
<h2 class="is-size-5">{% trans "Step 1:" %}</h2>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
Select an export file generated from another BookWyrm account. The file format should be <code>.tar.gz</code>.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="block m-5">
|
||||
<label class="label" for="id_archive_file">{% trans "Data file:" %}</label>
|
||||
{{ import_form.archive_file }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr aria-hidden="true">
|
||||
|
||||
<div class="block">
|
||||
<div class="notification">
|
||||
<h2 class="is-size-5">{% trans "Step 2:" %}</h2>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
Deselect any checkboxes for data you do not wish to include in your import.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p class="block">Unless specified below, importing will not delete any data. Imported data will be <strong>added if it does not already exist</strong>. For example, if you have an existing list with the same name as an imported list, the existing list settings will not change, any new list items will be added, and no existing list items will be deleted.</p>
|
||||
</div>
|
||||
<div class="block m-5 columns">
|
||||
<div class="column is-half">
|
||||
<div class="field">
|
||||
<label class="label mb-0">
|
||||
<input type="checkbox" name="include_user_profile" checked aria-describedby="desc_include_user_profile">
|
||||
{% trans "User profile" %}
|
||||
</label>
|
||||
<p id="desc_include_user_profile">
|
||||
{% trans "Overwrites display name, summary, and avatar" %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label mb-0">
|
||||
<input type="checkbox" name="include_user_settings" checked aria-describedby="desc_include_user_settings">
|
||||
{% trans "User settings" %}
|
||||
</label>
|
||||
<div id="desc_include_user_settings">
|
||||
{% trans "Overwrites:" %}
|
||||
<ul class="mt-0">
|
||||
<li>
|
||||
{% trans "Whether manual approval is required for other users to follow your account" %}
|
||||
</li>
|
||||
<li>
|
||||
{% trans "Whether following/followers are shown on your profile" %}
|
||||
</li>
|
||||
<li>
|
||||
{% trans "Whether your reading goal is shown on your profile" %}
|
||||
</li>
|
||||
<li>
|
||||
{% trans "Whether you see user follow suggestions" %}
|
||||
</li>
|
||||
<li>
|
||||
{% trans "Whether your account is suggested to others" %}
|
||||
</li>
|
||||
<li>
|
||||
{% trans "Your timezone" %}
|
||||
</li>
|
||||
<li>
|
||||
{% trans "Your default post privacy setting" %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">
|
||||
<input type="checkbox" name="include_follows" checked>
|
||||
{% trans "Followers and following" %}
|
||||
</label>
|
||||
</div>
|
||||
<label class="label">
|
||||
<input type="checkbox" name="include_blocks" checked> {% trans "User blocks" %}
|
||||
</label>
|
||||
</div>
|
||||
<div class="column is-half">
|
||||
<div class="field">
|
||||
<label class="label mb-0">
|
||||
<input type="checkbox" name="include_goals" checked aria-describedby="desc_include_goals">
|
||||
{% trans "Reading goals" %}
|
||||
</label>
|
||||
<p id="desc_include_goals">
|
||||
{% trans "Overwrites reading goals for all years listed in the import file" %}
|
||||
</p>
|
||||
</div>
|
||||
<label class="label">
|
||||
<input type="checkbox" name="include_shelves" checked> {% trans "Shelves" %}
|
||||
</label>
|
||||
<label class="label">
|
||||
<input type="checkbox" name="include_readthroughs" checked> {% trans "Reading history" %}
|
||||
</label>
|
||||
<label class="label">
|
||||
<input type="checkbox" name="include_reviews" checked> {% trans "Book reviews" %}
|
||||
</label>
|
||||
<label class="label">
|
||||
<input type="checkbox" name="include_quotations" checked> {% trans "Quotations" %}
|
||||
</label>
|
||||
<label class="label">
|
||||
<input type="checkbox" name="include_comments" checked> {% trans "Comments about books" %}
|
||||
</label>
|
||||
<label class="label">
|
||||
<input type="checkbox" name="include_lists" checked> {% trans "Book lists" %}
|
||||
</label>
|
||||
<label class="label">
|
||||
<input type="checkbox" name="include_saved_lists" checked> {% trans "Saved lists" %}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not import_limit_reset and not import_size_limit or allowed_imports > 0 %}
|
||||
<button class="button is-primary" type="submit">{% trans "Import" %}</button>
|
||||
{% else %}
|
||||
<button class="button is-primary is-disabled" type="submit">{% trans "Import" %}</button>
|
||||
<p>{% trans "You've reached the import limit." %}</p>
|
||||
{% endif%}
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="content block">
|
||||
<h2 class="title">{% trans "Recent Imports" %}</h2>
|
||||
<div class="table-container">
|
||||
<table class="table is-striped is-fullwidth">
|
||||
<tr>
|
||||
<th>
|
||||
{% trans "Date Created" %}
|
||||
</th>
|
||||
<th>
|
||||
{% trans "Last Updated" %}
|
||||
</th>
|
||||
<th>
|
||||
{% trans "Status" %}
|
||||
</th>
|
||||
</tr>
|
||||
{% if not jobs %}
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<em>{% trans "No recent imports" %}</em>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% for job in jobs %}
|
||||
<tr>
|
||||
<td>
|
||||
<p>{{ job.created_date }}</p>
|
||||
</td>
|
||||
<td>{{ job.updated_date }}</td>
|
||||
<td>
|
||||
<span
|
||||
{% if job.status == "stopped" or job.status == "failed" %}
|
||||
class="tag is-danger"
|
||||
{% elif job.status == "pending" %}
|
||||
class="tag is-warning"
|
||||
{% elif job.complete %}
|
||||
class="tag"
|
||||
{% else %}
|
||||
class="tag is-success"
|
||||
{% endif %}
|
||||
>
|
||||
{% if job.status %}
|
||||
{{ job.status }}
|
||||
{{ job.status_display }}
|
||||
{% elif job.complete %}
|
||||
{% trans "Complete" %}
|
||||
{% else %}
|
||||
{% trans "Active" %}
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% include 'snippets/pagination.html' with page=jobs path=request.path %}
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -27,6 +27,7 @@
|
|||
<nav class="navbar" aria-label="main navigation">
|
||||
<div class="container">
|
||||
{% with notification_count=request.user.unread_notification_count has_unread_mentions=request.user.has_unread_mentions %}
|
||||
{% if not request.user.moved_to %}
|
||||
<div class="navbar-brand">
|
||||
<a class="navbar-item" href="/">
|
||||
<img class="image logo" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="{% blocktrans with site_name=site.name %}{{ site_name }} home page{% endblocktrans %}" loading="lazy" decoding="async">
|
||||
|
@ -34,7 +35,7 @@
|
|||
<form class="navbar-item column is-align-items-start pt-5" action="{% url 'search' %}">
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
{% if user.is_authenticated %}
|
||||
{% if request.user.is_authenticated %}
|
||||
{% trans "Search for a book, user, or list" as search_placeholder %}
|
||||
{% else %}
|
||||
{% trans "Search for a book" as search_placeholder %}
|
||||
|
@ -80,19 +81,18 @@
|
|||
</strong>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="navbar-menu" id="main_nav">
|
||||
<div class="navbar-start" id="tour-navbar-start">
|
||||
{% if request.user.is_authenticated %}
|
||||
<a href="/#feed" class="navbar-item mt-3 py-0">
|
||||
{% trans "Feed" %}
|
||||
</a>
|
||||
<a href="{% url 'lists' %}" class="navbar-item mt-3 py-0">
|
||||
{% trans "Lists" %}
|
||||
</a>
|
||||
<a href="{% url 'discover' %}" class="navbar-item mt-3 py-0">
|
||||
{% trans "Discover" %}
|
||||
</a>
|
||||
<a href="{% url 'user-shelves' request.user.localname %}" class="navbar-item mt-3 py-0">
|
||||
{% trans "Your Books" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
@ -157,6 +157,13 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="navbar-brand">
|
||||
<a class="navbar-item" href="/">
|
||||
<img class="image logo" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="{% blocktrans with site_name=site.name %}{{ site_name }} home page{% endblocktrans %}" loading="lazy" decoding="async">
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</nav>
|
||||
|
@ -173,11 +180,15 @@
|
|||
|
||||
<main class="section is-flex-grow-1">
|
||||
<div class="container">
|
||||
{# almost every view needs to know the user shelves #}
|
||||
{% with request.user.shelf_set.all as user_shelves %}
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
{% endwith %}
|
||||
{% if request.user.moved_to %}
|
||||
{% include "moved.html" %}
|
||||
{% else %}
|
||||
{# almost every view needs to know the user shelves #}
|
||||
{% with request.user.shelf_set.all as user_shelves %}
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
|
52
bookwyrm/templates/moved.html
Normal file
52
bookwyrm/templates/moved.html
Normal file
|
@ -0,0 +1,52 @@
|
|||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load utilities %}
|
||||
|
||||
<div class="container my-6">
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="media">
|
||||
<div class="media-left">
|
||||
<figure class="image is-48x48">
|
||||
<img src="{% if request.user.avatar %}{% get_media_prefix %}{{ request.user.avatar }}{% else %}{% static "images/default_avi.jpg" %}{% endif %}"
|
||||
{% if ariaHide %}aria-hidden="true"{% endif %}
|
||||
alt="{{ request.user.alt_text }}"
|
||||
loading="lazy"
|
||||
decoding="async">
|
||||
</figure>
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<p class="title is-4">{{ request.user.display_name }}</p>
|
||||
<p class="subtitle is-6"><s>{{request.user.username}}</s></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notification is-warning">
|
||||
<p>
|
||||
{% id_to_username request.user.moved_to as username %}
|
||||
{% blocktrans trimmed with moved_to=user.moved_to %}
|
||||
<strong>You have moved your account</strong> to <a href="{{ moved_to }}">{{ username }}</a>
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
{% trans "You can undo the move to restore full functionality, but some followers may have already unfollowed this account." %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns is-justify-content-center">
|
||||
<div class="column is-one-quarter">
|
||||
<div class="level">
|
||||
<form class="level-left" name="remove-alias" action="{% url 'prefs-unmove' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="remote_id" id="remote_id" value="{{user.moved_to}}">
|
||||
<button type="submit" class="button is-medium is-danger">{% trans "Undo move" %}</button>
|
||||
</form>
|
||||
<form class="level-right" name="logout" action="{% url 'logout' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="button is-medium is-primary">{% trans 'Log out' %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -13,12 +13,18 @@
|
|||
{% 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' %}
|
||||
{% include 'notifications/items/report.html' %}
|
||||
{% elif notification.notification_type == 'LINK_DOMAIN' %}
|
||||
{% include 'notifications/items/link_domain.html' %}
|
||||
{% elif notification.notification_type == 'INVITE_REQUEST' %}
|
||||
{% include 'notifications/items/invite_request.html' %}
|
||||
{% elif notification.notification_type == 'INVITE' %}
|
||||
{% include 'notifications/items/invite.html' %}
|
||||
{% elif notification.notification_type == 'ACCEPT' %}
|
||||
|
@ -35,4 +41,6 @@
|
|||
{% include 'notifications/items/update.html' %}
|
||||
{% elif notification.notification_type == 'GROUP_DESCRIPTION' %}
|
||||
{% include 'notifications/items/update.html' %}
|
||||
{% elif notification.notification_type == 'MOVE' %}
|
||||
{% include 'notifications/items/move_user.html' %}
|
||||
{% endif %}
|
||||
|
|
20
bookwyrm/templates/notifications/items/invite_request.html
Normal file
20
bookwyrm/templates/notifications/items/invite_request.html
Normal file
|
@ -0,0 +1,20 @@
|
|||
{% extends 'notifications/items/layout.html' %}
|
||||
{% load humanize %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block primary_link %}{% spaceless %}
|
||||
{% url 'settings-invite-requests' %}
|
||||
{% endspaceless %}{% endblock %}
|
||||
|
||||
{% block icon %}
|
||||
<span class="icon icon-envelope"></span>
|
||||
{% endblock %}
|
||||
|
||||
{% block description %}
|
||||
{% url 'settings-invite-requests' as path %}
|
||||
{% blocktrans trimmed count counter=notification.related_invite_requests.count with display_count=notification.related_invite_requests.count|intcomma %}
|
||||
New <a href="{{ path }}">invite request</a> awaiting response
|
||||
{% plural %}
|
||||
{{ display_count }} new <a href="{{ path }}">invite requests</a> awaiting response
|
||||
{% endblocktrans %}
|
||||
{% endblock %}
|
|
@ -39,6 +39,8 @@
|
|||
|
||||
{% with related_user=related_users.0.display_name %}
|
||||
{% with related_user_link=related_users.0.local_path %}
|
||||
{% with related_user_moved_to=related_users.0.moved_to %}
|
||||
{% with related_user_username=related_users.0.username %}
|
||||
{% with second_user=related_users.1.display_name %}
|
||||
{% with second_user_link=related_users.1.local_path %}
|
||||
{% with other_user_count=related_user_count|add:"-1" %}
|
||||
|
@ -50,6 +52,8 @@
|
|||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
{% if related_status %}
|
||||
|
|
29
bookwyrm/templates/notifications/items/move_user.html
Normal file
29
bookwyrm/templates/notifications/items/move_user.html
Normal file
|
@ -0,0 +1,29 @@
|
|||
{% extends 'notifications/items/layout.html' %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load utilities %}
|
||||
{% load user_page_tags %}
|
||||
|
||||
{% block primary_link %}{% spaceless %}
|
||||
{{ notification.related_object.local_path }}
|
||||
{% endspaceless %}{% endblock %}
|
||||
|
||||
{% block icon %}
|
||||
<span class="icon icon-local"></span>
|
||||
{% endblock %}
|
||||
|
||||
{% block description %}
|
||||
{% if related_user_moved_to %}
|
||||
{% id_to_username related_user_moved_to as username %}
|
||||
{% blocktrans trimmed %}
|
||||
{{ related_user }} has moved to <a href="{{ related_user_moved_to }}">{{ username }}</a>
|
||||
{% endblocktrans %}
|
||||
<div class="row shrink my-2">
|
||||
{% include 'snippets/move_user_buttons.html' with group=notification.related_group %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% blocktrans trimmed %}
|
||||
{{ related_user }} has undone their move
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
15
bookwyrm/templates/notifications/items/user_export.html
Normal file
15
bookwyrm/templates/notifications/items/user_export.html
Normal file
|
@ -0,0 +1,15 @@
|
|||
{% extends 'notifications/items/layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block primary_link %}{% spaceless %}
|
||||
{% url 'prefs-user-export' %}
|
||||
{% endspaceless %}{% endblock %}
|
||||
|
||||
{% block icon %}
|
||||
<span class="icon icon-list"></span>
|
||||
{% endblock %}
|
||||
|
||||
{% block description %}
|
||||
{% url 'prefs-user-export' as url %}
|
||||
{% blocktrans %}Your <a href="{{ url }}">user export</a> is ready.{% endblocktrans %}
|
||||
{% endblock %}
|
16
bookwyrm/templates/notifications/items/user_import.html
Normal file
16
bookwyrm/templates/notifications/items/user_import.html
Normal file
|
@ -0,0 +1,16 @@
|
|||
{% extends 'notifications/items/layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block primary_link %}{% spaceless %}
|
||||
{% url 'user-import' %}
|
||||
{% endspaceless %}{% endblock %}
|
||||
|
||||
{% block icon %}
|
||||
<span class="icon icon-list"></span>
|
||||
{% endblock %}
|
||||
|
||||
{% block description %}
|
||||
{% url 'user-import' as import_url %}
|
||||
{% blocktrans %}Your <a href="{{ import_url }}">user import</a> is complete.{% endblocktrans %}
|
||||
|
||||
{% endblock %}
|
59
bookwyrm/templates/preferences/alias_user.html
Normal file
59
bookwyrm/templates/preferences/alias_user.html
Normal file
|
@ -0,0 +1,59 @@
|
|||
{% extends 'preferences/layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Move Account" %}{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
{% trans "Create Alias" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
<div class="block">
|
||||
<h2 class="title is-4">{% trans "Add another account as an alias" %}</h2>
|
||||
<div class="box">
|
||||
<div class="notification is-info is-light">
|
||||
<p>
|
||||
{% trans "Marking another account as an alias is required if you want to move that account to this one." %}
|
||||
</p>
|
||||
<p>
|
||||
{% trans "This is a reversable action and will not change the functionality of this account." %}
|
||||
</p>
|
||||
</div>
|
||||
<form name="alias-user" action="{% url 'prefs-alias' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label class="label" for="id_target">{% trans "Enter the username for the account you want to add as an alias e.g. <em>user@example.com </em>:" %}</label>
|
||||
<input class="input {% if form.username.errors %}is-danger{% endif %}" type="text" name="username" id="id_username" required aria-describedby="desc_username">
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.username.errors id="desc_username" %}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="id_password">{% trans "Confirm your password:" %}</label>
|
||||
<input class="input {% if form.password.errors %}is-danger{% endif %}" type="password" name="password" id="id_password" required aria-describedby="desc_password">
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.password.errors id="desc_password" %}
|
||||
</div>
|
||||
<button type="submit" class="button is-success">{% trans "Create Alias" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% if user.also_known_as.all.0 %}
|
||||
<div class="box">
|
||||
<h2 class="title is-4">{% trans "Aliases" %}</h2>
|
||||
<div class="table-container block">
|
||||
<table class="table is-striped is-fullwidth">
|
||||
{% for alias in user.also_known_as.all %}
|
||||
<tr>
|
||||
<td>{{ alias.username }}</td>
|
||||
<td>
|
||||
<form name="remove-alias" action="{% url 'prefs-remove-alias' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="alias" id="alias" value="{{ alias.id }}">
|
||||
<button type="submit" class="button is-info">{% trans "Remove alias" %}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
142
bookwyrm/templates/preferences/export-user.html
Normal file
142
bookwyrm/templates/preferences/export-user.html
Normal file
|
@ -0,0 +1,142 @@
|
|||
{% extends 'preferences/layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load utilities %}
|
||||
|
||||
{% block title %}{% trans "Export BookWyrm Account" %}{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
{% trans "Export BookWyrm Account" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
<div class="block content">
|
||||
<div class="block content">
|
||||
<p> {% trans "You can create an export file here. This will allow you to migrate your data to another BookWyrm account." %}</p>
|
||||
</div>
|
||||
<div class="block mx-5 columns">
|
||||
{% blocktrans trimmed %}
|
||||
<div class="column is-half">
|
||||
<h2 class="is-size-5">Your file will include:</h2>
|
||||
<ul>
|
||||
<li>User profile</li>
|
||||
<li>Most user settings</li>
|
||||
<li>Reading goals</li>
|
||||
<li>Shelves</li>
|
||||
<li>Reading history</li>
|
||||
<li>Book reviews</li>
|
||||
<li>Statuses</li>
|
||||
<li>Your own lists and saved lists</li>
|
||||
<li>Which users you follow and block</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="column is-half">
|
||||
<h2 class="is-size-5">Your file will not include:</h2>
|
||||
<ul>
|
||||
<li>Direct messages</li>
|
||||
<li>Replies to your statuses</li>
|
||||
<li>Groups</li>
|
||||
<li>Favorites</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
<p class="block">{% trans "In your new BookWyrm account can choose what to import: you will not have to import everything that is exported." %}</p>
|
||||
<p class="notification is-warning">
|
||||
{% spaceless %}
|
||||
{% trans "If you wish to migrate any statuses (comments, reviews, or quotes) you must either set the account you are moving to as an <strong>alias</strong> of this one, or <strong>move</strong> this account to the new account, before you import your user data." %}
|
||||
{% endspaceless %}
|
||||
</p>
|
||||
{% if not site.user_exports_enabled %}
|
||||
<p class="notification is-danger">
|
||||
{% trans "New user exports are currently disabled." %}
|
||||
</p>
|
||||
{% elif next_available %}
|
||||
<p class="notification is-warning">
|
||||
{% blocktrans trimmed %}
|
||||
You will be able to create a new export file at {{ next_available }}
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% else %}
|
||||
<form name="export" method="POST" href="{% url 'prefs-user-export' %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="button">
|
||||
<span class="icon icon-download" aria-hidden="true"></span>
|
||||
<span>{% trans "Create user export file" %}</span>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
<div class="content block">
|
||||
<h2 class="title">{% trans "Recent Exports" %}</h2>
|
||||
<p class="content">
|
||||
{% trans "User export files will show 'complete' once ready. This may take a little while. Click the link to download your file." %}
|
||||
</p>
|
||||
<div class="table-container">
|
||||
<table class="table is-striped is-fullwidth">
|
||||
<tr>
|
||||
<th>
|
||||
{% trans "Date" %}
|
||||
</th>
|
||||
<th>
|
||||
{% trans "Status" %}
|
||||
</th>
|
||||
<th colspan="2">
|
||||
{% trans "Size" %}
|
||||
</th>
|
||||
</tr>
|
||||
{% if not jobs %}
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<em>{% trans "No recent imports" %}</em>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% for job in jobs %}
|
||||
<tr>
|
||||
<td>{{ job.updated_date }}</td>
|
||||
<td>
|
||||
<span
|
||||
{% if job.status == "stopped" or job.status == "failed" %}
|
||||
class="tag is-danger"
|
||||
{% elif job.status == "pending" %}
|
||||
class="tag is-warning"
|
||||
{% elif job.complete %}
|
||||
class="tag"
|
||||
{% else %}
|
||||
class="tag is-success"
|
||||
{% endif %}
|
||||
>
|
||||
{% if job.status %}
|
||||
{{ job.status }}
|
||||
{{ job.status_display }}
|
||||
{% elif job.complete %}
|
||||
{% trans "Complete" %}
|
||||
{% else %}
|
||||
{% trans "Active" %}
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span>{{ job.export_data|get_file_size }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if job.complete and not job.status == "stopped" and not job.status == "failed" %}
|
||||
<p>
|
||||
<a download="" href="/preferences/user-export/{{ job.task_id }}">
|
||||
<span class="icon icon-download" aria-hidden="true"></span>
|
||||
<span class="is-hidden-mobile">
|
||||
{% trans "Download your export" %}
|
||||
</span>
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% include 'snippets/pagination.html' with page=jobs path=request.path %}
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,16 +1,16 @@
|
|||
{% extends 'preferences/layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "CSV Export" %}{% endblock %}
|
||||
{% block title %}{% trans "Export Book List" %}{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
{% trans "CSV Export" %}
|
||||
{% trans "Export Book List" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
<div class="block content">
|
||||
<p class="notification">
|
||||
{% trans "Your export will include all the books on your shelves, books you have reviewed, and books with reading activity." %}
|
||||
{% trans "Your CSV export file will include all the books on your shelves, books you have reviewed, and books with reading activity. <br/>Use this to import into a service like Goodreads." %}
|
||||
</p>
|
||||
<p>
|
||||
<form name="export" method="POST" href="{% url 'prefs-export' %}">
|
||||
|
|
|
@ -23,6 +23,14 @@
|
|||
{% url 'prefs-2fa' as url %}
|
||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Two Factor Authentication" %}</a>
|
||||
</li>
|
||||
<li>
|
||||
{% url 'prefs-alias' as url %}
|
||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Aliases" %}</a>
|
||||
</li>
|
||||
<li>
|
||||
{% url 'prefs-move' as url %}
|
||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Move Account" %}</a>
|
||||
</li>
|
||||
<li>
|
||||
{% url 'prefs-delete' as url %}
|
||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Delete Account" %}</a>
|
||||
|
@ -32,11 +40,19 @@
|
|||
<ul class="menu-list">
|
||||
<li>
|
||||
{% url 'import' as url %}
|
||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Import" %}</a>
|
||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Import Book List" %}</a>
|
||||
</li>
|
||||
<li>
|
||||
{% url 'prefs-export' as url %}
|
||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "CSV export" %}</a>
|
||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Export Book List" %}</a>
|
||||
</li>
|
||||
<li>
|
||||
{% url 'user-import' as url %}
|
||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Import BookWyrm Account" %}</a>
|
||||
</li>
|
||||
<li>
|
||||
{% url 'prefs-user-export' as url %}
|
||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Export BookWyrm Account" %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<h2 class="menu-label">{% trans "Relationships" %}</h2>
|
||||
|
|
43
bookwyrm/templates/preferences/move_user.html
Normal file
43
bookwyrm/templates/preferences/move_user.html
Normal file
|
@ -0,0 +1,43 @@
|
|||
{% extends 'preferences/layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Move Account" %}{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
{% trans "Move Account" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
<div class="block">
|
||||
<h2 class="title is-4">{% trans "Migrate account to another server" %}</h2>
|
||||
<div class="box">
|
||||
<div class="notification is-danger is-light">
|
||||
<p>
|
||||
{% trans "Moving your account will notify all your followers and direct them to follow the new account." %}
|
||||
</p>
|
||||
<p>
|
||||
{% blocktrans %}
|
||||
<strong>{{ user }}</strong> will be marked as moved and will not be discoverable or usable unless you undo the move.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="notification is-info is-light">
|
||||
<p>{% trans "Remember to add this user as an alias of the target account before you try to move." %}</p>
|
||||
</div>
|
||||
<form name="move-user" action="{% url 'prefs-move' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label class="label" for="id_target">{% trans "Enter the username for the account you want to move to e.g. <em>user@example.com </em>:" %}</label>
|
||||
<input class="input {% if form.target.errors %}is-danger{% endif %}" type="text" name="target" id="id_target" required aria-describedby="desc_target">
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.target.errors id="desc_target" %}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="id_password">{% trans "Confirm your password:" %}</label>
|
||||
<input class="input {% if form.password.errors %}is-danger{% endif %}" type="password" name="password" id="id_password" required aria-describedby="desc_password">
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.password.errors id="desc_password" %}
|
||||
</div>
|
||||
<button type="submit" class="button is-danger">{% trans "Move Account" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -29,7 +29,7 @@
|
|||
</div>
|
||||
<div class="column is-4">
|
||||
<div class="notification">
|
||||
<p class="header">{% trans "Broadcasts" %}</p>
|
||||
<p class="header">{% trans "Broadcast" %}</p>
|
||||
<p class="title is-5">{{ queues.broadcast|intcomma }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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 %}
|
|
@ -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,264 @@
|
|||
</div>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
{% if site.user_exports_enabled %}
|
||||
<details class="details-panel box">
|
||||
<summary>
|
||||
<span role="heading" aria-level="2" class="title is-6">
|
||||
{% trans "Disable starting new user exports" %}
|
||||
</span>
|
||||
<span class="details-close icon icon-x" aria-hidden="true"></span>
|
||||
</summary>
|
||||
<form
|
||||
name="disable-user-exports"
|
||||
id="disable-user-exports"
|
||||
method="POST"
|
||||
action="{% url 'settings-user-exports-disable' %}"
|
||||
>
|
||||
<div class="notification">
|
||||
{% trans "This is only intended to be used when things have gone very wrong with exports and you need to pause the feature while addressing issues." %}
|
||||
{% trans "While exports are disabled, users will not be allowed to start new user exports, but existing exports will not be affected." %}
|
||||
</div>
|
||||
{% csrf_token %}
|
||||
<div class="control">
|
||||
<button type="submit" class="button is-danger">
|
||||
{% trans "Disable user exports" %}
|
||||
</button>
|
||||
</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 "Limit how often users can import and export user data" %}</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>
|
||||
{% else %}
|
||||
<form
|
||||
name="enable-user-imports"
|
||||
id="enable-user-imports"
|
||||
method="POST"
|
||||
action="{% url 'settings-user-exports-enable' %}"
|
||||
class="box"
|
||||
>
|
||||
<div class="notification is-danger is-light">
|
||||
<p class="my-2">{% trans "Users are currently unable to start new user exports. This is the default setting." %}</p>
|
||||
{% if use_s3 %}
|
||||
<p>{% 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." %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% csrf_token %}
|
||||
<div class="control">
|
||||
<button type="submit" class="button is-success" {% if use_s3 %}disabled{% endif %}>
|
||||
{% trans "Enable user exports" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</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>
|
||||
{% 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>
|
||||
<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>
|
||||
|
||||
{% include 'snippets/pagination.html' with page=imports path=request.path %}
|
||||
<div class="table-container block content">
|
||||
<table class="table is-striped is-fullwidth">
|
||||
<tr>
|
||||
{% url 'settings-imports' status as url %}
|
||||
<th>
|
||||
{% trans "ID" %}
|
||||
</th>
|
||||
<th>
|
||||
{% trans "User" as text %}
|
||||
{% include 'snippets/table-sort-header.html' with field="user" sort=sort text=text %}
|
||||
</th>
|
||||
<th>
|
||||
{% trans "Date Created" as text %}
|
||||
{% include 'snippets/table-sort-header.html' with field="created_date" sort=sort text=text %}
|
||||
</th>
|
||||
{% if status != "active" %}
|
||||
<th>
|
||||
{% trans "Date Updated" %}
|
||||
</th>
|
||||
{% endif %}
|
||||
|
||||
{% if status == "active" %}
|
||||
<th>{% trans "Actions" %}</th>
|
||||
{% else %}
|
||||
<th>{% trans "Status" %}</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% for import in user_imports %}
|
||||
<tr>
|
||||
<td>{{ import.id }}</td>
|
||||
<td class="overflow-wrap-anywhere">
|
||||
<a href="{% url 'settings-user' import.user.id %}">{{ import.user|username }}</a>
|
||||
</td>
|
||||
<td>{{ import.created_date }}</td>
|
||||
{% if status != "active" %}
|
||||
<td>{{ import.updated_date }}</td>
|
||||
{% endif %}
|
||||
{% if status == "active" %}
|
||||
<td>
|
||||
{% join "complete" import.id as modal_id %}
|
||||
<button type="button" data-modal-open="{{ modal_id }}" class="button is-danger">{% trans "Stop import" %}</button>
|
||||
{% include "settings/imports/complete_user_import_modal.html" with id=modal_id %}
|
||||
</td>
|
||||
{% else %}
|
||||
<td>
|
||||
<span
|
||||
{% if import.status == "stopped" or import.status == "failed" %}
|
||||
class="tag is-danger"
|
||||
{% elif import.status == "pending" %}
|
||||
class="tag is-warning"
|
||||
{% elif import.complete %}
|
||||
class="tag"
|
||||
{% else %}
|
||||
class="tag is-success"
|
||||
{% endif %}
|
||||
>{{ import.status }}
|
||||
</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not user_imports %}
|
||||
<tr>
|
||||
<td colspan="6">
|
||||
<em>{% trans "No matching imports found." %} </em>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% include 'snippets/pagination.html' with page=user_imports path=request.path %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
@ -12,6 +12,15 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
{% if broken_theme %}
|
||||
<div class="notification is-danger">
|
||||
<span class="icon icon-warning" aria-hidden="true"></span>
|
||||
<span>
|
||||
{% trans "One of your themes appears to be broken. Selecting this theme will make the application unusable." %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if success %}
|
||||
<div class="notification is-success is-light">
|
||||
<span class="icon icon-check" aria-hidden="true"></span>
|
||||
|
@ -98,6 +107,9 @@
|
|||
<th>
|
||||
{% trans "Actions" %}
|
||||
</th>
|
||||
<th>
|
||||
{% trans "Status" %}
|
||||
</th>
|
||||
</tr>
|
||||
{% for theme in themes %}
|
||||
<tr>
|
||||
|
@ -112,6 +124,37 @@
|
|||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
{% if theme.loads is None %}
|
||||
|
||||
<form method="POST" action="{% url 'settings-themes-test' theme.id %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="button is-small">
|
||||
<span class="icon icon-question-circle" aria-hidden="true"></span>
|
||||
<span>{% trans "Test theme" %}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% elif not theme.loads %}
|
||||
|
||||
<span class="tag is-danger">
|
||||
<span class="icon icon-warning" aria-hidden="true"></span>
|
||||
<span>
|
||||
{% trans "Broken theme" %}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{% else %}
|
||||
|
||||
<span class="tag is-success">
|
||||
<span class="icon icon-check" aria-hidden="true"></span>
|
||||
<span>
|
||||
{% trans "Loaded successfully" %}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
|
|
@ -74,24 +74,7 @@
|
|||
<td>{{ user.created_date }}</td>
|
||||
<td>{{ user.last_active_date }}</td>
|
||||
<td>
|
||||
{% if user.is_active %}
|
||||
<span class="tag is-success" aria-hidden="true">
|
||||
<span class="icon icon-check"></span>
|
||||
</span>
|
||||
{% trans "Active" %}
|
||||
{% elif user.deactivation_reason == "moderator_deletion" or user.deactivation_reason == "self_deletion" %}
|
||||
<span class="tag is-danger" aria-hidden="true">
|
||||
<span class="icon icon-x"></span>
|
||||
</span>
|
||||
{% trans "Deleted" %}
|
||||
<span class="help">({{ user.get_deactivation_reason_display }})</span>
|
||||
{% else %}
|
||||
<span class="tag is-warning" aria-hidden="true">
|
||||
<span class="icon icon-x"></span>
|
||||
</span>
|
||||
{% trans "Inactive" %}
|
||||
<span class="help">({{ user.get_deactivation_reason_display }})</span>
|
||||
{% endif %}
|
||||
{% include "snippets/user_active_tag.html" with user=user %}
|
||||
</td>
|
||||
{% if status == "federated" %}
|
||||
<td>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{% load i18n %}
|
||||
{% load markdown %}
|
||||
{% load humanize %}
|
||||
{% load utilities %}
|
||||
|
||||
<div class="block columns">
|
||||
<div class="column is-flex is-flex-direction-column">
|
||||
|
@ -13,7 +14,17 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if user.localname|is_instance_admin %}
|
||||
<div class="message is-warning">
|
||||
<div class="message-body">
|
||||
{% trans "This account is the instance actor for signing HTTP requests." %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="mt-2"><a href="{{ user.local_path }}">{% trans "View user profile" %}</a></p>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% url 'settings-user' user.id as url %}
|
||||
{% if not request.path == url %}
|
||||
<p class="mt-2"><a href="{{ url }}">{% trans "Go to user admin" %}</a></p>
|
||||
|
@ -23,18 +34,7 @@
|
|||
<div class="column is-flex is-flex-direction-column is-4">
|
||||
<h4 class="title is-4">{% trans "Status" %}</h4>
|
||||
<div class="box is-flex-grow-1 has-text-weight-bold">
|
||||
{% if user.is_active %}
|
||||
<p class="notification is-success">
|
||||
{% trans "Active" %}
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="notification is-warning">
|
||||
{% trans "Inactive" %}
|
||||
{% if user.deactivation_reason %}
|
||||
<span class="help">({% trans user.get_deactivation_reason_display %})</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% include "snippets/user_active_tag.html" with large=True %}
|
||||
<p class="notification">
|
||||
{% if user.local %}
|
||||
{% trans "Local" %}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
{% load i18n %}
|
||||
{% load utilities %}
|
||||
<div class="block content">
|
||||
{% if not user.is_active and user.deactivation_reason == "self_deletion" or user.deactivation_reason == "moderator_deletion" %}
|
||||
<div class="notification is-danger">
|
||||
|
@ -7,77 +8,90 @@
|
|||
{% else %}
|
||||
<h3>{% trans "User Actions" %}</h3>
|
||||
|
||||
<div class="box">
|
||||
<div class="is-flex">
|
||||
{% if user.is_active %}
|
||||
<p class="mr-1">
|
||||
<a class="button" href="{% url 'direct-messages-user' user.username %}">{% trans "Send direct message" %}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if user.localname|is_instance_admin %}
|
||||
<div class="box">
|
||||
<div class="message is-warning">
|
||||
<div class="message-header">
|
||||
<p>{% trans "This is the instance admin actor" %}</p>
|
||||
</div>
|
||||
<div class="message-body">
|
||||
<p>{% trans "You must not delete or disable this account as it is critical to the functioning of your server. This actor signs outgoing GET requests to smooth interaction with secure ActivityPub servers." %}</p>
|
||||
<p>{% trans "This account is not discoverable by ordinary users and does not have a profile page." %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="box">
|
||||
<div class="is-flex">
|
||||
{% if user.is_active %}
|
||||
<p class="mr-1">
|
||||
<a class="button" href="{% url 'direct-messages-user' user.username %}">{% trans "Send direct message" %}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if not user.is_active and user.deactivation_reason == "pending" %}
|
||||
<form name="activate" method="post" action="{% url 'settings-activate-user' user.id %}" class="mr-1">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="button is-success is-light">{% trans "Activate user" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if user.is_active or user.deactivation_reason == "pending" %}
|
||||
<form name="suspend" method="post" action="{% url 'settings-report-suspend' user.id report.id %}" class="mr-1">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="button is-danger is-light">{% trans "Suspend user" %}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form name="unsuspend" method="post" action="{% url 'settings-report-unsuspend' user.id report.id %}" class="mr-1">
|
||||
{% csrf_token %}
|
||||
<button class="button">{% trans "Un-suspend user" %}</button>
|
||||
</form>
|
||||
{% if not user.is_active and user.deactivation_reason == "pending" %}
|
||||
<form name="activate" method="post" action="{% url 'settings-activate-user' user.id %}" class="mr-1">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="button is-success is-light">{% trans "Activate user" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if user.is_active or user.deactivation_reason == "pending" %}
|
||||
<form name="suspend" method="post" action="{% url 'settings-report-suspend' user.id report.id %}" class="mr-1">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="button is-danger is-light">{% trans "Suspend user" %}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form name="unsuspend" method="post" action="{% url 'settings-report-unsuspend' user.id report.id %}" class="mr-1">
|
||||
{% csrf_token %}
|
||||
<button class="button">{% trans "Un-suspend user" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if user.local %}
|
||||
<div>
|
||||
{% trans "Permanently delete user" as button_text %}
|
||||
{% include "snippets/toggle/open_button.html" with controls_text="delete_user" text=button_text class="is-danger is-light" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if user.local %}
|
||||
<div>
|
||||
{% include "settings/users/delete_user_form.html" with controls_text="delete_user" class="mt-2 mb-2" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if user.local %}
|
||||
<div>
|
||||
{% trans "Permanently delete user" as button_text %}
|
||||
{% include "snippets/toggle/open_button.html" with controls_text="delete_user" text=button_text class="is-danger is-light" %}
|
||||
<form name="permission" method="post" action="{% url 'settings-user' user.id report.id %}">
|
||||
{% csrf_token %}
|
||||
<label class="label" for="id_user_group">{% trans "Access level:" %}</label>
|
||||
{% if group_form.non_field_errors %}
|
||||
{{ group_form.non_field_errors }}
|
||||
{% endif %}
|
||||
{% with group=user.groups.first %}
|
||||
<div class="select">
|
||||
<select name="groups" id="id_user_group" aria-describedby="desc_user_group">
|
||||
{% for value, name in group_form.fields.groups.choices %}
|
||||
<option value="{{ value }}" {% if name == group.name %}selected{% endif %}>
|
||||
{{ name|title }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
<option value="" {% if not group %}selected{% endif %}>
|
||||
User
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=group_form.groups.errors id="desc_user_group" %}
|
||||
{% endwith %}
|
||||
<button class="button">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if user.local %}
|
||||
<div>
|
||||
{% include "settings/users/delete_user_form.html" with controls_text="delete_user" class="mt-2 mb-2" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if user.local %}
|
||||
<div>
|
||||
<form name="permission" method="post" action="{% url 'settings-user' user.id report.id %}">
|
||||
{% csrf_token %}
|
||||
<label class="label" for="id_user_group">{% trans "Access level:" %}</label>
|
||||
{% if group_form.non_field_errors %}
|
||||
{{ group_form.non_field_errors }}
|
||||
{% endif %}
|
||||
{% with group=user.groups.first %}
|
||||
<div class="select">
|
||||
<select name="groups" id="id_user_group" aria-describedby="desc_user_group">
|
||||
{% for value, name in group_form.fields.groups.choices %}
|
||||
<option value="{{ value }}" {% if name == group.name %}selected{% endif %}>
|
||||
{{ name|title }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
<option value="" {% if not group %}selected{% endif %}>
|
||||
User
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=group_form.groups.errors id="desc_user_group" %}
|
||||
{% endwith %}
|
||||
<button class="button">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
@ -18,7 +18,9 @@
|
|||
{% include 'user/books_header.html' %}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{% if user.moved_to %}
|
||||
{% include "snippets/moved_user_notice.html" with user=user %}
|
||||
{% else %}
|
||||
<nav class="breadcrumb subtitle" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="{% url 'user-feed' user|username %}">{% trans "User profile" %}</a></li>
|
||||
|
@ -99,7 +101,6 @@
|
|||
{% plural %}
|
||||
{{ formatted_count }} books
|
||||
{% endblocktrans %}
|
||||
|
||||
{% if books.has_other_pages %}
|
||||
{% blocktrans trimmed with start=books.start_index end=books.end_index %}
|
||||
(showing {{ start }}-{{ end }})
|
||||
|
@ -109,6 +110,8 @@
|
|||
{% endif %}
|
||||
{% endwith %}
|
||||
</h2>
|
||||
{% include 'shelf/shelves_filters.html' with user=user query=query %}
|
||||
|
||||
</div>
|
||||
{% if is_self and shelf.id %}
|
||||
<div class="column is-narrow">
|
||||
|
@ -207,7 +210,17 @@
|
|||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p><em>{% trans "This shelf is empty." %}</em></p>
|
||||
<p>
|
||||
<em>
|
||||
{% if shelves_filter_query %}
|
||||
{% blocktrans trimmed %}
|
||||
We couldn't find any books that matched {{ shelves_filter_query }}
|
||||
{% endblocktrans %}
|
||||
{% else %}
|
||||
{% trans "This shelf is empty." %}
|
||||
{% endif %}
|
||||
</em>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
@ -215,6 +228,7 @@
|
|||
<div>
|
||||
{% include 'snippets/pagination.html' with page=books path=request.path %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
|
|
9
bookwyrm/templates/shelf/shelves_filter_field.html
Normal file
9
bookwyrm/templates/shelf/shelves_filter_field.html
Normal file
|
@ -0,0 +1,9 @@
|
|||
{% extends 'snippets/filters_panel/filter_field.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block filter %}
|
||||
<div class="control">
|
||||
<label class="label" for="filter_query">{% trans 'Filter by keyword' %}</label>
|
||||
<input aria-label="Filter by keyword" id="my-books-filter" class="input" type="text" name="filter" placeholder="{% trans 'Enter text here' %}" value="{{ shelves_filter_query|default:'' }}" spellcheck="false" />
|
||||
</div>
|
||||
{% endblock %}
|
5
bookwyrm/templates/shelf/shelves_filters.html
Normal file
5
bookwyrm/templates/shelf/shelves_filters.html
Normal file
|
@ -0,0 +1,5 @@
|
|||
{% extends 'snippets/filters_panel/filters_panel.html' %}
|
||||
|
||||
{% block filter_fields %}
|
||||
{% include 'shelf/shelves_filter_field.html' %}
|
||||
{% endblock %}
|
|
@ -43,7 +43,7 @@
|
|||
</div>
|
||||
{% if not minimal %}
|
||||
<div class="control">
|
||||
{% include 'snippets/user_options.html' with user=user class="is-small" %}
|
||||
{% include 'snippets/user_options.html' with user=user followers_page=followers_page class="is-small" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
13
bookwyrm/templates/snippets/move_user_buttons.html
Normal file
13
bookwyrm/templates/snippets/move_user_buttons.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
{% load i18n %}
|
||||
{% load utilities %}
|
||||
|
||||
|
||||
{% if related_user_moved_to|user_from_remote_id not in request.user.following.all %}
|
||||
<div class="field is-grouped">
|
||||
<form action="{% url 'follow' %}" method="POST" class="interaction follow_{{ related_users.0.id }}" data-id="follow_{{ related_users.0.id }}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="user" value="{% id_to_username related_user_moved_to %}">
|
||||
<button class="button is-link is-small" type="submit">{% trans "Follow at new account" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
12
bookwyrm/templates/snippets/moved_user_notice.html
Normal file
12
bookwyrm/templates/snippets/moved_user_notice.html
Normal file
|
@ -0,0 +1,12 @@
|
|||
{% load i18n %}
|
||||
{% load utilities %}
|
||||
<div class="container my-6">
|
||||
<div class="notification is-info has-text-centered">
|
||||
<p>
|
||||
{% id_to_username user.moved_to as moved_to_name %}
|
||||
{% blocktrans trimmed with user=user|username moved_to_link=user.moved_to %}
|
||||
<em>{{ user }}</em> has moved to <a href="{{ moved_to_link }}">{{ moved_to_name }}</a>
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
5
bookwyrm/templates/snippets/remove_follower_button.html
Normal file
5
bookwyrm/templates/snippets/remove_follower_button.html
Normal file
|
@ -0,0 +1,5 @@
|
|||
{% load i18n %}
|
||||
<form name="remove" method="post" action="/remove-follow/{{ user.id }}">
|
||||
{% csrf_token %}
|
||||
<button class="button is-danger is-light is-small {{ class }}" type="submit">{% trans "Remove" %}</button>
|
||||
</form>
|
17
bookwyrm/templates/snippets/user_active_tag.html
Normal file
17
bookwyrm/templates/snippets/user_active_tag.html
Normal file
|
@ -0,0 +1,17 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% if user.is_active %}
|
||||
{% if user.moved_to %}
|
||||
{% trans "Moved" as text %}
|
||||
{% include "snippets/user_active_tag_item.html" with icon="x" text=text level="info" %}
|
||||
{% else %}
|
||||
{% trans "Active" as text %}
|
||||
{% include "snippets/user_active_tag_item.html" with icon="check" text=text level="success" %}
|
||||
{% endif %}
|
||||
{% elif user.is_deleted %}
|
||||
{% trans "Deleted" as text %}
|
||||
{% include "snippets/user_active_tag_item.html" with icon="x" text=text level="danger" deactivation_reason=user.get_deactivation_reason_display %}
|
||||
{% else %}
|
||||
{% trans "Inactive" as text %}
|
||||
{% include "snippets/user_active_tag_item.html" with icon="x" text=text level="warning" deactivation_reason=user.get_deactivation_reason_display %}
|
||||
{% endif %}
|
19
bookwyrm/templates/snippets/user_active_tag_item.html
Normal file
19
bookwyrm/templates/snippets/user_active_tag_item.html
Normal file
|
@ -0,0 +1,19 @@
|
|||
{% if large %}
|
||||
|
||||
<p class="notification is-{{ level }}">
|
||||
<span class="icon icon-{{ icon }}" aria-hidden="true"></span>
|
||||
{{ text }}
|
||||
{% if deactivation_reason %}
|
||||
<span class="help">({{ deactivation_reason }})</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% else %}
|
||||
|
||||
<span class="tag is-{{ level }}" aria-hidden="true">
|
||||
<span class="icon icon-{{ icon }}"></span>
|
||||
</span>
|
||||
{{ text }}
|
||||
|
||||
{% endif %}
|
||||
|
|
@ -20,4 +20,9 @@
|
|||
<li role="menuitem">
|
||||
{% include 'snippets/block_button.html' with user=user class="is-fullwidth" blocks=False %}
|
||||
</li>
|
||||
{% if followers_page %}
|
||||
<li role="menuitem">
|
||||
{% include 'snippets/remove_follower_button.html' with user=user class="is-fullwidth" %}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
{% load markdown %}
|
||||
{% load layout %}
|
||||
{% load group_tags %}
|
||||
{% load user_page_tags %}
|
||||
|
||||
{% block title %}{{ user.display_name }}{% endblock %}
|
||||
|
||||
|
@ -27,7 +28,11 @@
|
|||
<div class="block">
|
||||
<div class="columns">
|
||||
<div class="column is-two-fifths">
|
||||
{% include 'user/user_preview.html' with user=user %}
|
||||
{% if user.moved_to %}
|
||||
{% include 'user/moved.html' with user=user %}
|
||||
{% else %}
|
||||
{% include 'user/user_preview.html' with user=user %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if user.summary %}
|
||||
|
@ -38,70 +43,78 @@
|
|||
{% endspaceless %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not is_self and request.user.is_authenticated %}
|
||||
{% include 'snippets/follow_button.html' with user=user %}
|
||||
{% endif %}
|
||||
{% if not is_self %}
|
||||
{% include 'ostatus/remote_follow_button.html' with user=user %}
|
||||
{% endif %}
|
||||
<div>
|
||||
{% if user.moved_to %}
|
||||
{% include "snippets/moved_user_notice.html" with user=user %}
|
||||
{% else %}
|
||||
{% if not is_self and request.user.is_authenticated %}
|
||||
{% include 'snippets/follow_button.html' with user=user %}
|
||||
{% endif %}
|
||||
{% if not is_self %}
|
||||
{% include 'ostatus/remote_follow_button.html' with user=user %}
|
||||
{% endif %}
|
||||
|
||||
{% if is_self and user.active_follower_requests.all %}
|
||||
<div class="follow-requests">
|
||||
<h2>{% trans "Follow Requests" %}</h2>
|
||||
{% for requester in user.follower_requests.all %}
|
||||
<div class="row shrink">
|
||||
<p>
|
||||
<a href="{{ requester.local_path }}">{{ requester.display_name }}</a> ({{ requester.username }})
|
||||
</p>
|
||||
{% include 'snippets/follow_request_buttons.html' with user=requester %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if is_self and user.active_follower_requests.all %}
|
||||
<div class="follow-requests">
|
||||
<h2>{% trans "Follow Requests" %}</h2>
|
||||
{% for requester in user.follower_requests.all %}
|
||||
<div class="row shrink">
|
||||
<p>
|
||||
<a href="{{ requester.local_path }}">{{ requester.display_name }}</a> ({{ requester.username }})
|
||||
</p>
|
||||
{% include 'snippets/follow_request_buttons.html' with user=requester %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% block tabs %}
|
||||
{% if not user.moved_to %}
|
||||
{% with user|username as username %}
|
||||
<nav class="tabs">
|
||||
<ul>
|
||||
{% url 'user-feed' user|username as url %}
|
||||
<li{% if url == request.path or url == request.path|add:'/' %} class="is-active"{% endif %}>
|
||||
<a href="{{ url }}">{% trans "Activity" %}</a>
|
||||
</li>
|
||||
{% url 'user-reviews-comments' user|username as url %}
|
||||
<li{% if url == request.path or url == request.path|add:'/' %} class="is-active"{% endif %}>
|
||||
<a href="{{ url }}">{% trans "Reviews and Comments" %}</a>
|
||||
</li>
|
||||
{% if is_self or user.goal.exists %}
|
||||
{% now 'Y' as year %}
|
||||
{% url 'user-goal' user|username year as url %}
|
||||
<li{% if url in request.path %} class="is-active"{% endif %} id="tour-reading-goal">
|
||||
<a href="{{ url }}">{% trans "Reading Goal" %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if is_self or user|has_groups %}
|
||||
{% url 'user-groups' user|username as url %}
|
||||
<li{% if url in request.path %} class="is-active"{% endif %} id="tour-groups-tab">
|
||||
<a href="{{ url }}">{% trans "Groups" %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if is_self or user.list_set.exists %}
|
||||
{% url 'user-lists' user|username as url %}
|
||||
<li{% if url in request.path %} class="is-active"{% endif %} id="tour-lists-tab">
|
||||
<a href="{{ url }}">{% trans "Lists" %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if user.shelf_set.exists %}
|
||||
{% url 'user-shelves' user|username as url %}
|
||||
<li{% if url in request.path %} class="is-active"{% endif %} id="tour-shelves-tab">
|
||||
<a href="{{ url }}">{% trans "Books" %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
{% block tabs %}
|
||||
{% with user|username as username %}
|
||||
<nav class="tabs">
|
||||
<ul>
|
||||
{% url 'user-feed' user|username as url %}
|
||||
<li{% if url == request.path or url == request.path|add:'/' %} class="is-active"{% endif %}>
|
||||
<a href="{{ url }}">{% trans "Activity" %}</a>
|
||||
</li>
|
||||
{% url 'user-reviews-comments' user|username as url %}
|
||||
<li{% if url == request.path or url == request.path|add:'/' %} class="is-active"{% endif %}>
|
||||
<a href="{{ url }}">{% trans "Reviews and Comments" %}</a>
|
||||
</li>
|
||||
{% if is_self or user.goal.exists %}
|
||||
{% now 'Y' as year %}
|
||||
{% url 'user-goal' user|username year as url %}
|
||||
<li{% if url in request.path %} class="is-active"{% endif %} id="tour-reading-goal">
|
||||
<a href="{{ url }}">{% trans "Reading Goal" %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if is_self or user|has_groups %}
|
||||
{% url 'user-groups' user|username as url %}
|
||||
<li{% if url in request.path %} class="is-active"{% endif %} id="tour-groups-tab">
|
||||
<a href="{{ url }}">{% trans "Groups" %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if is_self or user.list_set.exists %}
|
||||
{% url 'user-lists' user|username as url %}
|
||||
<li{% if url in request.path %} class="is-active"{% endif %} id="tour-lists-tab">
|
||||
<a href="{{ url }}">{% trans "Lists" %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if user.shelf_set.exists %}
|
||||
{% url 'user-shelves' user|username as url %}
|
||||
<li{% if url in request.path %} class="is-active"{% endif %} id="tour-shelves-tab">
|
||||
<a href="{{ url }}">{% trans "Books" %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block panel %}{% endblock %}
|
||||
|
||||
{% if not user.moved_to %}
|
||||
{% block panel %}{% endblock %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
27
bookwyrm/templates/user/moved.html
Normal file
27
bookwyrm/templates/user/moved.html
Normal file
|
@ -0,0 +1,27 @@
|
|||
{% load i18n %}
|
||||
{% load humanize %}
|
||||
{% load utilities %}
|
||||
{% load markdown %}
|
||||
{% load layout %}
|
||||
{% load group_tags %}
|
||||
|
||||
|
||||
<div class="media block">
|
||||
<div class="media-left">
|
||||
<a href="{{ user.local_path }}">
|
||||
{% include 'snippets/avatar.html' with user=user large=True %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<p>
|
||||
{% if user.name %}{{ user.name }}{% else %}{{ user.localname }}{% endif %}
|
||||
{% if user.manually_approves_followers %}
|
||||
<span class="icon icon-lock is-size-7" title="{% trans 'Locked account' %}">
|
||||
<span class="is-sr-only">{% trans "Locked account" %}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p>{{ user.username }}</p>
|
||||
<p>{% blocktrans with date=user.created_date|naturaltime %}Joined {{ date }}{% endblocktrans %}</p>
|
||||
</div>
|
||||
</div>
|
|
@ -25,6 +25,11 @@
|
|||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
{% with followers_page=True %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block nullstate %}
|
||||
<div>
|
||||
|
|
|
@ -31,7 +31,7 @@
|
|||
({{ follow.username }})
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
{% include 'snippets/follow_button.html' with user=follow %}
|
||||
{% include 'snippets/follow_button.html' with user=follow followers_page=followers_page %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
|
|
@ -34,11 +34,6 @@
|
|||
{% trans "Directory" %}
|
||||
</a>
|
||||
</li>
|
||||
<li role="menuitem">
|
||||
<a href="{% url 'user-shelves' request.user.localname %}" class="navbar-item">
|
||||
{% trans 'Your Books' %}
|
||||
</a>
|
||||
</li>
|
||||
<li role="menuitem">
|
||||
<a href="{% url 'direct-messages' %}" class="navbar-item">
|
||||
{% trans "Direct Messages" %}
|
||||
|
|
24
bookwyrm/templatetags/date_ext.py
Normal file
24
bookwyrm/templatetags/date_ext.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
""" additional formatting of dates """
|
||||
from django import template
|
||||
from django.template import defaultfilters
|
||||
from django.contrib.humanize.templatetags.humanize import naturalday
|
||||
|
||||
from bookwyrm.utils.partial_date import PartialDate
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter(expects_localtime=True)
|
||||
def naturalday_partial(date, arg=None):
|
||||
"""chooses appropriate precision if date is a PartialDate object
|
||||
|
||||
If arg is a Django-defined format such as "DATE_FORMAT", it will be adjusted
|
||||
so that the precision of the PartialDate object is honored.
|
||||
"""
|
||||
if not isinstance(date, PartialDate) or date.has_day:
|
||||
return naturalday(date, arg)
|
||||
if not arg or arg == "DATE_FORMAT":
|
||||
arg = "YEAR_MONTH_FORMAT" if date.has_month else "Y"
|
||||
elif not date.has_month and arg in ("SHORT_DATE_FORMAT", "YEAR_MONTH_FORMAT"):
|
||||
arg = "Y"
|
||||
return defaultfilters.date(date, arg)
|
|
@ -2,11 +2,14 @@
|
|||
import os
|
||||
import re
|
||||
from uuid import uuid4
|
||||
from urllib.parse import urlparse
|
||||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
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
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
@ -29,6 +32,13 @@ def get_user_identifier(user):
|
|||
return user.localname if user.localname else user.username
|
||||
|
||||
|
||||
@register.filter(name="user_from_remote_id")
|
||||
def get_user_identifier_from_remote_id(remote_id):
|
||||
"""get the local user id from their remote id"""
|
||||
user = User.objects.get(remote_id=remote_id)
|
||||
return user if user else None
|
||||
|
||||
|
||||
@register.filter(name="book_title")
|
||||
def get_title(book, too_short=5):
|
||||
"""display the subtitle if the title is short"""
|
||||
|
@ -103,3 +113,47 @@ def get_isni(existing, author, autoescape=True):
|
|||
f'<input type="text" name="isni-for-{author.id}" value="{isni}" hidden>'
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=False)
|
||||
def id_to_username(user_id):
|
||||
"""given an arbitrary remote id, return the username"""
|
||||
if user_id:
|
||||
url = urlparse(user_id)
|
||||
domain = url.netloc
|
||||
parts = url.path.split("/")
|
||||
name = parts[-1]
|
||||
value = f"{name}@{domain}"
|
||||
|
||||
return value
|
||||
return "a new user account"
|
||||
|
||||
|
||||
@register.filter(name="get_file_size")
|
||||
def get_file_size(file):
|
||||
"""display the size of a file in human readable terms"""
|
||||
|
||||
try:
|
||||
raw_size = os.stat(file.path).st_size
|
||||
if raw_size < 1024:
|
||||
return f"{raw_size} bytes"
|
||||
if raw_size < 1024**2:
|
||||
return f"{raw_size/1024:.2f} KB"
|
||||
if raw_size < 1024**3:
|
||||
return f"{raw_size/1024**2:.2f} MB"
|
||||
return f"{raw_size/1024**3:.2f} GB"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
return ""
|
||||
|
||||
|
||||
@register.filter(name="get_user_permission")
|
||||
def get_user_permission(user):
|
||||
"""given a user, return their permission level"""
|
||||
|
||||
return user.groups.first() or "User"
|
||||
|
||||
|
||||
@register.filter(name="is_instance_admin")
|
||||
def is_instance_admin(localname):
|
||||
"""Returns a boolean indicating whether the user is the instance admin account"""
|
||||
return localname == INSTANCE_ACTOR_USERNAME
|
||||
|
|
|
@ -6,7 +6,8 @@ from bookwyrm import models
|
|||
class Author(TestCase):
|
||||
"""serialize author tests"""
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(self): # pylint: disable=bad-classmethod-argument
|
||||
"""initial data"""
|
||||
self.book = models.Edition.objects.create(
|
||||
title="Example Edition",
|
||||
|
|
|
@ -28,8 +28,8 @@ from bookwyrm import models
|
|||
class BaseActivity(TestCase):
|
||||
"""the super class for model-linked activitypub dataclasses"""
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(self): # pylint: disable=bad-classmethod-argument
|
||||
"""we're probably going to re-use this so why copy/paste"""
|
||||
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
|
||||
"bookwyrm.activitystreams.populate_stream_task.delay"
|
||||
|
@ -40,6 +40,7 @@ class BaseActivity(TestCase):
|
|||
self.user.remote_id = "http://example.com/a/b"
|
||||
self.user.save(broadcast=False, update_fields=["remote_id"])
|
||||
|
||||
def setUp(self):
|
||||
datafile = pathlib.Path(__file__).parent.joinpath("../data/ap_user.json")
|
||||
self.userdata = json.loads(datafile.read_bytes())
|
||||
# don't try to load the user icon
|
||||
|
|
|
@ -10,8 +10,8 @@ from bookwyrm import models
|
|||
class Note(TestCase):
|
||||
"""the model-linked ActivityPub dataclass for Note-based types"""
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(self): # pylint: disable=bad-classmethod-argument
|
||||
"""create a shared user"""
|
||||
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
|
||||
"bookwyrm.activitystreams.populate_stream_task.delay"
|
||||
|
|
|
@ -10,7 +10,8 @@ from bookwyrm import activitypub, models
|
|||
class Quotation(TestCase):
|
||||
"""we have hecka ways to create statuses"""
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(self): # pylint: disable=bad-classmethod-argument
|
||||
"""model objects we'll need"""
|
||||
with patch("bookwyrm.models.user.set_remote_server.delay"):
|
||||
self.user = models.User.objects.create_user(
|
||||
|
@ -26,6 +27,9 @@ class Quotation(TestCase):
|
|||
title="Example Edition",
|
||||
remote_id="https://example.com/book/1",
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
"""other test data"""
|
||||
datafile = pathlib.Path(__file__).parent.joinpath("../data/ap_quotation.json")
|
||||
self.status_data = json.loads(datafile.read_bytes())
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue