Merge branch 'production' into nix
This commit is contained in:
commit
b59c205c6c
197 changed files with 16637 additions and 4120 deletions
|
@ -36,7 +36,7 @@ FLOWER_PORT=8888
|
||||||
#FLOWER_USER=mouse
|
#FLOWER_USER=mouse
|
||||||
#FLOWER_PASSWORD=changeme
|
#FLOWER_PASSWORD=changeme
|
||||||
|
|
||||||
EMAIL_HOST="smtp.mailgun.org"
|
EMAIL_HOST=smtp.mailgun.org
|
||||||
EMAIL_PORT=587
|
EMAIL_PORT=587
|
||||||
EMAIL_HOST_USER=mail@your.domain.here
|
EMAIL_HOST_USER=mail@your.domain.here
|
||||||
EMAIL_HOST_PASSWORD=emailpassword123
|
EMAIL_HOST_PASSWORD=emailpassword123
|
||||||
|
|
|
@ -36,7 +36,7 @@ FLOWER_PORT=8888
|
||||||
FLOWER_USER=mouse
|
FLOWER_USER=mouse
|
||||||
FLOWER_PASSWORD=changeme
|
FLOWER_PASSWORD=changeme
|
||||||
|
|
||||||
EMAIL_HOST="smtp.mailgun.org"
|
EMAIL_HOST=smtp.mailgun.org
|
||||||
EMAIL_PORT=587
|
EMAIL_PORT=587
|
||||||
EMAIL_HOST_USER=mail@your.domain.here
|
EMAIL_HOST_USER=mail@your.domain.here
|
||||||
EMAIL_HOST_PASSWORD=emailpassword123
|
EMAIL_HOST_PASSWORD=emailpassword123
|
||||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -4,6 +4,7 @@
|
||||||
*.swp
|
*.swp
|
||||||
**/__pycache__
|
**/__pycache__
|
||||||
.local
|
.local
|
||||||
|
/nginx/nginx.conf
|
||||||
|
|
||||||
# Poetry
|
# Poetry
|
||||||
.cache/
|
.cache/
|
||||||
|
|
|
@ -7,7 +7,7 @@ from django.utils import timezone
|
||||||
|
|
||||||
from bookwyrm import models
|
from bookwyrm import models
|
||||||
from bookwyrm.redis_store import RedisStore, r
|
from bookwyrm.redis_store import RedisStore, r
|
||||||
from bookwyrm.tasks import app
|
from bookwyrm.tasks import app, LOW, MEDIUM, HIGH
|
||||||
|
|
||||||
|
|
||||||
class ActivityStream(RedisStore):
|
class ActivityStream(RedisStore):
|
||||||
|
@ -22,6 +22,11 @@ class ActivityStream(RedisStore):
|
||||||
stream_id = self.stream_id(user)
|
stream_id = self.stream_id(user)
|
||||||
return f"{stream_id}-unread"
|
return f"{stream_id}-unread"
|
||||||
|
|
||||||
|
def unread_by_status_type_id(self, user):
|
||||||
|
"""the redis key for this user's unread count for this stream"""
|
||||||
|
stream_id = self.stream_id(user)
|
||||||
|
return f"{stream_id}-unread-by-type"
|
||||||
|
|
||||||
def get_rank(self, obj): # pylint: disable=no-self-use
|
def get_rank(self, obj): # pylint: disable=no-self-use
|
||||||
"""statuses are sorted by date published"""
|
"""statuses are sorted by date published"""
|
||||||
return obj.published_date.timestamp()
|
return obj.published_date.timestamp()
|
||||||
|
@ -35,6 +40,10 @@ class ActivityStream(RedisStore):
|
||||||
for user in self.get_audience(status):
|
for user in self.get_audience(status):
|
||||||
# add to the unread status count
|
# add to the unread status count
|
||||||
pipeline.incr(self.unread_id(user))
|
pipeline.incr(self.unread_id(user))
|
||||||
|
# add to the unread status count for status type
|
||||||
|
pipeline.hincrby(
|
||||||
|
self.unread_by_status_type_id(user), get_status_type(status), 1
|
||||||
|
)
|
||||||
|
|
||||||
# and go!
|
# and go!
|
||||||
pipeline.execute()
|
pipeline.execute()
|
||||||
|
@ -55,6 +64,7 @@ class ActivityStream(RedisStore):
|
||||||
"""load the statuses to be displayed"""
|
"""load the statuses to be displayed"""
|
||||||
# clear unreads for this feed
|
# clear unreads for this feed
|
||||||
r.set(self.unread_id(user), 0)
|
r.set(self.unread_id(user), 0)
|
||||||
|
r.delete(self.unread_by_status_type_id(user))
|
||||||
|
|
||||||
statuses = self.get_store(self.stream_id(user))
|
statuses = self.get_store(self.stream_id(user))
|
||||||
return (
|
return (
|
||||||
|
@ -75,6 +85,14 @@ class ActivityStream(RedisStore):
|
||||||
"""get the unread status count for this user's feed"""
|
"""get the unread status count for this user's feed"""
|
||||||
return int(r.get(self.unread_id(user)) or 0)
|
return int(r.get(self.unread_id(user)) or 0)
|
||||||
|
|
||||||
|
def get_unread_count_by_status_type(self, user):
|
||||||
|
"""get the unread status count for this user's feed's status types"""
|
||||||
|
status_types = r.hgetall(self.unread_by_status_type_id(user))
|
||||||
|
return {
|
||||||
|
str(key.decode("utf-8")): int(value) or 0
|
||||||
|
for key, value in status_types.items()
|
||||||
|
}
|
||||||
|
|
||||||
def populate_streams(self, user):
|
def populate_streams(self, user):
|
||||||
"""go from zero to a timeline"""
|
"""go from zero to a timeline"""
|
||||||
self.populate_store(self.stream_id(user))
|
self.populate_store(self.stream_id(user))
|
||||||
|
@ -277,7 +295,18 @@ def add_status_on_create(sender, instance, created, *args, **kwargs):
|
||||||
|
|
||||||
def add_status_on_create_command(sender, instance, created):
|
def add_status_on_create_command(sender, instance, created):
|
||||||
"""runs this code only after the database commit completes"""
|
"""runs this code only after the database commit completes"""
|
||||||
add_status_task.delay(instance.id, increment_unread=created)
|
priority = HIGH
|
||||||
|
# check if this is an old status, de-prioritize if so
|
||||||
|
# (this will happen if federation is very slow, or, more expectedly, on csv import)
|
||||||
|
one_day = 60 * 60 * 24
|
||||||
|
if (instance.created_date - instance.published_date).seconds > one_day:
|
||||||
|
priority = LOW
|
||||||
|
|
||||||
|
add_status_task.apply_async(
|
||||||
|
args=(instance.id,),
|
||||||
|
kwargs={"increment_unread": created},
|
||||||
|
queue=priority,
|
||||||
|
)
|
||||||
|
|
||||||
if sender == models.Boost:
|
if sender == models.Boost:
|
||||||
handle_boost_task.delay(instance.id)
|
handle_boost_task.delay(instance.id)
|
||||||
|
@ -409,7 +438,7 @@ def remove_statuses_on_unshelve(sender, instance, *args, **kwargs):
|
||||||
# ---- TASKS
|
# ---- TASKS
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def add_book_statuses_task(user_id, book_id):
|
def add_book_statuses_task(user_id, book_id):
|
||||||
"""add statuses related to a book on shelve"""
|
"""add statuses related to a book on shelve"""
|
||||||
user = models.User.objects.get(id=user_id)
|
user = models.User.objects.get(id=user_id)
|
||||||
|
@ -417,7 +446,7 @@ def add_book_statuses_task(user_id, book_id):
|
||||||
BooksStream().add_book_statuses(user, book)
|
BooksStream().add_book_statuses(user, book)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def remove_book_statuses_task(user_id, book_id):
|
def remove_book_statuses_task(user_id, book_id):
|
||||||
"""remove statuses about a book from a user's books feed"""
|
"""remove statuses about a book from a user's books feed"""
|
||||||
user = models.User.objects.get(id=user_id)
|
user = models.User.objects.get(id=user_id)
|
||||||
|
@ -425,7 +454,7 @@ def remove_book_statuses_task(user_id, book_id):
|
||||||
BooksStream().remove_book_statuses(user, book)
|
BooksStream().remove_book_statuses(user, book)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="medium_priority")
|
@app.task(queue=MEDIUM)
|
||||||
def populate_stream_task(stream, user_id):
|
def populate_stream_task(stream, user_id):
|
||||||
"""background task for populating an empty activitystream"""
|
"""background task for populating an empty activitystream"""
|
||||||
user = models.User.objects.get(id=user_id)
|
user = models.User.objects.get(id=user_id)
|
||||||
|
@ -433,7 +462,7 @@ def populate_stream_task(stream, user_id):
|
||||||
stream.populate_streams(user)
|
stream.populate_streams(user)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="medium_priority")
|
@app.task(queue=MEDIUM)
|
||||||
def remove_status_task(status_ids):
|
def remove_status_task(status_ids):
|
||||||
"""remove a status from any stream it might be in"""
|
"""remove a status from any stream it might be in"""
|
||||||
# this can take an id or a list of ids
|
# this can take an id or a list of ids
|
||||||
|
@ -446,10 +475,10 @@ def remove_status_task(status_ids):
|
||||||
stream.remove_object_from_related_stores(status)
|
stream.remove_object_from_related_stores(status)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="high_priority")
|
@app.task(queue=HIGH)
|
||||||
def add_status_task(status_id, increment_unread=False):
|
def add_status_task(status_id, increment_unread=False):
|
||||||
"""add a status to any stream it should be in"""
|
"""add a status to any stream it should be in"""
|
||||||
status = models.Status.objects.get(id=status_id)
|
status = models.Status.objects.select_subclasses().get(id=status_id)
|
||||||
# we don't want to tick the unread count for csv import statuses, idk how better
|
# we don't want to tick the unread count for csv import statuses, idk how better
|
||||||
# to check than just to see if the states is more than a few days old
|
# to check than just to see if the states is more than a few days old
|
||||||
if status.created_date < timezone.now() - timedelta(days=2):
|
if status.created_date < timezone.now() - timedelta(days=2):
|
||||||
|
@ -458,7 +487,7 @@ def add_status_task(status_id, increment_unread=False):
|
||||||
stream.add_status(status, increment_unread=increment_unread)
|
stream.add_status(status, increment_unread=increment_unread)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="medium_priority")
|
@app.task(queue=MEDIUM)
|
||||||
def remove_user_statuses_task(viewer_id, user_id, stream_list=None):
|
def remove_user_statuses_task(viewer_id, user_id, stream_list=None):
|
||||||
"""remove all statuses by a user from a viewer's stream"""
|
"""remove all statuses by a user from a viewer's stream"""
|
||||||
stream_list = [streams[s] for s in stream_list] if stream_list else streams.values()
|
stream_list = [streams[s] for s in stream_list] if stream_list else streams.values()
|
||||||
|
@ -468,7 +497,7 @@ def remove_user_statuses_task(viewer_id, user_id, stream_list=None):
|
||||||
stream.remove_user_statuses(viewer, user)
|
stream.remove_user_statuses(viewer, user)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="medium_priority")
|
@app.task(queue=MEDIUM)
|
||||||
def add_user_statuses_task(viewer_id, user_id, stream_list=None):
|
def add_user_statuses_task(viewer_id, user_id, stream_list=None):
|
||||||
"""add all statuses by a user to a viewer's stream"""
|
"""add all statuses by a user to a viewer's stream"""
|
||||||
stream_list = [streams[s] for s in stream_list] if stream_list else streams.values()
|
stream_list = [streams[s] for s in stream_list] if stream_list else streams.values()
|
||||||
|
@ -478,7 +507,7 @@ def add_user_statuses_task(viewer_id, user_id, stream_list=None):
|
||||||
stream.add_user_statuses(viewer, user)
|
stream.add_user_statuses(viewer, user)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="medium_priority")
|
@app.task(queue=MEDIUM)
|
||||||
def handle_boost_task(boost_id):
|
def handle_boost_task(boost_id):
|
||||||
"""remove the original post and other, earlier boosts"""
|
"""remove the original post and other, earlier boosts"""
|
||||||
instance = models.Status.objects.get(id=boost_id)
|
instance = models.Status.objects.get(id=boost_id)
|
||||||
|
@ -496,3 +525,20 @@ def handle_boost_task(boost_id):
|
||||||
stream.remove_object_from_related_stores(boosted, stores=audience)
|
stream.remove_object_from_related_stores(boosted, stores=audience)
|
||||||
for status in old_versions:
|
for status in old_versions:
|
||||||
stream.remove_object_from_related_stores(status, stores=audience)
|
stream.remove_object_from_related_stores(status, stores=audience)
|
||||||
|
|
||||||
|
|
||||||
|
def get_status_type(status):
|
||||||
|
"""return status type even for boosted statuses"""
|
||||||
|
status_type = status.status_type.lower()
|
||||||
|
|
||||||
|
# Check if current status is a boost
|
||||||
|
if hasattr(status, "boost"):
|
||||||
|
# Act in accordance of your findings
|
||||||
|
if hasattr(status.boost.boosted_status, "review"):
|
||||||
|
status_type = "review"
|
||||||
|
if hasattr(status.boost.boosted_status, "comment"):
|
||||||
|
status_type = "comment"
|
||||||
|
if hasattr(status.boost.boosted_status, "quotation"):
|
||||||
|
status_type = "quotation"
|
||||||
|
|
||||||
|
return status_type
|
||||||
|
|
|
@ -82,6 +82,8 @@ def search_identifiers(query, *filters, return_first=False):
|
||||||
*filters, reduce(operator.or_, (Q(**f) for f in or_filters))
|
*filters, reduce(operator.or_, (Q(**f) for f in or_filters))
|
||||||
).distinct()
|
).distinct()
|
||||||
if results.count() <= 1:
|
if results.count() <= 1:
|
||||||
|
if return_first:
|
||||||
|
return results.first()
|
||||||
return results
|
return results
|
||||||
|
|
||||||
# when there are multiple editions of the same work, pick the default.
|
# when there are multiple editions of the same work, pick the default.
|
||||||
|
@ -124,6 +126,7 @@ def search_title_author(query, min_confidence, *filters, return_first=False):
|
||||||
result = default
|
result = default
|
||||||
else:
|
else:
|
||||||
result = editions.first()
|
result = editions.first()
|
||||||
|
|
||||||
if return_first:
|
if return_first:
|
||||||
return result
|
return result
|
||||||
list_results.append(result)
|
list_results.append(result)
|
||||||
|
|
|
@ -67,7 +67,7 @@ class Connector(AbstractConnector):
|
||||||
extracted = list(data.get("entities").values())
|
extracted = list(data.get("entities").values())
|
||||||
try:
|
try:
|
||||||
data = extracted[0]
|
data = extracted[0]
|
||||||
except KeyError:
|
except (KeyError, IndexError):
|
||||||
raise ConnectorException("Invalid book data")
|
raise ConnectorException("Invalid book data")
|
||||||
# flatten the data so that images, uri, and claims are on the same level
|
# flatten the data so that images, uri, and claims are on the same level
|
||||||
return {
|
return {
|
||||||
|
@ -128,6 +128,7 @@ class Connector(AbstractConnector):
|
||||||
|
|
||||||
def load_edition_data(self, work_uri):
|
def load_edition_data(self, work_uri):
|
||||||
"""get a list of editions for a work"""
|
"""get a list of editions for a work"""
|
||||||
|
# pylint: disable=line-too-long
|
||||||
url = f"{self.books_url}?action=reverse-claims&property=wdt:P629&value={work_uri}&sort=true"
|
url = f"{self.books_url}?action=reverse-claims&property=wdt:P629&value={work_uri}&sort=true"
|
||||||
return get_data(url)
|
return get_data(url)
|
||||||
|
|
||||||
|
|
|
@ -10,14 +10,9 @@ from bookwyrm.settings import DOMAIN
|
||||||
def email_data():
|
def email_data():
|
||||||
"""fields every email needs"""
|
"""fields every email needs"""
|
||||||
site = models.SiteSettings.objects.get()
|
site = models.SiteSettings.objects.get()
|
||||||
if site.logo_small:
|
|
||||||
logo_path = f"/images/{site.logo_small.url}"
|
|
||||||
else:
|
|
||||||
logo_path = "/static/images/logo-small.png"
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"site_name": site.name,
|
"site_name": site.name,
|
||||||
"logo": logo_path,
|
"logo": site.logo_small_url,
|
||||||
"domain": DOMAIN,
|
"domain": DOMAIN,
|
||||||
"user": None,
|
"user": None,
|
||||||
}
|
}
|
||||||
|
@ -46,6 +41,18 @@ def password_reset_email(reset_code):
|
||||||
send_email.delay(reset_code.user.email, *format_email("password_reset", data))
|
send_email.delay(reset_code.user.email, *format_email("password_reset", data))
|
||||||
|
|
||||||
|
|
||||||
|
def moderation_report_email(report):
|
||||||
|
"""a report was created"""
|
||||||
|
data = email_data()
|
||||||
|
data["reporter"] = report.reporter.localname or report.reporter.username
|
||||||
|
data["reportee"] = report.user.localname or report.user.username
|
||||||
|
data["report_link"] = report.remote_id
|
||||||
|
|
||||||
|
for admin in models.User.objects.filter(groups__name__in=["admin", "moderator"]):
|
||||||
|
data["user"] = admin.display_name
|
||||||
|
send_email.delay(admin.email, *format_email("moderation_report", data))
|
||||||
|
|
||||||
|
|
||||||
def format_email(email_name, data):
|
def format_email(email_name, data):
|
||||||
"""render the email templates"""
|
"""render the email templates"""
|
||||||
subject = get_template(f"email/{email_name}/subject.html").render(data).strip()
|
subject = get_template(f"email/{email_name}/subject.html").render(data).strip()
|
||||||
|
|
|
@ -9,6 +9,8 @@ from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from bookwyrm import models
|
from bookwyrm import models
|
||||||
|
from bookwyrm.models.fields import ClearableFileInputWithWarning
|
||||||
|
from bookwyrm.models.user import FeedFilterChoices
|
||||||
|
|
||||||
|
|
||||||
class CustomForm(ModelForm):
|
class CustomForm(ModelForm):
|
||||||
|
@ -147,6 +149,17 @@ class EditUserForm(CustomForm):
|
||||||
"preferred_language",
|
"preferred_language",
|
||||||
]
|
]
|
||||||
help_texts = {f: None for f in fields}
|
help_texts = {f: None for f in fields}
|
||||||
|
widgets = {
|
||||||
|
"avatar": ClearableFileInputWithWarning(
|
||||||
|
attrs={"aria-describedby": "desc_avatar"}
|
||||||
|
),
|
||||||
|
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
|
||||||
|
"summary": forms.Textarea(attrs={"aria-describedby": "desc_summary"}),
|
||||||
|
"email": forms.EmailInput(attrs={"aria-describedby": "desc_email"}),
|
||||||
|
"discoverable": forms.CheckboxInput(
|
||||||
|
attrs={"aria-describedby": "desc_discoverable"}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class LimitedEditUserForm(CustomForm):
|
class LimitedEditUserForm(CustomForm):
|
||||||
|
@ -160,6 +173,16 @@ class LimitedEditUserForm(CustomForm):
|
||||||
"discoverable",
|
"discoverable",
|
||||||
]
|
]
|
||||||
help_texts = {f: None for f in fields}
|
help_texts = {f: None for f in fields}
|
||||||
|
widgets = {
|
||||||
|
"avatar": ClearableFileInputWithWarning(
|
||||||
|
attrs={"aria-describedby": "desc_avatar"}
|
||||||
|
),
|
||||||
|
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
|
||||||
|
"summary": forms.Textarea(attrs={"aria-describedby": "desc_summary"}),
|
||||||
|
"discoverable": forms.CheckboxInput(
|
||||||
|
attrs={"aria-describedby": "desc_discoverable"}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class DeleteUserForm(CustomForm):
|
class DeleteUserForm(CustomForm):
|
||||||
|
@ -174,6 +197,18 @@ class UserGroupForm(CustomForm):
|
||||||
fields = ["groups"]
|
fields = ["groups"]
|
||||||
|
|
||||||
|
|
||||||
|
class FeedStatusTypesForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.User
|
||||||
|
fields = ["feed_status_types"]
|
||||||
|
help_texts = {f: None for f in fields}
|
||||||
|
widgets = {
|
||||||
|
"feed_status_types": widgets.CheckboxSelectMultiple(
|
||||||
|
choices=FeedFilterChoices,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class CoverForm(CustomForm):
|
class CoverForm(CustomForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Book
|
model = models.Book
|
||||||
|
@ -196,18 +231,92 @@ class EditionForm(CustomForm):
|
||||||
"connector",
|
"connector",
|
||||||
"search_vector",
|
"search_vector",
|
||||||
]
|
]
|
||||||
|
widgets = {
|
||||||
|
"title": forms.TextInput(attrs={"aria-describedby": "desc_title"}),
|
||||||
|
"subtitle": forms.TextInput(attrs={"aria-describedby": "desc_subtitle"}),
|
||||||
|
"description": forms.Textarea(
|
||||||
|
attrs={"aria-describedby": "desc_description"}
|
||||||
|
),
|
||||||
|
"series": forms.TextInput(attrs={"aria-describedby": "desc_series"}),
|
||||||
|
"series_number": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_series_number"}
|
||||||
|
),
|
||||||
|
"languages": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_languages_help desc_languages"}
|
||||||
|
),
|
||||||
|
"publishers": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_publishers_help desc_publishers"}
|
||||||
|
),
|
||||||
|
"first_published_date": forms.SelectDateWidget(
|
||||||
|
attrs={"aria-describedby": "desc_first_published_date"}
|
||||||
|
),
|
||||||
|
"published_date": forms.SelectDateWidget(
|
||||||
|
attrs={"aria-describedby": "desc_published_date"}
|
||||||
|
),
|
||||||
|
"cover": ClearableFileInputWithWarning(
|
||||||
|
attrs={"aria-describedby": "desc_cover"}
|
||||||
|
),
|
||||||
|
"physical_format": forms.Select(
|
||||||
|
attrs={"aria-describedby": "desc_physical_format"}
|
||||||
|
),
|
||||||
|
"physical_format_detail": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_physical_format_detail"}
|
||||||
|
),
|
||||||
|
"pages": forms.NumberInput(attrs={"aria-describedby": "desc_pages"}),
|
||||||
|
"isbn_13": forms.TextInput(attrs={"aria-describedby": "desc_isbn_13"}),
|
||||||
|
"isbn_10": forms.TextInput(attrs={"aria-describedby": "desc_isbn_10"}),
|
||||||
|
"openlibrary_key": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_openlibrary_key"}
|
||||||
|
),
|
||||||
|
"inventaire_id": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_inventaire_id"}
|
||||||
|
),
|
||||||
|
"oclc_number": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_oclc_number"}
|
||||||
|
),
|
||||||
|
"ASIN": forms.TextInput(attrs={"aria-describedby": "desc_ASIN"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class AuthorForm(CustomForm):
|
class AuthorForm(CustomForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Author
|
model = models.Author
|
||||||
exclude = [
|
fields = [
|
||||||
"remote_id",
|
"last_edited_by",
|
||||||
"origin_id",
|
"name",
|
||||||
"created_date",
|
"aliases",
|
||||||
"updated_date",
|
"bio",
|
||||||
"search_vector",
|
"wikipedia_link",
|
||||||
|
"born",
|
||||||
|
"died",
|
||||||
|
"openlibrary_key",
|
||||||
|
"inventaire_id",
|
||||||
|
"librarything_key",
|
||||||
|
"goodreads_key",
|
||||||
|
"isni",
|
||||||
]
|
]
|
||||||
|
widgets = {
|
||||||
|
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
|
||||||
|
"aliases": forms.TextInput(attrs={"aria-describedby": "desc_aliases"}),
|
||||||
|
"bio": forms.Textarea(attrs={"aria-describedby": "desc_bio"}),
|
||||||
|
"wikipedia_link": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_wikipedia_link"}
|
||||||
|
),
|
||||||
|
"born": forms.SelectDateWidget(attrs={"aria-describedby": "desc_born"}),
|
||||||
|
"died": forms.SelectDateWidget(attrs={"aria-describedby": "desc_died"}),
|
||||||
|
"oepnlibrary_key": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_oepnlibrary_key"}
|
||||||
|
),
|
||||||
|
"inventaire_id": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_inventaire_id"}
|
||||||
|
),
|
||||||
|
"librarything_key": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_librarything_key"}
|
||||||
|
),
|
||||||
|
"goodreads_key": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_goodreads_key"}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ImportForm(forms.Form):
|
class ImportForm(forms.Form):
|
||||||
|
@ -282,12 +391,37 @@ class SiteForm(CustomForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.SiteSettings
|
model = models.SiteSettings
|
||||||
exclude = []
|
exclude = []
|
||||||
|
widgets = {
|
||||||
|
"instance_short_description": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_instance_short_description"}
|
||||||
|
),
|
||||||
|
"require_confirm_email": forms.CheckboxInput(
|
||||||
|
attrs={"aria-describedby": "desc_require_confirm_email"}
|
||||||
|
),
|
||||||
|
"invite_request_text": forms.Textarea(
|
||||||
|
attrs={"aria-describedby": "desc_invite_request_text"}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class AnnouncementForm(CustomForm):
|
class AnnouncementForm(CustomForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Announcement
|
model = models.Announcement
|
||||||
exclude = ["remote_id"]
|
exclude = ["remote_id"]
|
||||||
|
widgets = {
|
||||||
|
"preview": forms.TextInput(attrs={"aria-describedby": "desc_preview"}),
|
||||||
|
"content": forms.Textarea(attrs={"aria-describedby": "desc_content"}),
|
||||||
|
"event_date": forms.SelectDateWidget(
|
||||||
|
attrs={"aria-describedby": "desc_event_date"}
|
||||||
|
),
|
||||||
|
"start_date": forms.SelectDateWidget(
|
||||||
|
attrs={"aria-describedby": "desc_start_date"}
|
||||||
|
),
|
||||||
|
"end_date": forms.SelectDateWidget(
|
||||||
|
attrs={"aria-describedby": "desc_end_date"}
|
||||||
|
),
|
||||||
|
"active": forms.CheckboxInput(attrs={"aria-describedby": "desc_active"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ListForm(CustomForm):
|
class ListForm(CustomForm):
|
||||||
|
@ -312,6 +446,9 @@ class EmailBlocklistForm(CustomForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.EmailBlocklist
|
model = models.EmailBlocklist
|
||||||
fields = ["domain"]
|
fields = ["domain"]
|
||||||
|
widgets = {
|
||||||
|
"avatar": forms.TextInput(attrs={"aria-describedby": "desc_domain"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class IPBlocklistForm(CustomForm):
|
class IPBlocklistForm(CustomForm):
|
||||||
|
|
|
@ -7,10 +7,3 @@ class GoodreadsImporter(Importer):
|
||||||
For a more complete example of overriding see librarything_import.py"""
|
For a more complete example of overriding see librarything_import.py"""
|
||||||
|
|
||||||
service = "Goodreads"
|
service = "Goodreads"
|
||||||
|
|
||||||
def parse_fields(self, entry):
|
|
||||||
"""handle the specific fields in goodreads csvs"""
|
|
||||||
entry.update({"import_source": self.service})
|
|
||||||
# add missing 'Date Started' field
|
|
||||||
entry.update({"Date Started": None})
|
|
||||||
return entry
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from bookwyrm import models
|
from bookwyrm import models
|
||||||
from bookwyrm.models import ImportJob, ImportItem
|
from bookwyrm.models import ImportJob, ImportItem
|
||||||
from bookwyrm.tasks import app
|
from bookwyrm.tasks import app, LOW
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -15,33 +15,90 @@ logger = logging.getLogger(__name__)
|
||||||
class Importer:
|
class Importer:
|
||||||
"""Generic class for csv data import from an outside service"""
|
"""Generic class for csv data import from an outside service"""
|
||||||
|
|
||||||
service = "Unknown"
|
service = "Import"
|
||||||
delimiter = ","
|
delimiter = ","
|
||||||
encoding = "UTF-8"
|
encoding = "UTF-8"
|
||||||
mandatory_fields = ["Title", "Author"]
|
|
||||||
|
# these are from Goodreads
|
||||||
|
row_mappings_guesses = [
|
||||||
|
("id", ["id", "book id"]),
|
||||||
|
("title", ["title"]),
|
||||||
|
("authors", ["author", "authors", "primary author"]),
|
||||||
|
("isbn_10", ["isbn10", "isbn"]),
|
||||||
|
("isbn_13", ["isbn13", "isbn", "isbns"]),
|
||||||
|
("shelf", ["shelf", "exclusive shelf", "read status"]),
|
||||||
|
("review_name", ["review name"]),
|
||||||
|
("review_body", ["my review", "review"]),
|
||||||
|
("rating", ["my rating", "rating", "star rating"]),
|
||||||
|
("date_added", ["date added", "entry date", "added"]),
|
||||||
|
("date_started", ["date started", "started"]),
|
||||||
|
("date_finished", ["date finished", "last date read", "date read", "finished"]),
|
||||||
|
]
|
||||||
|
date_fields = ["date_added", "date_started", "date_finished"]
|
||||||
|
shelf_mapping_guesses = {
|
||||||
|
"to-read": ["to-read"],
|
||||||
|
"read": ["read"],
|
||||||
|
"reading": ["currently-reading", "reading"],
|
||||||
|
}
|
||||||
|
|
||||||
def create_job(self, user, csv_file, include_reviews, privacy):
|
def create_job(self, user, csv_file, include_reviews, privacy):
|
||||||
"""check over a csv and creates a database entry for the job"""
|
"""check over a csv and creates a database entry for the job"""
|
||||||
|
csv_reader = csv.DictReader(csv_file, delimiter=self.delimiter)
|
||||||
|
rows = enumerate(list(csv_reader))
|
||||||
job = ImportJob.objects.create(
|
job = ImportJob.objects.create(
|
||||||
user=user, include_reviews=include_reviews, privacy=privacy
|
user=user,
|
||||||
|
include_reviews=include_reviews,
|
||||||
|
privacy=privacy,
|
||||||
|
mappings=self.create_row_mappings(csv_reader.fieldnames),
|
||||||
|
source=self.service,
|
||||||
)
|
)
|
||||||
for index, entry in enumerate(
|
|
||||||
list(csv.DictReader(csv_file, delimiter=self.delimiter))
|
for index, entry in rows:
|
||||||
):
|
self.create_item(job, index, entry)
|
||||||
if not all(x in entry for x in self.mandatory_fields):
|
|
||||||
raise ValueError("Author and title must be in data.")
|
|
||||||
entry = self.parse_fields(entry)
|
|
||||||
self.save_item(job, index, entry)
|
|
||||||
return job
|
return job
|
||||||
|
|
||||||
def save_item(self, job, index, data): # pylint: disable=no-self-use
|
def update_legacy_job(self, job):
|
||||||
"""creates and saves an import item"""
|
"""patch up a job that was in the old format"""
|
||||||
ImportItem(job=job, index=index, data=data).save()
|
items = job.items
|
||||||
|
headers = list(items.first().data.keys())
|
||||||
|
job.mappings = self.create_row_mappings(headers)
|
||||||
|
job.updated_date = timezone.now()
|
||||||
|
job.save()
|
||||||
|
|
||||||
def parse_fields(self, entry):
|
for item in items.all():
|
||||||
"""updates csv data with additional info"""
|
normalized = self.normalize_row(item.data, job.mappings)
|
||||||
entry.update({"import_source": self.service})
|
normalized["shelf"] = self.get_shelf(normalized)
|
||||||
return entry
|
item.normalized_data = normalized
|
||||||
|
item.save()
|
||||||
|
|
||||||
|
def create_row_mappings(self, headers):
|
||||||
|
"""guess what the headers mean"""
|
||||||
|
mappings = {}
|
||||||
|
for (key, guesses) in self.row_mappings_guesses:
|
||||||
|
value = [h for h in headers if h.lower() in guesses]
|
||||||
|
value = value[0] if len(value) else None
|
||||||
|
if value:
|
||||||
|
headers.remove(value)
|
||||||
|
mappings[key] = value
|
||||||
|
return mappings
|
||||||
|
|
||||||
|
def create_item(self, job, index, data):
|
||||||
|
"""creates and saves an import item"""
|
||||||
|
normalized = self.normalize_row(data, job.mappings)
|
||||||
|
normalized["shelf"] = self.get_shelf(normalized)
|
||||||
|
ImportItem(job=job, index=index, data=data, normalized_data=normalized).save()
|
||||||
|
|
||||||
|
def get_shelf(self, normalized_row):
|
||||||
|
"""determine which shelf to use"""
|
||||||
|
shelf_name = normalized_row["shelf"]
|
||||||
|
shelf = [
|
||||||
|
s for (s, gs) in self.shelf_mapping_guesses.items() if shelf_name in gs
|
||||||
|
]
|
||||||
|
return shelf[0] if shelf else None
|
||||||
|
|
||||||
|
def normalize_row(self, entry, mappings): # pylint: disable=no-self-use
|
||||||
|
"""use the dataclass to create the formatted row of data"""
|
||||||
|
return {k: entry.get(v) for k, v in mappings.items()}
|
||||||
|
|
||||||
def create_retry_job(self, user, original_job, items):
|
def create_retry_job(self, user, original_job, items):
|
||||||
"""retry items that didn't import"""
|
"""retry items that didn't import"""
|
||||||
|
@ -49,55 +106,65 @@ class Importer:
|
||||||
user=user,
|
user=user,
|
||||||
include_reviews=original_job.include_reviews,
|
include_reviews=original_job.include_reviews,
|
||||||
privacy=original_job.privacy,
|
privacy=original_job.privacy,
|
||||||
|
# TODO: allow users to adjust mappings
|
||||||
|
mappings=original_job.mappings,
|
||||||
retry=True,
|
retry=True,
|
||||||
)
|
)
|
||||||
for item in items:
|
for item in items:
|
||||||
self.save_item(job, item.index, item.data)
|
# this will re-normalize the raw data
|
||||||
|
self.create_item(job, item.index, item.data)
|
||||||
return job
|
return job
|
||||||
|
|
||||||
def start_import(self, job):
|
def start_import(self, job): # pylint: disable=no-self-use
|
||||||
"""initalizes a csv import job"""
|
"""initalizes a csv import job"""
|
||||||
result = import_data.delay(self.service, job.id)
|
result = start_import_task.delay(job.id)
|
||||||
job.task_id = result.id
|
job.task_id = result.id
|
||||||
job.save()
|
job.save()
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue="low_priority")
|
||||||
def import_data(source, job_id):
|
def start_import_task(job_id):
|
||||||
"""does the actual lookup work in a celery task"""
|
"""trigger the child tasks for each row"""
|
||||||
job = ImportJob.objects.get(id=job_id)
|
job = ImportJob.objects.get(id=job_id)
|
||||||
|
# these are sub-tasks so that one big task doesn't use up all the memory in celery
|
||||||
|
for item in job.items.values_list("id", flat=True).all():
|
||||||
|
import_item_task.delay(item)
|
||||||
|
|
||||||
|
|
||||||
|
@app.task(queue="low_priority")
|
||||||
|
def import_item_task(item_id):
|
||||||
|
"""resolve a row into a book"""
|
||||||
|
item = models.ImportItem.objects.get(id=item_id)
|
||||||
try:
|
try:
|
||||||
for item in job.items.all():
|
item.resolve()
|
||||||
try:
|
except Exception as err: # pylint: disable=broad-except
|
||||||
item.resolve()
|
item.fail_reason = _("Error loading book")
|
||||||
except Exception as err: # pylint: disable=broad-except
|
item.save()
|
||||||
logger.exception(err)
|
item.update_job()
|
||||||
item.fail_reason = _("Error loading book")
|
raise err
|
||||||
item.save()
|
|
||||||
continue
|
|
||||||
|
|
||||||
if item.book or item.book_guess:
|
if item.book:
|
||||||
item.save()
|
# shelves book and handles reviews
|
||||||
|
handle_imported_book(item)
|
||||||
|
else:
|
||||||
|
item.fail_reason = _("Could not find a match for book")
|
||||||
|
|
||||||
if item.book:
|
item.save()
|
||||||
# shelves book and handles reviews
|
item.update_job()
|
||||||
handle_imported_book(
|
|
||||||
source, job.user, item, job.include_reviews, job.privacy
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
item.fail_reason = _("Could not find a match for book")
|
|
||||||
item.save()
|
|
||||||
finally:
|
|
||||||
job.complete = True
|
|
||||||
job.save()
|
|
||||||
|
|
||||||
|
|
||||||
def handle_imported_book(source, user, item, include_reviews, privacy):
|
def handle_imported_book(item):
|
||||||
"""process a csv and then post about it"""
|
"""process a csv and then post about it"""
|
||||||
|
job = item.job
|
||||||
|
user = job.user
|
||||||
if isinstance(item.book, models.Work):
|
if isinstance(item.book, models.Work):
|
||||||
item.book = item.book.default_edition
|
item.book = item.book.default_edition
|
||||||
if not item.book:
|
if not item.book:
|
||||||
|
item.fail_reason = _("Error loading book")
|
||||||
|
item.save()
|
||||||
return
|
return
|
||||||
|
if not isinstance(item.book, models.Edition):
|
||||||
|
item.book = item.book.edition
|
||||||
|
|
||||||
existing_shelf = models.ShelfBook.objects.filter(book=item.book, user=user).exists()
|
existing_shelf = models.ShelfBook.objects.filter(book=item.book, user=user).exists()
|
||||||
|
|
||||||
|
@ -105,9 +172,9 @@ def handle_imported_book(source, user, item, include_reviews, privacy):
|
||||||
if item.shelf and not existing_shelf:
|
if item.shelf and not existing_shelf:
|
||||||
desired_shelf = models.Shelf.objects.get(identifier=item.shelf, user=user)
|
desired_shelf = models.Shelf.objects.get(identifier=item.shelf, user=user)
|
||||||
shelved_date = item.date_added or timezone.now()
|
shelved_date = item.date_added or timezone.now()
|
||||||
models.ShelfBook.objects.create(
|
models.ShelfBook(
|
||||||
book=item.book, shelf=desired_shelf, user=user, shelved_date=shelved_date
|
book=item.book, shelf=desired_shelf, user=user, shelved_date=shelved_date
|
||||||
)
|
).save(priority=LOW)
|
||||||
|
|
||||||
for read in item.reads:
|
for read in item.reads:
|
||||||
# check for an existing readthrough with the same dates
|
# check for an existing readthrough with the same dates
|
||||||
|
@ -122,35 +189,52 @@ def handle_imported_book(source, user, item, include_reviews, privacy):
|
||||||
read.user = user
|
read.user = user
|
||||||
read.save()
|
read.save()
|
||||||
|
|
||||||
if include_reviews and (item.rating or item.review):
|
if job.include_reviews and (item.rating or item.review) and not item.linked_review:
|
||||||
# we don't know the publication date of the review,
|
# we don't know the publication date of the review,
|
||||||
# but "now" is a bad guess
|
# but "now" is a bad guess
|
||||||
published_date_guess = item.date_read or item.date_added
|
published_date_guess = item.date_read or item.date_added
|
||||||
if item.review:
|
if item.review:
|
||||||
# pylint: disable=consider-using-f-string
|
# pylint: disable=consider-using-f-string
|
||||||
review_title = (
|
review_title = "Review of {!r} on {!r}".format(
|
||||||
"Review of {!r} on {!r}".format(
|
item.book.title,
|
||||||
item.book.title,
|
job.source,
|
||||||
source,
|
|
||||||
)
|
|
||||||
if item.review
|
|
||||||
else ""
|
|
||||||
)
|
)
|
||||||
models.Review.objects.create(
|
review = models.Review.objects.filter(
|
||||||
user=user,
|
user=user,
|
||||||
book=item.book,
|
book=item.book,
|
||||||
name=review_title,
|
name=review_title,
|
||||||
content=item.review,
|
|
||||||
rating=item.rating,
|
rating=item.rating,
|
||||||
published_date=published_date_guess,
|
published_date=published_date_guess,
|
||||||
privacy=privacy,
|
).first()
|
||||||
)
|
if not review:
|
||||||
|
review = models.Review(
|
||||||
|
user=user,
|
||||||
|
book=item.book,
|
||||||
|
name=review_title,
|
||||||
|
content=item.review,
|
||||||
|
rating=item.rating,
|
||||||
|
published_date=published_date_guess,
|
||||||
|
privacy=job.privacy,
|
||||||
|
)
|
||||||
|
review.save(software="bookwyrm", priority=LOW)
|
||||||
else:
|
else:
|
||||||
# just a rating
|
# just a rating
|
||||||
models.ReviewRating.objects.create(
|
review = models.ReviewRating.objects.filter(
|
||||||
user=user,
|
user=user,
|
||||||
book=item.book,
|
book=item.book,
|
||||||
rating=item.rating,
|
|
||||||
published_date=published_date_guess,
|
published_date=published_date_guess,
|
||||||
privacy=privacy,
|
rating=item.rating,
|
||||||
)
|
).first()
|
||||||
|
if not review:
|
||||||
|
review = models.ReviewRating(
|
||||||
|
user=user,
|
||||||
|
book=item.book,
|
||||||
|
rating=item.rating,
|
||||||
|
published_date=published_date_guess,
|
||||||
|
privacy=job.privacy,
|
||||||
|
)
|
||||||
|
review.save(software="bookwyrm", priority=LOW)
|
||||||
|
|
||||||
|
# only broadcast this review to other bookwyrm instances
|
||||||
|
item.linked_review = review
|
||||||
|
item.save()
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
""" handle reading a csv from librarything """
|
""" handle reading a tsv from librarything """
|
||||||
import re
|
import re
|
||||||
import math
|
|
||||||
|
|
||||||
from . import Importer
|
from . import Importer
|
||||||
|
|
||||||
|
|
||||||
|
@ -11,32 +9,18 @@ class LibrarythingImporter(Importer):
|
||||||
service = "LibraryThing"
|
service = "LibraryThing"
|
||||||
delimiter = "\t"
|
delimiter = "\t"
|
||||||
encoding = "ISO-8859-1"
|
encoding = "ISO-8859-1"
|
||||||
# mandatory_fields : fields matching the book title and author
|
|
||||||
mandatory_fields = ["Title", "Primary Author"]
|
|
||||||
|
|
||||||
def parse_fields(self, entry):
|
def normalize_row(self, entry, mappings): # pylint: disable=no-self-use
|
||||||
"""custom parsing for librarything"""
|
"""use the dataclass to create the formatted row of data"""
|
||||||
data = {}
|
remove_brackets = lambda v: re.sub(r"\[|\]", "", v) if v else None
|
||||||
data["import_source"] = self.service
|
normalized = {k: remove_brackets(entry.get(v)) for k, v in mappings.items()}
|
||||||
data["Book Id"] = entry["Book Id"]
|
isbn_13 = normalized["isbn_13"].split(", ")
|
||||||
data["Title"] = entry["Title"]
|
normalized["isbn_13"] = isbn_13[1] if len(isbn_13) > 0 else None
|
||||||
data["Author"] = entry["Primary Author"]
|
return normalized
|
||||||
data["ISBN13"] = entry["ISBN"]
|
|
||||||
data["My Review"] = entry["Review"]
|
|
||||||
if entry["Rating"]:
|
|
||||||
data["My Rating"] = math.ceil(float(entry["Rating"]))
|
|
||||||
else:
|
|
||||||
data["My Rating"] = ""
|
|
||||||
data["Date Added"] = re.sub(r"\[|\]", "", entry["Entry Date"])
|
|
||||||
data["Date Started"] = re.sub(r"\[|\]", "", entry["Date Started"])
|
|
||||||
data["Date Read"] = re.sub(r"\[|\]", "", entry["Date Read"])
|
|
||||||
|
|
||||||
data["Exclusive Shelf"] = None
|
def get_shelf(self, normalized_row):
|
||||||
if data["Date Read"]:
|
if normalized_row["date_finished"]:
|
||||||
data["Exclusive Shelf"] = "read"
|
return "read"
|
||||||
elif data["Date Started"]:
|
if normalized_row["date_started"]:
|
||||||
data["Exclusive Shelf"] = "reading"
|
return "reading"
|
||||||
else:
|
return "to-read"
|
||||||
data["Exclusive Shelf"] = "to-read"
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
|
@ -1,7 +1,4 @@
|
||||||
""" handle reading a csv from librarything """
|
""" handle reading a csv from storygraph"""
|
||||||
import re
|
|
||||||
import math
|
|
||||||
|
|
||||||
from . import Importer
|
from . import Importer
|
||||||
|
|
||||||
|
|
||||||
|
@ -9,26 +6,3 @@ class StorygraphImporter(Importer):
|
||||||
"""csv downloads from librarything"""
|
"""csv downloads from librarything"""
|
||||||
|
|
||||||
service = "Storygraph"
|
service = "Storygraph"
|
||||||
# mandatory_fields : fields matching the book title and author
|
|
||||||
mandatory_fields = ["Title"]
|
|
||||||
|
|
||||||
def parse_fields(self, entry):
|
|
||||||
"""custom parsing for storygraph"""
|
|
||||||
data = {}
|
|
||||||
data["import_source"] = self.service
|
|
||||||
data["Title"] = entry["Title"]
|
|
||||||
data["Author"] = entry["Authors"] if "Authors" in entry else entry["Author"]
|
|
||||||
data["ISBN13"] = entry["ISBN"]
|
|
||||||
data["My Review"] = entry["Review"]
|
|
||||||
if entry["Star Rating"]:
|
|
||||||
data["My Rating"] = math.ceil(float(entry["Star Rating"]))
|
|
||||||
else:
|
|
||||||
data["My Rating"] = ""
|
|
||||||
|
|
||||||
data["Date Added"] = re.sub(r"[/]", "-", entry["Date Added"])
|
|
||||||
data["Date Read"] = re.sub(r"[/]", "-", entry["Last Date Read"])
|
|
||||||
|
|
||||||
data["Exclusive Shelf"] = (
|
|
||||||
{"read": "read", "currently-reading": "reading", "to-read": "to-read"}
|
|
||||||
).get(entry["Read Status"], None)
|
|
||||||
return data
|
|
||||||
|
|
25
bookwyrm/migrations/0113_auto_20211110_2104.py
Normal file
25
bookwyrm/migrations/0113_auto_20211110_2104.py
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
# Generated by Django 3.2.5 on 2021-11-10 21:04
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0112_auto_20211022_0844"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="importitem",
|
||||||
|
name="normalized_data",
|
||||||
|
field=models.JSONField(default={}),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="importjob",
|
||||||
|
name="mappings",
|
||||||
|
field=models.JSONField(default={}),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
19
bookwyrm/migrations/0114_importjob_source.py
Normal file
19
bookwyrm/migrations/0114_importjob_source.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
# Generated by Django 3.2.5 on 2021-11-13 00:56
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0113_auto_20211110_2104"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="importjob",
|
||||||
|
name="source",
|
||||||
|
field=models.CharField(default="Import", max_length=100),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
24
bookwyrm/migrations/0115_importitem_linked_review.py
Normal file
24
bookwyrm/migrations/0115_importitem_linked_review.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
# Generated by Django 3.2.5 on 2021-11-13 19:35
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0114_importjob_source"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="importitem",
|
||||||
|
name="linked_review",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to="bookwyrm.review",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
23
bookwyrm/migrations/0116_auto_20211114_1734.py
Normal file
23
bookwyrm/migrations/0116_auto_20211114_1734.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 3.2.5 on 2021-11-14 17:34
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.utils.timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0115_importitem_linked_review"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="importjob",
|
||||||
|
name="task_id",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="importjob",
|
||||||
|
name="updated_date",
|
||||||
|
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
]
|
32
bookwyrm/migrations/0117_alter_user_preferred_language.py
Normal file
32
bookwyrm/migrations/0117_alter_user_preferred_language.py
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
# Generated by Django 3.2.5 on 2021-11-15 18:22
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0116_auto_20211114_1734"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="preferred_language",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("en-us", "English"),
|
||||||
|
("de-de", "Deutsch (German)"),
|
||||||
|
("es-es", "Español (Spanish)"),
|
||||||
|
("fr-fr", "Français (French)"),
|
||||||
|
("lt-lt", "lietuvių (Lithuanian)"),
|
||||||
|
("pt-br", "Português - Brasil (Brazilian Portuguese)"),
|
||||||
|
("zh-hans", "简体中文 (Simplified Chinese)"),
|
||||||
|
("zh-hant", "繁體中文 (Traditional Chinese)"),
|
||||||
|
],
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
33
bookwyrm/migrations/0118_alter_user_preferred_language.py
Normal file
33
bookwyrm/migrations/0118_alter_user_preferred_language.py
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
# Generated by Django 3.2.5 on 2021-11-17 18:01
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0117_alter_user_preferred_language"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="preferred_language",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("en-us", "English"),
|
||||||
|
("de-de", "Deutsch (German)"),
|
||||||
|
("es-es", "Español (Spanish)"),
|
||||||
|
("gl-es", "Galego (Galician)"),
|
||||||
|
("fr-fr", "Français (French)"),
|
||||||
|
("lt-lt", "Lietuvių (Lithuanian)"),
|
||||||
|
("pt-br", "Português - Brasil (Brazilian Portuguese)"),
|
||||||
|
("zh-hans", "简体中文 (Simplified Chinese)"),
|
||||||
|
("zh-hant", "繁體中文 (Traditional Chinese)"),
|
||||||
|
],
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
32
bookwyrm/migrations/0119_user_feed_status_types.py
Normal file
32
bookwyrm/migrations/0119_user_feed_status_types.py
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
# Generated by Django 3.2.5 on 2021-11-24 10:15
|
||||||
|
|
||||||
|
import bookwyrm.models.user
|
||||||
|
import django.contrib.postgres.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0118_alter_user_preferred_language"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="feed_status_types",
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("review", "Reviews"),
|
||||||
|
("comment", "Comments"),
|
||||||
|
("quotation", "Quotations"),
|
||||||
|
("everything", "Everything else"),
|
||||||
|
],
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
default=bookwyrm.models.user.get_feed_filter_choices,
|
||||||
|
size=8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -20,7 +20,7 @@ from django.utils.http import http_date
|
||||||
from bookwyrm import activitypub
|
from bookwyrm import activitypub
|
||||||
from bookwyrm.settings import USER_AGENT, PAGE_LENGTH
|
from bookwyrm.settings import USER_AGENT, PAGE_LENGTH
|
||||||
from bookwyrm.signatures import make_signature, make_digest
|
from bookwyrm.signatures import make_signature, make_digest
|
||||||
from bookwyrm.tasks import app
|
from bookwyrm.tasks import app, MEDIUM
|
||||||
from bookwyrm.models.fields import ImageField, ManyToManyField
|
from bookwyrm.models.fields import ImageField, ManyToManyField
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -29,7 +29,6 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PropertyField = namedtuple("PropertyField", ("set_activity_from_field"))
|
PropertyField = namedtuple("PropertyField", ("set_activity_from_field"))
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
def set_activity_from_property_field(activity, obj, field):
|
def set_activity_from_property_field(activity, obj, field):
|
||||||
"""assign a model property value to the activity json"""
|
"""assign a model property value to the activity json"""
|
||||||
|
@ -126,12 +125,15 @@ class ActivitypubMixin:
|
||||||
# there OUGHT to be only one match
|
# there OUGHT to be only one match
|
||||||
return match.first()
|
return match.first()
|
||||||
|
|
||||||
def broadcast(self, activity, sender, software=None):
|
def broadcast(self, activity, sender, software=None, queue=MEDIUM):
|
||||||
"""send out an activity"""
|
"""send out an activity"""
|
||||||
broadcast_task.delay(
|
broadcast_task.apply_async(
|
||||||
sender.id,
|
args=(
|
||||||
json.dumps(activity, cls=activitypub.ActivityEncoder),
|
sender.id,
|
||||||
self.get_recipients(software=software),
|
json.dumps(activity, cls=activitypub.ActivityEncoder),
|
||||||
|
self.get_recipients(software=software),
|
||||||
|
),
|
||||||
|
queue=queue,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_recipients(self, software=None):
|
def get_recipients(self, software=None):
|
||||||
|
@ -195,7 +197,7 @@ class ActivitypubMixin:
|
||||||
class ObjectMixin(ActivitypubMixin):
|
class ObjectMixin(ActivitypubMixin):
|
||||||
"""add this mixin for object models that are AP serializable"""
|
"""add this mixin for object models that are AP serializable"""
|
||||||
|
|
||||||
def save(self, *args, created=None, **kwargs):
|
def save(self, *args, created=None, software=None, priority=MEDIUM, **kwargs):
|
||||||
"""broadcast created/updated/deleted objects as appropriate"""
|
"""broadcast created/updated/deleted objects as appropriate"""
|
||||||
broadcast = kwargs.get("broadcast", True)
|
broadcast = kwargs.get("broadcast", True)
|
||||||
# this bonus kwarg would cause an error in the base save method
|
# this bonus kwarg would cause an error in the base save method
|
||||||
|
@ -219,15 +221,17 @@ class ObjectMixin(ActivitypubMixin):
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
software = None
|
|
||||||
# do we have a "pure" activitypub version of this for mastodon?
|
# do we have a "pure" activitypub version of this for mastodon?
|
||||||
if hasattr(self, "pure_content"):
|
if software != "bookwyrm" and hasattr(self, "pure_content"):
|
||||||
pure_activity = self.to_create_activity(user, pure=True)
|
pure_activity = self.to_create_activity(user, pure=True)
|
||||||
self.broadcast(pure_activity, user, software="other")
|
self.broadcast(
|
||||||
|
pure_activity, user, software="other", queue=priority
|
||||||
|
)
|
||||||
|
# set bookwyrm so that that type is also sent
|
||||||
software = "bookwyrm"
|
software = "bookwyrm"
|
||||||
# sends to BW only if we just did a pure version for masto
|
# sends to BW only if we just did a pure version for masto
|
||||||
activity = self.to_create_activity(user)
|
activity = self.to_create_activity(user)
|
||||||
self.broadcast(activity, user, software=software)
|
self.broadcast(activity, user, software=software, queue=priority)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
# janky as heck, this catches the mutliple inheritence chain
|
# janky as heck, this catches the mutliple inheritence chain
|
||||||
# for boosts and ignores this auxilliary broadcast
|
# for boosts and ignores this auxilliary broadcast
|
||||||
|
@ -241,8 +245,7 @@ class ObjectMixin(ActivitypubMixin):
|
||||||
if isinstance(self, user_model):
|
if isinstance(self, user_model):
|
||||||
user = self
|
user = self
|
||||||
# book data tracks last editor
|
# book data tracks last editor
|
||||||
elif hasattr(self, "last_edited_by"):
|
user = user or getattr(self, "last_edited_by", None)
|
||||||
user = self.last_edited_by
|
|
||||||
# again, if we don't know the user or they're remote, don't bother
|
# again, if we don't know the user or they're remote, don't bother
|
||||||
if not user or not user.local:
|
if not user or not user.local:
|
||||||
return
|
return
|
||||||
|
@ -252,7 +255,7 @@ class ObjectMixin(ActivitypubMixin):
|
||||||
activity = self.to_delete_activity(user)
|
activity = self.to_delete_activity(user)
|
||||||
else:
|
else:
|
||||||
activity = self.to_update_activity(user)
|
activity = self.to_update_activity(user)
|
||||||
self.broadcast(activity, user)
|
self.broadcast(activity, user, queue=priority)
|
||||||
|
|
||||||
def to_create_activity(self, user, **kwargs):
|
def to_create_activity(self, user, **kwargs):
|
||||||
"""returns the object wrapped in a Create activity"""
|
"""returns the object wrapped in a Create activity"""
|
||||||
|
@ -375,9 +378,9 @@ class CollectionItemMixin(ActivitypubMixin):
|
||||||
|
|
||||||
activity_serializer = activitypub.CollectionItem
|
activity_serializer = activitypub.CollectionItem
|
||||||
|
|
||||||
def broadcast(self, activity, sender, software="bookwyrm"):
|
def broadcast(self, activity, sender, software="bookwyrm", queue=MEDIUM):
|
||||||
"""only send book collection updates to other bookwyrm instances"""
|
"""only send book collection updates to other bookwyrm instances"""
|
||||||
super().broadcast(activity, sender, software=software)
|
super().broadcast(activity, sender, software=software, queue=queue)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def privacy(self):
|
def privacy(self):
|
||||||
|
@ -396,7 +399,7 @@ class CollectionItemMixin(ActivitypubMixin):
|
||||||
return []
|
return []
|
||||||
return [collection_field.user]
|
return [collection_field.user]
|
||||||
|
|
||||||
def save(self, *args, broadcast=True, **kwargs):
|
def save(self, *args, broadcast=True, priority=MEDIUM, **kwargs):
|
||||||
"""broadcast updated"""
|
"""broadcast updated"""
|
||||||
# first off, we want to save normally no matter what
|
# first off, we want to save normally no matter what
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
@ -407,7 +410,7 @@ class CollectionItemMixin(ActivitypubMixin):
|
||||||
|
|
||||||
# adding an obj to the collection
|
# adding an obj to the collection
|
||||||
activity = self.to_add_activity(self.user)
|
activity = self.to_add_activity(self.user)
|
||||||
self.broadcast(activity, self.user)
|
self.broadcast(activity, self.user, queue=priority)
|
||||||
|
|
||||||
def delete(self, *args, broadcast=True, **kwargs):
|
def delete(self, *args, broadcast=True, **kwargs):
|
||||||
"""broadcast a remove activity"""
|
"""broadcast a remove activity"""
|
||||||
|
@ -440,12 +443,12 @@ class CollectionItemMixin(ActivitypubMixin):
|
||||||
class ActivityMixin(ActivitypubMixin):
|
class ActivityMixin(ActivitypubMixin):
|
||||||
"""add this mixin for models that are AP serializable"""
|
"""add this mixin for models that are AP serializable"""
|
||||||
|
|
||||||
def save(self, *args, broadcast=True, **kwargs):
|
def save(self, *args, broadcast=True, priority=MEDIUM, **kwargs):
|
||||||
"""broadcast activity"""
|
"""broadcast activity"""
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
user = self.user if hasattr(self, "user") else self.user_subject
|
user = self.user if hasattr(self, "user") else self.user_subject
|
||||||
if broadcast and user.local:
|
if broadcast and user.local:
|
||||||
self.broadcast(self.to_activity(), user)
|
self.broadcast(self.to_activity(), user, queue=priority)
|
||||||
|
|
||||||
def delete(self, *args, broadcast=True, **kwargs):
|
def delete(self, *args, broadcast=True, **kwargs):
|
||||||
"""nevermind, undo that activity"""
|
"""nevermind, undo that activity"""
|
||||||
|
@ -502,7 +505,7 @@ def unfurl_related_field(related_field, sort_field=None):
|
||||||
return related_field.remote_id
|
return related_field.remote_id
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="medium_priority")
|
@app.task(queue=MEDIUM)
|
||||||
def broadcast_task(sender_id, activity, recipients):
|
def broadcast_task(sender_id, activity, recipients):
|
||||||
"""the celery task for broadcast"""
|
"""the celery task for broadcast"""
|
||||||
user_model = apps.get_model("bookwyrm.User", require_ready=True)
|
user_model = apps.get_model("bookwyrm.User", require_ready=True)
|
||||||
|
|
|
@ -27,7 +27,7 @@ class Author(BookDataModel):
|
||||||
# idk probably other keys would be useful here?
|
# idk probably other keys would be useful here?
|
||||||
born = fields.DateTimeField(blank=True, null=True)
|
born = fields.DateTimeField(blank=True, null=True)
|
||||||
died = fields.DateTimeField(blank=True, null=True)
|
died = fields.DateTimeField(blank=True, null=True)
|
||||||
name = fields.CharField(max_length=255, deduplication_field=True)
|
name = fields.CharField(max_length=255)
|
||||||
aliases = fields.ArrayField(
|
aliases = fields.ArrayField(
|
||||||
models.CharField(max_length=255), blank=True, default=list
|
models.CharField(max_length=255), blank=True, default=list
|
||||||
)
|
)
|
||||||
|
|
|
@ -66,9 +66,10 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
|
||||||
self.remote_id = None
|
self.remote_id = None
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
def broadcast(self, activity, sender, software="bookwyrm"):
|
# pylint: disable=arguments-differ
|
||||||
|
def broadcast(self, activity, sender, software="bookwyrm", **kwargs):
|
||||||
"""only send book data updates to other bookwyrm instances"""
|
"""only send book data updates to other bookwyrm instances"""
|
||||||
super().broadcast(activity, sender, software=software)
|
super().broadcast(activity, sender, software=software, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class Book(BookDataModel):
|
class Book(BookDataModel):
|
||||||
|
|
|
@ -3,6 +3,7 @@ from dataclasses import MISSING
|
||||||
import imghdr
|
import imghdr
|
||||||
import re
|
import re
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
import dateutil.parser
|
import dateutil.parser
|
||||||
from dateutil.parser import ParserError
|
from dateutil.parser import ParserError
|
||||||
|
@ -13,11 +14,12 @@ from django.db import models
|
||||||
from django.forms import ClearableFileInput, ImageField as DjangoImageField
|
from django.forms import ClearableFileInput, ImageField as DjangoImageField
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.utils.encoding import filepath_to_uri
|
||||||
|
|
||||||
from bookwyrm import activitypub
|
from bookwyrm import activitypub
|
||||||
from bookwyrm.connectors import get_image
|
from bookwyrm.connectors import get_image
|
||||||
from bookwyrm.sanitize_html import InputHtmlParser
|
from bookwyrm.sanitize_html import InputHtmlParser
|
||||||
from bookwyrm.settings import DOMAIN
|
from bookwyrm.settings import MEDIA_FULL_URL
|
||||||
|
|
||||||
|
|
||||||
def validate_remote_id(value):
|
def validate_remote_id(value):
|
||||||
|
@ -294,7 +296,7 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def set_field_from_activity(self, instance, data, overwrite=True):
|
def set_field_from_activity(self, instance, data, overwrite=True):
|
||||||
"""helper function for assinging a value to the field"""
|
"""helper function for assigning a value to the field"""
|
||||||
if not overwrite and getattr(instance, self.name).exists():
|
if not overwrite and getattr(instance, self.name).exists():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -381,17 +383,6 @@ class CustomImageField(DjangoImageField):
|
||||||
widget = ClearableFileInputWithWarning
|
widget = ClearableFileInputWithWarning
|
||||||
|
|
||||||
|
|
||||||
def image_serializer(value, alt):
|
|
||||||
"""helper for serializing images"""
|
|
||||||
if value and hasattr(value, "url"):
|
|
||||||
url = value.url
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
if not url[:4] == "http":
|
|
||||||
url = f"https://{DOMAIN}{url}"
|
|
||||||
return activitypub.Document(url=url, name=alt)
|
|
||||||
|
|
||||||
|
|
||||||
class ImageField(ActivitypubFieldMixin, models.ImageField):
|
class ImageField(ActivitypubFieldMixin, models.ImageField):
|
||||||
"""activitypub-aware image field"""
|
"""activitypub-aware image field"""
|
||||||
|
|
||||||
|
@ -407,7 +398,11 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
|
||||||
if formatted is None or formatted is MISSING:
|
if formatted is None or formatted is MISSING:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not overwrite and hasattr(instance, self.name):
|
if (
|
||||||
|
not overwrite
|
||||||
|
and hasattr(instance, self.name)
|
||||||
|
and getattr(instance, self.name)
|
||||||
|
):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
getattr(instance, self.name).save(*formatted, save=save)
|
getattr(instance, self.name).save(*formatted, save=save)
|
||||||
|
@ -424,7 +419,12 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
|
||||||
activity[key] = formatted
|
activity[key] = formatted
|
||||||
|
|
||||||
def field_to_activity(self, value, alt=None):
|
def field_to_activity(self, value, alt=None):
|
||||||
return image_serializer(value, alt)
|
url = get_absolute_url(value)
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return activitypub.Document(url=url, name=alt)
|
||||||
|
|
||||||
def field_from_activity(self, value):
|
def field_from_activity(self, value):
|
||||||
image_slug = value
|
image_slug = value
|
||||||
|
@ -461,6 +461,20 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_absolute_url(value):
|
||||||
|
"""returns an absolute URL for the image"""
|
||||||
|
name = getattr(value, "name")
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
url = filepath_to_uri(name)
|
||||||
|
if url is not None:
|
||||||
|
url = url.lstrip("/")
|
||||||
|
url = urljoin(MEDIA_FULL_URL, url)
|
||||||
|
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
|
class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
|
||||||
"""activitypub-aware datetime field"""
|
"""activitypub-aware datetime field"""
|
||||||
|
|
||||||
|
|
|
@ -6,20 +6,14 @@ from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from bookwyrm.connectors import connector_manager
|
from bookwyrm.connectors import connector_manager
|
||||||
from bookwyrm.models import ReadThrough, User, Book
|
from bookwyrm.models import ReadThrough, User, Book, Edition
|
||||||
from .fields import PrivacyLevels
|
from .fields import PrivacyLevels
|
||||||
|
|
||||||
|
|
||||||
# Mapping goodreads -> bookwyrm shelf titles.
|
|
||||||
GOODREADS_SHELVES = {
|
|
||||||
"read": "read",
|
|
||||||
"currently-reading": "reading",
|
|
||||||
"to-read": "to-read",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def unquote_string(text):
|
def unquote_string(text):
|
||||||
"""resolve csv quote weirdness"""
|
"""resolve csv quote weirdness"""
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
match = re.match(r'="([^"]*)"', text)
|
match = re.match(r'="([^"]*)"', text)
|
||||||
if match:
|
if match:
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
|
@ -41,14 +35,21 @@ class ImportJob(models.Model):
|
||||||
|
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
created_date = models.DateTimeField(default=timezone.now)
|
created_date = models.DateTimeField(default=timezone.now)
|
||||||
task_id = models.CharField(max_length=100, null=True)
|
updated_date = models.DateTimeField(default=timezone.now)
|
||||||
include_reviews = models.BooleanField(default=True)
|
include_reviews = models.BooleanField(default=True)
|
||||||
|
mappings = models.JSONField()
|
||||||
complete = models.BooleanField(default=False)
|
complete = models.BooleanField(default=False)
|
||||||
|
source = models.CharField(max_length=100)
|
||||||
privacy = models.CharField(
|
privacy = models.CharField(
|
||||||
max_length=255, default="public", choices=PrivacyLevels.choices
|
max_length=255, default="public", choices=PrivacyLevels.choices
|
||||||
)
|
)
|
||||||
retry = models.BooleanField(default=False)
|
retry = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pending_items(self):
|
||||||
|
"""items that haven't been processed yet"""
|
||||||
|
return self.items.filter(fail_reason__isnull=True, book__isnull=True)
|
||||||
|
|
||||||
|
|
||||||
class ImportItem(models.Model):
|
class ImportItem(models.Model):
|
||||||
"""a single line of a csv being imported"""
|
"""a single line of a csv being imported"""
|
||||||
|
@ -56,6 +57,7 @@ class ImportItem(models.Model):
|
||||||
job = models.ForeignKey(ImportJob, on_delete=models.CASCADE, related_name="items")
|
job = models.ForeignKey(ImportJob, on_delete=models.CASCADE, related_name="items")
|
||||||
index = models.IntegerField()
|
index = models.IntegerField()
|
||||||
data = models.JSONField()
|
data = models.JSONField()
|
||||||
|
normalized_data = models.JSONField()
|
||||||
book = models.ForeignKey(Book, on_delete=models.SET_NULL, null=True, blank=True)
|
book = models.ForeignKey(Book, on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
book_guess = models.ForeignKey(
|
book_guess = models.ForeignKey(
|
||||||
Book,
|
Book,
|
||||||
|
@ -65,9 +67,26 @@ class ImportItem(models.Model):
|
||||||
related_name="book_guess",
|
related_name="book_guess",
|
||||||
)
|
)
|
||||||
fail_reason = models.TextField(null=True)
|
fail_reason = models.TextField(null=True)
|
||||||
|
linked_review = models.ForeignKey(
|
||||||
|
"Review", on_delete=models.SET_NULL, null=True, blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_job(self):
|
||||||
|
"""let the job know when the items get work done"""
|
||||||
|
job = self.job
|
||||||
|
job.updated_date = timezone.now()
|
||||||
|
job.save()
|
||||||
|
if not job.pending_items.exists() and not job.complete:
|
||||||
|
job.complete = True
|
||||||
|
job.save(update_fields=["complete"])
|
||||||
|
|
||||||
def resolve(self):
|
def resolve(self):
|
||||||
"""try various ways to lookup a book"""
|
"""try various ways to lookup a book"""
|
||||||
|
# we might be calling this after manually adding the book,
|
||||||
|
# so no need to do searches
|
||||||
|
if self.book:
|
||||||
|
return
|
||||||
|
|
||||||
if self.isbn:
|
if self.isbn:
|
||||||
self.book = self.get_book_from_isbn()
|
self.book = self.get_book_from_isbn()
|
||||||
else:
|
else:
|
||||||
|
@ -85,6 +104,10 @@ class ImportItem(models.Model):
|
||||||
self.isbn, min_confidence=0.999
|
self.isbn, min_confidence=0.999
|
||||||
)
|
)
|
||||||
if search_result:
|
if search_result:
|
||||||
|
# it's already in the right format
|
||||||
|
if isinstance(search_result, Edition):
|
||||||
|
return search_result
|
||||||
|
# it's just a search result, book needs to be created
|
||||||
# raises ConnectorException
|
# raises ConnectorException
|
||||||
return search_result.connector.get_or_create_book(search_result.key)
|
return search_result.connector.get_or_create_book(search_result.key)
|
||||||
return None
|
return None
|
||||||
|
@ -96,6 +119,8 @@ class ImportItem(models.Model):
|
||||||
search_term, min_confidence=0.1
|
search_term, min_confidence=0.1
|
||||||
)
|
)
|
||||||
if search_result:
|
if search_result:
|
||||||
|
if isinstance(search_result, Edition):
|
||||||
|
return (search_result, 1)
|
||||||
# raises ConnectorException
|
# raises ConnectorException
|
||||||
return (
|
return (
|
||||||
search_result.connector.get_or_create_book(search_result.key),
|
search_result.connector.get_or_create_book(search_result.key),
|
||||||
|
@ -106,56 +131,62 @@ class ImportItem(models.Model):
|
||||||
@property
|
@property
|
||||||
def title(self):
|
def title(self):
|
||||||
"""get the book title"""
|
"""get the book title"""
|
||||||
return self.data["Title"]
|
return self.normalized_data.get("title")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def author(self):
|
def author(self):
|
||||||
"""get the book title"""
|
"""get the book's authors"""
|
||||||
return self.data["Author"]
|
return self.normalized_data.get("authors")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def isbn(self):
|
def isbn(self):
|
||||||
"""pulls out the isbn13 field from the csv line data"""
|
"""pulls out the isbn13 field from the csv line data"""
|
||||||
return unquote_string(self.data["ISBN13"])
|
return unquote_string(self.normalized_data.get("isbn_13")) or unquote_string(
|
||||||
|
self.normalized_data.get("isbn_10")
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def shelf(self):
|
def shelf(self):
|
||||||
"""the goodreads shelf field"""
|
"""the goodreads shelf field"""
|
||||||
if self.data["Exclusive Shelf"]:
|
return self.normalized_data.get("shelf")
|
||||||
return GOODREADS_SHELVES.get(self.data["Exclusive Shelf"])
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def review(self):
|
def review(self):
|
||||||
"""a user-written review, to be imported with the book data"""
|
"""a user-written review, to be imported with the book data"""
|
||||||
return self.data["My Review"]
|
return self.normalized_data.get("review_body")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def rating(self):
|
def rating(self):
|
||||||
"""x/5 star rating for a book"""
|
"""x/5 star rating for a book"""
|
||||||
if self.data.get("My Rating", None):
|
if self.normalized_data.get("rating"):
|
||||||
return int(self.data["My Rating"])
|
return float(self.normalized_data.get("rating"))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def date_added(self):
|
def date_added(self):
|
||||||
"""when the book was added to this dataset"""
|
"""when the book was added to this dataset"""
|
||||||
if self.data["Date Added"]:
|
if self.normalized_data.get("date_added"):
|
||||||
return timezone.make_aware(dateutil.parser.parse(self.data["Date Added"]))
|
return timezone.make_aware(
|
||||||
|
dateutil.parser.parse(self.normalized_data.get("date_added"))
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def date_started(self):
|
def date_started(self):
|
||||||
"""when the book was started"""
|
"""when the book was started"""
|
||||||
if "Date Started" in self.data and self.data["Date Started"]:
|
if self.normalized_data.get("date_started"):
|
||||||
return timezone.make_aware(dateutil.parser.parse(self.data["Date Started"]))
|
return timezone.make_aware(
|
||||||
|
dateutil.parser.parse(self.normalized_data.get("date_started"))
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def date_read(self):
|
def date_read(self):
|
||||||
"""the date a book was completed"""
|
"""the date a book was completed"""
|
||||||
if self.data["Date Read"]:
|
if self.normalized_data.get("date_finished"):
|
||||||
return timezone.make_aware(dateutil.parser.parse(self.data["Date Read"]))
|
return timezone.make_aware(
|
||||||
|
dateutil.parser.parse(self.normalized_data.get("date_finished"))
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -174,7 +205,9 @@ class ImportItem(models.Model):
|
||||||
if start_date and start_date is not None and not self.date_read:
|
if start_date and start_date is not None and not self.date_read:
|
||||||
return [ReadThrough(start_date=start_date)]
|
return [ReadThrough(start_date=start_date)]
|
||||||
if self.date_read:
|
if self.date_read:
|
||||||
start_date = start_date if start_date < self.date_read else None
|
start_date = (
|
||||||
|
start_date if start_date and start_date < self.date_read else None
|
||||||
|
)
|
||||||
return [
|
return [
|
||||||
ReadThrough(
|
ReadThrough(
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
|
@ -185,8 +218,10 @@ class ImportItem(models.Model):
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
# pylint: disable=consider-using-f-string
|
# pylint: disable=consider-using-f-string
|
||||||
return "<{!r}Item {!r}>".format(self.data["import_source"], self.data["Title"])
|
return "<{!r} Item {!r}>".format(self.index, self.normalized_data.get("title"))
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
# pylint: disable=consider-using-f-string
|
# pylint: disable=consider-using-f-string
|
||||||
return "{} by {}".format(self.data["Title"], self.data["Author"])
|
return "{} by {}".format(
|
||||||
|
self.normalized_data.get("title"), self.normalized_data.get("authors")
|
||||||
|
)
|
||||||
|
|
|
@ -157,9 +157,12 @@ def notify_user_on_unboost(sender, instance, *args, **kwargs):
|
||||||
|
|
||||||
@receiver(models.signals.post_save, sender=ImportJob)
|
@receiver(models.signals.post_save, sender=ImportJob)
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def notify_user_on_import_complete(sender, instance, *args, **kwargs):
|
def notify_user_on_import_complete(
|
||||||
|
sender, instance, *args, update_fields=None, **kwargs
|
||||||
|
):
|
||||||
"""we imported your books! aren't you proud of us"""
|
"""we imported your books! aren't you proud of us"""
|
||||||
if not instance.complete:
|
update_fields = update_fields or []
|
||||||
|
if not instance.complete or "complete" not in update_fields:
|
||||||
return
|
return
|
||||||
Notification.objects.create(
|
Notification.objects.create(
|
||||||
user=instance.user,
|
user=instance.user,
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
""" the particulars for this instance of BookWyrm """
|
""" the particulars for this instance of BookWyrm """
|
||||||
import datetime
|
import datetime
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
from django.db import models, IntegrityError
|
from django.db import models, IntegrityError
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
@ -7,9 +8,10 @@ from django.utils import timezone
|
||||||
from model_utils import FieldTracker
|
from model_utils import FieldTracker
|
||||||
|
|
||||||
from bookwyrm.preview_images import generate_site_preview_image_task
|
from bookwyrm.preview_images import generate_site_preview_image_task
|
||||||
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES
|
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, STATIC_FULL_URL
|
||||||
from .base_model import BookWyrmModel, new_access_code
|
from .base_model import BookWyrmModel, new_access_code
|
||||||
from .user import User
|
from .user import User
|
||||||
|
from .fields import get_absolute_url
|
||||||
|
|
||||||
|
|
||||||
class SiteSettings(models.Model):
|
class SiteSettings(models.Model):
|
||||||
|
@ -66,6 +68,28 @@ class SiteSettings(models.Model):
|
||||||
default_settings.save()
|
default_settings.save()
|
||||||
return default_settings
|
return default_settings
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logo_url(self):
|
||||||
|
"""helper to build the logo url"""
|
||||||
|
return self.get_url("logo", "images/logo.png")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logo_small_url(self):
|
||||||
|
"""helper to build the logo url"""
|
||||||
|
return self.get_url("logo_small", "images/logo-small.png")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def favicon_url(self):
|
||||||
|
"""helper to build the logo url"""
|
||||||
|
return self.get_url("favicon", "images/favicon.png")
|
||||||
|
|
||||||
|
def get_url(self, field, default_path):
|
||||||
|
"""get a media url or a default static path"""
|
||||||
|
uploaded = getattr(self, field, None)
|
||||||
|
if uploaded:
|
||||||
|
return get_absolute_url(uploaded)
|
||||||
|
return urljoin(STATIC_FULL_URL, default_path)
|
||||||
|
|
||||||
|
|
||||||
class SiteInvite(models.Model):
|
class SiteInvite(models.Model):
|
||||||
"""gives someone access to create an account on the instance"""
|
"""gives someone access to create an account on the instance"""
|
||||||
|
|
|
@ -19,7 +19,6 @@ from bookwyrm.settings import ENABLE_PREVIEW_IMAGES
|
||||||
from .activitypub_mixin import ActivitypubMixin, ActivityMixin
|
from .activitypub_mixin import ActivitypubMixin, ActivityMixin
|
||||||
from .activitypub_mixin import OrderedCollectionPageMixin
|
from .activitypub_mixin import OrderedCollectionPageMixin
|
||||||
from .base_model import BookWyrmModel
|
from .base_model import BookWyrmModel
|
||||||
from .fields import image_serializer
|
|
||||||
from .readthrough import ProgressMode
|
from .readthrough import ProgressMode
|
||||||
from . import fields
|
from . import fields
|
||||||
|
|
||||||
|
@ -190,15 +189,26 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
if hasattr(activity, "name"):
|
if hasattr(activity, "name"):
|
||||||
activity.name = self.pure_name
|
activity.name = self.pure_name
|
||||||
activity.type = self.pure_type
|
activity.type = self.pure_type
|
||||||
activity.attachment = [
|
book = getattr(self, "book", None)
|
||||||
image_serializer(b.cover, b.alt_text)
|
books = [book] if book else []
|
||||||
for b in self.mention_books.all()[:4]
|
books += list(self.mention_books.all())
|
||||||
if b.cover
|
if len(books) == 1 and getattr(books[0], "preview_image", None):
|
||||||
]
|
covers = [
|
||||||
if hasattr(self, "book") and self.book.cover:
|
activitypub.Document(
|
||||||
activity.attachment.append(
|
url=fields.get_absolute_url(books[0].preview_image),
|
||||||
image_serializer(self.book.cover, self.book.alt_text)
|
name=books[0].alt_text,
|
||||||
)
|
)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
covers = [
|
||||||
|
activitypub.Document(
|
||||||
|
url=fields.get_absolute_url(b.cover),
|
||||||
|
name=b.alt_text,
|
||||||
|
)
|
||||||
|
for b in books
|
||||||
|
if b and b.cover
|
||||||
|
]
|
||||||
|
activity.attachment = covers
|
||||||
return activity
|
return activity
|
||||||
|
|
||||||
def to_activity(self, pure=False): # pylint: disable=arguments-differ
|
def to_activity(self, pure=False): # pylint: disable=arguments-differ
|
||||||
|
|
|
@ -4,11 +4,12 @@ from urllib.parse import urlparse
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.contrib.auth.models import AbstractUser, Group
|
from django.contrib.auth.models import AbstractUser, Group
|
||||||
from django.contrib.postgres.fields import CICharField
|
from django.contrib.postgres.fields import ArrayField, CICharField
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from model_utils import FieldTracker
|
from model_utils import FieldTracker
|
||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
|
@ -27,6 +28,19 @@ from .federated_server import FederatedServer
|
||||||
from . import fields, Review
|
from . import fields, Review
|
||||||
|
|
||||||
|
|
||||||
|
FeedFilterChoices = [
|
||||||
|
("review", _("Reviews")),
|
||||||
|
("comment", _("Comments")),
|
||||||
|
("quotation", _("Quotations")),
|
||||||
|
("everything", _("Everything else")),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_feed_filter_choices():
|
||||||
|
"""return a list of filter choice keys"""
|
||||||
|
return [f[0] for f in FeedFilterChoices]
|
||||||
|
|
||||||
|
|
||||||
def site_link():
|
def site_link():
|
||||||
"""helper for generating links to the site"""
|
"""helper for generating links to the site"""
|
||||||
protocol = "https" if USE_HTTPS else "http"
|
protocol = "https" if USE_HTTPS else "http"
|
||||||
|
@ -128,6 +142,13 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
show_suggested_users = models.BooleanField(default=True)
|
show_suggested_users = models.BooleanField(default=True)
|
||||||
discoverable = fields.BooleanField(default=False)
|
discoverable = fields.BooleanField(default=False)
|
||||||
|
|
||||||
|
# feed options
|
||||||
|
feed_status_types = ArrayField(
|
||||||
|
models.CharField(max_length=10, blank=False, choices=FeedFilterChoices),
|
||||||
|
size=8,
|
||||||
|
default=get_feed_filter_choices,
|
||||||
|
)
|
||||||
|
|
||||||
preferred_timezone = models.CharField(
|
preferred_timezone = models.CharField(
|
||||||
choices=[(str(tz), str(tz)) for tz in pytz.all_timezones],
|
choices=[(str(tz), str(tz)) for tz in pytz.all_timezones],
|
||||||
default=str(pytz.utc),
|
default=str(pytz.utc),
|
||||||
|
|
|
@ -49,6 +49,28 @@ def get_font(font_name, size=28):
|
||||||
return font
|
return font
|
||||||
|
|
||||||
|
|
||||||
|
def get_wrapped_text(text, font, content_width):
|
||||||
|
"""text wrap length depends on the max width of the content"""
|
||||||
|
|
||||||
|
low = 0
|
||||||
|
high = len(text)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# ideal length is determined via binary search
|
||||||
|
while low < high:
|
||||||
|
mid = math.floor(low + high)
|
||||||
|
wrapped_text = textwrap.fill(text, width=mid)
|
||||||
|
width = font.getsize_multiline(wrapped_text)[0]
|
||||||
|
if width < content_width:
|
||||||
|
low = mid
|
||||||
|
else:
|
||||||
|
high = mid - 1
|
||||||
|
except AttributeError:
|
||||||
|
wrapped_text = text
|
||||||
|
|
||||||
|
return wrapped_text
|
||||||
|
|
||||||
|
|
||||||
def generate_texts_layer(texts, content_width):
|
def generate_texts_layer(texts, content_width):
|
||||||
"""Adds text for images"""
|
"""Adds text for images"""
|
||||||
font_text_zero = get_font("bold", size=20)
|
font_text_zero = get_font("bold", size=20)
|
||||||
|
@ -63,7 +85,8 @@ def generate_texts_layer(texts, content_width):
|
||||||
|
|
||||||
if "text_zero" in texts and texts["text_zero"]:
|
if "text_zero" in texts and texts["text_zero"]:
|
||||||
# Text one (Book title)
|
# Text one (Book title)
|
||||||
text_zero = textwrap.fill(texts["text_zero"], width=72)
|
text_zero = get_wrapped_text(texts["text_zero"], font_text_zero, content_width)
|
||||||
|
|
||||||
text_layer_draw.multiline_text(
|
text_layer_draw.multiline_text(
|
||||||
(0, text_y), text_zero, font=font_text_zero, fill=TEXT_COLOR
|
(0, text_y), text_zero, font=font_text_zero, fill=TEXT_COLOR
|
||||||
)
|
)
|
||||||
|
@ -75,7 +98,8 @@ def generate_texts_layer(texts, content_width):
|
||||||
|
|
||||||
if "text_one" in texts and texts["text_one"]:
|
if "text_one" in texts and texts["text_one"]:
|
||||||
# Text one (Book title)
|
# Text one (Book title)
|
||||||
text_one = textwrap.fill(texts["text_one"], width=28)
|
text_one = get_wrapped_text(texts["text_one"], font_text_one, content_width)
|
||||||
|
|
||||||
text_layer_draw.multiline_text(
|
text_layer_draw.multiline_text(
|
||||||
(0, text_y), text_one, font=font_text_one, fill=TEXT_COLOR
|
(0, text_y), text_one, font=font_text_one, fill=TEXT_COLOR
|
||||||
)
|
)
|
||||||
|
@ -87,7 +111,8 @@ def generate_texts_layer(texts, content_width):
|
||||||
|
|
||||||
if "text_two" in texts and texts["text_two"]:
|
if "text_two" in texts and texts["text_two"]:
|
||||||
# Text one (Book subtitle)
|
# Text one (Book subtitle)
|
||||||
text_two = textwrap.fill(texts["text_two"], width=36)
|
text_two = get_wrapped_text(texts["text_two"], font_text_two, content_width)
|
||||||
|
|
||||||
text_layer_draw.multiline_text(
|
text_layer_draw.multiline_text(
|
||||||
(0, text_y), text_two, font=font_text_two, fill=TEXT_COLOR
|
(0, text_y), text_two, font=font_text_two, fill=TEXT_COLOR
|
||||||
)
|
)
|
||||||
|
@ -99,7 +124,10 @@ def generate_texts_layer(texts, content_width):
|
||||||
|
|
||||||
if "text_three" in texts and texts["text_three"]:
|
if "text_three" in texts and texts["text_three"]:
|
||||||
# Text three (Book authors)
|
# Text three (Book authors)
|
||||||
text_three = textwrap.fill(texts["text_three"], width=36)
|
text_three = get_wrapped_text(
|
||||||
|
texts["text_three"], font_text_three, content_width
|
||||||
|
)
|
||||||
|
|
||||||
text_layer_draw.multiline_text(
|
text_layer_draw.multiline_text(
|
||||||
(0, text_y), text_three, font=font_text_three, fill=TEXT_COLOR
|
(0, text_y), text_three, font=font_text_three, fill=TEXT_COLOR
|
||||||
)
|
)
|
||||||
|
@ -317,15 +345,21 @@ def save_and_cleanup(image, instance=None):
|
||||||
"""Save and close the file"""
|
"""Save and close the file"""
|
||||||
if not isinstance(instance, (models.Book, models.User, models.SiteSettings)):
|
if not isinstance(instance, (models.Book, models.User, models.SiteSettings)):
|
||||||
return False
|
return False
|
||||||
uuid = uuid4()
|
|
||||||
file_name = f"{instance.id}-{uuid}.jpg"
|
|
||||||
image_buffer = BytesIO()
|
image_buffer = BytesIO()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
old_path = instance.preview_image.name
|
file_name = instance.preview_image.name
|
||||||
except ValueError:
|
except ValueError:
|
||||||
old_path = None
|
file_name = None
|
||||||
|
|
||||||
|
if not file_name or file_name == "":
|
||||||
|
uuid = uuid4()
|
||||||
|
file_name = f"{instance.id}-{uuid}.jpg"
|
||||||
|
|
||||||
|
# Clean up old file before saving
|
||||||
|
if file_name and default_storage.exists(file_name):
|
||||||
|
default_storage.delete(file_name)
|
||||||
|
|
||||||
# Save
|
# Save
|
||||||
image.save(image_buffer, format="jpeg", quality=75)
|
image.save(image_buffer, format="jpeg", quality=75)
|
||||||
|
@ -345,10 +379,6 @@ def save_and_cleanup(image, instance=None):
|
||||||
else:
|
else:
|
||||||
instance.save(update_fields=["preview_image"])
|
instance.save(update_fields=["preview_image"])
|
||||||
|
|
||||||
# Clean up old file after saving
|
|
||||||
if old_path and default_storage.exists(old_path):
|
|
||||||
default_storage.delete(old_path)
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
image_buffer.close()
|
image_buffer.close()
|
||||||
return True
|
return True
|
||||||
|
|
|
@ -14,7 +14,7 @@ VERSION = "0.1.0"
|
||||||
PAGE_LENGTH = env("PAGE_LENGTH", 15)
|
PAGE_LENGTH = env("PAGE_LENGTH", 15)
|
||||||
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
|
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
|
||||||
|
|
||||||
JS_CACHE = "3eb4edb1"
|
JS_CACHE = "3891b373"
|
||||||
|
|
||||||
# email
|
# email
|
||||||
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
|
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
|
||||||
|
@ -166,7 +166,9 @@ LANGUAGES = [
|
||||||
("en-us", _("English")),
|
("en-us", _("English")),
|
||||||
("de-de", _("Deutsch (German)")),
|
("de-de", _("Deutsch (German)")),
|
||||||
("es-es", _("Español (Spanish)")),
|
("es-es", _("Español (Spanish)")),
|
||||||
|
("gl-es", _("Galego (Galician)")),
|
||||||
("fr-fr", _("Français (French)")),
|
("fr-fr", _("Français (French)")),
|
||||||
|
("lt-lt", _("Lietuvių (Lithuanian)")),
|
||||||
("pt-br", _("Português - Brasil (Brazilian Portuguese)")),
|
("pt-br", _("Português - Brasil (Brazilian Portuguese)")),
|
||||||
("zh-hans", _("简体中文 (Simplified Chinese)")),
|
("zh-hans", _("简体中文 (Simplified Chinese)")),
|
||||||
("zh-hant", _("繁體中文 (Traditional Chinese)")),
|
("zh-hant", _("繁體中文 (Traditional Chinese)")),
|
||||||
|
|
|
@ -115,6 +115,34 @@ input[type=file]::file-selector-button:hover {
|
||||||
color: #363636;
|
color: #363636;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
details .dropdown-menu {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.dropdown[open] summary.dropdown-trigger::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary::marker {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-pinned-button summary {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-pinned-button form {
|
||||||
|
float: left;
|
||||||
|
width: -webkit-fill-available;
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
/** Shelving
|
/** Shelving
|
||||||
******************************************************************************/
|
******************************************************************************/
|
||||||
|
|
||||||
|
|
|
@ -45,6 +45,13 @@ let BookWyrm = new class {
|
||||||
'change',
|
'change',
|
||||||
this.disableIfTooLarge.bind(this)
|
this.disableIfTooLarge.bind(this)
|
||||||
));
|
));
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-duplicate]')
|
||||||
|
.forEach(node => node.addEventListener(
|
||||||
|
'click',
|
||||||
|
this.duplicateInput.bind(this)
|
||||||
|
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -113,9 +120,44 @@ let BookWyrm = new class {
|
||||||
* @return {undefined}
|
* @return {undefined}
|
||||||
*/
|
*/
|
||||||
updateCountElement(counter, data) {
|
updateCountElement(counter, data) {
|
||||||
|
let count = data.count;
|
||||||
|
const count_by_type = data.count_by_type;
|
||||||
const currentCount = counter.innerText;
|
const currentCount = counter.innerText;
|
||||||
const count = data.count;
|
|
||||||
const hasMentions = data.has_mentions;
|
const hasMentions = data.has_mentions;
|
||||||
|
const allowedStatusTypesEl = document.getElementById('unread-notifications-wrapper');
|
||||||
|
|
||||||
|
// If we're on the right counter element
|
||||||
|
if (counter.closest('[data-poll-wrapper]').contains(allowedStatusTypesEl)) {
|
||||||
|
const allowedStatusTypes = JSON.parse(allowedStatusTypesEl.textContent);
|
||||||
|
|
||||||
|
// For keys in common between allowedStatusTypes and count_by_type
|
||||||
|
// This concerns 'review', 'quotation', 'comment'
|
||||||
|
count = allowedStatusTypes.reduce(function(prev, currentKey) {
|
||||||
|
const currentValue = count_by_type[currentKey] | 0;
|
||||||
|
|
||||||
|
return prev + currentValue;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Add all the "other" in count_by_type if 'everything' is allowed
|
||||||
|
if (allowedStatusTypes.includes('everything')) {
|
||||||
|
// Clone count_by_type with 0 for reviews/quotations/comments
|
||||||
|
const count_by_everything_else = Object.assign(
|
||||||
|
{},
|
||||||
|
count_by_type,
|
||||||
|
{review: 0, quotation: 0, comment: 0}
|
||||||
|
);
|
||||||
|
|
||||||
|
count = Object.keys(count_by_everything_else).reduce(
|
||||||
|
function(prev, currentKey) {
|
||||||
|
const currentValue =
|
||||||
|
count_by_everything_else[currentKey] | 0
|
||||||
|
|
||||||
|
return prev + currentValue;
|
||||||
|
},
|
||||||
|
count
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (count != currentCount) {
|
if (count != currentCount) {
|
||||||
this.addRemoveClass(counter.closest('[data-poll-wrapper]'), 'is-hidden', count < 1);
|
this.addRemoveClass(counter.closest('[data-poll-wrapper]'), 'is-hidden', count < 1);
|
||||||
|
@ -368,4 +410,24 @@ let BookWyrm = new class {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
duplicateInput (event ) {
|
||||||
|
const trigger = event.currentTarget;
|
||||||
|
const input_id = trigger.dataset['duplicate']
|
||||||
|
const orig = document.getElementById(input_id);
|
||||||
|
const parent = orig.parentNode;
|
||||||
|
const new_count = parent.querySelectorAll("input").length + 1
|
||||||
|
|
||||||
|
let input = orig.cloneNode();
|
||||||
|
|
||||||
|
input.id += ("-" + (new_count))
|
||||||
|
input.value = ""
|
||||||
|
|
||||||
|
let label = parent.querySelector("label").cloneNode();
|
||||||
|
|
||||||
|
label.setAttribute("for", input.id)
|
||||||
|
|
||||||
|
parent.appendChild(label)
|
||||||
|
parent.appendChild(input)
|
||||||
|
}
|
||||||
}();
|
}();
|
||||||
|
|
|
@ -187,6 +187,7 @@ let StatusCache = new class {
|
||||||
.forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", false));
|
.forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", false));
|
||||||
|
|
||||||
// Remove existing disabled states
|
// Remove existing disabled states
|
||||||
|
|
||||||
button.querySelectorAll("[data-shelf-dropdown-identifier] button")
|
button.querySelectorAll("[data-shelf-dropdown-identifier] button")
|
||||||
.forEach(item => item.disabled = false);
|
.forEach(item => item.disabled = false);
|
||||||
|
|
||||||
|
@ -209,10 +210,10 @@ let StatusCache = new class {
|
||||||
.forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", true));
|
.forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", true));
|
||||||
|
|
||||||
// Close menu
|
// Close menu
|
||||||
let menu = button.querySelector(".dropdown-trigger[aria-expanded=true]");
|
let menu = button.querySelector("details[open]");
|
||||||
|
|
||||||
if (menu) {
|
if (menu) {
|
||||||
menu.click();
|
menu.removeAttribute("open");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,3 +9,8 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "celerywyrm.settings")
|
||||||
app = Celery(
|
app = Celery(
|
||||||
"tasks", broker=settings.CELERY_BROKER_URL, backend=settings.CELERY_RESULT_BACKEND
|
"tasks", broker=settings.CELERY_BROKER_URL, backend=settings.CELERY_RESULT_BACKEND
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# priorities
|
||||||
|
LOW = "low_priority"
|
||||||
|
MEDIUM = "medium_priority"
|
||||||
|
HIGH = "high_priority"
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load markdown %}
|
{% load markdown %}
|
||||||
{% load humanize %}
|
{% load humanize %}
|
||||||
|
{% load utilities %}
|
||||||
|
|
||||||
{% block title %}{{ author.name }}{% endblock %}
|
{% block title %}{{ author.name }}{% endblock %}
|
||||||
|
|
||||||
|
@ -25,7 +26,7 @@
|
||||||
<div class="block columns content" itemscope itemtype="https://schema.org/Person">
|
<div class="block columns content" itemscope itemtype="https://schema.org/Person">
|
||||||
<meta itemprop="name" content="{{ author.name }}">
|
<meta itemprop="name" content="{{ author.name }}">
|
||||||
|
|
||||||
{% if author.aliases or author.born or author.died or author.wikipedia_link or author.openlibrary_key or author.inventaire_id %}
|
{% if author.aliases or author.born or author.died or author.wikipedia_link or author.openlibrary_key or author.inventaire_id or author.isni %}
|
||||||
<div class="column is-two-fifths">
|
<div class="column is-two-fifths">
|
||||||
<div class="box py-2">
|
<div class="box py-2">
|
||||||
<dl>
|
<dl>
|
||||||
|
@ -63,6 +64,14 @@
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if author.isni %}
|
||||||
|
<p class="my-1">
|
||||||
|
<a itemprop="sameAs" href="https://isni.org/isni/{{ author.isni|remove_spaces }}" rel="noopener" target="_blank">
|
||||||
|
{% trans "View ISNI record" %}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if author.openlibrary_key %}
|
{% if author.openlibrary_key %}
|
||||||
<p class="my-1">
|
<p class="my-1">
|
||||||
<a itemprop="sameAs" href="https://openlibrary.org/authors/{{ author.openlibrary_key }}" target="_blank" rel="noopener">
|
<a itemprop="sameAs" href="https://openlibrary.org/authors/{{ author.openlibrary_key }}" target="_blank" rel="noopener">
|
||||||
|
|
|
@ -34,47 +34,41 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_name">{% trans "Name:" %}</label>
|
<label class="label" for="id_name">{% trans "Name:" %}</label>
|
||||||
{{ form.name }}
|
{{ form.name }}
|
||||||
{% for error in form.name.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.name.errors id="desc_name" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_aliases">{% trans "Aliases:" %}</label>
|
<label class="label" for="id_aliases">{% trans "Aliases:" %}</label>
|
||||||
{{ form.aliases }}
|
{{ form.aliases }}
|
||||||
<span class="help">{% trans "Separate multiple values with commas." %}</span>
|
<span class="help">{% trans "Separate multiple values with commas." %}</span>
|
||||||
{% for error in form.aliases.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.aliases.errors id="desc_aliases" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_bio">{% trans "Bio:" %}</label>
|
<label class="label" for="id_bio">{% trans "Bio:" %}</label>
|
||||||
{{ form.bio }}
|
{{ form.bio }}
|
||||||
{% for error in form.bio.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.bio.errors id="desc_bio" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="field"><label class="label" for="id_wikipedia_link">{% trans "Wikipedia link:" %}</label> {{ form.wikipedia_link }}</p>
|
<p class="field"><label class="label" for="id_wikipedia_link">{% trans "Wikipedia link:" %}</label> {{ form.wikipedia_link }}</p>
|
||||||
{% for error in form.wikipedia_link.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.wikipedia_link.errors id="desc_wikipedia_link" %}
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_born">{% trans "Birth date:" %}</label>
|
<label class="label" for="id_born">{% trans "Birth date:" %}</label>
|
||||||
<input type="date" name="born" value="{{ form.born.value|date:'Y-m-d' }}" class="input" id="id_born">
|
<input type="date" name="born" value="{{ form.born.value|date:'Y-m-d' }}" class="input" id="id_born">
|
||||||
{% for error in form.born.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.born.errors id="desc_born" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_died">{% trans "Death date:" %}</label>
|
<label class="label" for="id_died">{% trans "Death date:" %}</label>
|
||||||
<input type="date" name="died" value="{{ form.died.value|date:'Y-m-d' }}" class="input" id="id_died">
|
<input type="date" name="died" value="{{ form.died.value|date:'Y-m-d' }}" class="input" id="id_died">
|
||||||
{% for error in form.died.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.died.errors id="desc_died" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
|
@ -82,33 +76,36 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_openlibrary_key">{% trans "Openlibrary key:" %}</label>
|
<label class="label" for="id_openlibrary_key">{% trans "Openlibrary key:" %}</label>
|
||||||
{{ form.openlibrary_key }}
|
{{ form.openlibrary_key }}
|
||||||
{% for error in form.openlibrary_key.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.oepnlibrary_key.errors id="desc_oepnlibrary_key" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_inventaire_id">{% trans "Inventaire ID:" %}</label>
|
<label class="label" for="id_inventaire_id">{% trans "Inventaire ID:" %}</label>
|
||||||
{{ form.inventaire_id }}
|
{{ form.inventaire_id }}
|
||||||
{% for error in form.inventaire_id.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.inventaire_id.errors id="desc_inventaire_id" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_librarything_key">{% trans "Librarything key:" %}</label>
|
<label class="label" for="id_librarything_key">{% trans "Librarything key:" %}</label>
|
||||||
{{ form.librarything_key }}
|
{{ form.librarything_key }}
|
||||||
{% for error in form.librarything_key.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.librarything_key.errors id="desc_librarything_key" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_goodreads_key">{% trans "Goodreads key:" %}</label>
|
<label class="label" for="id_goodreads_key">{% trans "Goodreads key:" %}</label>
|
||||||
{{ form.goodreads_key }}
|
{{ form.goodreads_key }}
|
||||||
{% for error in form.goodreads_key.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.goodreads_key.errors id="desc_goodreads_key" %}
|
||||||
{% endfor %}
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="id_isni">{% trans "ISNI:" %}</label>
|
||||||
|
{{ form.isni }}
|
||||||
|
|
||||||
|
{% include 'snippets/form_errors.html' with errors_list=form.isni.errors id="desc_isni" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -153,12 +153,21 @@
|
||||||
|
|
||||||
{# user's relationship to the book #}
|
{# user's relationship to the book #}
|
||||||
<div class="block">
|
<div class="block">
|
||||||
|
{% if user_shelfbooks.count > 0 %}
|
||||||
|
<h2 class="title is-5">
|
||||||
|
{% trans "You have shelved this edition in:" %}
|
||||||
|
</h2>
|
||||||
|
<ul>
|
||||||
{% for shelf in user_shelfbooks %}
|
{% for shelf in user_shelfbooks %}
|
||||||
<p>
|
<li class="box">
|
||||||
{% blocktrans with path=shelf.shelf.local_path shelf_name=shelf.shelf.name %}This edition is on your <a href="{{ path }}">{{ shelf_name }}</a> shelf.{% endblocktrans %}
|
<a href="{{ shelf.shelf.local_path }}">{{ shelf.shelf.name }}</a>
|
||||||
{% include 'snippets/shelf_selector.html' with current=shelf.shelf %}
|
<div class="mb-3">
|
||||||
</p>
|
{% include 'snippets/shelf_selector.html' with shelf=shelf.shelf class="is-small" readthrough=readthrough %}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
{% for shelf in other_edition_shelves %}
|
{% for shelf in other_edition_shelves %}
|
||||||
<p>
|
<p>
|
||||||
{% blocktrans with book_path=shelf.book.local_path shelf_path=shelf.shelf.local_path shelf_name=shelf.shelf.name %}A <a href="{{ book_path }}">different edition</a> of this book is on your <a href="{{ shelf_path }}">{{ shelf_name }}</a> shelf.{% endblocktrans %}
|
{% blocktrans with book_path=shelf.book.local_path shelf_path=shelf.shelf.local_path shelf_name=shelf.shelf.name %}A <a href="{{ book_path }}">different edition</a> of this book is on your <a href="{{ shelf_path }}">{{ shelf_name }}</a> shelf.{% endblocktrans %}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{% extends 'layout.html' %}
|
{% extends 'layout.html' %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load humanize %}
|
{% load humanize %}
|
||||||
|
{% load utilities %}
|
||||||
|
|
||||||
{% block title %}{% if book %}{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}{% else %}{% trans "Add Book" %}{% endif %}{% endblock %}
|
{% block title %}{% if book %}{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}{% else %}{% trans "Add Book" %}{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
@ -52,19 +53,29 @@
|
||||||
{% for author in author_matches %}
|
{% for author in author_matches %}
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend class="title is-5 mb-1">
|
<legend class="title is-5 mb-1">
|
||||||
{% blocktrans with name=author.name %}Is "{{ name }}" an existing author?{% endblocktrans %}
|
{% blocktrans with name=author.name %}Is "{{ name }}" one of these authors?{% endblocktrans %}
|
||||||
</legend>
|
</legend>
|
||||||
{% with forloop.counter0 as counter %}
|
{% with forloop.counter0 as counter %}
|
||||||
{% for match in author.matches %}
|
{% for match in author.matches %}
|
||||||
<label class="label mb-2">
|
<label class="label">
|
||||||
<input type="radio" name="author_match-{{ counter }}" value="{{ match.id }}" required>
|
<input type="radio" name="author_match-{{ counter }}" value="{{ match.id }}" required>
|
||||||
{{ match.name }}
|
{{ match.name }}
|
||||||
</label>
|
</label>
|
||||||
<p class="help">
|
<p class="help ml-5 mb-2">
|
||||||
<a href="{{ match.local_path }}" target="_blank">{% blocktrans with book_title=match.book_set.first.title %}Author of <em>{{ book_title }}</em>{% endblocktrans %}</a>
|
{% with book_title=match.book_set.first.title alt_title=match.bio %}
|
||||||
|
{% if book_title %}
|
||||||
|
<a href="{{ match.local_path }}" target="_blank">{% trans "Author of " %}<em>{{ book_title }}</em></a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ match.id }}" target="_blank">{% if alt_title %}{% trans "Author of " %}<em>{{ alt_title }}</em>{% else %} {% trans "Find more information at isni.org" %}{% endif %}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
</p>
|
</p>
|
||||||
|
<p class="help ml-5">
|
||||||
|
{{ author.existing_isnis|get_isni_bio:match }}
|
||||||
|
</p>
|
||||||
|
{{ author.existing_isnis|get_isni:match }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<label class="label">
|
<label class="label mt-2">
|
||||||
<input type="radio" name="author_match-{{ counter }}" value="{{ author.name }}" required> {% trans "This is a new author" %}
|
<input type="radio" name="author_match-{{ counter }}" value="{{ author.name }}" required> {% trans "This is a new author" %}
|
||||||
</label>
|
</label>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
|
@ -12,106 +12,125 @@
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column is-half">
|
<div class="column is-half">
|
||||||
<section class="block">
|
<section class="block">
|
||||||
<h2 class="title is-4">{% trans "Metadata" %}</h2>
|
<h2 class="title is-4">
|
||||||
|
{% trans "Metadata" %}
|
||||||
|
</h2>
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_title">{% trans "Title:" %}</label>
|
<label class="label" for="id_title">
|
||||||
<input type="text" name="title" value="{{ form.title.value|default:'' }}" maxlength="255" class="input" required="" id="id_title">
|
{% trans "Title:" %}
|
||||||
{% for error in form.title.errors %}
|
</label>
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
<input type="text" name="title" value="{{ form.title.value|default:'' }}" maxlength="255" class="input" required="" id="id_title" aria-describedby="desc_title">
|
||||||
{% endfor %}
|
|
||||||
|
{% include 'snippets/form_errors.html' with errors_list=form.title.errors id="desc_title" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_subtitle">{% trans "Subtitle:" %}</label>
|
<label class="label" for="id_subtitle">
|
||||||
<input type="text" name="subtitle" value="{{ form.subtitle.value|default:'' }}" maxlength="255" class="input" id="id_subtitle">
|
{% trans "Subtitle:" %}
|
||||||
{% for error in form.subtitle.errors %}
|
</label>
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
<input type="text" name="subtitle" value="{{ form.subtitle.value|default:'' }}" maxlength="255" class="input" id="id_subtitle" aria-describedby="desc_subtitle">
|
||||||
{% endfor %}
|
|
||||||
|
{% include 'snippets/form_errors.html' with errors_list=form.subtitle.errors id="desc_subtitle" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_description">{% trans "Description:" %}</label>
|
<label class="label" for="id_description">
|
||||||
|
{% trans "Description:" %}
|
||||||
|
</label>
|
||||||
{{ form.description }}
|
{{ form.description }}
|
||||||
{% for error in form.description.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.description.errors id="desc_description" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column is-two-thirds">
|
<div class="column is-two-thirds">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_series">{% trans "Series:" %}</label>
|
<label class="label" for="id_series">
|
||||||
<input type="text" class="input" name="series" id="id_series" value="{{ form.series.value|default:'' }}">
|
{% trans "Series:" %}
|
||||||
{% for error in form.series.errors %}
|
</label>
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
<input type="text" class="input" name="series" id="id_series" value="{{ form.series.value|default:'' }}" aria-describedby="desc_series">
|
||||||
{% endfor %}
|
|
||||||
|
{% include 'snippets/form_errors.html' with errors_list=form.series.errors id="desc_series" %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-one-third">
|
<div class="column is-one-third">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_series_number">{% trans "Series number:" %}</label>
|
<label class="label" for="id_series_number">
|
||||||
|
{% trans "Series number:" %}
|
||||||
|
</label>
|
||||||
{{ form.series_number }}
|
{{ form.series_number }}
|
||||||
{% for error in form.series_number.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.series_number.errors id="desc_series_number" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_languages">{% trans "Languages:" %}</label>
|
<label class="label" for="id_languages">
|
||||||
|
{% trans "Languages:" %}
|
||||||
|
</label>
|
||||||
{{ form.languages }}
|
{{ form.languages }}
|
||||||
<span class="help">{% trans "Separate multiple values with commas." %}</span>
|
<span class="help" id="desc_languages_help">
|
||||||
{% for error in form.languages.errors %}
|
{% trans "Separate multiple values with commas." %}
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
</span>
|
||||||
{% endfor %}
|
|
||||||
|
{% include 'snippets/form_errors.html' with errors_list=form.languages.errors id="desc_languages" %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="block">
|
<section class="block">
|
||||||
<h2 class="title is-4">{% trans "Publication" %}</h2>
|
<h2 class="title is-4">
|
||||||
|
{% trans "Publication" %}
|
||||||
|
</h2>
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_publishers">{% trans "Publisher:" %}</label>
|
<label class="label" for="id_publishers">
|
||||||
|
{% trans "Publisher:" %}
|
||||||
|
</label>
|
||||||
{{ form.publishers }}
|
{{ form.publishers }}
|
||||||
<span class="help">{% trans "Separate multiple values with commas." %}</span>
|
<span class="help" id="desc_publishers_help">
|
||||||
{% for error in form.publishers.errors %}
|
{% trans "Separate multiple values with commas." %}
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
</span>
|
||||||
{% endfor %}
|
|
||||||
|
{% include 'snippets/form_errors.html' with errors_list=form.publishers.errors id="desc_publishers" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_first_published_date">{% trans "First published date:" %}</label>
|
<label class="label" for="id_first_published_date">
|
||||||
<input type="date" name="first_published_date" class="input" id="id_first_published_date"{% if form.first_published_date.value %} value="{{ form.first_published_date.value|date:'Y-m-d' }}"{% endif %}>
|
{% trans "First published date:" %}
|
||||||
{% for error in form.first_published_date.errors %}
|
</label>
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
<input type="date" name="first_published_date" class="input" id="id_first_published_date"{% if form.first_published_date.value %} value="{{ form.first_published_date.value|date:'Y-m-d' }}"{% endif %} aria-describedby="desc_first_published_date">
|
||||||
{% endfor %}
|
|
||||||
|
{% include 'snippets/form_errors.html' with errors_list=form.first_published_date.errors id="desc_first_published_date" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_published_date">{% trans "Published date:" %}</label>
|
<label class="label" for="id_published_date">
|
||||||
<input type="date" name="published_date" class="input" id="id_published_date"{% if form.published_date.value %} value="{{ form.published_date.value|date:'Y-m-d'}}"{% endif %}>
|
{% trans "Published date:" %}
|
||||||
{% for error in form.published_date.errors %}
|
</label>
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
<input type="date" name="published_date" class="input" id="id_published_date"{% if form.published_date.value %} value="{{ form.published_date.value|date:'Y-m-d'}}"{% endif %} aria-describedby="desc_published_date">
|
||||||
{% endfor %}
|
|
||||||
|
{% include 'snippets/form_errors.html' with errors_list=form.published_date.errors id="desc_published_date" %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="block">
|
<section class="block">
|
||||||
<h2 class="title is-4">{% trans "Authors" %}</h2>
|
<h2 class="title is-4">
|
||||||
|
{% trans "Authors" %}
|
||||||
|
</h2>
|
||||||
<div class="box">
|
<div class="box">
|
||||||
{% if book.authors.exists %}
|
{% if book.authors.exists %}
|
||||||
<fieldset>
|
<fieldset>
|
||||||
{% for author in book.authors.all %}
|
{% for author in book.authors.all %}
|
||||||
<div class="is-flex is-justify-content-space-between">
|
<div class="is-flex is-justify-content-space-between">
|
||||||
<label class="label mb-2">
|
<label class="label mb-2">
|
||||||
<input type="checkbox" name="remove_authors" value="{{ author.id }}" {% if author.id|stringformat:"i" in remove_authors %}checked{% endif %}>
|
<input type="checkbox" name="remove_authors" value="{{ author.id }}" {% if author.id|stringformat:"i" in remove_authors %}checked{% endif %} aria-describedby="desc_remove_author_{{author.id}}">
|
||||||
{% blocktrans with name=author.name %}Remove {{ name }}{% endblocktrans %}
|
{% blocktrans with name=author.name %}Remove {{ name }}{% endblocktrans %}
|
||||||
</label>
|
</label>
|
||||||
<p class="help">
|
<p class="help" id="desc_remove_author_{{author.id}}">
|
||||||
<a href="{{ author.local_path }}">{% blocktrans with name=author.name %}Author page for {{ name }}{% endblocktrans %}</a>
|
<a href="{{ author.local_path }}">{% blocktrans with name=author.name %}Author page for {{ name }}{% endblocktrans %}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -119,17 +138,27 @@
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_add_author">{% trans "Add Authors:" %}</label>
|
<label class="label" for="id_add_author">
|
||||||
<input class="input" type="text" name="add_author" id="id_add_author" placeholder="{% trans 'John Doe, Jane Smith' %}" value="{{ add_author }}" {% if confirm_mode %}readonly{% endif %}>
|
{% trans "Add Authors:" %}
|
||||||
<span class="help">{% trans "Separate multiple values with commas." %}</span>
|
</label>
|
||||||
|
{% for author in add_author %}
|
||||||
|
<label class="label is-sr-only" for="id_add_author{% if not forloop.first %}-{{forloop.counter}}{% endif %}">{% trans "Add Author" %}</label>
|
||||||
|
<input class="input" type="text" name="add_author" id="id_add_author{% if not forloop.first %}-{{forloop.counter}}{% endif %}" placeholder="{% trans 'Jane Doe' %}" value="{{ author }}" {% if confirm_mode %}readonly{% endif %}>
|
||||||
|
{% empty %}
|
||||||
|
<label class="label is-sr-only" for="id_add_author">{% trans "Add Author" %}</label>
|
||||||
|
<input class="input" type="text" name="add_author" id="id_add_author" placeholder="{% trans 'Jane Doe' %}" value="{{ author }}" {% if confirm_mode %}readonly{% endif %}>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
<span class="help"><button class="button is-small" type="button" data-duplicate="id_add_author" id="another_author_field">{% trans "Add Another Author" %}</button></span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-half">
|
<div class="column is-half">
|
||||||
<section class="block">
|
<section class="block">
|
||||||
<h2 class="title is-4">{% trans "Cover" %}</h2>
|
<h2 class="title is-4">
|
||||||
|
{% trans "Cover" %}
|
||||||
|
</h2>
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
{% if book.cover %}
|
{% if book.cover %}
|
||||||
|
@ -140,108 +169,122 @@
|
||||||
|
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_cover">{% trans "Upload cover:" %}</label>
|
<label class="label" for="id_cover">
|
||||||
|
{% trans "Upload cover:" %}
|
||||||
|
</label>
|
||||||
{{ form.cover }}
|
{{ form.cover }}
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_cover_url">
|
<label class="label" for="id_cover_url">
|
||||||
{% trans "Load cover from url:" %}
|
{% trans "Load cover from url:" %}
|
||||||
</label>
|
</label>
|
||||||
<input class="input" name="cover-url" id="id_cover_url" type="url" value="{{ cover_url|default:'' }}">
|
<input class="input" name="cover-url" id="id_cover_url" type="url" value="{{ cover_url|default:'' }}" aria-describedby="desc_cover">
|
||||||
</div>
|
</div>
|
||||||
{% for error in form.cover.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.cover.errors id="desc_cover" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="block">
|
<section class="block">
|
||||||
<h2 class="title is-4">{% trans "Physical Properties" %}</h2>
|
<h2 class="title is-4">
|
||||||
|
{% trans "Physical Properties" %}
|
||||||
|
</h2>
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column is-one-third">
|
<div class="column is-one-third">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_physical_format">{% trans "Format:" %}</label>
|
<label class="label" for="id_physical_format">
|
||||||
|
{% trans "Format:" %}
|
||||||
|
</label>
|
||||||
<div class="select">
|
<div class="select">
|
||||||
{{ form.physical_format }}
|
{{ form.physical_format }}
|
||||||
</div>
|
</div>
|
||||||
{% for error in form.physical_format.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.physical_format.errors id="desc_physical_format" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_physical_format_detail">{% trans "Format details:" %}</label>
|
<label class="label" for="id_physical_format_detail">
|
||||||
|
{% trans "Format details:" %}
|
||||||
|
</label>
|
||||||
{{ form.physical_format_detail }}
|
{{ form.physical_format_detail }}
|
||||||
{% for error in form.physical_format_detail.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.physical_format_detail.errors id="desc_physical_format_detail" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_pages">{% trans "Pages:" %}</label>
|
<label class="label" for="id_pages">
|
||||||
|
{% trans "Pages:" %}
|
||||||
|
</label>
|
||||||
{{ form.pages }}
|
{{ form.pages }}
|
||||||
{% for error in form.pages.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.pages.errors id="desc_pages" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="block">
|
<section class="block">
|
||||||
<h2 class="title is-4">{% trans "Book Identifiers" %}</h2>
|
<h2 class="title is-4">
|
||||||
|
{% trans "Book Identifiers" %}
|
||||||
|
</h2>
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_isbn_13">{% trans "ISBN 13:" %}</label>
|
<label class="label" for="id_isbn_13">
|
||||||
|
{% trans "ISBN 13:" %}
|
||||||
|
</label>
|
||||||
{{ form.isbn_13 }}
|
{{ form.isbn_13 }}
|
||||||
{% for error in form.isbn_13.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.isbn_13.errors id="desc_isbn_13" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_isbn_10">{% trans "ISBN 10:" %}</label>
|
<label class="label" for="id_isbn_10">
|
||||||
|
{% trans "ISBN 10:" %}
|
||||||
|
</label>
|
||||||
{{ form.isbn_10 }}
|
{{ form.isbn_10 }}
|
||||||
{% for error in form.isbn_10.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.isbn_10.errors id="desc_isbn_10" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_openlibrary_key">{% trans "Openlibrary ID:" %}</label>
|
<label class="label" for="id_openlibrary_key">
|
||||||
|
{% trans "Openlibrary ID:" %}
|
||||||
|
</label>
|
||||||
{{ form.openlibrary_key }}
|
{{ form.openlibrary_key }}
|
||||||
{% for error in form.openlibrary_key.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.openlibrary_key.errors id="desc_openlibrary_key" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_inventaire_id">{% trans "Inventaire ID:" %}</label>
|
<label class="label" for="id_inventaire_id">
|
||||||
|
{% trans "Inventaire ID:" %}
|
||||||
|
</label>
|
||||||
{{ form.inventaire_id }}
|
{{ form.inventaire_id }}
|
||||||
{% for error in form.inventaire_id.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.inventaire_id.errors id="desc_inventaire_id" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_oclc_number">{% trans "OCLC Number:" %}</label>
|
<label class="label" for="id_oclc_number">
|
||||||
|
{% trans "OCLC Number:" %}
|
||||||
|
</label>
|
||||||
{{ form.oclc_number }}
|
{{ form.oclc_number }}
|
||||||
{% for error in form.oclc_number.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.oclc_number.errors id="desc_oclc_number" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_asin">{% trans "ASIN:" %}</label>
|
<label class="label" for="id_asin">
|
||||||
|
{% trans "ASIN:" %}
|
||||||
|
</label>
|
||||||
{{ form.asin }}
|
{{ form.asin }}
|
||||||
{% for error in form.ASIN.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.ASIN.errors id="desc_ASIN" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
@ -2,25 +2,20 @@
|
||||||
{% load utilities %}
|
{% load utilities %}
|
||||||
|
|
||||||
{% with 0|uuid as uuid %}
|
{% with 0|uuid as uuid %}
|
||||||
<div
|
<details
|
||||||
id="menu_{{ uuid }}"
|
id="menu_{{ uuid }}"
|
||||||
class="
|
class="
|
||||||
dropdown control
|
dropdown control
|
||||||
{% if right %}is-right{% endif %}
|
{% if right %}is-right{% endif %}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<button
|
<summary
|
||||||
class="button dropdown-trigger pulldown-menu {{ class }}"
|
class="button control dropdown-trigger pulldown-menu {{ class }}"
|
||||||
type="button"
|
|
||||||
aria-expanded="false"
|
|
||||||
aria-haspopup="true"
|
|
||||||
aria-controls="menu_options_{{ uuid }}"
|
|
||||||
data-controls="menu_{{ uuid }}"
|
|
||||||
>
|
>
|
||||||
{% block dropdown-trigger %}{% endblock %}
|
{% block dropdown-trigger %}{% endblock %}
|
||||||
</button>
|
</summary>
|
||||||
|
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu control">
|
||||||
<ul
|
<ul
|
||||||
id="menu_options_{{ uuid }}"
|
id="menu_options_{{ uuid }}"
|
||||||
class="dropdown-content p-0 is-clipped"
|
class="dropdown-content p-0 is-clipped"
|
||||||
|
@ -29,6 +24,6 @@
|
||||||
{% block dropdown-list %}{% endblock %}
|
{% block dropdown-list %}{% endblock %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endspaceless %}
|
{% endspaceless %}
|
||||||
|
|
|
@ -18,7 +18,7 @@
|
||||||
</p>
|
</p>
|
||||||
<form name="directory" method="POST" action="{% url 'directory' %}">
|
<form name="directory" method="POST" action="{% url 'directory' %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button class="button is-primary" type="submit">Join Directory</button>
|
<button class="button is-primary" type="submit">{% trans "Join Directory" %}</button>
|
||||||
<p class="help">
|
<p class="help">
|
||||||
{% url 'prefs-profile' as path %}
|
{% url 'prefs-profile' as path %}
|
||||||
{% blocktrans with path=path %}You can opt-out at any time in your <a href="{{ path }}">profile settings.</a>{% endblocktrans %}
|
{% blocktrans with path=path %}You can opt-out at any time in your <a href="{{ path }}">profile settings.</a>{% endblocktrans %}
|
||||||
|
@ -28,7 +28,7 @@
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
{% trans "Dismiss message" as button_text %}
|
{% trans "Dismiss message" as button_text %}
|
||||||
<button type="button" class="delete set-display" data-id="hide_join_directory" data-value="true">
|
<button type="button" class="delete set-display" data-id="hide_join_directory" data-value="true">
|
||||||
<span>Dismiss message</span>
|
<span>{% trans "Dismiss message" %}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div></div>
|
</div></div>
|
||||||
|
|
|
@ -1,10 +1,24 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load utilities %}
|
{% load utilities %}
|
||||||
|
|
||||||
{% with user_path=status.user.local_path username=status.user.display_name book_path=status.book.local_poth book_title=book|book_title %}
|
{% with user_path=status.user.local_path username=status.user.display_name book_path=book.local_path book_title=book|book_title %}
|
||||||
|
|
||||||
{% if status.status_type == 'GeneratedNote' %}
|
{% if status.status_type == 'GeneratedNote' %}
|
||||||
{{ status.content|safe }}
|
{% if status.content == 'wants to read' %}
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
<a href="{{ user_path}}">{{ username }}</a> wants to read <a href="{{ book_path }}">{{ book_title }}</a>
|
||||||
|
{% endblocktrans %}
|
||||||
|
{% endif %}
|
||||||
|
{% if status.content == 'finished reading' %}
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
<a href="{{ user_path}}">{{ username }}</a> finished reading <a href="{{ book_path }}">{{ book_title }}</a>
|
||||||
|
{% endblocktrans %}
|
||||||
|
{% endif %}
|
||||||
|
{% if status.content == 'started reading' %}
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
<a href="{{ user_path}}">{{ username }}</a> started reading <a href="{{ book_path }}">{{ book_title }}</a>
|
||||||
|
{% endblocktrans %}
|
||||||
|
{% endif %}
|
||||||
{% elif status.status_type == 'Rating' %}
|
{% elif status.status_type == 'Rating' %}
|
||||||
{% blocktrans trimmed %}
|
{% blocktrans trimmed %}
|
||||||
<a href="{{ user_path}}">{{ username }}</a> rated <a href="{{ book_path }}">{{ book_title }}</a>
|
<a href="{{ user_path}}">{{ username }}</a> rated <a href="{{ book_path }}">{{ book_title }}</a>
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
<div style="font-family: BlinkMacSystemFont,-apple-system,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Fira Sans','Droid Sans','Helvetica Neue',Helvetica,Arial,sans-serif; border-radius: 6px; background-color: #efefef; max-width: 632px;">
|
<div style="font-family: BlinkMacSystemFont,-apple-system,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Fira Sans','Droid Sans','Helvetica Neue',Helvetica,Arial,sans-serif; border-radius: 6px; background-color: #efefef; max-width: 632px;">
|
||||||
<div style="padding: 1rem; overflow: auto;">
|
<div style="padding: 1rem; overflow: auto;">
|
||||||
<div style="float: left; margin-right: 1rem;">
|
<div style="float: left; margin-right: 1rem;">
|
||||||
<a style="color: #3273dc;" href="https://{{ domain }}" style="text-decoration: none;"><img src="https://{{ domain }}/{{ logo }}" alt="logo"></a>
|
<a style="color: #3273dc;" href="https://{{ domain }}" style="text-decoration: none;"><img src="{{ logo }}" alt="logo"></a>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<a style="color: black; text-decoration: none" href="https://{{ domain }}" style="text-decoration: none;"><strong>{{ site_name }}</strong><br>
|
<a style="color: black; text-decoration: none" href="https://{{ domain }}" style="text-decoration: none;"><strong>{{ site_name }}</strong><br>
|
||||||
|
|
11
bookwyrm/templates/email/moderation_report/html_content.html
Normal file
11
bookwyrm/templates/email/moderation_report/html_content.html
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
{% extends 'email/html_layout.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p>
|
||||||
|
{% blocktrans %}@{{ reporter }} has flagged behavior by @{{ reportee }} for moderation. {% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% trans "View report" as text %}
|
||||||
|
{% include 'email/snippets/action.html' with path=report_link text=text %}
|
||||||
|
{% endblock %}
|
2
bookwyrm/templates/email/moderation_report/subject.html
Normal file
2
bookwyrm/templates/email/moderation_report/subject.html
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
{% load i18n %}
|
||||||
|
{% blocktrans %}New report for {{ site_name }}{% endblocktrans %}
|
|
@ -0,0 +1,9 @@
|
||||||
|
{% extends 'email/text_layout.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{% blocktrans %}@{{ reporter}} has flagged behavior by @{{ reportee }} for moderation. {% endblocktrans %}
|
||||||
|
|
||||||
|
{% trans "View report" %}
|
||||||
|
{{ report_link }}
|
||||||
|
{% endblock %}
|
|
@ -1,4 +1,4 @@
|
||||||
<html lang="{% get_lang %}">
|
<html lang="en">
|
||||||
<body>
|
<body>
|
||||||
<div>
|
<div>
|
||||||
<strong>Subject:</strong> {% include subject_path %}
|
<strong>Subject:</strong> {% include subject_path %}
|
||||||
|
|
|
@ -6,20 +6,62 @@
|
||||||
<h1 class="title">
|
<h1 class="title">
|
||||||
{{ tab.name }}
|
{{ tab.name }}
|
||||||
</h1>
|
</h1>
|
||||||
<div class="tabs">
|
<div class="block is-clipped">
|
||||||
<ul>
|
<div class="is-pulled-left">
|
||||||
{% for stream in streams %}
|
<div class="tabs">
|
||||||
<li class="{% if tab.key == stream.key %}is-active{% endif %}"{% if tab.key == stream.key %} aria-current="page"{% endif %}>
|
<ul>
|
||||||
<a href="/{{ stream.key }}#feed">{{ stream.shortname }}</a>
|
{% for stream in streams %}
|
||||||
</li>
|
<li class="{% if tab.key == stream.key %}is-active{% endif %}"{% if tab.key == stream.key %} aria-current="page"{% endif %}>
|
||||||
{% endfor %}
|
<a href="/{{ stream.key }}#feed">{{ stream.shortname }}</a>
|
||||||
</ul>
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# feed settings #}
|
||||||
|
<details class="detail-pinned-button" {% if settings_saved %}open{% endif %}>
|
||||||
|
<summary class="control">
|
||||||
|
<span class="button">
|
||||||
|
<span class="icon icon-dots-three m-0-mobile" aria-hidden="true"></span>
|
||||||
|
<span class="is-sr-only-mobile">{{ _("Feed settings") }}</span>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<form class="notification level is-align-items-flex-end" method="post" action="/{{ tab.key }}#feed">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="level-left">
|
||||||
|
<div class="field">
|
||||||
|
<div class="control">
|
||||||
|
<span class="is-flex is-align-items-baseline">
|
||||||
|
<label class="label mt-2 mb-1">Status types</label>
|
||||||
|
{% if settings_saved %}
|
||||||
|
<span class="tag is-success is-light ml-2">{{ _("Saved!") }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% for name, value in feed_status_types_options %}
|
||||||
|
<label class="mr-2">
|
||||||
|
<input type="checkbox" name="feed_status_types" value="{{ name }}" {% if name in user.feed_status_types %}checked=""{% endif %}/>
|
||||||
|
{{ value }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="level-right control">
|
||||||
|
<button class="button is-small is-primary is-outlined" type="submit">
|
||||||
|
{{ _("Save settings") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# announcements and system messages #}
|
{# announcements and system messages #}
|
||||||
{% if not activities.number > 1 %}
|
{% if not activities.number > 1 %}
|
||||||
<a href="{{ request.path }}" class="transition-y is-hidden notification is-primary is-block" data-poll-wrapper>
|
<a href="{{ request.path }}" class="transition-y is-hidden notification is-primary is-block" data-poll-wrapper>
|
||||||
{% blocktrans with tab_key=tab.key %}load <span data-poll="stream/{{ tab_key }}">0</span> unread status(es){% endblocktrans %}
|
{% blocktrans with tab_key=tab.key %}load <span data-poll="stream/{{ tab_key }}">0</span> unread status(es){% endblocktrans %}
|
||||||
|
{{ allowed_status_types|json_script:"unread-notifications-wrapper" }}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{% if request.user.show_goal and not goal and tab.key == 'home' %}
|
{% if request.user.show_goal and not goal and tab.key == 'home' %}
|
||||||
|
@ -36,6 +78,7 @@
|
||||||
{% if not activities %}
|
{% if not activities %}
|
||||||
<div class="block content">
|
<div class="block content">
|
||||||
<p>{% trans "There aren't any activities right now! Try following a user to get started" %}</p>
|
<p>{% trans "There aren't any activities right now! Try following a user to get started" %}</p>
|
||||||
|
<p>{% if user.feed_status_types|length < 4 %}{% trans "Alternatively, you can try enabling more status types" %}{% endif %}</p>
|
||||||
|
|
||||||
{% if request.user.show_suggested_users and suggested_users %}
|
{% if request.user.show_suggested_users and suggested_users %}
|
||||||
{# suggested users for when things are very lonely #}
|
{# suggested users for when things are very lonely #}
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<div class="column is-one-third">
|
<div class="column is-one-third">
|
||||||
<section class="block">
|
<section class="block">
|
||||||
<h2 class="title is-4">{% trans "Your books" %}</h2>
|
<h2 class="title is-4">{% trans "Your Books" %}</h2>
|
||||||
{% if not suggested_books %}
|
{% if not suggested_books %}
|
||||||
<p>{% trans "There are no books here right now! Try searching for a book to get started" %}</p>
|
<p>{% trans "There are no books here right now! Try searching for a book to get started" %}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
|
@ -2,6 +2,18 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load bookwyrm_tags %}
|
{% load bookwyrm_tags %}
|
||||||
|
|
||||||
|
{% block opengraph_images %}
|
||||||
|
|
||||||
|
{% firstof status.book status.mention_books.first as book %}
|
||||||
|
{% if book %}
|
||||||
|
{% include 'snippets/opengraph_images.html' with image=preview %}
|
||||||
|
{% else %}
|
||||||
|
{% include 'snippets/opengraph_images.html' %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
<header class="block">
|
<header class="block">
|
||||||
<a href="/#feed" class="button" data-back>
|
<a href="/#feed" class="button" data-back>
|
||||||
|
|
|
@ -4,9 +4,14 @@
|
||||||
|
|
||||||
<div class="select is-small mt-1 mb-3">
|
<div class="select is-small mt-1 mb-3">
|
||||||
<select name="{{ book.id }}" aria-label="{% blocktrans with book_title=book.title %}Have you read {{ book_title }}?{% endblocktrans %}">
|
<select name="{{ book.id }}" aria-label="{% blocktrans with book_title=book.title %}Have you read {{ book_title }}?{% endblocktrans %}">
|
||||||
<option disabled selected value>Add to your books</option>
|
<option disabled selected value>{% trans 'Add to your books' %}</option>
|
||||||
{% for shelf in user_shelves %}
|
{% for shelf in user_shelves %}
|
||||||
<option value="{{ shelf.id }}">{{ shelf.name }}</option>
|
<option value="{{ shelf.id }}">
|
||||||
|
{% if shelf.identifier == 'to-read' %}{% trans "To Read" %}
|
||||||
|
{% elif shelf.identifier == 'reading' %}{% trans "Currently Reading" %}
|
||||||
|
{% elif shelf.identifier == 'read' %}{% trans "Read" %}
|
||||||
|
{% else %}{{ shelf.name }}{% endif %}
|
||||||
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -14,16 +14,14 @@
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<label class="label" for="id_name">{% trans "Display name:" %}</label>
|
<label class="label" for="id_name">{% trans "Display name:" %}</label>
|
||||||
<input type="text" name="name" maxlength="100" class="input" id="id_name" placeholder="{{ user.localname }}" value="{% if request.user.name %}{{ request.user.name }}{% endif %}">
|
<input type="text" name="name" maxlength="100" class="input" id="id_name" placeholder="{{ user.localname }}" value="{% if request.user.name %}{{ request.user.name }}{% endif %}">
|
||||||
{% for error in form.name.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.name.errors id="desc_name" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<label class="label" for="id_summary">{% trans "Summary:" %}</label>
|
<label class="label" for="id_summary">{% trans "Summary:" %}</label>
|
||||||
<textarea name="summary" cols="None" rows="None" class="textarea" id="id_summary" placeholder="{% trans 'A little bit about you' %}">{% if request.user.summary %}{{ request.user.summary }}{% endif %}</textarea>
|
<textarea name="summary" cols="None" rows="None" class="textarea" id="id_summary" placeholder="{% trans 'A little bit about you' %}">{% if request.user.summary %}{{ request.user.summary }}{% endif %}</textarea>
|
||||||
{% for error in form.summary.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.summary.errors id="desc_summary" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -31,9 +29,8 @@
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<label class="label" for="id_avatar">{% trans "Avatar:" %}</label>
|
<label class="label" for="id_avatar">{% trans "Avatar:" %}</label>
|
||||||
{{ form.avatar }}
|
{{ form.avatar }}
|
||||||
{% for error in form.avatar.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.avatar.errors id="desc_avatar" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -46,10 +46,10 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>
|
<label class="label" for="privacy_import">
|
||||||
<span class="label">{% trans "Privacy setting for imported reviews:" %}</span>
|
{% trans "Privacy setting for imported reviews:" %}
|
||||||
{% include 'snippets/privacy_select.html' with no_label=True %}
|
|
||||||
</label>
|
</label>
|
||||||
|
{% include 'snippets/privacy_select.html' with no_label=True privacy_uuid="import" %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -6,153 +6,223 @@
|
||||||
{% block title %}{% trans "Import Status" %}{% endblock %}
|
{% block title %}{% trans "Import Status" %}{% endblock %}
|
||||||
|
|
||||||
{% block content %}{% spaceless %}
|
{% block content %}{% spaceless %}
|
||||||
<div class="block">
|
<header class="block">
|
||||||
<h1 class="title">{% trans "Import Status" %}</h1>
|
<h1 class="title">
|
||||||
<a href="{% url 'import' %}" class="has-text-weight-normal help subtitle is-link">{% trans "Back to imports" %}</a>
|
{% block page_title %}
|
||||||
|
{% if job.retry %}
|
||||||
<dl>
|
{% trans "Retry Status" %}
|
||||||
<div class="is-flex">
|
{% else %}
|
||||||
<dt class="has-text-weight-medium">{% trans "Import started:" %}</dt>
|
{% trans "Import Status" %}
|
||||||
<dd class="ml-2">{{ job.created_date | naturaltime }}</dd>
|
|
||||||
</div>
|
|
||||||
{% if job.complete %}
|
|
||||||
<div class="is-flex">
|
|
||||||
<dt class="has-text-weight-medium">{% trans "Import completed:" %}</dt>
|
|
||||||
<dd class="ml-2">{{ task.date_done | naturaltime }}</dd>
|
|
||||||
</div>
|
|
||||||
{% elif task.failed %}
|
|
||||||
<div class="notification is-danger">{% trans "TASK FAILED" %}</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</dl>
|
{% endblock %}
|
||||||
</div>
|
</h1>
|
||||||
|
|
||||||
|
<nav class="breadcrumb subtitle" aria-label="breadcrumbs">
|
||||||
|
<ul>
|
||||||
|
<li><a href="{% url 'import' %}">{% trans "Imports" %}</a></li>
|
||||||
|
{% url 'import-status' job.id as path %}
|
||||||
|
<li{% if request.path in path %} class="is-active"{% endif %}>
|
||||||
|
<a href="{{ path }}" {% if request.path in path %}aria-current="page"{% endif %}>
|
||||||
|
{% if job.retry %}
|
||||||
|
{% trans "Retry Status" %}
|
||||||
|
{% else %}
|
||||||
|
{% trans "Import Status" %}
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% block breadcrumbs %}{% endblock %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="block">
|
||||||
|
<dl>
|
||||||
|
<dt class="is-pulled-left mr-5">{% trans "Import started:" %}</dt>
|
||||||
|
<dd>{{ job.created_date | naturaltime }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="block">
|
|
||||||
{% if not job.complete %}
|
{% if not job.complete %}
|
||||||
<p>
|
<div class="box is-processing">
|
||||||
{% trans "Import still in progress." %}
|
<div class="block">
|
||||||
<br/>
|
<span class="icon icon-spinner is-pulled-left" aria-hidden="true"></span>
|
||||||
{% trans "(Hit reload to update!)" %}
|
<span>{% trans "In progress" %}</span>
|
||||||
</p>
|
<span class="is-pulled-right">
|
||||||
|
<a href="#" class="button is-small">{% trans "Refresh" %}</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="is-flex">
|
||||||
|
<progress
|
||||||
|
class="progress is-success is-medium mr-2"
|
||||||
|
role="progressbar"
|
||||||
|
aria-min="0"
|
||||||
|
value="{{ complete_count }}"
|
||||||
|
aria-valuenow="{{ complete_count }}"
|
||||||
|
max="{{ item_count }}"
|
||||||
|
aria-valuemax="{{ item_count }}">
|
||||||
|
{{ percent }} %
|
||||||
|
</progress>
|
||||||
|
<span>{{ percent }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if manual_review_count and not legacy %}
|
||||||
|
<div class="notification">
|
||||||
|
{% blocktrans trimmed count counter=manual_review_count with display_counter=manual_review_count|intcomma %}
|
||||||
|
{{ display_counter }} item needs manual approval.
|
||||||
|
{% plural %}
|
||||||
|
{{ display_counter }} items need manual approval.
|
||||||
|
{% endblocktrans %}
|
||||||
|
<a href="{% url 'import-review' job.id %}">{% trans "Review items" %}</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if job.complete and fail_count and not job.retry and not legacy %}
|
||||||
|
<div class="notification is-warning">
|
||||||
|
{% blocktrans trimmed count counter=fail_count with display_counter=fail_count|intcomma %}
|
||||||
|
{{ display_counter }} item failed to import.
|
||||||
|
{% plural %}
|
||||||
|
{{ display_counter }} items failed to import.
|
||||||
|
{% endblocktrans %}
|
||||||
|
<a href="{% url 'import-troubleshoot' job.id %}">
|
||||||
|
{% trans "View and troubleshoot failed items" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="block">
|
||||||
|
{% block actions %}{% endblock %}
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table is-striped">
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
{% trans "Row" %}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{% trans "Title" %}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{% trans "ISBN" %}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{% trans "Author" %}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{% trans "Shelf" %}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{% trans "Review" %}
|
||||||
|
</th>
|
||||||
|
{% block import_cols_headers %}
|
||||||
|
<th>
|
||||||
|
{% trans "Book" %}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{% trans "Status" %}
|
||||||
|
</th>
|
||||||
|
{% endblock %}
|
||||||
|
</tr>
|
||||||
|
{% if legacy %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="8">
|
||||||
|
<p>
|
||||||
|
<em>{% trans "Import preview unavailable." %}</em>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
{% for item in items %}
|
||||||
|
<tr>
|
||||||
|
{% block index_col %}
|
||||||
|
<td>
|
||||||
|
{{ item.index }}
|
||||||
|
</td>
|
||||||
|
{% endblock %}
|
||||||
|
<td>
|
||||||
|
{{ item.normalized_data.title }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ item.isbn|default:'' }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ item.normalized_data.authors }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ item.normalized_data.shelf }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if item.rating %}
|
||||||
|
<p>{% include 'snippets/stars.html' with rating=item.rating %}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.review %}
|
||||||
|
<p>{{ item.review|truncatechars:100 }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.linked_review %}
|
||||||
|
<a href="{{ item.linked_review.remote_id }}" target="_blank">{% trans "View imported review" %}</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% block import_cols %}
|
||||||
|
<td>
|
||||||
|
{% if item.book %}
|
||||||
|
<a href="{{ item.book.local_path }}">
|
||||||
|
{% include 'snippets/book_cover.html' with book=item.book cover_class='is-h-s' size='small' %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if item.book %}
|
||||||
|
<span class="icon icon-check" aria-hidden="true"></span>
|
||||||
|
<span class="is-sr-only-mobile">{% trans "Imported" %}</span>
|
||||||
|
|
||||||
|
{% elif item.fail_reason %}
|
||||||
|
<span class="icon icon-x has-text-danger" aria-hidden="true"></span>
|
||||||
|
<span class="is-sr-only-mobile">
|
||||||
|
{% if item.book_guess %}
|
||||||
|
{% trans "Needs manual review" %}
|
||||||
|
{% else %}
|
||||||
|
{{ item.fail_reason }}
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<div class="is-flex">
|
||||||
|
<span class="icon icon-dots-three" aria-hidden="true"></span>
|
||||||
|
<span class="is-sr-only-mobile">{% trans "Pending" %}</span>
|
||||||
|
{# retry option if an item appears to be hanging #}
|
||||||
|
{% if job.created_date != job.updated_date and inactive_time > 24 %}
|
||||||
|
<form class="ml-2" method="POST" action="{% url 'import-item-retry' job.id item.id %}" name="retry-{{ item.id }}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="button is-danger is-outlined is-small">{% trans "Retry" %}</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endblock %}
|
||||||
|
</tr>
|
||||||
|
{% block action_row %}{% endblock %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% if legacy %}
|
||||||
|
<div class="content">
|
||||||
|
<form name="update-import" method="POST" action="{% url 'import-status' job.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<p>
|
||||||
|
{% trans "This import is in an old format that is no longer supported. If you would like to troubleshoot missing items from this import, click the button below to update the import format." %}
|
||||||
|
</p>
|
||||||
|
<button class="button">{% trans "Update import" %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if failed_items %}
|
{% if not legacy %}
|
||||||
<div class="block">
|
<div>
|
||||||
<h2 class="title is-4">{% trans "Failed to load" %}</h2>
|
{% include 'snippets/pagination.html' with page=items %}
|
||||||
{% if not job.retry %}
|
|
||||||
<form name="retry" action="/import/{{ job.id }}" method="post" class="box">
|
|
||||||
{% csrf_token %}
|
|
||||||
|
|
||||||
{% with failed_count=failed_items|length %}
|
|
||||||
{% if failed_count > 10 %}
|
|
||||||
<p class="block">
|
|
||||||
<a href="#select-all-failed-imports">
|
|
||||||
{% blocktrans %}Jump to the bottom of the list to select the {{ failed_count }} items which failed to import.{% endblocktrans %}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<fieldset id="failed_imports">
|
|
||||||
<ul>
|
|
||||||
{% for item in failed_items %}
|
|
||||||
<li class="mb-2 is-flex is-align-items-start">
|
|
||||||
<input class="checkbox mt-1" type="checkbox" name="import_item" value="{{ item.id }}" id="import_item_{{ item.id }}">
|
|
||||||
<label class="ml-1" for="import-item-{{ item.id }}">
|
|
||||||
{% blocktrans with index=item.index title=item.data.Title author=item.data.Author %}Line {{ index }}: <strong>{{ title }}</strong> by {{ author }}{% endblocktrans %}
|
|
||||||
<br/>
|
|
||||||
{{ item.fail_reason }}.
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset class="mt-3">
|
|
||||||
<a name="select-all-failed-imports"></a>
|
|
||||||
|
|
||||||
<label class="label is-inline">
|
|
||||||
<input
|
|
||||||
id="toggle-all-checkboxes-failed-imports"
|
|
||||||
class="checkbox"
|
|
||||||
type="checkbox"
|
|
||||||
data-action="toggle-all"
|
|
||||||
data-target="failed_imports"
|
|
||||||
/>
|
|
||||||
{% trans "Select all" %}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button class="button is-block mt-3" type="submit">{% trans "Retry items" %}</button>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
<ul>
|
|
||||||
{% for item in failed_items %}
|
|
||||||
<li class="pb-1">
|
|
||||||
<p>
|
|
||||||
Line {{ item.index }}:
|
|
||||||
<strong>{{ item.data.Title }}</strong> by
|
|
||||||
{{ item.data.Author }}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
{{ item.fail_reason }}.
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="block">
|
|
||||||
{% if job.complete %}
|
|
||||||
<h2 class="title is-4">{% trans "Successfully imported" %}</h2>
|
|
||||||
{% else %}
|
|
||||||
<h2 class="title is-4">{% trans "Import Progress" %}</h2>
|
|
||||||
{% endif %}
|
|
||||||
<table class="table">
|
|
||||||
<tr>
|
|
||||||
<th>
|
|
||||||
{% trans "Book" %}
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
{% trans "Title" %}
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
{% trans "Author" %}
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
{% for item in items %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
{% if item.book %}
|
|
||||||
<a href="{{ item.book.local_path }}">
|
|
||||||
{% include 'snippets/book_cover.html' with book=item.book cover_class='is-h-s' size='small' %}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{ item.data.Title }}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{ item.data.Author }}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if item.book %}
|
|
||||||
<span class="icon icon-check">
|
|
||||||
<span class="is-sr-only">{% trans "Imported" %}</span>
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% endspaceless %}{% endblock %}
|
{% endspaceless %}{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
|
75
bookwyrm/templates/import/manual_review.html
Normal file
75
bookwyrm/templates/import/manual_review.html
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
{% extends 'import/import_status.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load utilities %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Import Troubleshooting" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block page_title %}
|
||||||
|
{% trans "Review items" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
<li class="is-active">
|
||||||
|
<a href="#" aria-current="page">{% trans "Review" %}</a>
|
||||||
|
</li>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
<div class="block">
|
||||||
|
<div class="notification content">
|
||||||
|
<p>
|
||||||
|
{% trans "Approving a suggestion will permanently add the suggested book to your shelves and associate your reading dates, reviews, and ratings with that book." %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block import_cols_headers %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block index_col %}
|
||||||
|
<td rowspan="2">
|
||||||
|
{{ item.index }}
|
||||||
|
</td>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block import_cols %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block action_row %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5">
|
||||||
|
<div class="columns is-mobile">
|
||||||
|
{% with guess=item.book_guess %}
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<a href="{{ item.book.local_path }}" target="_blank">
|
||||||
|
{% include 'snippets/book_cover.html' with book=guess cover_class='is-h-s' size='small' %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="column content">
|
||||||
|
<p>
|
||||||
|
{% include 'snippets/book_titleby.html' with book=guess %}
|
||||||
|
</p>
|
||||||
|
<div class="content is-flex">
|
||||||
|
<form class="pr-2" name="approve-{{ item.id }}" method="POST" action="{% url 'import-approve' job.id item.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="button is-success">
|
||||||
|
<span class="icon icon-check m-0-mobile" aria-hidden="true"></span>
|
||||||
|
<span class="is-sr-only-mobile">{% trans "Approve" %}</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form name="delete-{{ item.id }}" method="POST" action="{% url 'import-delete' job.id item.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="button is-danger is-light is-outlined">
|
||||||
|
<span class="icon icon-x m-0-mobile" aria-hidden="true"></span>
|
||||||
|
<span class="is-sr-only-mobile">{% trans "Reject" %}</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endblock %}
|
36
bookwyrm/templates/import/troubleshoot.html
Normal file
36
bookwyrm/templates/import/troubleshoot.html
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
{% extends 'import/import_status.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Import Troubleshooting" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block page_title %}
|
||||||
|
{% trans "Failed items" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
<li class="is-active">
|
||||||
|
<a href="#" aria-current="page">{% trans "Troubleshooting" %}</a>
|
||||||
|
</li>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
<div class="block">
|
||||||
|
<div class="notification content">
|
||||||
|
<p>
|
||||||
|
{% trans "Re-trying an import can fix missing items in cases such as:" %}
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>{% trans "The book has been added to the instance since this import" %}</li>
|
||||||
|
<li>{% trans "A transient error or timeout caused the external data source to be unavailable." %}</li>
|
||||||
|
<li>{% trans "BookWyrm has been updated since this import with a bug fix" %}</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
{% trans "Contact your admin or <a href='https://github.com/bookwyrm-social/bookwyrm/issues'>open an issue</a> if you are seeing unexpected failed items." %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form name="retry" method="post" action="{% url 'import-troubleshoot' job.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="button">Retry all</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -65,10 +65,9 @@
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<label for="id_request_email" class="label">{% trans "Email address:" %}</label>
|
<label for="id_request_email" class="label">{% trans "Email address:" %}</label>
|
||||||
<input type="email" name="email" maxlength="255" class="input" required="" id="id_request_email">
|
<input type="email" name="email" maxlength="255" class="input" required="" id="id_request_email" aria-describedby="desc_request_email">
|
||||||
{% for error in request_form.email.errors %}
|
|
||||||
<p class="help is-danger">{{ error|escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=request_form.email.errors id="desc_request_email" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="button is-link">{% trans "Submit" %}</button>
|
<button type="submit" class="button is-link">{% trans "Submit" %}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -26,11 +26,10 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_password_confirm">{% trans "Password:" %}</label>
|
<label class="label" for="id_password_confirm">{% trans "Password:" %}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password_confirm">
|
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password_confirm" aria-describedby="desc_password">
|
||||||
</div>
|
</div>
|
||||||
{% for error in login_form.password.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=login_form.password.errors id="desc_password" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
|
|
|
@ -8,21 +8,33 @@
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<h1 class="title">{% trans "Reset Password" %}</h1>
|
<h1 class="title">{% trans "Reset Password" %}</h1>
|
||||||
{% for error in errors %}
|
|
||||||
<p class="is-danger">{{ error }}</p>
|
{% if errors %}
|
||||||
{% endfor %}
|
<div id="form_errors">
|
||||||
|
{% for error in errors %}
|
||||||
|
<p class="is-danger">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
<form name="password-reset" method="post" action="/password-reset/{{ code }}">
|
<form name="password-reset" method="post" action="/password-reset/{{ code }}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_new_password">{% trans "Password:" %}</label>
|
<label class="label" for="id_new_password">
|
||||||
|
{% trans "Password:" %}
|
||||||
|
</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input type="password" name="password" maxlength="128" class="input" required="" id="id_new_password">
|
<input type="password" name="password" maxlength="128" class="input" required="" id="id_new_password" aria-describedby="form_errors">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_confirm_password">{% trans "Confirm password:" %}</label>
|
<label class="label" for="id_confirm_password">
|
||||||
|
{% trans "Confirm password:" %}
|
||||||
|
</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input type="password" name="confirm-password" maxlength="128" class="input" required="" id="id_confirm_password">
|
<input type="password" name="confirm-password" maxlength="128" class="input" required="" id="id_confirm_password" aria-describedby="form_errors">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
|
|
|
@ -7,8 +7,8 @@
|
||||||
<div class="column is-one-quarter">
|
<div class="column is-one-quarter">
|
||||||
<div class="card is-stretchable">
|
<div class="card is-stretchable">
|
||||||
<header class="card-header">
|
<header class="card-header">
|
||||||
<h4 class="card-header-title">
|
<h4 class="card-header-title is-clipped">
|
||||||
<a href="{{ list.local_path }}">{{ list.name }}</a> <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=list %}</span>
|
<a href="{{ list.local_path }}" class="is-clipped">{{ list.name }}</a> <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=list %}</span>
|
||||||
</h4>
|
</h4>
|
||||||
{% if request.user.is_authenticated and request.user|saved:list %}
|
{% if request.user.is_authenticated and request.user|saved:list %}
|
||||||
<div class="card-header-icon">
|
<div class="card-header-icon">
|
||||||
|
|
|
@ -18,10 +18,9 @@
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_password">{% trans "Confirm password:" %}</label>
|
<label class="label" for="id_password">{% trans "Confirm password:" %}</label>
|
||||||
<input class="input {% if form.password.errors %}is-danger{% endif %}" type="password" name="password" id="id_password" required>
|
<input class="input {% if form.password.errors %}is-danger{% endif %}" type="password" name="password" id="id_password" required aria-describedby="desc_password">
|
||||||
{% for error in form.password.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.password.errors id="desc_password" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="button is-danger">{% trans "Delete Account" %}</button>
|
<button type="submit" class="button is-danger">{% trans "Delete Account" %}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -33,31 +33,27 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="column">
|
<div class="column">
|
||||||
{{ form.avatar }}
|
{{ form.avatar }}
|
||||||
{% for error in form.avatar.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.avatar.errors id="desc_avatar" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_name">{% trans "Display name:" %}</label>
|
<label class="label" for="id_name">{% trans "Display name:" %}</label>
|
||||||
{{ form.name }}
|
{{ form.name }}
|
||||||
{% for error in form.name.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.name.errors id="desc_name" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_summary">{% trans "Summary:" %}</label>
|
<label class="label" for="id_summary">{% trans "Summary:" %}</label>
|
||||||
{{ form.summary }}
|
{{ form.summary }}
|
||||||
{% for error in form.summary.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.summary.errors id="desc_summary" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_email">{% trans "Email address:" %}</label>
|
<label class="label" for="id_email">{% trans "Email address:" %}</label>
|
||||||
{{ form.email }}
|
{{ form.email }}
|
||||||
{% for error in form.email.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.email.errors id="desc_email" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
@ -69,19 +65,23 @@
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="checkbox label" for="id_show_goal">
|
<label class="checkbox label" for="id_show_goal">
|
||||||
{% trans "Show reading goal prompt in feed:" %}
|
|
||||||
{{ form.show_goal }}
|
{{ form.show_goal }}
|
||||||
|
{% trans "Show reading goal prompt in feed" %}
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
<label class="checkbox label" for="id_show_suggested_users">
|
<label class="checkbox label" for="id_show_suggested_users">
|
||||||
{% trans "Show suggested users:" %}
|
|
||||||
{{ form.show_suggested_users }}
|
{{ form.show_suggested_users }}
|
||||||
|
{% trans "Show suggested users" %}
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
<label class="checkbox label" for="id_discoverable">
|
<label class="checkbox label" for="id_discoverable">
|
||||||
{% trans "Show this account in suggested users:" %}
|
|
||||||
{{ form.discoverable }}
|
{{ form.discoverable }}
|
||||||
|
{% trans "Show this account in suggested users" %}
|
||||||
</label>
|
</label>
|
||||||
{% url 'directory' as path %}
|
{% url 'directory' as path %}
|
||||||
<p class="help">
|
<p class="help" id="desc_discoverable">
|
||||||
{% blocktrans %}Your account will show up in the <a href="{{ path }}">directory</a>, and may be recommended to other BookWyrm users.{% endblocktrans %}
|
{% blocktrans %}Your account will show up in the <a href="{{ path }}">directory</a>, and may be recommended to other BookWyrm users.{% endblocktrans %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -107,8 +107,8 @@
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="checkbox label" for="id_manually_approves_followers">
|
<label class="checkbox label" for="id_manually_approves_followers">
|
||||||
{% trans "Manually approve followers:" %}
|
|
||||||
{{ form.manually_approves_followers }}
|
{{ form.manually_approves_followers }}
|
||||||
|
{% trans "Manually approve followers" %}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|
|
@ -39,7 +39,7 @@
|
||||||
<header class="columns is-mobile">
|
<header class="columns is-mobile">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<h3 class="title is-5">
|
<h3 class="title is-5">
|
||||||
Results from
|
{% trans 'Results from' %}
|
||||||
<a href="{{ result_set.connector.base_url }}" target="_blank">{{ result_set.connector.name|default:result_set.connector.identifier }}</a>
|
<a href="{{ result_set.connector.base_url }}" target="_blank">{{ result_set.connector.name|default:result_set.connector.identifier }}</a>
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -13,60 +13,68 @@
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="user" value="{{ request.user.id }}">
|
<input type="hidden" name="user" value="{{ request.user.id }}">
|
||||||
<p>
|
<p>
|
||||||
<label class="label" for="id_preview">{% trans "Preview:" %}</label>
|
<label class="label" for="id_preview">
|
||||||
|
{% trans "Preview:" %}
|
||||||
|
</label>
|
||||||
{{ form.preview }}
|
{{ form.preview }}
|
||||||
{% for error in form.preview.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.preview.errors id="desc_preview" %}
|
||||||
{% endfor %}
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<label class="label" for="id_content">{% trans "Content:" %}</label>
|
<label class="label" for="id_content">
|
||||||
|
{% trans "Content:" %}
|
||||||
|
</label>
|
||||||
{{ form.content }}
|
{{ form.content }}
|
||||||
{% for error in form.content.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.content.errors id="desc_content" %}
|
||||||
{% endfor %}
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<label class="label" for="id_event_date">{% trans "Event date:" %}</label>
|
<label class="label" for="id_event_date">
|
||||||
|
{% trans "Event date:" %}
|
||||||
|
</label>
|
||||||
<input type="date" name="event_date" value="{{ form.event_date.value|date:'Y-m-d' }}" class="input" id="id_event_date">
|
<input type="date" name="event_date" value="{{ form.event_date.value|date:'Y-m-d' }}" class="input" id="id_event_date">
|
||||||
{% for error in form.event_date.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.event_date.errors id="desc_event_date" %}
|
||||||
{% endfor %}
|
|
||||||
</p>
|
</p>
|
||||||
<hr aria-hidden="true">
|
<hr aria-hidden="true">
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<p>
|
<p>
|
||||||
<label class="label" for="id_start_date">{% trans "Start date:" %}</label>
|
<label class="label" for="id_start_date">
|
||||||
|
{% trans "Start date:" %}
|
||||||
|
</label>
|
||||||
<input type="date" name="start_date" class="input" value="{{ form.start_date.value|date:'Y-m-d' }}" id="id_start_date">
|
<input type="date" name="start_date" class="input" value="{{ form.start_date.value|date:'Y-m-d' }}" id="id_start_date">
|
||||||
{% for error in form.start_date.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.start_date.errors id="desc_start_date" %}
|
||||||
{% endfor %}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<p>
|
<p>
|
||||||
<label class="label" for="id_end_date">{% trans "End date:" %}</label>
|
<label class="label" for="id_end_date">
|
||||||
|
{% trans "End date:" %}
|
||||||
|
</label>
|
||||||
<input type="date" name="end_date" class="input" id="id_end_date" value="{{ form.end_date.value|date:'Y-m-d' }}">
|
<input type="date" name="end_date" class="input" id="id_end_date" value="{{ form.end_date.value|date:'Y-m-d' }}">
|
||||||
{% for error in form.end_date.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.end_date.errors id="desc_end_date" %}
|
||||||
{% endfor %}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
<p>
|
<p>
|
||||||
<label class="label" for="id_active">{% trans "Active:" %}</label>
|
<label class="label" for="id_active">
|
||||||
|
{% trans "Active:" %}
|
||||||
|
</label>
|
||||||
{{ form.active }}
|
{{ form.active }}
|
||||||
{% for error in form.active.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.active.errors id="desc_active" %}
|
||||||
{% endfor %}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field has-addons">
|
<div class="field has-addons">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
|
<button type="submit" class="button is-primary">
|
||||||
|
{% trans "Save" %}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -17,10 +17,8 @@
|
||||||
{{ form.domain }}
|
{{ form.domain }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% for error in form.domain.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
|
{% include 'snippets/form_errors.html' with errors_list=form.domain.errors id="desc_domain" %}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button type="submit" class="button is-primary">{% trans "Add" %}</button>
|
<button type="submit" class="button is-primary">{% trans "Add" %}</button>
|
||||||
|
|
|
@ -27,11 +27,12 @@
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column is-half">
|
<div class="column is-half">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_server_name">{% trans "Instance:" %}</label>
|
<label class="label" for="id_server_name">
|
||||||
<input type="text" name="server_name" maxlength="255" class="input" required id="id_server_name" value="{{ form.server_name.value|default:'' }}" placeholder="domain.com">
|
{% trans "Instance:" %}
|
||||||
{% for error in form.server_name.errors %}
|
</label>
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
<input type="text" name="server_name" maxlength="255" class="input" required id="id_server_name" value="{{ form.server_name.value|default:'' }}" placeholder="domain.com" aria-describedby="desc_server_name">
|
||||||
{% endfor %}
|
|
||||||
|
{% include 'snippets/form_errors.html' with errors_list=form.server_name.errors id="desc_server_name" %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-half">
|
<div class="column is-half">
|
||||||
|
@ -49,29 +50,37 @@
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column is-half">
|
<div class="column is-half">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_application_type">{% trans "Software:" %}</label>
|
<label class="label" for="id_application_type">
|
||||||
<input type="text" name="application_type" maxlength="255" class="input" id="id_application_type" value="{{ form.application_type.value|default:'' }}">
|
{% trans "Software:" %}
|
||||||
{% for error in form.application_type.errors %}
|
</label>
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
<input type="text" name="application_type" maxlength="255" class="input" id="id_application_type" value="{{ form.application_type.value|default:'' }}" aria-describedby="desc_application_type">
|
||||||
{% endfor %}
|
|
||||||
|
{% include 'snippets/form_errors.html' with errors_list=form.application_type.errors id="desc_application_type" %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-half">
|
<div class="column is-half">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_application_version">{% trans "Version:" %}</label>
|
<label class="label" for="id_application_version">
|
||||||
<input type="text" name="application_version" maxlength="255" class="input" id="id_application_version" value="{{ form.application_version.value|default:'' }}">
|
{% trans "Version:" %}
|
||||||
{% for error in form.application_version.errors %}
|
</label>
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
<input type="text" name="application_version" maxlength="255" class="input" id="id_application_version" value="{{ form.application_version.value|default:'' }}" aria-describedby="desc_application_version">
|
||||||
{% endfor %}
|
|
||||||
|
{% include 'snippets/form_errors.html' with errors_list=form.application_version.errors id="desc_application_version" %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_notes">{% trans "Notes:" %}</label>
|
<label class="label" for="id_notes">
|
||||||
<textarea name="notes" cols="40" rows="5" class="textarea" id="id_notes">{{ form.notes.value|default:'' }}</textarea>
|
{% trans "Notes:" %}
|
||||||
|
</label>
|
||||||
|
<textarea name="notes" cols="40" rows="5" class="textarea" id="id_notes">
|
||||||
|
{{ form.notes.value|default:'' }}
|
||||||
|
</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
|
<button type="submit" class="button is-primary">
|
||||||
|
{% trans "Save" %}
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -20,16 +20,16 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<input type="text" name="address" maxlength="255" class="input" required="" id="id_address" placeholder="190.0.2.0/24">
|
<input type="text" name="address" maxlength="255" class="input" required="" id="id_address" placeholder="190.0.2.0/24" aria-describedby="desc_address">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% for error in form.address.errors %}
|
{% include 'snippets/form_errors.html' with errors_list=form.address.errors id="desc_address" %}
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button type="submit" class="button is-primary">{% trans "Add" %}</button>
|
<button type="submit" class="button is-primary">
|
||||||
|
{% trans "Add" %}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -33,8 +33,8 @@
|
||||||
{{ site_form.instance_description }}
|
{{ site_form.instance_description }}
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label mb-0" for="id_short_description">{% trans "Short description:" %}</label>
|
<label class="label mb-0" for="id_instance_short_description">{% trans "Short description:" %}</label>
|
||||||
<p class="help">{% trans "Used when the instance is previewed on joinbookwyrm.com. Does not support HTML or Markdown." %}</p>
|
<p class="help" id="desc_instance_short_description">{% trans "Used when the instance is previewed on joinbookwyrm.com. Does not support HTML or Markdown." %}</p>
|
||||||
{{ site_form.instance_short_description }}
|
{{ site_form.instance_short_description }}
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
@ -114,7 +114,7 @@
|
||||||
{{ site_form.require_confirm_email }}
|
{{ site_form.require_confirm_email }}
|
||||||
{% trans "Require users to confirm email address" %}
|
{% trans "Require users to confirm email address" %}
|
||||||
</label>
|
</label>
|
||||||
<p class="help">{% trans "(Recommended if registration is open)" %}</p>
|
<p class="help" id="desc_require_confirm_email">{% trans "(Recommended if registration is open)" %}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_registration_closed_text">{% trans "Registration closed text:" %}</label>
|
<label class="label" for="id_registration_closed_text">{% trans "Registration closed text:" %}</label>
|
||||||
|
@ -123,9 +123,8 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_invite_request_text">{% trans "Invite request text:" %}</label>
|
<label class="label" for="id_invite_request_text">{% trans "Invite request text:" %}</label>
|
||||||
{{ site_form.invite_request_text }}
|
{{ site_form.invite_request_text }}
|
||||||
{% for error in site_form.invite_request_text.errors %}
|
|
||||||
<p class="help is-danger">{{ error|escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=site_form.invite_request_text.errors id="desc_invite_request_text" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
@ -15,10 +15,9 @@
|
||||||
</p>
|
</p>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_password">{% trans "Your password:" %}</label>
|
<label class="label" for="id_password">{% trans "Your password:" %}</label>
|
||||||
<input class="input {% if form.password.errors %}is-danger{% endif %}" type="password" name="password" id="id_password" required>
|
<input class="input {% if form.password.errors %}is-danger{% endif %}" type="password" name="password" id="id_password" required aria-describedby="desc_password">
|
||||||
{% for error in form.password.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=form.password.errors id="desc_password" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="button is-danger">{% trans "Delete Account" %}</button>
|
<button type="submit" class="button is-danger">{% trans "Delete Account" %}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -50,18 +50,23 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% with group=user.groups.first %}
|
{% with group=user.groups.first %}
|
||||||
<div class="select">
|
<div class="select">
|
||||||
<select name="groups" id="id_user_group">
|
<select name="groups" id="id_user_group" aria-describedby="desc_user_group">
|
||||||
{% for value, name in group_form.fields.groups.choices %}
|
{% for value, name in group_form.fields.groups.choices %}
|
||||||
<option value="{{ value }}" {% if name == group.name %}selected{% endif %}>{{ name|title }}</option>
|
<option value="{{ value }}" {% if name == group.name %}selected{% endif %}>
|
||||||
|
{{ name|title }}
|
||||||
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<option value="" {% if not group %}selected{% endif %}>User</option>
|
<option value="" {% if not group %}selected{% endif %}>
|
||||||
|
User
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{% for error in group_form.groups.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=group_form.groups.errors id="desc_user_group" %}
|
||||||
{% endfor %}
|
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
<button class="button">{% trans "Save" %}</button>
|
<button class="button">
|
||||||
|
{% trans "Save" %}
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% include 'user/books_header.html' %}
|
{% include 'user/books_header.html' with shelf=shelf %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block opengraph_images %}
|
{% block opengraph_images %}
|
||||||
|
@ -80,7 +80,10 @@
|
||||||
<div class="block columns is-mobile">
|
<div class="block columns is-mobile">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<h2 class="title is-3">
|
<h2 class="title is-3">
|
||||||
{{ shelf.name }}
|
{% if shelf.identifier == 'to-read' %}{% trans "To Read" %}
|
||||||
|
{% elif shelf.identifier == 'reading' %}{% trans "Currently Reading" %}
|
||||||
|
{% elif shelf.identifier == 'read' %}{% trans "Read" %}
|
||||||
|
{% else %}{{ shelf.name }}{% endif %}
|
||||||
<span class="subtitle">
|
<span class="subtitle">
|
||||||
{% include 'snippets/privacy-icons.html' with item=shelf %}
|
{% include 'snippets/privacy-icons.html' with item=shelf %}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -5,11 +5,9 @@
|
||||||
{% if book.authors.exists %}
|
{% if book.authors.exists %}
|
||||||
{% blocktrans trimmed with path=book.local_path title=book|book_title %}
|
{% blocktrans trimmed with path=book.local_path title=book|book_title %}
|
||||||
<a href="{{ path }}">{{ title }}</a> by
|
<a href="{{ path }}">{{ title }}</a> by
|
||||||
{% endblocktrans %}
|
{% endblocktrans %} {% include 'snippets/authors.html' with book=book limit=3 %}
|
||||||
{% include 'snippets/authors.html' with book=book limit=3 %}
|
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ book.local_path }}">{{ book|book_title }}</a>
|
<a href="{{ book.local_path }}">{{ book|book_title }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endspaceless %}
|
{% endspaceless %}
|
||||||
|
|
9
bookwyrm/templates/snippets/form_errors.html
Normal file
9
bookwyrm/templates/snippets/form_errors.html
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{% if errors_list %}
|
||||||
|
<div id="{{ id }}">
|
||||||
|
{% for error in errors_list %}
|
||||||
|
<p class="help is-danger">
|
||||||
|
{{ error | escape }}
|
||||||
|
</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
|
@ -9,10 +9,11 @@ Finish "<em>{{ book_title }}</em>"
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block modal-form-open %}
|
{% block modal-form-open %}
|
||||||
<form name="finish-reading" action="{% url 'reading-status' 'finish' book.id %}" method="post" class="submit-status">
|
<form name="finish-reading" action="{% url 'reading-status' 'finish' book.id %}" method="post" {% if not refresh %}class="submit-status"{% endif %}>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="id" value="{{ readthrough.id }}">
|
<input type="hidden" name="id" value="{{ readthrough.id }}">
|
||||||
<input type="hidden" name="reading_status" value="read">
|
<input type="hidden" name="reading_status" value="read">
|
||||||
|
<input type="hidden" name="shelf" value="{{ move_from }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block reading-dates %}
|
{% block reading-dates %}
|
||||||
|
|
|
@ -9,8 +9,9 @@ Start "<em>{{ book_title }}</em>"
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block modal-form-open %}
|
{% block modal-form-open %}
|
||||||
<form name="start-reading" action="{% url 'reading-status' 'start' book.id %}" method="post" class="submit-status">
|
<form name="start-reading" action="{% url 'reading-status' 'start' book.id %}" method="post" {% if not refresh %}class="submit-status"{% endif %}>
|
||||||
<input type="hidden" name="reading_status" value="reading">
|
<input type="hidden" name="reading_status" value="reading">
|
||||||
|
<input type="hidden" name="shelf" value="{{ move_from }}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
@ -9,8 +9,9 @@ Want to Read "<em>{{ book_title }}</em>"
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block modal-form-open %}
|
{% block modal-form-open %}
|
||||||
<form name="shelve" action="{% url 'reading-status' 'want' book.id %}" method="post" class="submit-status">
|
<form name="shelve" action="{% url 'reading-status' 'want' book.id %}" method="post" {% if not refresh %}class="submit-status"{% endif %}>
|
||||||
<input type="hidden" name="reading_status" value="to-read">
|
<input type="hidden" name="reading_status" value="to-read">
|
||||||
|
<input type="hidden" name="shelf" value="{{ move_from }}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
@ -3,32 +3,31 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_localname_register">{% trans "Username:" %}</label>
|
<label class="label" for="id_localname_register">{% trans "Username:" %}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input type="text" name="localname" maxlength="150" class="input" required="" id="id_localname_register" value="{% if register_form.localname.value %}{{ register_form.localname.value }}{% endif %}">
|
<input type="text" name="localname" maxlength="150" class="input" required="" id="id_localname_register" value="{% if register_form.localname.value %}{{ register_form.localname.value }}{% endif %}" aria-describedby="desc_localname_register">
|
||||||
|
|
||||||
|
{% include 'snippets/form_errors.html' with errors_list=register_form.localname.errors id="desc_localname_register" %}
|
||||||
</div>
|
</div>
|
||||||
{% for error in register_form.localname.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_email_register">{% trans "Email address:" %}</label>
|
<label class="label" for="id_email_register">{% trans "Email address:" %}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input type="email" name="email" maxlength="254" class="input" id="id_email_register" value="{% if register_form.email.value %}{{ register_form.email.value }}{% endif %}" required>
|
<input type="email" name="email" maxlength="254" class="input" id="id_email_register" value="{% if register_form.email.value %}{{ register_form.email.value }}{% endif %}" required aria-describedby="desc_email_register">
|
||||||
{% for error in register_form.email.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=register_form.email.errors id="desc_email_register" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_password_register">{% trans "Password:" %}</label>
|
<label class="label" for="id_password_register">{% trans "Password:" %}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password_register">
|
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password_register" aria-describedby="desc_password_register">
|
||||||
{% for error in register_form.password.errors %}
|
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
{% include 'snippets/form_errors.html' with errors_list=register_form.password.errors id="desc_password_register" %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-primary" type="submit">{% trans "Sign Up" %}</button>
|
<button class="button is-primary" type="submit">
|
||||||
|
{% trans "Sign Up" %}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,29 +1,96 @@
|
||||||
{% extends 'components/dropdown.html' %}
|
{% extends 'components/dropdown.html' %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load bookwyrm_tags %}
|
||||||
|
{% load utilities %}
|
||||||
|
|
||||||
{% block dropdown-trigger %}
|
{% block dropdown-trigger %}
|
||||||
<span>{% trans "Move book" %}</span>
|
<span>{% trans "Move book" %}</span>
|
||||||
<span class="icon icon-arrow-down" aria-hidden="true"></span>
|
<span class="icon icon-arrow-down" aria-hidden="true"></span>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block dropdown-list %}
|
{% block dropdown-list %}
|
||||||
|
{% with book.id|uuid as uuid %}
|
||||||
|
{% active_shelf book as active_shelf %}
|
||||||
|
{% latest_read_through book request.user as readthrough %}
|
||||||
|
|
||||||
{% for shelf in user_shelves %}
|
{% for shelf in user_shelves %}
|
||||||
|
|
||||||
|
{% if shelf.editable %}
|
||||||
<li role="menuitem" class="dropdown-item p-0">
|
<li role="menuitem" class="dropdown-item p-0">
|
||||||
<form name="shelve" action="/shelve/" method="post">
|
<form name="shelve" action="/shelve/" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="book" value="{{ book.id }}">
|
<input type="hidden" name="book" value="{{ book.id }}">
|
||||||
<input type="hidden" name="change-shelf-from" value="{{ current.identifier }}">
|
<input type="hidden" name="change-shelf-from" value="{{ current.identifier }}">
|
||||||
<input type="hidden" name="shelf" value="{{ shelf.identifier }}">
|
<input type="hidden" name="shelf" value="{{ shelf.identifier }}">
|
||||||
<button class="button is-fullwidth is-small shelf-option is-radiusless is-white" type="submit" {% if shelf.identifier == current.identifier %}disabled{% endif %}><span>{{ shelf.name }}</span></button>
|
|
||||||
|
<button class="button is-fullwidth is-small shelf-option is-radiusless is-white" type="submit" {% if shelf.identifier == current.identifier %}disabled{% endif %}>
|
||||||
|
<span>
|
||||||
|
{% if shelf.identifier == 'to-read' %}{% trans "To Read" %}
|
||||||
|
{% elif shelf.identifier == 'reading' %}{% trans "Currently Reading" %}
|
||||||
|
{% elif shelf.identifier == 'read' %}{% trans "Read" %}
|
||||||
|
{% else %}{{ shelf.name }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</li>
|
</li>
|
||||||
|
{% else%}
|
||||||
|
{% comparison_bool shelf.identifier active_shelf.shelf.identifier as is_current %}
|
||||||
|
{% with button_class="is-fullwidth is-small shelf-option is-radiusless is-white" %}
|
||||||
|
<li role="menuitem" class="dropdown-item p-0">
|
||||||
|
{% if shelf.identifier == 'reading' %}
|
||||||
|
|
||||||
|
{% trans "Start reading" as button_text %}
|
||||||
|
{% url 'reading-status' 'start' book.id as fallback_url %}
|
||||||
|
{% include 'snippets/toggle/toggle_button.html' with class=button_class text=button_text controls_text="start_reading" controls_uid=uuid focus="modal_title_start_reading" disabled=is_current fallback_url=fallback_url %}
|
||||||
|
|
||||||
|
|
||||||
|
{% elif shelf.identifier == 'read' %}
|
||||||
|
|
||||||
|
{% trans "Read" as button_text %}
|
||||||
|
{% url 'reading-status' 'finish' book.id as fallback_url %}
|
||||||
|
{% include 'snippets/toggle/toggle_button.html' with class=button_class text=button_text controls_text="finish_reading" controls_uid=uuid focus="modal_title_finish_reading" disabled=is_current fallback_url=fallback_url %}
|
||||||
|
|
||||||
|
{% elif shelf.identifier == 'to-read' %}
|
||||||
|
|
||||||
|
{% trans "Want to read" as button_text %}
|
||||||
|
{% url 'reading-status' 'want' book.id as fallback_url %}
|
||||||
|
{% include 'snippets/toggle/toggle_button.html' with class=button_class text=button_text controls_text="want_to_read" controls_uid=uuid focus="modal_title_want_to_read" disabled=is_current fallback_url=fallback_url %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<li class="navbar-divider" role="separator"></li>
|
|
||||||
|
{% if shelf.identifier == 'all' %}
|
||||||
|
{% for shelved_in in book.shelves.all %}
|
||||||
|
<li class="navbar-divider m-0" role="separator" ></li>
|
||||||
<li role="menuitem" class="dropdown-item p-0">
|
<li role="menuitem" class="dropdown-item p-0">
|
||||||
<form name="shelve" action="/unshelve/" method="post">
|
<form name="shelve" action="/unshelve/" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="book" value="{{ book.id }}">
|
<input type="hidden" name="book" value="{{ book.id }}">
|
||||||
<input type="hidden" name="shelf" value="{{ current.id }}">
|
<input type="hidden" name="shelf" value="{{ shelved_in.id }}">
|
||||||
<button class="button is-fullwidth is-small is-radiusless is-danger is-light" type="submit">{% trans "Remove" %}</button>
|
<button class="button is-fullwidth is-small is-radiusless is-danger is-light" type="submit">{% trans "Remove from" %} {{ shelved_in.name }}</button>
|
||||||
</form>
|
</form>
|
||||||
</li>
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<li class="navbar-divider" role="separator" ></li>
|
||||||
|
<li role="menuitem" class="dropdown-item p-0">
|
||||||
|
<form name="shelve" action="/unshelve/" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="book" value="{{ book.id }}">
|
||||||
|
<input type="hidden" name="shelf" value="{{ shelf.id }}">
|
||||||
|
<button class="button is-fullwidth is-small is-radiusless is-danger is-light" type="submit">{% trans "Remove from" %} {{ shelf.name }}</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% include 'snippets/reading_modals/want_to_read_modal.html' with book=active_shelf.book controls_text="want_to_read" controls_uid=uuid move_from=current.id refresh=True class="" %}
|
||||||
|
|
||||||
|
{% include 'snippets/reading_modals/start_reading_modal.html' with book=active_shelf.book controls_text="start_reading" controls_uid=uuid move_from=current.id refresh=True class="" %}
|
||||||
|
|
||||||
|
{% include 'snippets/reading_modals/finish_reading_modal.html' with book=active_shelf.book controls_text="finish_reading" controls_uid=uuid move_from=current.id readthrough=readthrough refresh=True class="" %}
|
||||||
|
|
||||||
|
{% endwith %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -32,7 +32,7 @@
|
||||||
|
|
||||||
{% elif shelf.editable %}
|
{% elif shelf.editable %}
|
||||||
|
|
||||||
<form name="shelve" action="/shelve/" method="post">
|
<form name="shelve" action="/shelve/" method="post" autocomplete="off">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="book" value="{{ active_shelf.book.id }}">
|
<input type="hidden" name="book" value="{{ active_shelf.book.id }}">
|
||||||
<button class="button {{ class }}" name="shelf" type="submit" value="{{ shelf.identifier }}" {% if shelf in book.shelf_set.all %} disabled {% endif %}>
|
<button class="button {{ class }}" name="shelf" type="submit" value="{{ shelf.identifier }}" {% if shelf in book.shelf_set.all %} disabled {% endif %}>
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
{% if status.content == 'wants to read' %}
|
||||||
|
{% include 'snippets/status/headers/to_read.html' with book=status.mention_books.first %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if status.content == 'finished reading' %}
|
||||||
|
{% include 'snippets/status/headers/read.html' with book=status.mention_books.first %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if status.content == 'started reading' %}
|
||||||
|
{% include 'snippets/status/headers/reading.html' with book=status.mention_books.first %}
|
||||||
|
{% endif %}
|
|
@ -3,13 +3,6 @@
|
||||||
{% load status_display %}
|
{% load status_display %}
|
||||||
{% load utilities %}
|
{% load utilities %}
|
||||||
|
|
||||||
{% if status.status_type == 'GeneratedNote' %}
|
|
||||||
{% with book=status.mention_books.first %}
|
|
||||||
{{ status.content|safe }} <a href="{{ book.local_path }}">{{ book|book_title }}</a>
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
|
|
||||||
{% with parent_status=status|parent %}
|
{% with parent_status=status|parent %}
|
||||||
{% if parent_status %}
|
{% if parent_status %}
|
||||||
{% blocktrans trimmed with username=parent_status.user.display_name user_path=parent_status.user.local_path status_path=parent_status.local_path %}
|
{% blocktrans trimmed with username=parent_status.user.display_name user_path=parent_status.user.local_path status_path=parent_status.local_path %}
|
||||||
|
@ -18,5 +11,4 @@ replied to <a href="{{ user_path }}">{{ username}}</a>'s <a href="{{ status_path
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
{% endif %}
|
|
||||||
{% endspaceless %}
|
{% endspaceless %}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{% load utilities %}
|
{% load utilities %}
|
||||||
{% if fallback_url %}
|
{% if fallback_url %}
|
||||||
<form name="fallback_form_{{ 0|uuid }}" method="GET" action="{{ fallback_url }}">
|
<form name="fallback_form_{{ 0|uuid }}" method="GET" action="{{ fallback_url }}" autocomplete="off">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button
|
<button
|
||||||
{% if not fallback_url %}
|
{% if not fallback_url %}
|
||||||
|
|
|
@ -1,6 +1,16 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% if is_self %}
|
{% if is_self %}
|
||||||
|
{% if shelf.identifier == 'to-read' %}
|
||||||
|
{% trans "To Read" %}
|
||||||
|
{% elif shelf.identifier == 'reading' %}
|
||||||
|
{% trans "Currently Reading" %}
|
||||||
|
{% elif shelf.identifier == 'read' %}
|
||||||
|
{% trans "Read" %}
|
||||||
|
{% elif shelf.identifier == 'all' %}
|
||||||
{% trans "Your books" %}
|
{% trans "Your books" %}
|
||||||
{% else %}
|
{% else %}
|
||||||
|
{{ shelf.name }}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
{% blocktrans with username=user.display_name %}{{ username }}'s books{% endblocktrans %}
|
{% blocktrans with username=user.display_name %}{{ username }}'s books{% endblocktrans %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -29,8 +29,13 @@
|
||||||
<div class="columns is-mobile scroll-x">
|
<div class="columns is-mobile scroll-x">
|
||||||
{% for shelf in shelves %}
|
{% for shelf in shelves %}
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
<h3>{{ shelf.name }}
|
<h3>
|
||||||
{% if shelf.size > 3 %}<small>(<a href="{{ shelf.local_path }}">{% blocktrans with size=shelf.size %}View all {{ size }}{% endblocktrans %}</a>)</small>{% endif %}</h3>
|
{% if shelf.name == 'To Read' %}{% trans "To Read" %}
|
||||||
|
{% elif shelf.name == 'Currently Reading' %}{% trans "Currently Reading" %}
|
||||||
|
{% elif shelf.name == 'Read' %}{% trans "Read" %}
|
||||||
|
{% else %}{{ shelf.name }}{% endif %}
|
||||||
|
{% if shelf.size > 3 %}<small>(<a href="{{ shelf.local_path }}">{% blocktrans with size=shelf.size %}View all {{ size }}{% endblocktrans %}</a>)</small>{% endif %}
|
||||||
|
</h3>
|
||||||
<div class="is-mobile field is-grouped">
|
<div class="is-mobile field is-grouped">
|
||||||
{% for book in shelf.books %}
|
{% for book in shelf.books %}
|
||||||
<div class="control">
|
<div class="control">
|
||||||
|
@ -49,7 +54,8 @@
|
||||||
|
|
||||||
{% if goal %}
|
{% if goal %}
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<h2 class="title">{% now 'Y' %} Reading Goal</h2>
|
{% now 'Y' as current_year%}
|
||||||
|
<h2 class="title">{% blocktrans %}{{ current_year }} Reading Goal{% endblocktrans %}</h2>
|
||||||
{% include 'snippets/goal_progress.html' with goal=goal %}
|
{% include 'snippets/goal_progress.html' with goal=goal %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -77,7 +77,12 @@ def related_status(notification):
|
||||||
def active_shelf(context, book):
|
def active_shelf(context, book):
|
||||||
"""check what shelf a user has a book on, if any"""
|
"""check what shelf a user has a book on, if any"""
|
||||||
if hasattr(book, "current_shelves"):
|
if hasattr(book, "current_shelves"):
|
||||||
return book.current_shelves[0] if len(book.current_shelves) else {"book": book}
|
read_shelves = [
|
||||||
|
s
|
||||||
|
for s in book.current_shelves
|
||||||
|
if s.shelf.identifier in models.Shelf.READ_STATUS_IDENTIFIERS
|
||||||
|
]
|
||||||
|
return read_shelves[0] if len(read_shelves) else {"book": book}
|
||||||
|
|
||||||
shelf = (
|
shelf = (
|
||||||
models.ShelfBook.objects.filter(
|
models.ShelfBook.objects.filter(
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
""" template filters for really common utilities """
|
""" template filters for really common utilities """
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from django import template
|
from django import template
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.template.defaultfilters import stringfilter
|
||||||
from django.templatetags.static import static
|
from django.templatetags.static import static
|
||||||
|
|
||||||
|
|
||||||
|
@ -66,3 +69,39 @@ def get_book_cover_thumbnail(book, size="medium", ext="jpg"):
|
||||||
return cover_thumbnail.url
|
return cover_thumbnail.url
|
||||||
except OSError:
|
except OSError:
|
||||||
return static("images/no_cover.jpg")
|
return static("images/no_cover.jpg")
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter(name="get_isni_bio")
|
||||||
|
def get_isni_bio(existing, author):
|
||||||
|
"""Returns the isni bio string if an existing author has an isni listed"""
|
||||||
|
auth_isni = re.sub(r"\D", "", str(author.isni))
|
||||||
|
if len(existing) == 0:
|
||||||
|
return ""
|
||||||
|
for value in existing:
|
||||||
|
if hasattr(value, "bio") and auth_isni == re.sub(r"\D", "", str(value.isni)):
|
||||||
|
return mark_safe(f"Author of <em>{value.bio}</em>")
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
@register.filter(name="get_isni", needs_autoescape=True)
|
||||||
|
def get_isni(existing, author, autoescape=True):
|
||||||
|
"""Returns the isni ID if an existing author has an ISNI listing"""
|
||||||
|
auth_isni = re.sub(r"\D", "", str(author.isni))
|
||||||
|
if len(existing) == 0:
|
||||||
|
return ""
|
||||||
|
for value in existing:
|
||||||
|
if hasattr(value, "isni") and auth_isni == re.sub(r"\D", "", str(value.isni)):
|
||||||
|
isni = value.isni
|
||||||
|
return mark_safe(
|
||||||
|
f'<input type="text" name="isni-for-{author.id}" value="{isni}" hidden>'
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter(name="remove_spaces")
|
||||||
|
@stringfilter
|
||||||
|
def remove_spaces(arg):
|
||||||
|
"""Removes spaces from argument passed in"""
|
||||||
|
return re.sub(r"\s", "", str(arg))
|
||||||
|
|
|
@ -146,7 +146,7 @@ class BaseActivity(TestCase):
|
||||||
self.user.avatar.file # pylint: disable=pointless-statement
|
self.user.avatar.file # pylint: disable=pointless-statement
|
||||||
|
|
||||||
# this would trigger a broadcast because it's a local user
|
# this would trigger a broadcast because it's a local user
|
||||||
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
|
||||||
activity.to_model(model=models.User, instance=self.user)
|
activity.to_model(model=models.User, instance=self.user)
|
||||||
self.assertIsNotNone(self.user.avatar.file)
|
self.assertIsNotNone(self.user.avatar.file)
|
||||||
self.assertEqual(self.user.name, "New Name")
|
self.assertEqual(self.user.name, "New Name")
|
||||||
|
@ -154,7 +154,7 @@ class BaseActivity(TestCase):
|
||||||
|
|
||||||
def test_to_model_many_to_many(self, *_):
|
def test_to_model_many_to_many(self, *_):
|
||||||
"""annoying that these all need special handling"""
|
"""annoying that these all need special handling"""
|
||||||
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
|
||||||
status = models.Status.objects.create(
|
status = models.Status.objects.create(
|
||||||
content="test status",
|
content="test status",
|
||||||
user=self.user,
|
user=self.user,
|
||||||
|
@ -186,7 +186,7 @@ class BaseActivity(TestCase):
|
||||||
def test_to_model_one_to_many(self, *_):
|
def test_to_model_one_to_many(self, *_):
|
||||||
"""these are reversed relationships, where the secondary object
|
"""these are reversed relationships, where the secondary object
|
||||||
keys the primary object but not vice versa"""
|
keys the primary object but not vice versa"""
|
||||||
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
|
||||||
status = models.Status.objects.create(
|
status = models.Status.objects.create(
|
||||||
content="test status",
|
content="test status",
|
||||||
user=self.user,
|
user=self.user,
|
||||||
|
@ -224,7 +224,7 @@ class BaseActivity(TestCase):
|
||||||
@responses.activate
|
@responses.activate
|
||||||
def test_set_related_field(self, *_):
|
def test_set_related_field(self, *_):
|
||||||
"""celery task to add back-references to created objects"""
|
"""celery task to add back-references to created objects"""
|
||||||
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
|
||||||
status = models.Status.objects.create(
|
status = models.Status.objects.create(
|
||||||
content="test status",
|
content="test status",
|
||||||
user=self.user,
|
user=self.user,
|
||||||
|
|
|
@ -4,7 +4,7 @@ from django.test import TestCase
|
||||||
from bookwyrm import activitystreams, models
|
from bookwyrm import activitystreams, models
|
||||||
|
|
||||||
|
|
||||||
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay")
|
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
|
||||||
@patch("bookwyrm.activitystreams.add_status_task.delay")
|
@patch("bookwyrm.activitystreams.add_status_task.delay")
|
||||||
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
|
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
|
||||||
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
|
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
|
||||||
|
|
|
@ -4,7 +4,7 @@ from django.test import TestCase
|
||||||
from bookwyrm import activitystreams, models
|
from bookwyrm import activitystreams, models
|
||||||
|
|
||||||
|
|
||||||
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay")
|
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
|
||||||
@patch("bookwyrm.activitystreams.add_status_task.delay")
|
@patch("bookwyrm.activitystreams.add_status_task.delay")
|
||||||
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
|
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
|
||||||
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
|
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
|
||||||
|
|
|
@ -4,7 +4,7 @@ from django.test import TestCase
|
||||||
from bookwyrm import activitystreams, models
|
from bookwyrm import activitystreams, models
|
||||||
|
|
||||||
|
|
||||||
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay")
|
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
|
||||||
@patch("bookwyrm.activitystreams.add_status_task.delay")
|
@patch("bookwyrm.activitystreams.add_status_task.delay")
|
||||||
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
|
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
|
||||||
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
|
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
|
||||||
|
|
|
@ -4,7 +4,7 @@ from django.test import TestCase
|
||||||
from bookwyrm import activitystreams, models
|
from bookwyrm import activitystreams, models
|
||||||
|
|
||||||
|
|
||||||
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay")
|
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
|
||||||
@patch("bookwyrm.activitystreams.add_status_task.delay")
|
@patch("bookwyrm.activitystreams.add_status_task.delay")
|
||||||
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
|
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
|
||||||
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
|
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
|
||||||
|
|
|
@ -4,7 +4,7 @@ from django.test import TestCase
|
||||||
from bookwyrm import activitystreams, models
|
from bookwyrm import activitystreams, models
|
||||||
|
|
||||||
|
|
||||||
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay")
|
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
|
||||||
class ActivitystreamsSignals(TestCase):
|
class ActivitystreamsSignals(TestCase):
|
||||||
"""using redis to build activity streams"""
|
"""using redis to build activity streams"""
|
||||||
|
|
||||||
|
@ -53,11 +53,12 @@ class ActivitystreamsSignals(TestCase):
|
||||||
status = models.Status.objects.create(
|
status = models.Status.objects.create(
|
||||||
user=self.remote_user, content="hi", privacy="public"
|
user=self.remote_user, content="hi", privacy="public"
|
||||||
)
|
)
|
||||||
with patch("bookwyrm.activitystreams.add_status_task.delay") as mock:
|
with patch("bookwyrm.activitystreams.add_status_task.apply_async") as mock:
|
||||||
activitystreams.add_status_on_create_command(models.Status, status, False)
|
activitystreams.add_status_on_create_command(models.Status, status, False)
|
||||||
self.assertEqual(mock.call_count, 1)
|
self.assertEqual(mock.call_count, 1)
|
||||||
args = mock.call_args[0]
|
args = mock.call_args[1]
|
||||||
self.assertEqual(args[0], status.id)
|
self.assertEqual(args["args"][0], status.id)
|
||||||
|
self.assertEqual(args["queue"], "high_priority")
|
||||||
|
|
||||||
def test_populate_streams_on_account_create(self, _):
|
def test_populate_streams_on_account_create(self, _):
|
||||||
"""create streams for a user"""
|
"""create streams for a user"""
|
||||||
|
|
|
@ -34,7 +34,7 @@ class Activitystreams(TestCase):
|
||||||
)
|
)
|
||||||
work = models.Work.objects.create(title="test work")
|
work = models.Work.objects.create(title="test work")
|
||||||
self.book = models.Edition.objects.create(title="test book", parent_work=work)
|
self.book = models.Edition.objects.create(title="test book", parent_work=work)
|
||||||
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
|
||||||
self.status = models.Status.objects.create(
|
self.status = models.Status.objects.create(
|
||||||
content="hi", user=self.local_user
|
content="hi", user=self.local_user
|
||||||
)
|
)
|
||||||
|
@ -133,7 +133,7 @@ class Activitystreams(TestCase):
|
||||||
|
|
||||||
@patch("bookwyrm.activitystreams.LocalStream.remove_object_from_related_stores")
|
@patch("bookwyrm.activitystreams.LocalStream.remove_object_from_related_stores")
|
||||||
@patch("bookwyrm.activitystreams.BooksStream.remove_object_from_related_stores")
|
@patch("bookwyrm.activitystreams.BooksStream.remove_object_from_related_stores")
|
||||||
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay")
|
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
|
||||||
def test_boost_to_another_timeline(self, *_):
|
def test_boost_to_another_timeline(self, *_):
|
||||||
"""boost from a non-follower doesn't remove original status from feed"""
|
"""boost from a non-follower doesn't remove original status from feed"""
|
||||||
status = models.Status.objects.create(user=self.local_user, content="hi")
|
status = models.Status.objects.create(user=self.local_user, content="hi")
|
||||||
|
@ -155,7 +155,7 @@ class Activitystreams(TestCase):
|
||||||
|
|
||||||
@patch("bookwyrm.activitystreams.LocalStream.remove_object_from_related_stores")
|
@patch("bookwyrm.activitystreams.LocalStream.remove_object_from_related_stores")
|
||||||
@patch("bookwyrm.activitystreams.BooksStream.remove_object_from_related_stores")
|
@patch("bookwyrm.activitystreams.BooksStream.remove_object_from_related_stores")
|
||||||
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay")
|
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
|
||||||
def test_boost_to_another_timeline_remote(self, *_):
|
def test_boost_to_another_timeline_remote(self, *_):
|
||||||
"""boost from a remote non-follower doesn't remove original status from feed"""
|
"""boost from a remote non-follower doesn't remove original status from feed"""
|
||||||
status = models.Status.objects.create(user=self.local_user, content="hi")
|
status = models.Status.objects.create(user=self.local_user, content="hi")
|
||||||
|
@ -177,7 +177,7 @@ class Activitystreams(TestCase):
|
||||||
|
|
||||||
@patch("bookwyrm.activitystreams.LocalStream.remove_object_from_related_stores")
|
@patch("bookwyrm.activitystreams.LocalStream.remove_object_from_related_stores")
|
||||||
@patch("bookwyrm.activitystreams.BooksStream.remove_object_from_related_stores")
|
@patch("bookwyrm.activitystreams.BooksStream.remove_object_from_related_stores")
|
||||||
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay")
|
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
|
||||||
def test_boost_to_following_timeline(self, *_):
|
def test_boost_to_following_timeline(self, *_):
|
||||||
"""add a boost and deduplicate the boosted status on the timeline"""
|
"""add a boost and deduplicate the boosted status on the timeline"""
|
||||||
self.local_user.following.add(self.another_user)
|
self.local_user.following.add(self.another_user)
|
||||||
|
@ -199,7 +199,7 @@ class Activitystreams(TestCase):
|
||||||
|
|
||||||
@patch("bookwyrm.activitystreams.LocalStream.remove_object_from_related_stores")
|
@patch("bookwyrm.activitystreams.LocalStream.remove_object_from_related_stores")
|
||||||
@patch("bookwyrm.activitystreams.BooksStream.remove_object_from_related_stores")
|
@patch("bookwyrm.activitystreams.BooksStream.remove_object_from_related_stores")
|
||||||
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay")
|
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
|
||||||
def test_boost_to_same_timeline(self, *_):
|
def test_boost_to_same_timeline(self, *_):
|
||||||
"""add a boost and deduplicate the boosted status on the timeline"""
|
"""add a boost and deduplicate the boosted status on the timeline"""
|
||||||
status = models.Status.objects.create(user=self.local_user, content="hi")
|
status = models.Status.objects.create(user=self.local_user, content="hi")
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
""" testing book data connectors """
|
""" testing book data connectors """
|
||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
import responses
|
import responses
|
||||||
|
|
||||||
from bookwyrm import models
|
from bookwyrm import models
|
||||||
from bookwyrm.connectors.inventaire import Connector, get_language_code
|
from bookwyrm.connectors.inventaire import Connector, get_language_code
|
||||||
|
from bookwyrm.connectors.connector_manager import ConnectorException
|
||||||
|
|
||||||
|
|
||||||
class Inventaire(TestCase):
|
class Inventaire(TestCase):
|
||||||
|
@ -48,6 +51,44 @@ class Inventaire(TestCase):
|
||||||
self.assertEqual(result["wdt:P31"], ["wd:Q3331189"])
|
self.assertEqual(result["wdt:P31"], ["wd:Q3331189"])
|
||||||
self.assertEqual(result["uri"], "isbn:9780375757853")
|
self.assertEqual(result["uri"], "isbn:9780375757853")
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_get_book_data_invalid(self):
|
||||||
|
"""error if there isn't any entity data"""
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://test.url/ok",
|
||||||
|
json={
|
||||||
|
"entities": {},
|
||||||
|
"redirects": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(ConnectorException):
|
||||||
|
self.connector.get_book_data("https://test.url/ok")
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_search(self):
|
||||||
|
"""min confidence filtering"""
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://inventaire.io/search?q=hi",
|
||||||
|
json={
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"_score": 200,
|
||||||
|
"label": "hello",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_score": 100,
|
||||||
|
"label": "hi",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
results = self.connector.search("hi", min_confidence=0.5)
|
||||||
|
self.assertEqual(len(results), 1)
|
||||||
|
self.assertEqual(results[0].title, "hello")
|
||||||
|
|
||||||
def test_format_search_result(self):
|
def test_format_search_result(self):
|
||||||
"""json to search result objs"""
|
"""json to search result objs"""
|
||||||
search_file = pathlib.Path(__file__).parent.joinpath(
|
search_file = pathlib.Path(__file__).parent.joinpath(
|
||||||
|
@ -157,6 +198,88 @@ class Inventaire(TestCase):
|
||||||
"https://covers.inventaire.io/img/entities/12345",
|
"https://covers.inventaire.io/img/entities/12345",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_isbn_search_empty(self):
|
||||||
|
"""another search type"""
|
||||||
|
search_results = {}
|
||||||
|
results = self.connector.parse_isbn_search_data(search_results)
|
||||||
|
self.assertEqual(results, [])
|
||||||
|
|
||||||
|
def test_isbn_search_no_title(self):
|
||||||
|
"""another search type"""
|
||||||
|
search_file = pathlib.Path(__file__).parent.joinpath(
|
||||||
|
"../data/inventaire_isbn_search.json"
|
||||||
|
)
|
||||||
|
search_results = json.loads(search_file.read_bytes())
|
||||||
|
search_results["entities"]["isbn:9782290349229"]["claims"]["wdt:P1476"] = None
|
||||||
|
|
||||||
|
result = self.connector.format_isbn_search_result(
|
||||||
|
search_results.get("entities")
|
||||||
|
)
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_is_work_data(self):
|
||||||
|
"""is it a work"""
|
||||||
|
work_file = pathlib.Path(__file__).parent.joinpath(
|
||||||
|
"../data/inventaire_work.json"
|
||||||
|
)
|
||||||
|
work_data = json.loads(work_file.read_bytes())
|
||||||
|
with patch("bookwyrm.connectors.inventaire.get_data") as get_data_mock:
|
||||||
|
get_data_mock.return_value = work_data
|
||||||
|
formatted = self.connector.get_book_data("hi")
|
||||||
|
self.assertTrue(self.connector.is_work_data(formatted))
|
||||||
|
|
||||||
|
edition_file = pathlib.Path(__file__).parent.joinpath(
|
||||||
|
"../data/inventaire_edition.json"
|
||||||
|
)
|
||||||
|
edition_data = json.loads(edition_file.read_bytes())
|
||||||
|
with patch("bookwyrm.connectors.inventaire.get_data") as get_data_mock:
|
||||||
|
get_data_mock.return_value = edition_data
|
||||||
|
formatted = self.connector.get_book_data("hi")
|
||||||
|
self.assertFalse(self.connector.is_work_data(formatted))
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_get_edition_from_work_data(self):
|
||||||
|
"""load edition"""
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://inventaire.io/?action=by-uris&uris=hello",
|
||||||
|
json={"entities": {}},
|
||||||
|
)
|
||||||
|
data = {"uri": "blah"}
|
||||||
|
with patch(
|
||||||
|
"bookwyrm.connectors.inventaire.Connector.load_edition_data"
|
||||||
|
) as loader_mock, patch(
|
||||||
|
"bookwyrm.connectors.inventaire.Connector.get_book_data"
|
||||||
|
) as getter_mock:
|
||||||
|
loader_mock.return_value = {"uris": ["hello"]}
|
||||||
|
self.connector.get_edition_from_work_data(data)
|
||||||
|
self.assertTrue(getter_mock.called)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"bookwyrm.connectors.inventaire.Connector.load_edition_data"
|
||||||
|
) as loader_mock:
|
||||||
|
loader_mock.return_value = {"uris": []}
|
||||||
|
with self.assertRaises(ConnectorException):
|
||||||
|
self.connector.get_edition_from_work_data(data)
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_get_work_from_edition_data(self):
|
||||||
|
"""load work"""
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://inventaire.io/?action=by-uris&uris=hello",
|
||||||
|
)
|
||||||
|
data = {"wdt:P629": ["hello"]}
|
||||||
|
with patch("bookwyrm.connectors.inventaire.Connector.get_book_data") as mock:
|
||||||
|
self.connector.get_work_from_edition_data(data)
|
||||||
|
self.assertEqual(mock.call_count, 1)
|
||||||
|
args = mock.call_args[0]
|
||||||
|
self.assertEqual(args[0], "https://inventaire.io?action=by-uris&uris=hello")
|
||||||
|
|
||||||
|
data = {"wdt:P629": [None]}
|
||||||
|
with self.assertRaises(ConnectorException):
|
||||||
|
self.connector.get_work_from_edition_data(data)
|
||||||
|
|
||||||
def test_get_language_code(self):
|
def test_get_language_code(self):
|
||||||
"""get english or whatever is in reach"""
|
"""get english or whatever is in reach"""
|
||||||
options = {
|
options = {
|
||||||
|
|
5
bookwyrm/tests/data/generic.csv
Normal file
5
bookwyrm/tests/data/generic.csv
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
id,title,author,ISBN13,rating,shelf,review,added,finished
|
||||||
|
38,Gideon the Ninth,Tamsyn Muir,"9781250313195",,read,,2021-11-10,2021-11-11
|
||||||
|
48,Harrow the Ninth,Tamsyn Muir,,3,read,,2021-11-10
|
||||||
|
23,Subcutanean,Aaron A. Reed,,,read,,2021-11-10
|
||||||
|
10,Patisserie at Home,Mélanie Dupuis,"9780062445315",2,read,"mixed feelings",2021-11-10,2021-11-11
|
Can't render this file because it has a wrong number of fields in line 3.
|
|
@ -1,5 +0,0 @@
|
||||||
Book Id,Title,Author,Author l-f,Additional Authors,ISBN,ISBN13,My Rating,Average Rating,Publisher,Binding,Number of Pages,Year Published,Original Publication Year,Date Read,Date Added,Bookshelves,Bookshelves with positions,Exclusive Shelf,My Review,Spoiler,Private Notes,Read Count,Recommended For,Recommended By,Owned Copies,Original Purchase Date,Original Purchase Location,Condition,Condition Description,BCID
|
|
||||||
42036538,Gideon the Ninth (The Locked Tomb #1),Tamsyn Muir,"Muir, Tamsyn",,"=""1250313198""","=""9781250313195""",0,4.20,Tor,Hardcover,448,2019,2019,2020/10/25,2020/10/21,,,read,,,,1,,,0,,,,,
|
|
||||||
52691223,Subcutanean,Aaron A. Reed,"Reed, Aaron A.",,"=""""","=""""",0,4.45,,Paperback,232,2020,,2020/03/06,2020/03/05,,,read,,,,1,,,0,,,,,
|
|
||||||
28694510,Patisserie at Home,Mélanie Dupuis,"Dupuis, Mélanie",Anne Cazor,"=""0062445316""","=""9780062445315""",2,4.60,Harper Design,Hardcover,288,2016,,,2019/07/08,,,read,,,,2,,,0,,,,,
|
|
||||||
|
|
|
|
@ -1,4 +1,4 @@
|
||||||
Book Id,Title,Author,Author l-f,Additional Authors,ISBN,ISBN13,My Rating,Average Rating,Publisher,Binding,Number of Pages,Year Published,Original Publication Year,Date Read,Date Added,Bookshelves,Bookshelves with positions,Exclusive Shelf,My Review,Spoiler,Private Notes,Read Count,Recommended For,Recommended By,Owned Copies,Original Purchase Date,Original Purchase Location,Condition,Condition Description,BCID
|
Book Id,Title,Author,Author l-f,Additional Authors,ISBN,ISBN13,My Rating,Average Rating,Publisher,Binding,Number of Pages,Year Published,Original Publication Year,Date Read,Date Added,Bookshelves,Bookshelves with positions,Exclusive Shelf,My Review,Spoiler,Private Notes,Read Count,Recommended For,Recommended By,Owned Copies,Original Purchase Date,Original Purchase Location,Condition,Condition Description,BCID
|
||||||
42036538,Gideon the Ninth (The Locked Tomb #1),Tamsyn Muir,"Muir, Tamsyn",,"=""1250313198""","=""9781250313195""",0,4.20,Tor,Hardcover,448,2019,2019,2020/10/25,2020/10/21,,,read,,,,1,,,0,,,,,
|
42036538,Gideon the Ninth (The Locked Tomb #1),Tamsyn Muir,"Muir, Tamsyn",,"=""1250313198""","=""9781250313195""",3,4.20,Tor,Hardcover,448,2019,2019,2020/10/25,2020/10/21,,,read,,,,1,,,0,,,,,
|
||||||
52691223,Subcutanean,Aaron A. Reed,"Reed, Aaron A.",,"=""""","=""""",0,4.45,,Paperback,232,2020,,2020/03/06,2020/03/05,,,read,,,,1,,,0,,,,,
|
52691223,Subcutanean,Aaron A. Reed,"Reed, Aaron A.",,"=""""","=""""",0,4.45,,Paperback,232,2020,,2020/03/06,2020/03/05,,,read,,,,1,,,0,,,,,
|
||||||
28694510,Patisserie at Home,Mélanie Dupuis,"Dupuis, Mélanie",Anne Cazor,"=""0062445316""","=""9780062445315""",2,4.60,Harper Design,Hardcover,288,2016,,,2019/07/08,,,read,"mixed feelings",,,2,,,0,,,,,
|
28694510,Patisserie at Home,Mélanie Dupuis,"Dupuis, Mélanie",Anne Cazor,"=""0062445316""","=""9780062445315""",2,4.60,Harper Design,Hardcover,288,2016,,,2019/07/08,,,read,"mixed feelings",,,2,,,0,,,,,
|
||||||
|
|
|
|
@ -1,4 +1,4 @@
|
||||||
Book Id Title Sort Character Primary Author Primary Author Role Secondary Author Secondary Author Roles Publication Date Review Rating Comment Private Comment Summary Media Physical Description Weight Height Thickness Length Dimensions Page Count LCCN Acquired Date Started Date Read Barcode BCID Tags Collections Languages Original Languages LC Classification ISBN ISBNs Subjects Dewey Decimal Dewey Wording Other Call Number Copies Source Entry Date From Where OCLC Work id Lending Patron Lending Status Lending Start Lending End
|
Book Id Title Sort Character Primary Author Primary Author Role Secondary Author Secondary Author Roles Publication Date Review Rating Comment Private Comment Summary Media Physical Description Weight Height Thickness Length Dimensions Page Count LCCN Acquired Date Started Date Read Barcode BCID Tags Collections Languages Original Languages LC Classification ISBN ISBNs Subjects Dewey Decimal Dewey Wording Other Call Number Copies Source Entry Date From Where OCLC Work id Lending Patron Lending Status Lending Start Lending End
|
||||||
5498194 Marelle 1 Cortázar, Julio Gallimard (1979), Poche 1979 chef d'oeuvre 4.5 Marelle by Julio Cortázar (1979) Broché 590 p.; 7.24 inches 1.28 pounds 7.24 inches 1.26 inches 4.96 inches 7.24 x 4.96 x 1.26 inches 590 [2007-04-16] [2007-05-08] roman, espagnol, expérimental, bohème, philosophie Your library French Spanish PQ7797 .C7145 [2070291340] 2070291340, 9782070291342 Cortâazar, Julio. Rayuela 863 Literature > Spanish And Portuguese > Spanish fiction 1 Amazon.fr [2006-08-09] 57814
|
5498194 Marelle 1 Cortazar, Julio Gallimard (1979), Poche 1979 chef d'oeuvre 4.5 Marelle by Julio Cortázar (1979) Broché 590 p.; 7.24 inches 1.28 pounds 7.24 inches 1.26 inches 4.96 inches 7.24 x 4.96 x 1.26 inches 590 [2007-04-16] [2007-05-08] roman, espagnol, expérimental, bohème, philosophie Your library French Spanish PQ7797 .C7145 [2070291340] 2070291340, 9782070291342 Cortâazar, Julio. Rayuela 863 Literature > Spanish And Portuguese > Spanish fiction 1 Amazon.fr [2006-08-09] 57814
|
||||||
5015319 Le grand incendie de Londres: Récit, avec incises et bifurcations, 1985-1987 (Fiction & Cie) 1 Roubaud, Jacques Seuil (1989), Unknown Binding 1989 5 Le grand incendie de Londres: Récit, avec incises et bifurcations, 1985-1987 (Fiction & Cie) by Jacques Roubaud (1989) Broché 411 p.; 7.72 inches 0.88 pounds 7.72 inches 1.02 inches 5.43 inches 7.72 x 5.43 x 1.02 inches 411 Your library English PQ2678 .O77 [2020104725] 2020104725, 9782020104722 Autobiographical fiction|Roubaud, Jacques > Fiction 813 American And Canadian > Fiction > Literature 1 Amazon.com [2006-07-25] 478910
|
5015319 Le grand incendie de Londres: Récit, avec incises et bifurcations, 1985-1987 (Fiction & Cie) 1 Roubaud, Jacques Seuil (1989), Unknown Binding 1989 5 Le grand incendie de Londres: Récit, avec incises et bifurcations, 1985-1987 (Fiction & Cie) by Jacques Roubaud (1989) Broché 411 p.; 7.72 inches 0.88 pounds 7.72 inches 1.02 inches 5.43 inches 7.72 x 5.43 x 1.02 inches 411 Your library English PQ2678 .O77 [2020104725] 2020104725, 9782020104722 Autobiographical fiction|Roubaud, Jacques > Fiction 813 American And Canadian > Fiction > Literature 1 Amazon.com [2006-07-25] 478910
|
||||||
5015399 Le Maître et Marguerite 1 Boulgakov, Mikhaïl Pocket (1994), Poche 1994 Le Maître et Marguerite by Mikhaïl Boulgakov (1994) Broché 579 p.; 7.09 inches 0.66 pounds 7.09 inches 1.18 inches 4.33 inches 7.09 x 4.33 x 1.18 inches 579 Your library French PG3476 .B78 [2266062328] 2266062328, 9782266062329 Allegories|Bulgakov|Good and evil > Fiction|Humanities|Jerusalem > Fiction|Jesus Christ > Fiction|Literature|Mental illness > Fiction|Moscow (Russia) > Fiction|Novel|Pilate, Pontius, 1st cent. > Fiction|Political fiction|Russia > Fiction|Russian fiction|Russian publications (Form Entry)|Soviet Union > History > 1925-1953 > Fiction|literature 891.7342 1917-1945 > 1917-1991 (USSR) > Literature > Literature of other Indo-European languages > Other Languages > Russian > Russian Fiction 1 Amazon.fr [2006-07-25] 10151
|
5015399 Le Maître et Marguerite 1 Boulgakov, Mikhaïl Pocket (1994), Poche 1994 Le Maître et Marguerite by Mikhaïl Boulgakov (1994) Broché 579 p.; 7.09 inches 0.66 pounds 7.09 inches 1.18 inches 4.33 inches 7.09 x 4.33 x 1.18 inches 579 Your library French PG3476 .B78 [2266062328] 2266062328, 9782266062329 Allegories|Bulgakov|Good and evil > Fiction|Humanities|Jerusalem > Fiction|Jesus Christ > Fiction|Literature|Mental illness > Fiction|Moscow (Russia) > Fiction|Novel|Pilate, Pontius, 1st cent. > Fiction|Political fiction|Russia > Fiction|Russian fiction|Russian publications (Form Entry)|Soviet Union > History > 1925-1953 > Fiction|literature 891.7342 1917-1945 > 1917-1991 (USSR) > Literature > Literature of other Indo-European languages > Other Languages > Russian > Russian Fiction 1 Amazon.fr [2006-07-25] 10151
|
||||||
|
|
|
3
bookwyrm/tests/data/storygraph.csv
Normal file
3
bookwyrm/tests/data/storygraph.csv
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
Title,Authors,Contributors,ISBN,Format,Read Status,Date Added,Last Date Read,Dates Read,Read Count,Moods,Pace,Character- or Plot-Driven?,Strong Character Development?,Loveable Characters?,Diverse Characters?,Flawed Characters?,Star Rating,Review,Content Warnings,Content Warning Description,Tags,Owned?
|
||||||
|
Always Coming Home,"Ursula K. Le Guin, Todd Barton, Margaret Chodos-Irvine","",,,to-read,2021/05/10,"","",0,"",,,,,,,,,"",,"",No
|
||||||
|
Subprime Attention Crisis,Tim Hwang,"",,,read,2021/05/10,"","",1,informative,fast,,,,,,5.0,"","","","",No
|
|
|
@ -1,17 +1,14 @@
|
||||||
""" testing import """
|
""" testing import """
|
||||||
from collections import namedtuple
|
|
||||||
import csv
|
|
||||||
import pathlib
|
import pathlib
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
import datetime
|
import datetime
|
||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
import responses
|
|
||||||
|
|
||||||
from bookwyrm import models
|
from bookwyrm import models
|
||||||
from bookwyrm.importers import GoodreadsImporter
|
from bookwyrm.importers import GoodreadsImporter
|
||||||
from bookwyrm.importers.importer import import_data, handle_imported_book
|
from bookwyrm.importers.importer import handle_imported_book
|
||||||
|
|
||||||
|
|
||||||
def make_date(*args):
|
def make_date(*args):
|
||||||
|
@ -34,7 +31,7 @@ class GoodreadsImport(TestCase):
|
||||||
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
|
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
|
||||||
"bookwyrm.activitystreams.populate_stream_task.delay"
|
"bookwyrm.activitystreams.populate_stream_task.delay"
|
||||||
):
|
):
|
||||||
self.user = models.User.objects.create_user(
|
self.local_user = models.User.objects.create_user(
|
||||||
"mouse", "mouse@mouse.mouse", "password", local=True
|
"mouse", "mouse@mouse.mouse", "password", local=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -47,15 +44,17 @@ class GoodreadsImport(TestCase):
|
||||||
|
|
||||||
def test_create_job(self, *_):
|
def test_create_job(self, *_):
|
||||||
"""creates the import job entry and checks csv"""
|
"""creates the import job entry and checks csv"""
|
||||||
import_job = self.importer.create_job(self.user, self.csv, False, "public")
|
import_job = self.importer.create_job(
|
||||||
self.assertEqual(import_job.user, self.user)
|
self.local_user, self.csv, False, "public"
|
||||||
self.assertEqual(import_job.include_reviews, False)
|
)
|
||||||
self.assertEqual(import_job.privacy, "public")
|
|
||||||
|
|
||||||
import_items = models.ImportItem.objects.filter(job=import_job).all()
|
import_items = models.ImportItem.objects.filter(job=import_job).all()
|
||||||
self.assertEqual(len(import_items), 3)
|
self.assertEqual(len(import_items), 3)
|
||||||
self.assertEqual(import_items[0].index, 0)
|
self.assertEqual(import_items[0].index, 0)
|
||||||
self.assertEqual(import_items[0].data["Book Id"], "42036538")
|
self.assertEqual(import_items[0].data["Book Id"], "42036538")
|
||||||
|
self.assertEqual(import_items[0].normalized_data["isbn_13"], '="9781250313195"')
|
||||||
|
self.assertEqual(import_items[0].normalized_data["isbn_10"], '="1250313198"')
|
||||||
|
|
||||||
self.assertEqual(import_items[1].index, 1)
|
self.assertEqual(import_items[1].index, 1)
|
||||||
self.assertEqual(import_items[1].data["Book Id"], "52691223")
|
self.assertEqual(import_items[1].data["Book Id"], "52691223")
|
||||||
self.assertEqual(import_items[2].index, 2)
|
self.assertEqual(import_items[2].index, 2)
|
||||||
|
@ -63,12 +62,16 @@ class GoodreadsImport(TestCase):
|
||||||
|
|
||||||
def test_create_retry_job(self, *_):
|
def test_create_retry_job(self, *_):
|
||||||
"""trying again with items that didn't import"""
|
"""trying again with items that didn't import"""
|
||||||
import_job = self.importer.create_job(self.user, self.csv, False, "unlisted")
|
import_job = self.importer.create_job(
|
||||||
|
self.local_user, self.csv, False, "unlisted"
|
||||||
|
)
|
||||||
import_items = models.ImportItem.objects.filter(job=import_job).all()[:2]
|
import_items = models.ImportItem.objects.filter(job=import_job).all()[:2]
|
||||||
|
|
||||||
retry = self.importer.create_retry_job(self.user, import_job, import_items)
|
retry = self.importer.create_retry_job(
|
||||||
|
self.local_user, import_job, import_items
|
||||||
|
)
|
||||||
self.assertNotEqual(import_job, retry)
|
self.assertNotEqual(import_job, retry)
|
||||||
self.assertEqual(retry.user, self.user)
|
self.assertEqual(retry.user, self.local_user)
|
||||||
self.assertEqual(retry.include_reviews, False)
|
self.assertEqual(retry.include_reviews, False)
|
||||||
self.assertEqual(retry.privacy, "unlisted")
|
self.assertEqual(retry.privacy, "unlisted")
|
||||||
|
|
||||||
|
@ -79,52 +82,20 @@ class GoodreadsImport(TestCase):
|
||||||
self.assertEqual(retry_items[1].index, 1)
|
self.assertEqual(retry_items[1].index, 1)
|
||||||
self.assertEqual(retry_items[1].data["Book Id"], "52691223")
|
self.assertEqual(retry_items[1].data["Book Id"], "52691223")
|
||||||
|
|
||||||
def test_start_import(self, *_):
|
|
||||||
"""begin loading books"""
|
|
||||||
import_job = self.importer.create_job(self.user, self.csv, False, "unlisted")
|
|
||||||
MockTask = namedtuple("Task", ("id"))
|
|
||||||
mock_task = MockTask(7)
|
|
||||||
with patch("bookwyrm.importers.importer.import_data.delay") as start:
|
|
||||||
start.return_value = mock_task
|
|
||||||
self.importer.start_import(import_job)
|
|
||||||
import_job.refresh_from_db()
|
|
||||||
self.assertEqual(import_job.task_id, "7")
|
|
||||||
|
|
||||||
@responses.activate
|
|
||||||
def test_import_data(self, *_):
|
|
||||||
"""resolve entry"""
|
|
||||||
import_job = self.importer.create_job(self.user, self.csv, False, "unlisted")
|
|
||||||
book = models.Edition.objects.create(title="Test Book")
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"bookwyrm.models.import_job.ImportItem.get_book_from_isbn"
|
|
||||||
) as resolve:
|
|
||||||
resolve.return_value = book
|
|
||||||
with patch("bookwyrm.importers.importer.handle_imported_book"):
|
|
||||||
import_data(self.importer.service, import_job.id)
|
|
||||||
|
|
||||||
import_item = models.ImportItem.objects.get(job=import_job, index=0)
|
|
||||||
self.assertEqual(import_item.book.id, book.id)
|
|
||||||
|
|
||||||
def test_handle_imported_book(self, *_):
|
def test_handle_imported_book(self, *_):
|
||||||
"""goodreads import added a book, this adds related connections"""
|
"""goodreads import added a book, this adds related connections"""
|
||||||
shelf = self.user.shelf_set.filter(identifier="read").first()
|
shelf = self.local_user.shelf_set.filter(identifier="read").first()
|
||||||
self.assertIsNone(shelf.books.first())
|
self.assertIsNone(shelf.books.first())
|
||||||
|
|
||||||
import_job = models.ImportJob.objects.create(user=self.user)
|
import_job = self.importer.create_job(
|
||||||
datafile = pathlib.Path(__file__).parent.joinpath("../data/goodreads.csv")
|
self.local_user, self.csv, False, "public"
|
||||||
csv_file = open(datafile, "r") # pylint: disable=unspecified-encoding
|
)
|
||||||
for index, entry in enumerate(list(csv.DictReader(csv_file))):
|
import_item = import_job.items.first()
|
||||||
entry = self.importer.parse_fields(entry)
|
import_item.book = self.book
|
||||||
import_item = models.ImportItem.objects.create(
|
import_item.save()
|
||||||
job_id=import_job.id, index=index, data=entry, book=self.book
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
|
||||||
handle_imported_book(
|
handle_imported_book(import_item)
|
||||||
self.importer.service, self.user, import_item, False, "public"
|
|
||||||
)
|
|
||||||
|
|
||||||
shelf.refresh_from_db()
|
shelf.refresh_from_db()
|
||||||
self.assertEqual(shelf.books.first(), self.book)
|
self.assertEqual(shelf.books.first(), self.book)
|
||||||
|
@ -132,77 +103,7 @@ class GoodreadsImport(TestCase):
|
||||||
shelf.shelfbook_set.first().shelved_date, make_date(2020, 10, 21)
|
shelf.shelfbook_set.first().shelved_date, make_date(2020, 10, 21)
|
||||||
)
|
)
|
||||||
|
|
||||||
readthrough = models.ReadThrough.objects.get(user=self.user)
|
readthrough = models.ReadThrough.objects.get(user=self.local_user)
|
||||||
self.assertEqual(readthrough.book, self.book)
|
|
||||||
self.assertEqual(readthrough.start_date, make_date(2020, 10, 21))
|
|
||||||
self.assertEqual(readthrough.finish_date, make_date(2020, 10, 25))
|
|
||||||
|
|
||||||
def test_handle_imported_book_already_shelved(self, *_):
|
|
||||||
"""goodreads import added a book, this adds related connections"""
|
|
||||||
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
|
||||||
shelf = self.user.shelf_set.filter(identifier="to-read").first()
|
|
||||||
models.ShelfBook.objects.create(
|
|
||||||
shelf=shelf,
|
|
||||||
user=self.user,
|
|
||||||
book=self.book,
|
|
||||||
shelved_date=make_date(2020, 2, 2),
|
|
||||||
)
|
|
||||||
|
|
||||||
import_job = models.ImportJob.objects.create(user=self.user)
|
|
||||||
datafile = pathlib.Path(__file__).parent.joinpath("../data/goodreads.csv")
|
|
||||||
csv_file = open(datafile, "r") # pylint: disable=unspecified-encoding
|
|
||||||
for index, entry in enumerate(list(csv.DictReader(csv_file))):
|
|
||||||
entry = self.importer.parse_fields(entry)
|
|
||||||
import_item = models.ImportItem.objects.create(
|
|
||||||
job_id=import_job.id, index=index, data=entry, book=self.book
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
|
||||||
handle_imported_book(
|
|
||||||
self.importer.service, self.user, import_item, False, "public"
|
|
||||||
)
|
|
||||||
|
|
||||||
shelf.refresh_from_db()
|
|
||||||
self.assertEqual(shelf.books.first(), self.book)
|
|
||||||
self.assertEqual(
|
|
||||||
shelf.shelfbook_set.first().shelved_date, make_date(2020, 2, 2)
|
|
||||||
)
|
|
||||||
self.assertIsNone(self.user.shelf_set.get(identifier="read").books.first())
|
|
||||||
|
|
||||||
readthrough = models.ReadThrough.objects.get(user=self.user)
|
|
||||||
self.assertEqual(readthrough.book, self.book)
|
|
||||||
self.assertEqual(readthrough.start_date, make_date(2020, 10, 21))
|
|
||||||
self.assertEqual(readthrough.finish_date, make_date(2020, 10, 25))
|
|
||||||
|
|
||||||
def test_handle_import_twice(self, *_):
|
|
||||||
"""re-importing books"""
|
|
||||||
shelf = self.user.shelf_set.filter(identifier="read").first()
|
|
||||||
import_job = models.ImportJob.objects.create(user=self.user)
|
|
||||||
datafile = pathlib.Path(__file__).parent.joinpath("../data/goodreads.csv")
|
|
||||||
csv_file = open(datafile, "r") # pylint: disable=unspecified-encoding
|
|
||||||
for index, entry in enumerate(list(csv.DictReader(csv_file))):
|
|
||||||
entry = self.importer.parse_fields(entry)
|
|
||||||
import_item = models.ImportItem.objects.create(
|
|
||||||
job_id=import_job.id, index=index, data=entry, book=self.book
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
|
||||||
handle_imported_book(
|
|
||||||
self.importer.service, self.user, import_item, False, "public"
|
|
||||||
)
|
|
||||||
handle_imported_book(
|
|
||||||
self.importer.service, self.user, import_item, False, "public"
|
|
||||||
)
|
|
||||||
|
|
||||||
shelf.refresh_from_db()
|
|
||||||
self.assertEqual(shelf.books.first(), self.book)
|
|
||||||
self.assertEqual(
|
|
||||||
shelf.shelfbook_set.first().shelved_date, make_date(2020, 10, 21)
|
|
||||||
)
|
|
||||||
|
|
||||||
readthrough = models.ReadThrough.objects.get(user=self.user)
|
|
||||||
self.assertEqual(readthrough.book, self.book)
|
self.assertEqual(readthrough.book, self.book)
|
||||||
self.assertEqual(readthrough.start_date, make_date(2020, 10, 21))
|
self.assertEqual(readthrough.start_date, make_date(2020, 10, 21))
|
||||||
self.assertEqual(readthrough.finish_date, make_date(2020, 10, 25))
|
self.assertEqual(readthrough.finish_date, make_date(2020, 10, 25))
|
||||||
|
@ -210,20 +111,17 @@ class GoodreadsImport(TestCase):
|
||||||
@patch("bookwyrm.activitystreams.add_status_task.delay")
|
@patch("bookwyrm.activitystreams.add_status_task.delay")
|
||||||
def test_handle_imported_book_review(self, *_):
|
def test_handle_imported_book_review(self, *_):
|
||||||
"""goodreads review import"""
|
"""goodreads review import"""
|
||||||
import_job = models.ImportJob.objects.create(user=self.user)
|
import_job = self.importer.create_job(
|
||||||
datafile = pathlib.Path(__file__).parent.joinpath("../data/goodreads.csv")
|
self.local_user, self.csv, True, "unlisted"
|
||||||
csv_file = open(datafile, "r") # pylint: disable=unspecified-encoding
|
|
||||||
entry = list(csv.DictReader(csv_file))[2]
|
|
||||||
entry = self.importer.parse_fields(entry)
|
|
||||||
import_item = models.ImportItem.objects.create(
|
|
||||||
job_id=import_job.id, index=0, data=entry, book=self.book
|
|
||||||
)
|
)
|
||||||
|
import_item = import_job.items.get(index=2)
|
||||||
|
import_item.book = self.book
|
||||||
|
import_item.save()
|
||||||
|
|
||||||
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
|
||||||
handle_imported_book(
|
handle_imported_book(import_item)
|
||||||
self.importer.service, self.user, import_item, True, "unlisted"
|
|
||||||
)
|
review = models.Review.objects.get(book=self.book, user=self.local_user)
|
||||||
review = models.Review.objects.get(book=self.book, user=self.user)
|
|
||||||
self.assertEqual(review.content, "mixed feelings")
|
self.assertEqual(review.content, "mixed feelings")
|
||||||
self.assertEqual(review.rating, 2)
|
self.assertEqual(review.rating, 2)
|
||||||
self.assertEqual(review.published_date, make_date(2019, 7, 8))
|
self.assertEqual(review.published_date, make_date(2019, 7, 8))
|
||||||
|
@ -232,42 +130,18 @@ class GoodreadsImport(TestCase):
|
||||||
@patch("bookwyrm.activitystreams.add_status_task.delay")
|
@patch("bookwyrm.activitystreams.add_status_task.delay")
|
||||||
def test_handle_imported_book_rating(self, *_):
|
def test_handle_imported_book_rating(self, *_):
|
||||||
"""goodreads rating import"""
|
"""goodreads rating import"""
|
||||||
import_job = models.ImportJob.objects.create(user=self.user)
|
import_job = self.importer.create_job(
|
||||||
datafile = pathlib.Path(__file__).parent.joinpath(
|
self.local_user, self.csv, True, "unlisted"
|
||||||
"../data/goodreads-rating.csv"
|
|
||||||
)
|
|
||||||
csv_file = open(datafile, "r") # pylint: disable=unspecified-encoding
|
|
||||||
entry = list(csv.DictReader(csv_file))[2]
|
|
||||||
entry = self.importer.parse_fields(entry)
|
|
||||||
import_item = models.ImportItem.objects.create(
|
|
||||||
job_id=import_job.id, index=0, data=entry, book=self.book
|
|
||||||
)
|
)
|
||||||
|
import_item = import_job.items.filter(index=0).first()
|
||||||
|
import_item.book = self.book
|
||||||
|
import_item.save()
|
||||||
|
|
||||||
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
|
||||||
handle_imported_book(
|
handle_imported_book(import_item)
|
||||||
self.importer.service, self.user, import_item, True, "unlisted"
|
|
||||||
)
|
review = models.ReviewRating.objects.get(book=self.book, user=self.local_user)
|
||||||
review = models.ReviewRating.objects.get(book=self.book, user=self.user)
|
|
||||||
self.assertIsInstance(review, models.ReviewRating)
|
self.assertIsInstance(review, models.ReviewRating)
|
||||||
self.assertEqual(review.rating, 2)
|
self.assertEqual(review.rating, 3)
|
||||||
self.assertEqual(review.published_date, make_date(2019, 7, 8))
|
self.assertEqual(review.published_date, make_date(2020, 10, 25))
|
||||||
self.assertEqual(review.privacy, "unlisted")
|
self.assertEqual(review.privacy, "unlisted")
|
||||||
|
|
||||||
def test_handle_imported_book_reviews_disabled(self, *_):
|
|
||||||
"""goodreads review import"""
|
|
||||||
import_job = models.ImportJob.objects.create(user=self.user)
|
|
||||||
datafile = pathlib.Path(__file__).parent.joinpath("../data/goodreads.csv")
|
|
||||||
csv_file = open(datafile, "r") # pylint: disable=unspecified-encoding
|
|
||||||
entry = list(csv.DictReader(csv_file))[2]
|
|
||||||
entry = self.importer.parse_fields(entry)
|
|
||||||
import_item = models.ImportItem.objects.create(
|
|
||||||
job_id=import_job.id, index=0, data=entry, book=self.book
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
|
||||||
handle_imported_book(
|
|
||||||
self.importer.service, self.user, import_item, False, "unlisted"
|
|
||||||
)
|
|
||||||
self.assertFalse(
|
|
||||||
models.Review.objects.filter(book=self.book, user=self.user).exists()
|
|
||||||
)
|
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue