forked from mirrors/bookwyrm
Merge branch 'main' into book-file-links
This commit is contained in:
commit
a4859668b8
314 changed files with 29692 additions and 9331 deletions
|
@ -36,7 +36,7 @@ FLOWER_PORT=8888
|
|||
#FLOWER_USER=mouse
|
||||
#FLOWER_PASSWORD=changeme
|
||||
|
||||
EMAIL_HOST="smtp.mailgun.org"
|
||||
EMAIL_HOST=smtp.mailgun.org
|
||||
EMAIL_PORT=587
|
||||
EMAIL_HOST_USER=mail@your.domain.here
|
||||
EMAIL_HOST_PASSWORD=emailpassword123
|
||||
|
@ -69,7 +69,7 @@ AWS_SECRET_ACCESS_KEY=
|
|||
# or use_dominant_color_light / use_dominant_color_dark
|
||||
PREVIEW_BG_COLOR=use_dominant_color_light
|
||||
# Change to #FFF if you use use_dominant_color_dark
|
||||
PREVIEW_TEXT_COLOR="#363636"
|
||||
PREVIEW_TEXT_COLOR=#363636
|
||||
PREVIEW_IMG_WIDTH=1200
|
||||
PREVIEW_IMG_HEIGHT=630
|
||||
PREVIEW_DEFAULT_COVER_COLOR="#002549"
|
||||
PREVIEW_DEFAULT_COVER_COLOR=#002549
|
||||
|
|
|
@ -36,7 +36,7 @@ FLOWER_PORT=8888
|
|||
FLOWER_USER=mouse
|
||||
FLOWER_PASSWORD=changeme
|
||||
|
||||
EMAIL_HOST="smtp.mailgun.org"
|
||||
EMAIL_HOST=smtp.mailgun.org
|
||||
EMAIL_PORT=587
|
||||
EMAIL_HOST_USER=mail@your.domain.here
|
||||
EMAIL_HOST_PASSWORD=emailpassword123
|
||||
|
@ -69,7 +69,7 @@ AWS_SECRET_ACCESS_KEY=
|
|||
# or use_dominant_color_light / use_dominant_color_dark
|
||||
PREVIEW_BG_COLOR=use_dominant_color_light
|
||||
# Change to #FFF if you use use_dominant_color_dark
|
||||
PREVIEW_TEXT_COLOR="#363636"
|
||||
PREVIEW_TEXT_COLOR=#363636
|
||||
PREVIEW_IMG_WIDTH=1200
|
||||
PREVIEW_IMG_HEIGHT=630
|
||||
PREVIEW_DEFAULT_COVER_COLOR="#002549"
|
||||
PREVIEW_DEFAULT_COVER_COLOR=#002549
|
||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -4,6 +4,7 @@
|
|||
*.swp
|
||||
**/__pycache__
|
||||
.local
|
||||
/nginx/nginx.conf
|
||||
|
||||
# VSCode
|
||||
/.vscode
|
||||
|
|
|
@ -35,6 +35,7 @@ class Note(ActivityObject):
|
|||
tag: List[Link] = field(default_factory=lambda: [])
|
||||
attachment: List[Document] = field(default_factory=lambda: [])
|
||||
sensitive: bool = False
|
||||
updated: str = None
|
||||
type: str = "Note"
|
||||
|
||||
|
||||
|
|
|
@ -69,8 +69,9 @@ class Update(Verb):
|
|||
|
||||
def action(self):
|
||||
"""update a model instance from the dataclass"""
|
||||
if self.object:
|
||||
self.object.to_model(allow_create=False)
|
||||
if not self.object:
|
||||
return
|
||||
self.object.to_model(allow_create=False)
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
|
|
|
@ -7,7 +7,7 @@ from django.utils import timezone
|
|||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.redis_store import RedisStore, r
|
||||
from bookwyrm.tasks import app
|
||||
from bookwyrm.tasks import app, LOW, MEDIUM, HIGH
|
||||
|
||||
|
||||
class ActivityStream(RedisStore):
|
||||
|
@ -22,6 +22,11 @@ class ActivityStream(RedisStore):
|
|||
stream_id = self.stream_id(user)
|
||||
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
|
||||
"""statuses are sorted by date published"""
|
||||
return obj.published_date.timestamp()
|
||||
|
@ -35,6 +40,10 @@ class ActivityStream(RedisStore):
|
|||
for user in self.get_audience(status):
|
||||
# add to the unread status count
|
||||
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!
|
||||
pipeline.execute()
|
||||
|
@ -55,6 +64,7 @@ class ActivityStream(RedisStore):
|
|||
"""load the statuses to be displayed"""
|
||||
# clear unreads for this feed
|
||||
r.set(self.unread_id(user), 0)
|
||||
r.delete(self.unread_by_status_type_id(user))
|
||||
|
||||
statuses = self.get_store(self.stream_id(user))
|
||||
return (
|
||||
|
@ -75,6 +85,14 @@ class ActivityStream(RedisStore):
|
|||
"""get the unread status count for this user's feed"""
|
||||
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):
|
||||
"""go from zero to a timeline"""
|
||||
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):
|
||||
"""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:
|
||||
handle_boost_task.delay(instance.id)
|
||||
|
@ -409,7 +438,7 @@ def remove_statuses_on_unshelve(sender, instance, *args, **kwargs):
|
|||
# ---- TASKS
|
||||
|
||||
|
||||
@app.task(queue="low_priority")
|
||||
@app.task(queue=LOW)
|
||||
def add_book_statuses_task(user_id, book_id):
|
||||
"""add statuses related to a book on shelve"""
|
||||
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)
|
||||
|
||||
|
||||
@app.task(queue="low_priority")
|
||||
@app.task(queue=LOW)
|
||||
def remove_book_statuses_task(user_id, book_id):
|
||||
"""remove statuses about a book from a user's books feed"""
|
||||
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)
|
||||
|
||||
|
||||
@app.task(queue="medium_priority")
|
||||
@app.task(queue=MEDIUM)
|
||||
def populate_stream_task(stream, user_id):
|
||||
"""background task for populating an empty activitystream"""
|
||||
user = models.User.objects.get(id=user_id)
|
||||
|
@ -433,7 +462,7 @@ def populate_stream_task(stream, user_id):
|
|||
stream.populate_streams(user)
|
||||
|
||||
|
||||
@app.task(queue="medium_priority")
|
||||
@app.task(queue=MEDIUM)
|
||||
def remove_status_task(status_ids):
|
||||
"""remove a status from any stream it might be in"""
|
||||
# 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)
|
||||
|
||||
|
||||
@app.task(queue="high_priority")
|
||||
@app.task(queue=HIGH)
|
||||
def add_status_task(status_id, increment_unread=False):
|
||||
"""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
|
||||
# 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):
|
||||
|
@ -458,7 +487,7 @@ def add_status_task(status_id, increment_unread=False):
|
|||
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):
|
||||
"""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()
|
||||
|
@ -468,7 +497,7 @@ def remove_user_statuses_task(viewer_id, user_id, stream_list=None):
|
|||
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):
|
||||
"""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()
|
||||
|
@ -478,7 +507,7 @@ def add_user_statuses_task(viewer_id, user_id, stream_list=None):
|
|||
stream.add_user_statuses(viewer, user)
|
||||
|
||||
|
||||
@app.task(queue="medium_priority")
|
||||
@app.task(queue=MEDIUM)
|
||||
def handle_boost_task(boost_id):
|
||||
"""remove the original post and other, earlier boosts"""
|
||||
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)
|
||||
for status in old_versions:
|
||||
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))
|
||||
).distinct()
|
||||
if results.count() <= 1:
|
||||
if return_first:
|
||||
return results.first()
|
||||
return results
|
||||
|
||||
# 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
|
||||
else:
|
||||
result = editions.first()
|
||||
|
||||
if return_first:
|
||||
return result
|
||||
list_results.append(result)
|
||||
|
|
|
@ -111,7 +111,7 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
return existing.default_edition
|
||||
return existing
|
||||
|
||||
# load the json
|
||||
# load the json data from the remote data source
|
||||
data = self.get_book_data(remote_id)
|
||||
if self.is_work_data(data):
|
||||
try:
|
||||
|
@ -150,27 +150,37 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
"""this allows connectors to override the default behavior"""
|
||||
return get_data(remote_id)
|
||||
|
||||
def create_edition_from_data(self, work, edition_data):
|
||||
def create_edition_from_data(self, work, edition_data, instance=None):
|
||||
"""if we already have the work, we're ready"""
|
||||
mapped_data = dict_from_mappings(edition_data, self.book_mappings)
|
||||
mapped_data["work"] = work.remote_id
|
||||
edition_activity = activitypub.Edition(**mapped_data)
|
||||
edition = edition_activity.to_model(model=models.Edition, overwrite=False)
|
||||
edition.connector = self.connector
|
||||
edition.save()
|
||||
edition = edition_activity.to_model(
|
||||
model=models.Edition, overwrite=False, instance=instance
|
||||
)
|
||||
|
||||
# if we're updating an existing instance, we don't need to load authors
|
||||
if instance:
|
||||
return edition
|
||||
|
||||
if not edition.connector:
|
||||
edition.connector = self.connector
|
||||
edition.save(broadcast=False, update_fields=["connector"])
|
||||
|
||||
for author in self.get_authors_from_data(edition_data):
|
||||
edition.authors.add(author)
|
||||
# use the authors from the work if none are found for the edition
|
||||
if not edition.authors.exists() and work.authors.exists():
|
||||
edition.authors.set(work.authors.all())
|
||||
|
||||
return edition
|
||||
|
||||
def get_or_create_author(self, remote_id):
|
||||
def get_or_create_author(self, remote_id, instance=None):
|
||||
"""load that author"""
|
||||
existing = models.Author.find_existing_by_remote_id(remote_id)
|
||||
if existing:
|
||||
return existing
|
||||
if not instance:
|
||||
existing = models.Author.find_existing_by_remote_id(remote_id)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
data = self.get_book_data(remote_id)
|
||||
|
||||
|
@ -181,7 +191,24 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
return None
|
||||
|
||||
# this will dedupe
|
||||
return activity.to_model(model=models.Author, overwrite=False)
|
||||
return activity.to_model(
|
||||
model=models.Author, overwrite=False, instance=instance
|
||||
)
|
||||
|
||||
def get_remote_id_from_model(self, obj):
|
||||
"""given the data stored, how can we look this up"""
|
||||
return getattr(obj, getattr(self, "generated_remote_link_field"))
|
||||
|
||||
def update_author_from_remote(self, obj):
|
||||
"""load the remote data from this connector and add it to an existing author"""
|
||||
remote_id = self.get_remote_id_from_model(obj)
|
||||
return self.get_or_create_author(remote_id, instance=obj)
|
||||
|
||||
def update_book_from_remote(self, obj):
|
||||
"""load the remote data from this connector and add it to an existing book"""
|
||||
remote_id = self.get_remote_id_from_model(obj)
|
||||
data = self.get_book_data(remote_id)
|
||||
return self.create_edition_from_data(obj.parent_work, data, instance=obj)
|
||||
|
||||
@abstractmethod
|
||||
def is_work_data(self, data):
|
||||
|
@ -227,15 +254,17 @@ def get_data(url, params=None, timeout=10):
|
|||
resp = requests.get(
|
||||
url,
|
||||
params=params,
|
||||
headers={
|
||||
"Accept": "application/json; charset=utf-8",
|
||||
headers={ # pylint: disable=line-too-long
|
||||
"Accept": (
|
||||
'application/json, application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'
|
||||
),
|
||||
"User-Agent": settings.USER_AGENT,
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
except RequestException as err:
|
||||
logger.exception(err)
|
||||
raise ConnectorException()
|
||||
raise ConnectorException(err)
|
||||
|
||||
if not resp.ok:
|
||||
raise ConnectorException()
|
||||
|
@ -243,7 +272,7 @@ def get_data(url, params=None, timeout=10):
|
|||
data = resp.json()
|
||||
except ValueError as err:
|
||||
logger.exception(err)
|
||||
raise ConnectorException()
|
||||
raise ConnectorException(err)
|
||||
|
||||
return data
|
||||
|
||||
|
|
|
@ -11,6 +11,8 @@ from .connector_manager import ConnectorException
|
|||
class Connector(AbstractConnector):
|
||||
"""instantiate a connector for inventaire"""
|
||||
|
||||
generated_remote_link_field = "inventaire_id"
|
||||
|
||||
def __init__(self, identifier):
|
||||
super().__init__(identifier)
|
||||
|
||||
|
@ -67,7 +69,7 @@ class Connector(AbstractConnector):
|
|||
extracted = list(data.get("entities").values())
|
||||
try:
|
||||
data = extracted[0]
|
||||
except KeyError:
|
||||
except (KeyError, IndexError):
|
||||
raise ConnectorException("Invalid book data")
|
||||
# flatten the data so that images, uri, and claims are on the same level
|
||||
return {
|
||||
|
@ -128,6 +130,7 @@ class Connector(AbstractConnector):
|
|||
|
||||
def load_edition_data(self, work_uri):
|
||||
"""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"
|
||||
return get_data(url)
|
||||
|
||||
|
@ -209,6 +212,11 @@ class Connector(AbstractConnector):
|
|||
return ""
|
||||
return data.get("extract")
|
||||
|
||||
def get_remote_id_from_model(self, obj):
|
||||
"""use get_remote_id to figure out the link from a model obj"""
|
||||
remote_id_value = obj.inventaire_id
|
||||
return self.get_remote_id(remote_id_value)
|
||||
|
||||
|
||||
def get_language_code(options, code="en"):
|
||||
"""when there are a bunch of translation but we need a single field"""
|
||||
|
|
|
@ -12,6 +12,8 @@ from .openlibrary_languages import languages
|
|||
class Connector(AbstractConnector):
|
||||
"""instantiate a connector for OL"""
|
||||
|
||||
generated_remote_link_field = "openlibrary_link"
|
||||
|
||||
def __init__(self, identifier):
|
||||
super().__init__(identifier)
|
||||
|
||||
|
@ -66,6 +68,7 @@ class Connector(AbstractConnector):
|
|||
Mapping("born", remote_field="birth_date"),
|
||||
Mapping("died", remote_field="death_date"),
|
||||
Mapping("bio", formatter=get_description),
|
||||
Mapping("isni", remote_field="remote_ids", formatter=get_isni),
|
||||
]
|
||||
|
||||
def get_book_data(self, remote_id):
|
||||
|
@ -224,6 +227,13 @@ def get_languages(language_blob):
|
|||
return langs
|
||||
|
||||
|
||||
def get_isni(remote_ids_blob):
|
||||
"""extract the isni from the remote id data for the author"""
|
||||
if not remote_ids_blob or not isinstance(remote_ids_blob, dict):
|
||||
return None
|
||||
return remote_ids_blob.get("isni")
|
||||
|
||||
|
||||
def pick_default_edition(options):
|
||||
"""favor physical copies with covers in english"""
|
||||
if not options:
|
||||
|
|
|
@ -10,14 +10,9 @@ from bookwyrm.settings import DOMAIN
|
|||
def email_data():
|
||||
"""fields every email needs"""
|
||||
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 {
|
||||
"site_name": site.name,
|
||||
"logo": logo_path,
|
||||
"logo": site.logo_small_url,
|
||||
"domain": DOMAIN,
|
||||
"user": None,
|
||||
}
|
||||
|
@ -46,6 +41,18 @@ def password_reset_email(reset_code):
|
|||
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):
|
||||
"""render the email templates"""
|
||||
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 bookwyrm import models
|
||||
from bookwyrm.models.fields import ClearableFileInputWithWarning
|
||||
from bookwyrm.models.user import FeedFilterChoices
|
||||
|
||||
|
||||
class CustomForm(ModelForm):
|
||||
|
@ -147,6 +149,17 @@ class EditUserForm(CustomForm):
|
|||
"preferred_language",
|
||||
]
|
||||
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):
|
||||
|
@ -160,6 +173,16 @@ class LimitedEditUserForm(CustomForm):
|
|||
"discoverable",
|
||||
]
|
||||
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):
|
||||
|
@ -174,6 +197,18 @@ class UserGroupForm(CustomForm):
|
|||
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 Meta:
|
||||
model = models.Book
|
||||
|
@ -198,19 +233,92 @@ class EditionForm(CustomForm):
|
|||
"links",
|
||||
"file_links",
|
||||
]
|
||||
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 Meta:
|
||||
model = models.Author
|
||||
exclude = [
|
||||
"remote_id",
|
||||
"origin_id",
|
||||
"created_date",
|
||||
"updated_date",
|
||||
"search_vector",
|
||||
"links",
|
||||
fields = [
|
||||
"last_edited_by",
|
||||
"name",
|
||||
"aliases",
|
||||
"bio",
|
||||
"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):
|
||||
|
@ -285,18 +393,49 @@ class SiteForm(CustomForm):
|
|||
class Meta:
|
||||
model = models.SiteSettings
|
||||
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 Meta:
|
||||
model = models.Announcement
|
||||
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 Meta:
|
||||
model = models.List
|
||||
fields = ["user", "name", "description", "curation", "privacy"]
|
||||
fields = ["user", "name", "description", "curation", "privacy", "group"]
|
||||
|
||||
|
||||
class GroupForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Group
|
||||
fields = ["user", "privacy", "name", "description"]
|
||||
|
||||
|
||||
class ReportForm(CustomForm):
|
||||
|
@ -309,6 +448,9 @@ class EmailBlocklistForm(CustomForm):
|
|||
class Meta:
|
||||
model = models.EmailBlocklist
|
||||
fields = ["domain"]
|
||||
widgets = {
|
||||
"avatar": forms.TextInput(attrs={"aria-describedby": "desc_domain"}),
|
||||
}
|
||||
|
||||
|
||||
class IPBlocklistForm(CustomForm):
|
||||
|
|
|
@ -3,4 +3,5 @@
|
|||
from .importer import Importer
|
||||
from .goodreads_import import GoodreadsImporter
|
||||
from .librarything_import import LibrarythingImporter
|
||||
from .openlibrary_import import OpenLibraryImporter
|
||||
from .storygraph_import import StorygraphImporter
|
||||
|
|
|
@ -3,14 +3,7 @@ from . import Importer
|
|||
|
||||
|
||||
class GoodreadsImporter(Importer):
|
||||
"""GoodReads is the default importer, thus Importer follows its structure.
|
||||
"""Goodreads is the default importer, thus Importer follows its structure.
|
||||
For a more complete example of overriding see librarything_import.py"""
|
||||
|
||||
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
|
||||
service = "Goodreads"
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
""" handle reading a csv from an external service, defaults are from GoodReads """
|
||||
""" handle reading a csv from an external service, defaults are from Goodreads """
|
||||
import csv
|
||||
import logging
|
||||
|
||||
|
@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _
|
|||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.models import ImportJob, ImportItem
|
||||
from bookwyrm.tasks import app
|
||||
from bookwyrm.tasks import app, LOW
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -15,33 +15,93 @@ logger = logging.getLogger(__name__)
|
|||
class Importer:
|
||||
"""Generic class for csv data import from an outside service"""
|
||||
|
||||
service = "Unknown"
|
||||
service = "Import"
|
||||
delimiter = ","
|
||||
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", "bookshelf"]),
|
||||
("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", "want to read"],
|
||||
"read": ["read", "already read"],
|
||||
"reading": ["currently-reading", "reading", "currently reading"],
|
||||
}
|
||||
|
||||
def create_job(self, user, csv_file, include_reviews, privacy):
|
||||
"""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(
|
||||
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))
|
||||
):
|
||||
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)
|
||||
|
||||
for index, entry in rows:
|
||||
self.create_item(job, index, entry)
|
||||
return job
|
||||
|
||||
def save_item(self, job, index, data): # pylint: disable=no-self-use
|
||||
"""creates and saves an import item"""
|
||||
ImportItem(job=job, index=index, data=data).save()
|
||||
def update_legacy_job(self, job):
|
||||
"""patch up a job that was in the old format"""
|
||||
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):
|
||||
"""updates csv data with additional info"""
|
||||
entry.update({"import_source": self.service})
|
||||
return entry
|
||||
for item in items.all():
|
||||
normalized = self.normalize_row(item.data, job.mappings)
|
||||
normalized["shelf"] = self.get_shelf(normalized)
|
||||
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.get("shelf")
|
||||
if not shelf_name:
|
||||
return None
|
||||
shelf_name = shelf_name.lower()
|
||||
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):
|
||||
"""retry items that didn't import"""
|
||||
|
@ -49,55 +109,66 @@ class Importer:
|
|||
user=user,
|
||||
include_reviews=original_job.include_reviews,
|
||||
privacy=original_job.privacy,
|
||||
source=original_job.source,
|
||||
# TODO: allow users to adjust mappings
|
||||
mappings=original_job.mappings,
|
||||
retry=True,
|
||||
)
|
||||
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
|
||||
|
||||
def start_import(self, job):
|
||||
def start_import(self, job): # pylint: disable=no-self-use
|
||||
"""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.save()
|
||||
|
||||
|
||||
@app.task(queue="low_priority")
|
||||
def import_data(source, job_id):
|
||||
"""does the actual lookup work in a celery task"""
|
||||
def start_import_task(job_id):
|
||||
"""trigger the child tasks for each row"""
|
||||
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:
|
||||
for item in job.items.all():
|
||||
try:
|
||||
item.resolve()
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
logger.exception(err)
|
||||
item.fail_reason = _("Error loading book")
|
||||
item.save()
|
||||
continue
|
||||
item.resolve()
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
item.fail_reason = _("Error loading book")
|
||||
item.save()
|
||||
item.update_job()
|
||||
raise err
|
||||
|
||||
if item.book or item.book_guess:
|
||||
item.save()
|
||||
if item.book:
|
||||
# shelves book and handles reviews
|
||||
handle_imported_book(item)
|
||||
else:
|
||||
item.fail_reason = _("Could not find a match for book")
|
||||
|
||||
if item.book:
|
||||
# shelves book and handles reviews
|
||||
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()
|
||||
item.save()
|
||||
item.update_job()
|
||||
|
||||
|
||||
def handle_imported_book(source, user, item, include_reviews, privacy):
|
||||
def handle_imported_book(item):
|
||||
"""process a csv and then post about it"""
|
||||
job = item.job
|
||||
user = job.user
|
||||
if isinstance(item.book, models.Work):
|
||||
item.book = item.book.default_edition
|
||||
if not item.book:
|
||||
item.fail_reason = _("Error loading book")
|
||||
item.save()
|
||||
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()
|
||||
|
||||
|
@ -105,9 +176,9 @@ def handle_imported_book(source, user, item, include_reviews, privacy):
|
|||
if item.shelf and not existing_shelf:
|
||||
desired_shelf = models.Shelf.objects.get(identifier=item.shelf, user=user)
|
||||
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
|
||||
)
|
||||
).save(priority=LOW)
|
||||
|
||||
for read in item.reads:
|
||||
# check for an existing readthrough with the same dates
|
||||
|
@ -122,35 +193,52 @@ def handle_imported_book(source, user, item, include_reviews, privacy):
|
|||
read.user = user
|
||||
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,
|
||||
# but "now" is a bad guess
|
||||
published_date_guess = item.date_read or item.date_added
|
||||
if item.review:
|
||||
# pylint: disable=consider-using-f-string
|
||||
review_title = (
|
||||
"Review of {!r} on {!r}".format(
|
||||
item.book.title,
|
||||
source,
|
||||
)
|
||||
if item.review
|
||||
else ""
|
||||
review_title = "Review of {!r} on {!r}".format(
|
||||
item.book.title,
|
||||
job.source,
|
||||
)
|
||||
models.Review.objects.create(
|
||||
review = models.Review.objects.filter(
|
||||
user=user,
|
||||
book=item.book,
|
||||
name=review_title,
|
||||
content=item.review,
|
||||
rating=item.rating,
|
||||
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:
|
||||
# just a rating
|
||||
models.ReviewRating.objects.create(
|
||||
review = models.ReviewRating.objects.filter(
|
||||
user=user,
|
||||
book=item.book,
|
||||
rating=item.rating,
|
||||
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 math
|
||||
|
||||
from . import Importer
|
||||
|
||||
|
||||
|
@ -11,32 +9,18 @@ class LibrarythingImporter(Importer):
|
|||
service = "LibraryThing"
|
||||
delimiter = "\t"
|
||||
encoding = "ISO-8859-1"
|
||||
# mandatory_fields : fields matching the book title and author
|
||||
mandatory_fields = ["Title", "Primary Author"]
|
||||
|
||||
def parse_fields(self, entry):
|
||||
"""custom parsing for librarything"""
|
||||
data = {}
|
||||
data["import_source"] = self.service
|
||||
data["Book Id"] = entry["Book Id"]
|
||||
data["Title"] = entry["Title"]
|
||||
data["Author"] = entry["Primary Author"]
|
||||
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"])
|
||||
def normalize_row(self, entry, mappings): # pylint: disable=no-self-use
|
||||
"""use the dataclass to create the formatted row of data"""
|
||||
remove_brackets = lambda v: re.sub(r"\[|\]", "", v) if v else None
|
||||
normalized = {k: remove_brackets(entry.get(v)) for k, v in mappings.items()}
|
||||
isbn_13 = normalized["isbn_13"].split(", ")
|
||||
normalized["isbn_13"] = isbn_13[1] if len(isbn_13) > 0 else None
|
||||
return normalized
|
||||
|
||||
data["Exclusive Shelf"] = None
|
||||
if data["Date Read"]:
|
||||
data["Exclusive Shelf"] = "read"
|
||||
elif data["Date Started"]:
|
||||
data["Exclusive Shelf"] = "reading"
|
||||
else:
|
||||
data["Exclusive Shelf"] = "to-read"
|
||||
|
||||
return data
|
||||
def get_shelf(self, normalized_row):
|
||||
if normalized_row["date_finished"]:
|
||||
return "read"
|
||||
if normalized_row["date_started"]:
|
||||
return "reading"
|
||||
return "to-read"
|
||||
|
|
13
bookwyrm/importers/openlibrary_import.py
Normal file
13
bookwyrm/importers/openlibrary_import.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
""" handle reading a csv from openlibrary"""
|
||||
from . import Importer
|
||||
|
||||
|
||||
class OpenLibraryImporter(Importer):
|
||||
"""csv downloads from OpenLibrary"""
|
||||
|
||||
service = "OpenLibrary"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.row_mappings_guesses.append(("openlibrary_key", ["edition id"]))
|
||||
self.row_mappings_guesses.append(("openlibrary_work_key", ["work id"]))
|
||||
super().__init__(*args, **kwargs)
|
|
@ -1,34 +1,8 @@
|
|||
""" handle reading a csv from librarything """
|
||||
import re
|
||||
import math
|
||||
|
||||
""" handle reading a csv from storygraph"""
|
||||
from . import Importer
|
||||
|
||||
|
||||
class StorygraphImporter(Importer):
|
||||
"""csv downloads from librarything"""
|
||||
"""csv downloads from 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
|
||||
|
|
31
bookwyrm/migrations/0107_alter_user_preferred_language.py
Normal file
31
bookwyrm/migrations/0107_alter_user_preferred_language.py
Normal file
|
@ -0,0 +1,31 @@
|
|||
# Generated by Django 3.2.5 on 2021-10-11 16:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0106_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", "Español (Spanish)"),
|
||||
("fr-fr", "Français (French)"),
|
||||
("pt-br", "Português - Brasil (Brazilian Portugues)"),
|
||||
("zh-hans", "简体中文 (Simplified Chinese)"),
|
||||
("zh-hant", "繁體中文 (Traditional Chinese)"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
871
bookwyrm/migrations/0107_auto_20211016_0639.py
Normal file
871
bookwyrm/migrations/0107_auto_20211016_0639.py
Normal file
|
@ -0,0 +1,871 @@
|
|||
# Generated by Django 3.2.5 on 2021-10-16 06:39
|
||||
|
||||
import bookwyrm.models.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0106_user_preferred_language"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Group",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_date", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_date", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"remote_id",
|
||||
bookwyrm.models.fields.RemoteIdField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
validators=[bookwyrm.models.fields.validate_remote_id],
|
||||
),
|
||||
),
|
||||
("name", bookwyrm.models.fields.CharField(max_length=100)),
|
||||
(
|
||||
"description",
|
||||
bookwyrm.models.fields.TextField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"privacy",
|
||||
bookwyrm.models.fields.PrivacyField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("unlisted", "Unlisted"),
|
||||
("followers", "Followers"),
|
||||
("direct", "Direct"),
|
||||
],
|
||||
default="public",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="GroupMember",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_date", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_date", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="GroupMemberInvitation",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_date", models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name="notification",
|
||||
name="notification_type_valid",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="list",
|
||||
name="curation",
|
||||
field=bookwyrm.models.fields.CharField(
|
||||
choices=[
|
||||
("closed", "Closed"),
|
||||
("open", "Open"),
|
||||
("curated", "Curated"),
|
||||
("group", "Group"),
|
||||
],
|
||||
default="closed",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="notification",
|
||||
name="notification_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("FAVORITE", "Favorite"),
|
||||
("REPLY", "Reply"),
|
||||
("MENTION", "Mention"),
|
||||
("TAG", "Tag"),
|
||||
("FOLLOW", "Follow"),
|
||||
("FOLLOW_REQUEST", "Follow Request"),
|
||||
("BOOST", "Boost"),
|
||||
("IMPORT", "Import"),
|
||||
("ADD", "Add"),
|
||||
("REPORT", "Report"),
|
||||
("INVITE", "Invite"),
|
||||
("ACCEPT", "Accept"),
|
||||
("JOIN", "Join"),
|
||||
("LEAVE", "Leave"),
|
||||
("REMOVE", "Remove"),
|
||||
],
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="preferred_language",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("en-us", "English"),
|
||||
("de-de", "Deutsch (German)"),
|
||||
("es", "Español (Spanish)"),
|
||||
("fr-fr", "Français (French)"),
|
||||
("zh-hans", "简体中文 (Simplified Chinese)"),
|
||||
("zh-hant", "繁體中文 (Traditional Chinese)"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="preferred_timezone",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("Africa/Abidjan", "Africa/Abidjan"),
|
||||
("Africa/Accra", "Africa/Accra"),
|
||||
("Africa/Addis_Ababa", "Africa/Addis_Ababa"),
|
||||
("Africa/Algiers", "Africa/Algiers"),
|
||||
("Africa/Asmara", "Africa/Asmara"),
|
||||
("Africa/Asmera", "Africa/Asmera"),
|
||||
("Africa/Bamako", "Africa/Bamako"),
|
||||
("Africa/Bangui", "Africa/Bangui"),
|
||||
("Africa/Banjul", "Africa/Banjul"),
|
||||
("Africa/Bissau", "Africa/Bissau"),
|
||||
("Africa/Blantyre", "Africa/Blantyre"),
|
||||
("Africa/Brazzaville", "Africa/Brazzaville"),
|
||||
("Africa/Bujumbura", "Africa/Bujumbura"),
|
||||
("Africa/Cairo", "Africa/Cairo"),
|
||||
("Africa/Casablanca", "Africa/Casablanca"),
|
||||
("Africa/Ceuta", "Africa/Ceuta"),
|
||||
("Africa/Conakry", "Africa/Conakry"),
|
||||
("Africa/Dakar", "Africa/Dakar"),
|
||||
("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"),
|
||||
("Africa/Djibouti", "Africa/Djibouti"),
|
||||
("Africa/Douala", "Africa/Douala"),
|
||||
("Africa/El_Aaiun", "Africa/El_Aaiun"),
|
||||
("Africa/Freetown", "Africa/Freetown"),
|
||||
("Africa/Gaborone", "Africa/Gaborone"),
|
||||
("Africa/Harare", "Africa/Harare"),
|
||||
("Africa/Johannesburg", "Africa/Johannesburg"),
|
||||
("Africa/Juba", "Africa/Juba"),
|
||||
("Africa/Kampala", "Africa/Kampala"),
|
||||
("Africa/Khartoum", "Africa/Khartoum"),
|
||||
("Africa/Kigali", "Africa/Kigali"),
|
||||
("Africa/Kinshasa", "Africa/Kinshasa"),
|
||||
("Africa/Lagos", "Africa/Lagos"),
|
||||
("Africa/Libreville", "Africa/Libreville"),
|
||||
("Africa/Lome", "Africa/Lome"),
|
||||
("Africa/Luanda", "Africa/Luanda"),
|
||||
("Africa/Lubumbashi", "Africa/Lubumbashi"),
|
||||
("Africa/Lusaka", "Africa/Lusaka"),
|
||||
("Africa/Malabo", "Africa/Malabo"),
|
||||
("Africa/Maputo", "Africa/Maputo"),
|
||||
("Africa/Maseru", "Africa/Maseru"),
|
||||
("Africa/Mbabane", "Africa/Mbabane"),
|
||||
("Africa/Mogadishu", "Africa/Mogadishu"),
|
||||
("Africa/Monrovia", "Africa/Monrovia"),
|
||||
("Africa/Nairobi", "Africa/Nairobi"),
|
||||
("Africa/Ndjamena", "Africa/Ndjamena"),
|
||||
("Africa/Niamey", "Africa/Niamey"),
|
||||
("Africa/Nouakchott", "Africa/Nouakchott"),
|
||||
("Africa/Ouagadougou", "Africa/Ouagadougou"),
|
||||
("Africa/Porto-Novo", "Africa/Porto-Novo"),
|
||||
("Africa/Sao_Tome", "Africa/Sao_Tome"),
|
||||
("Africa/Timbuktu", "Africa/Timbuktu"),
|
||||
("Africa/Tripoli", "Africa/Tripoli"),
|
||||
("Africa/Tunis", "Africa/Tunis"),
|
||||
("Africa/Windhoek", "Africa/Windhoek"),
|
||||
("America/Adak", "America/Adak"),
|
||||
("America/Anchorage", "America/Anchorage"),
|
||||
("America/Anguilla", "America/Anguilla"),
|
||||
("America/Antigua", "America/Antigua"),
|
||||
("America/Araguaina", "America/Araguaina"),
|
||||
(
|
||||
"America/Argentina/Buenos_Aires",
|
||||
"America/Argentina/Buenos_Aires",
|
||||
),
|
||||
("America/Argentina/Catamarca", "America/Argentina/Catamarca"),
|
||||
(
|
||||
"America/Argentina/ComodRivadavia",
|
||||
"America/Argentina/ComodRivadavia",
|
||||
),
|
||||
("America/Argentina/Cordoba", "America/Argentina/Cordoba"),
|
||||
("America/Argentina/Jujuy", "America/Argentina/Jujuy"),
|
||||
("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"),
|
||||
("America/Argentina/Mendoza", "America/Argentina/Mendoza"),
|
||||
(
|
||||
"America/Argentina/Rio_Gallegos",
|
||||
"America/Argentina/Rio_Gallegos",
|
||||
),
|
||||
("America/Argentina/Salta", "America/Argentina/Salta"),
|
||||
("America/Argentina/San_Juan", "America/Argentina/San_Juan"),
|
||||
("America/Argentina/San_Luis", "America/Argentina/San_Luis"),
|
||||
("America/Argentina/Tucuman", "America/Argentina/Tucuman"),
|
||||
("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"),
|
||||
("America/Aruba", "America/Aruba"),
|
||||
("America/Asuncion", "America/Asuncion"),
|
||||
("America/Atikokan", "America/Atikokan"),
|
||||
("America/Atka", "America/Atka"),
|
||||
("America/Bahia", "America/Bahia"),
|
||||
("America/Bahia_Banderas", "America/Bahia_Banderas"),
|
||||
("America/Barbados", "America/Barbados"),
|
||||
("America/Belem", "America/Belem"),
|
||||
("America/Belize", "America/Belize"),
|
||||
("America/Blanc-Sablon", "America/Blanc-Sablon"),
|
||||
("America/Boa_Vista", "America/Boa_Vista"),
|
||||
("America/Bogota", "America/Bogota"),
|
||||
("America/Boise", "America/Boise"),
|
||||
("America/Buenos_Aires", "America/Buenos_Aires"),
|
||||
("America/Cambridge_Bay", "America/Cambridge_Bay"),
|
||||
("America/Campo_Grande", "America/Campo_Grande"),
|
||||
("America/Cancun", "America/Cancun"),
|
||||
("America/Caracas", "America/Caracas"),
|
||||
("America/Catamarca", "America/Catamarca"),
|
||||
("America/Cayenne", "America/Cayenne"),
|
||||
("America/Cayman", "America/Cayman"),
|
||||
("America/Chicago", "America/Chicago"),
|
||||
("America/Chihuahua", "America/Chihuahua"),
|
||||
("America/Coral_Harbour", "America/Coral_Harbour"),
|
||||
("America/Cordoba", "America/Cordoba"),
|
||||
("America/Costa_Rica", "America/Costa_Rica"),
|
||||
("America/Creston", "America/Creston"),
|
||||
("America/Cuiaba", "America/Cuiaba"),
|
||||
("America/Curacao", "America/Curacao"),
|
||||
("America/Danmarkshavn", "America/Danmarkshavn"),
|
||||
("America/Dawson", "America/Dawson"),
|
||||
("America/Dawson_Creek", "America/Dawson_Creek"),
|
||||
("America/Denver", "America/Denver"),
|
||||
("America/Detroit", "America/Detroit"),
|
||||
("America/Dominica", "America/Dominica"),
|
||||
("America/Edmonton", "America/Edmonton"),
|
||||
("America/Eirunepe", "America/Eirunepe"),
|
||||
("America/El_Salvador", "America/El_Salvador"),
|
||||
("America/Ensenada", "America/Ensenada"),
|
||||
("America/Fort_Nelson", "America/Fort_Nelson"),
|
||||
("America/Fort_Wayne", "America/Fort_Wayne"),
|
||||
("America/Fortaleza", "America/Fortaleza"),
|
||||
("America/Glace_Bay", "America/Glace_Bay"),
|
||||
("America/Godthab", "America/Godthab"),
|
||||
("America/Goose_Bay", "America/Goose_Bay"),
|
||||
("America/Grand_Turk", "America/Grand_Turk"),
|
||||
("America/Grenada", "America/Grenada"),
|
||||
("America/Guadeloupe", "America/Guadeloupe"),
|
||||
("America/Guatemala", "America/Guatemala"),
|
||||
("America/Guayaquil", "America/Guayaquil"),
|
||||
("America/Guyana", "America/Guyana"),
|
||||
("America/Halifax", "America/Halifax"),
|
||||
("America/Havana", "America/Havana"),
|
||||
("America/Hermosillo", "America/Hermosillo"),
|
||||
("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"),
|
||||
("America/Indiana/Knox", "America/Indiana/Knox"),
|
||||
("America/Indiana/Marengo", "America/Indiana/Marengo"),
|
||||
("America/Indiana/Petersburg", "America/Indiana/Petersburg"),
|
||||
("America/Indiana/Tell_City", "America/Indiana/Tell_City"),
|
||||
("America/Indiana/Vevay", "America/Indiana/Vevay"),
|
||||
("America/Indiana/Vincennes", "America/Indiana/Vincennes"),
|
||||
("America/Indiana/Winamac", "America/Indiana/Winamac"),
|
||||
("America/Indianapolis", "America/Indianapolis"),
|
||||
("America/Inuvik", "America/Inuvik"),
|
||||
("America/Iqaluit", "America/Iqaluit"),
|
||||
("America/Jamaica", "America/Jamaica"),
|
||||
("America/Jujuy", "America/Jujuy"),
|
||||
("America/Juneau", "America/Juneau"),
|
||||
("America/Kentucky/Louisville", "America/Kentucky/Louisville"),
|
||||
("America/Kentucky/Monticello", "America/Kentucky/Monticello"),
|
||||
("America/Knox_IN", "America/Knox_IN"),
|
||||
("America/Kralendijk", "America/Kralendijk"),
|
||||
("America/La_Paz", "America/La_Paz"),
|
||||
("America/Lima", "America/Lima"),
|
||||
("America/Los_Angeles", "America/Los_Angeles"),
|
||||
("America/Louisville", "America/Louisville"),
|
||||
("America/Lower_Princes", "America/Lower_Princes"),
|
||||
("America/Maceio", "America/Maceio"),
|
||||
("America/Managua", "America/Managua"),
|
||||
("America/Manaus", "America/Manaus"),
|
||||
("America/Marigot", "America/Marigot"),
|
||||
("America/Martinique", "America/Martinique"),
|
||||
("America/Matamoros", "America/Matamoros"),
|
||||
("America/Mazatlan", "America/Mazatlan"),
|
||||
("America/Mendoza", "America/Mendoza"),
|
||||
("America/Menominee", "America/Menominee"),
|
||||
("America/Merida", "America/Merida"),
|
||||
("America/Metlakatla", "America/Metlakatla"),
|
||||
("America/Mexico_City", "America/Mexico_City"),
|
||||
("America/Miquelon", "America/Miquelon"),
|
||||
("America/Moncton", "America/Moncton"),
|
||||
("America/Monterrey", "America/Monterrey"),
|
||||
("America/Montevideo", "America/Montevideo"),
|
||||
("America/Montreal", "America/Montreal"),
|
||||
("America/Montserrat", "America/Montserrat"),
|
||||
("America/Nassau", "America/Nassau"),
|
||||
("America/New_York", "America/New_York"),
|
||||
("America/Nipigon", "America/Nipigon"),
|
||||
("America/Nome", "America/Nome"),
|
||||
("America/Noronha", "America/Noronha"),
|
||||
("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"),
|
||||
("America/North_Dakota/Center", "America/North_Dakota/Center"),
|
||||
(
|
||||
"America/North_Dakota/New_Salem",
|
||||
"America/North_Dakota/New_Salem",
|
||||
),
|
||||
("America/Nuuk", "America/Nuuk"),
|
||||
("America/Ojinaga", "America/Ojinaga"),
|
||||
("America/Panama", "America/Panama"),
|
||||
("America/Pangnirtung", "America/Pangnirtung"),
|
||||
("America/Paramaribo", "America/Paramaribo"),
|
||||
("America/Phoenix", "America/Phoenix"),
|
||||
("America/Port-au-Prince", "America/Port-au-Prince"),
|
||||
("America/Port_of_Spain", "America/Port_of_Spain"),
|
||||
("America/Porto_Acre", "America/Porto_Acre"),
|
||||
("America/Porto_Velho", "America/Porto_Velho"),
|
||||
("America/Puerto_Rico", "America/Puerto_Rico"),
|
||||
("America/Punta_Arenas", "America/Punta_Arenas"),
|
||||
("America/Rainy_River", "America/Rainy_River"),
|
||||
("America/Rankin_Inlet", "America/Rankin_Inlet"),
|
||||
("America/Recife", "America/Recife"),
|
||||
("America/Regina", "America/Regina"),
|
||||
("America/Resolute", "America/Resolute"),
|
||||
("America/Rio_Branco", "America/Rio_Branco"),
|
||||
("America/Rosario", "America/Rosario"),
|
||||
("America/Santa_Isabel", "America/Santa_Isabel"),
|
||||
("America/Santarem", "America/Santarem"),
|
||||
("America/Santiago", "America/Santiago"),
|
||||
("America/Santo_Domingo", "America/Santo_Domingo"),
|
||||
("America/Sao_Paulo", "America/Sao_Paulo"),
|
||||
("America/Scoresbysund", "America/Scoresbysund"),
|
||||
("America/Shiprock", "America/Shiprock"),
|
||||
("America/Sitka", "America/Sitka"),
|
||||
("America/St_Barthelemy", "America/St_Barthelemy"),
|
||||
("America/St_Johns", "America/St_Johns"),
|
||||
("America/St_Kitts", "America/St_Kitts"),
|
||||
("America/St_Lucia", "America/St_Lucia"),
|
||||
("America/St_Thomas", "America/St_Thomas"),
|
||||
("America/St_Vincent", "America/St_Vincent"),
|
||||
("America/Swift_Current", "America/Swift_Current"),
|
||||
("America/Tegucigalpa", "America/Tegucigalpa"),
|
||||
("America/Thule", "America/Thule"),
|
||||
("America/Thunder_Bay", "America/Thunder_Bay"),
|
||||
("America/Tijuana", "America/Tijuana"),
|
||||
("America/Toronto", "America/Toronto"),
|
||||
("America/Tortola", "America/Tortola"),
|
||||
("America/Vancouver", "America/Vancouver"),
|
||||
("America/Virgin", "America/Virgin"),
|
||||
("America/Whitehorse", "America/Whitehorse"),
|
||||
("America/Winnipeg", "America/Winnipeg"),
|
||||
("America/Yakutat", "America/Yakutat"),
|
||||
("America/Yellowknife", "America/Yellowknife"),
|
||||
("Antarctica/Casey", "Antarctica/Casey"),
|
||||
("Antarctica/Davis", "Antarctica/Davis"),
|
||||
("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"),
|
||||
("Antarctica/Macquarie", "Antarctica/Macquarie"),
|
||||
("Antarctica/Mawson", "Antarctica/Mawson"),
|
||||
("Antarctica/McMurdo", "Antarctica/McMurdo"),
|
||||
("Antarctica/Palmer", "Antarctica/Palmer"),
|
||||
("Antarctica/Rothera", "Antarctica/Rothera"),
|
||||
("Antarctica/South_Pole", "Antarctica/South_Pole"),
|
||||
("Antarctica/Syowa", "Antarctica/Syowa"),
|
||||
("Antarctica/Troll", "Antarctica/Troll"),
|
||||
("Antarctica/Vostok", "Antarctica/Vostok"),
|
||||
("Arctic/Longyearbyen", "Arctic/Longyearbyen"),
|
||||
("Asia/Aden", "Asia/Aden"),
|
||||
("Asia/Almaty", "Asia/Almaty"),
|
||||
("Asia/Amman", "Asia/Amman"),
|
||||
("Asia/Anadyr", "Asia/Anadyr"),
|
||||
("Asia/Aqtau", "Asia/Aqtau"),
|
||||
("Asia/Aqtobe", "Asia/Aqtobe"),
|
||||
("Asia/Ashgabat", "Asia/Ashgabat"),
|
||||
("Asia/Ashkhabad", "Asia/Ashkhabad"),
|
||||
("Asia/Atyrau", "Asia/Atyrau"),
|
||||
("Asia/Baghdad", "Asia/Baghdad"),
|
||||
("Asia/Bahrain", "Asia/Bahrain"),
|
||||
("Asia/Baku", "Asia/Baku"),
|
||||
("Asia/Bangkok", "Asia/Bangkok"),
|
||||
("Asia/Barnaul", "Asia/Barnaul"),
|
||||
("Asia/Beirut", "Asia/Beirut"),
|
||||
("Asia/Bishkek", "Asia/Bishkek"),
|
||||
("Asia/Brunei", "Asia/Brunei"),
|
||||
("Asia/Calcutta", "Asia/Calcutta"),
|
||||
("Asia/Chita", "Asia/Chita"),
|
||||
("Asia/Choibalsan", "Asia/Choibalsan"),
|
||||
("Asia/Chongqing", "Asia/Chongqing"),
|
||||
("Asia/Chungking", "Asia/Chungking"),
|
||||
("Asia/Colombo", "Asia/Colombo"),
|
||||
("Asia/Dacca", "Asia/Dacca"),
|
||||
("Asia/Damascus", "Asia/Damascus"),
|
||||
("Asia/Dhaka", "Asia/Dhaka"),
|
||||
("Asia/Dili", "Asia/Dili"),
|
||||
("Asia/Dubai", "Asia/Dubai"),
|
||||
("Asia/Dushanbe", "Asia/Dushanbe"),
|
||||
("Asia/Famagusta", "Asia/Famagusta"),
|
||||
("Asia/Gaza", "Asia/Gaza"),
|
||||
("Asia/Harbin", "Asia/Harbin"),
|
||||
("Asia/Hebron", "Asia/Hebron"),
|
||||
("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"),
|
||||
("Asia/Hong_Kong", "Asia/Hong_Kong"),
|
||||
("Asia/Hovd", "Asia/Hovd"),
|
||||
("Asia/Irkutsk", "Asia/Irkutsk"),
|
||||
("Asia/Istanbul", "Asia/Istanbul"),
|
||||
("Asia/Jakarta", "Asia/Jakarta"),
|
||||
("Asia/Jayapura", "Asia/Jayapura"),
|
||||
("Asia/Jerusalem", "Asia/Jerusalem"),
|
||||
("Asia/Kabul", "Asia/Kabul"),
|
||||
("Asia/Kamchatka", "Asia/Kamchatka"),
|
||||
("Asia/Karachi", "Asia/Karachi"),
|
||||
("Asia/Kashgar", "Asia/Kashgar"),
|
||||
("Asia/Kathmandu", "Asia/Kathmandu"),
|
||||
("Asia/Katmandu", "Asia/Katmandu"),
|
||||
("Asia/Khandyga", "Asia/Khandyga"),
|
||||
("Asia/Kolkata", "Asia/Kolkata"),
|
||||
("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"),
|
||||
("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"),
|
||||
("Asia/Kuching", "Asia/Kuching"),
|
||||
("Asia/Kuwait", "Asia/Kuwait"),
|
||||
("Asia/Macao", "Asia/Macao"),
|
||||
("Asia/Macau", "Asia/Macau"),
|
||||
("Asia/Magadan", "Asia/Magadan"),
|
||||
("Asia/Makassar", "Asia/Makassar"),
|
||||
("Asia/Manila", "Asia/Manila"),
|
||||
("Asia/Muscat", "Asia/Muscat"),
|
||||
("Asia/Nicosia", "Asia/Nicosia"),
|
||||
("Asia/Novokuznetsk", "Asia/Novokuznetsk"),
|
||||
("Asia/Novosibirsk", "Asia/Novosibirsk"),
|
||||
("Asia/Omsk", "Asia/Omsk"),
|
||||
("Asia/Oral", "Asia/Oral"),
|
||||
("Asia/Phnom_Penh", "Asia/Phnom_Penh"),
|
||||
("Asia/Pontianak", "Asia/Pontianak"),
|
||||
("Asia/Pyongyang", "Asia/Pyongyang"),
|
||||
("Asia/Qatar", "Asia/Qatar"),
|
||||
("Asia/Qostanay", "Asia/Qostanay"),
|
||||
("Asia/Qyzylorda", "Asia/Qyzylorda"),
|
||||
("Asia/Rangoon", "Asia/Rangoon"),
|
||||
("Asia/Riyadh", "Asia/Riyadh"),
|
||||
("Asia/Saigon", "Asia/Saigon"),
|
||||
("Asia/Sakhalin", "Asia/Sakhalin"),
|
||||
("Asia/Samarkand", "Asia/Samarkand"),
|
||||
("Asia/Seoul", "Asia/Seoul"),
|
||||
("Asia/Shanghai", "Asia/Shanghai"),
|
||||
("Asia/Singapore", "Asia/Singapore"),
|
||||
("Asia/Srednekolymsk", "Asia/Srednekolymsk"),
|
||||
("Asia/Taipei", "Asia/Taipei"),
|
||||
("Asia/Tashkent", "Asia/Tashkent"),
|
||||
("Asia/Tbilisi", "Asia/Tbilisi"),
|
||||
("Asia/Tehran", "Asia/Tehran"),
|
||||
("Asia/Tel_Aviv", "Asia/Tel_Aviv"),
|
||||
("Asia/Thimbu", "Asia/Thimbu"),
|
||||
("Asia/Thimphu", "Asia/Thimphu"),
|
||||
("Asia/Tokyo", "Asia/Tokyo"),
|
||||
("Asia/Tomsk", "Asia/Tomsk"),
|
||||
("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"),
|
||||
("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"),
|
||||
("Asia/Ulan_Bator", "Asia/Ulan_Bator"),
|
||||
("Asia/Urumqi", "Asia/Urumqi"),
|
||||
("Asia/Ust-Nera", "Asia/Ust-Nera"),
|
||||
("Asia/Vientiane", "Asia/Vientiane"),
|
||||
("Asia/Vladivostok", "Asia/Vladivostok"),
|
||||
("Asia/Yakutsk", "Asia/Yakutsk"),
|
||||
("Asia/Yangon", "Asia/Yangon"),
|
||||
("Asia/Yekaterinburg", "Asia/Yekaterinburg"),
|
||||
("Asia/Yerevan", "Asia/Yerevan"),
|
||||
("Atlantic/Azores", "Atlantic/Azores"),
|
||||
("Atlantic/Bermuda", "Atlantic/Bermuda"),
|
||||
("Atlantic/Canary", "Atlantic/Canary"),
|
||||
("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"),
|
||||
("Atlantic/Faeroe", "Atlantic/Faeroe"),
|
||||
("Atlantic/Faroe", "Atlantic/Faroe"),
|
||||
("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"),
|
||||
("Atlantic/Madeira", "Atlantic/Madeira"),
|
||||
("Atlantic/Reykjavik", "Atlantic/Reykjavik"),
|
||||
("Atlantic/South_Georgia", "Atlantic/South_Georgia"),
|
||||
("Atlantic/St_Helena", "Atlantic/St_Helena"),
|
||||
("Atlantic/Stanley", "Atlantic/Stanley"),
|
||||
("Australia/ACT", "Australia/ACT"),
|
||||
("Australia/Adelaide", "Australia/Adelaide"),
|
||||
("Australia/Brisbane", "Australia/Brisbane"),
|
||||
("Australia/Broken_Hill", "Australia/Broken_Hill"),
|
||||
("Australia/Canberra", "Australia/Canberra"),
|
||||
("Australia/Currie", "Australia/Currie"),
|
||||
("Australia/Darwin", "Australia/Darwin"),
|
||||
("Australia/Eucla", "Australia/Eucla"),
|
||||
("Australia/Hobart", "Australia/Hobart"),
|
||||
("Australia/LHI", "Australia/LHI"),
|
||||
("Australia/Lindeman", "Australia/Lindeman"),
|
||||
("Australia/Lord_Howe", "Australia/Lord_Howe"),
|
||||
("Australia/Melbourne", "Australia/Melbourne"),
|
||||
("Australia/NSW", "Australia/NSW"),
|
||||
("Australia/North", "Australia/North"),
|
||||
("Australia/Perth", "Australia/Perth"),
|
||||
("Australia/Queensland", "Australia/Queensland"),
|
||||
("Australia/South", "Australia/South"),
|
||||
("Australia/Sydney", "Australia/Sydney"),
|
||||
("Australia/Tasmania", "Australia/Tasmania"),
|
||||
("Australia/Victoria", "Australia/Victoria"),
|
||||
("Australia/West", "Australia/West"),
|
||||
("Australia/Yancowinna", "Australia/Yancowinna"),
|
||||
("Brazil/Acre", "Brazil/Acre"),
|
||||
("Brazil/DeNoronha", "Brazil/DeNoronha"),
|
||||
("Brazil/East", "Brazil/East"),
|
||||
("Brazil/West", "Brazil/West"),
|
||||
("CET", "CET"),
|
||||
("CST6CDT", "CST6CDT"),
|
||||
("Canada/Atlantic", "Canada/Atlantic"),
|
||||
("Canada/Central", "Canada/Central"),
|
||||
("Canada/Eastern", "Canada/Eastern"),
|
||||
("Canada/Mountain", "Canada/Mountain"),
|
||||
("Canada/Newfoundland", "Canada/Newfoundland"),
|
||||
("Canada/Pacific", "Canada/Pacific"),
|
||||
("Canada/Saskatchewan", "Canada/Saskatchewan"),
|
||||
("Canada/Yukon", "Canada/Yukon"),
|
||||
("Chile/Continental", "Chile/Continental"),
|
||||
("Chile/EasterIsland", "Chile/EasterIsland"),
|
||||
("Cuba", "Cuba"),
|
||||
("EET", "EET"),
|
||||
("EST", "EST"),
|
||||
("EST5EDT", "EST5EDT"),
|
||||
("Egypt", "Egypt"),
|
||||
("Eire", "Eire"),
|
||||
("Etc/GMT", "Etc/GMT"),
|
||||
("Etc/GMT+0", "Etc/GMT+0"),
|
||||
("Etc/GMT+1", "Etc/GMT+1"),
|
||||
("Etc/GMT+10", "Etc/GMT+10"),
|
||||
("Etc/GMT+11", "Etc/GMT+11"),
|
||||
("Etc/GMT+12", "Etc/GMT+12"),
|
||||
("Etc/GMT+2", "Etc/GMT+2"),
|
||||
("Etc/GMT+3", "Etc/GMT+3"),
|
||||
("Etc/GMT+4", "Etc/GMT+4"),
|
||||
("Etc/GMT+5", "Etc/GMT+5"),
|
||||
("Etc/GMT+6", "Etc/GMT+6"),
|
||||
("Etc/GMT+7", "Etc/GMT+7"),
|
||||
("Etc/GMT+8", "Etc/GMT+8"),
|
||||
("Etc/GMT+9", "Etc/GMT+9"),
|
||||
("Etc/GMT-0", "Etc/GMT-0"),
|
||||
("Etc/GMT-1", "Etc/GMT-1"),
|
||||
("Etc/GMT-10", "Etc/GMT-10"),
|
||||
("Etc/GMT-11", "Etc/GMT-11"),
|
||||
("Etc/GMT-12", "Etc/GMT-12"),
|
||||
("Etc/GMT-13", "Etc/GMT-13"),
|
||||
("Etc/GMT-14", "Etc/GMT-14"),
|
||||
("Etc/GMT-2", "Etc/GMT-2"),
|
||||
("Etc/GMT-3", "Etc/GMT-3"),
|
||||
("Etc/GMT-4", "Etc/GMT-4"),
|
||||
("Etc/GMT-5", "Etc/GMT-5"),
|
||||
("Etc/GMT-6", "Etc/GMT-6"),
|
||||
("Etc/GMT-7", "Etc/GMT-7"),
|
||||
("Etc/GMT-8", "Etc/GMT-8"),
|
||||
("Etc/GMT-9", "Etc/GMT-9"),
|
||||
("Etc/GMT0", "Etc/GMT0"),
|
||||
("Etc/Greenwich", "Etc/Greenwich"),
|
||||
("Etc/UCT", "Etc/UCT"),
|
||||
("Etc/UTC", "Etc/UTC"),
|
||||
("Etc/Universal", "Etc/Universal"),
|
||||
("Etc/Zulu", "Etc/Zulu"),
|
||||
("Europe/Amsterdam", "Europe/Amsterdam"),
|
||||
("Europe/Andorra", "Europe/Andorra"),
|
||||
("Europe/Astrakhan", "Europe/Astrakhan"),
|
||||
("Europe/Athens", "Europe/Athens"),
|
||||
("Europe/Belfast", "Europe/Belfast"),
|
||||
("Europe/Belgrade", "Europe/Belgrade"),
|
||||
("Europe/Berlin", "Europe/Berlin"),
|
||||
("Europe/Bratislava", "Europe/Bratislava"),
|
||||
("Europe/Brussels", "Europe/Brussels"),
|
||||
("Europe/Bucharest", "Europe/Bucharest"),
|
||||
("Europe/Budapest", "Europe/Budapest"),
|
||||
("Europe/Busingen", "Europe/Busingen"),
|
||||
("Europe/Chisinau", "Europe/Chisinau"),
|
||||
("Europe/Copenhagen", "Europe/Copenhagen"),
|
||||
("Europe/Dublin", "Europe/Dublin"),
|
||||
("Europe/Gibraltar", "Europe/Gibraltar"),
|
||||
("Europe/Guernsey", "Europe/Guernsey"),
|
||||
("Europe/Helsinki", "Europe/Helsinki"),
|
||||
("Europe/Isle_of_Man", "Europe/Isle_of_Man"),
|
||||
("Europe/Istanbul", "Europe/Istanbul"),
|
||||
("Europe/Jersey", "Europe/Jersey"),
|
||||
("Europe/Kaliningrad", "Europe/Kaliningrad"),
|
||||
("Europe/Kiev", "Europe/Kiev"),
|
||||
("Europe/Kirov", "Europe/Kirov"),
|
||||
("Europe/Lisbon", "Europe/Lisbon"),
|
||||
("Europe/Ljubljana", "Europe/Ljubljana"),
|
||||
("Europe/London", "Europe/London"),
|
||||
("Europe/Luxembourg", "Europe/Luxembourg"),
|
||||
("Europe/Madrid", "Europe/Madrid"),
|
||||
("Europe/Malta", "Europe/Malta"),
|
||||
("Europe/Mariehamn", "Europe/Mariehamn"),
|
||||
("Europe/Minsk", "Europe/Minsk"),
|
||||
("Europe/Monaco", "Europe/Monaco"),
|
||||
("Europe/Moscow", "Europe/Moscow"),
|
||||
("Europe/Nicosia", "Europe/Nicosia"),
|
||||
("Europe/Oslo", "Europe/Oslo"),
|
||||
("Europe/Paris", "Europe/Paris"),
|
||||
("Europe/Podgorica", "Europe/Podgorica"),
|
||||
("Europe/Prague", "Europe/Prague"),
|
||||
("Europe/Riga", "Europe/Riga"),
|
||||
("Europe/Rome", "Europe/Rome"),
|
||||
("Europe/Samara", "Europe/Samara"),
|
||||
("Europe/San_Marino", "Europe/San_Marino"),
|
||||
("Europe/Sarajevo", "Europe/Sarajevo"),
|
||||
("Europe/Saratov", "Europe/Saratov"),
|
||||
("Europe/Simferopol", "Europe/Simferopol"),
|
||||
("Europe/Skopje", "Europe/Skopje"),
|
||||
("Europe/Sofia", "Europe/Sofia"),
|
||||
("Europe/Stockholm", "Europe/Stockholm"),
|
||||
("Europe/Tallinn", "Europe/Tallinn"),
|
||||
("Europe/Tirane", "Europe/Tirane"),
|
||||
("Europe/Tiraspol", "Europe/Tiraspol"),
|
||||
("Europe/Ulyanovsk", "Europe/Ulyanovsk"),
|
||||
("Europe/Uzhgorod", "Europe/Uzhgorod"),
|
||||
("Europe/Vaduz", "Europe/Vaduz"),
|
||||
("Europe/Vatican", "Europe/Vatican"),
|
||||
("Europe/Vienna", "Europe/Vienna"),
|
||||
("Europe/Vilnius", "Europe/Vilnius"),
|
||||
("Europe/Volgograd", "Europe/Volgograd"),
|
||||
("Europe/Warsaw", "Europe/Warsaw"),
|
||||
("Europe/Zagreb", "Europe/Zagreb"),
|
||||
("Europe/Zaporozhye", "Europe/Zaporozhye"),
|
||||
("Europe/Zurich", "Europe/Zurich"),
|
||||
("GB", "GB"),
|
||||
("GB-Eire", "GB-Eire"),
|
||||
("GMT", "GMT"),
|
||||
("GMT+0", "GMT+0"),
|
||||
("GMT-0", "GMT-0"),
|
||||
("GMT0", "GMT0"),
|
||||
("Greenwich", "Greenwich"),
|
||||
("HST", "HST"),
|
||||
("Hongkong", "Hongkong"),
|
||||
("Iceland", "Iceland"),
|
||||
("Indian/Antananarivo", "Indian/Antananarivo"),
|
||||
("Indian/Chagos", "Indian/Chagos"),
|
||||
("Indian/Christmas", "Indian/Christmas"),
|
||||
("Indian/Cocos", "Indian/Cocos"),
|
||||
("Indian/Comoro", "Indian/Comoro"),
|
||||
("Indian/Kerguelen", "Indian/Kerguelen"),
|
||||
("Indian/Mahe", "Indian/Mahe"),
|
||||
("Indian/Maldives", "Indian/Maldives"),
|
||||
("Indian/Mauritius", "Indian/Mauritius"),
|
||||
("Indian/Mayotte", "Indian/Mayotte"),
|
||||
("Indian/Reunion", "Indian/Reunion"),
|
||||
("Iran", "Iran"),
|
||||
("Israel", "Israel"),
|
||||
("Jamaica", "Jamaica"),
|
||||
("Japan", "Japan"),
|
||||
("Kwajalein", "Kwajalein"),
|
||||
("Libya", "Libya"),
|
||||
("MET", "MET"),
|
||||
("MST", "MST"),
|
||||
("MST7MDT", "MST7MDT"),
|
||||
("Mexico/BajaNorte", "Mexico/BajaNorte"),
|
||||
("Mexico/BajaSur", "Mexico/BajaSur"),
|
||||
("Mexico/General", "Mexico/General"),
|
||||
("NZ", "NZ"),
|
||||
("NZ-CHAT", "NZ-CHAT"),
|
||||
("Navajo", "Navajo"),
|
||||
("PRC", "PRC"),
|
||||
("PST8PDT", "PST8PDT"),
|
||||
("Pacific/Apia", "Pacific/Apia"),
|
||||
("Pacific/Auckland", "Pacific/Auckland"),
|
||||
("Pacific/Bougainville", "Pacific/Bougainville"),
|
||||
("Pacific/Chatham", "Pacific/Chatham"),
|
||||
("Pacific/Chuuk", "Pacific/Chuuk"),
|
||||
("Pacific/Easter", "Pacific/Easter"),
|
||||
("Pacific/Efate", "Pacific/Efate"),
|
||||
("Pacific/Enderbury", "Pacific/Enderbury"),
|
||||
("Pacific/Fakaofo", "Pacific/Fakaofo"),
|
||||
("Pacific/Fiji", "Pacific/Fiji"),
|
||||
("Pacific/Funafuti", "Pacific/Funafuti"),
|
||||
("Pacific/Galapagos", "Pacific/Galapagos"),
|
||||
("Pacific/Gambier", "Pacific/Gambier"),
|
||||
("Pacific/Guadalcanal", "Pacific/Guadalcanal"),
|
||||
("Pacific/Guam", "Pacific/Guam"),
|
||||
("Pacific/Honolulu", "Pacific/Honolulu"),
|
||||
("Pacific/Johnston", "Pacific/Johnston"),
|
||||
("Pacific/Kanton", "Pacific/Kanton"),
|
||||
("Pacific/Kiritimati", "Pacific/Kiritimati"),
|
||||
("Pacific/Kosrae", "Pacific/Kosrae"),
|
||||
("Pacific/Kwajalein", "Pacific/Kwajalein"),
|
||||
("Pacific/Majuro", "Pacific/Majuro"),
|
||||
("Pacific/Marquesas", "Pacific/Marquesas"),
|
||||
("Pacific/Midway", "Pacific/Midway"),
|
||||
("Pacific/Nauru", "Pacific/Nauru"),
|
||||
("Pacific/Niue", "Pacific/Niue"),
|
||||
("Pacific/Norfolk", "Pacific/Norfolk"),
|
||||
("Pacific/Noumea", "Pacific/Noumea"),
|
||||
("Pacific/Pago_Pago", "Pacific/Pago_Pago"),
|
||||
("Pacific/Palau", "Pacific/Palau"),
|
||||
("Pacific/Pitcairn", "Pacific/Pitcairn"),
|
||||
("Pacific/Pohnpei", "Pacific/Pohnpei"),
|
||||
("Pacific/Ponape", "Pacific/Ponape"),
|
||||
("Pacific/Port_Moresby", "Pacific/Port_Moresby"),
|
||||
("Pacific/Rarotonga", "Pacific/Rarotonga"),
|
||||
("Pacific/Saipan", "Pacific/Saipan"),
|
||||
("Pacific/Samoa", "Pacific/Samoa"),
|
||||
("Pacific/Tahiti", "Pacific/Tahiti"),
|
||||
("Pacific/Tarawa", "Pacific/Tarawa"),
|
||||
("Pacific/Tongatapu", "Pacific/Tongatapu"),
|
||||
("Pacific/Truk", "Pacific/Truk"),
|
||||
("Pacific/Wake", "Pacific/Wake"),
|
||||
("Pacific/Wallis", "Pacific/Wallis"),
|
||||
("Pacific/Yap", "Pacific/Yap"),
|
||||
("Poland", "Poland"),
|
||||
("Portugal", "Portugal"),
|
||||
("ROC", "ROC"),
|
||||
("ROK", "ROK"),
|
||||
("Singapore", "Singapore"),
|
||||
("Turkey", "Turkey"),
|
||||
("UCT", "UCT"),
|
||||
("US/Alaska", "US/Alaska"),
|
||||
("US/Aleutian", "US/Aleutian"),
|
||||
("US/Arizona", "US/Arizona"),
|
||||
("US/Central", "US/Central"),
|
||||
("US/East-Indiana", "US/East-Indiana"),
|
||||
("US/Eastern", "US/Eastern"),
|
||||
("US/Hawaii", "US/Hawaii"),
|
||||
("US/Indiana-Starke", "US/Indiana-Starke"),
|
||||
("US/Michigan", "US/Michigan"),
|
||||
("US/Mountain", "US/Mountain"),
|
||||
("US/Pacific", "US/Pacific"),
|
||||
("US/Samoa", "US/Samoa"),
|
||||
("UTC", "UTC"),
|
||||
("Universal", "Universal"),
|
||||
("W-SU", "W-SU"),
|
||||
("WET", "WET"),
|
||||
("Zulu", "Zulu"),
|
||||
],
|
||||
default="UTC",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="notification",
|
||||
constraint=models.CheckConstraint(
|
||||
check=models.Q(
|
||||
(
|
||||
"notification_type__in",
|
||||
[
|
||||
"FAVORITE",
|
||||
"REPLY",
|
||||
"MENTION",
|
||||
"TAG",
|
||||
"FOLLOW",
|
||||
"FOLLOW_REQUEST",
|
||||
"BOOST",
|
||||
"IMPORT",
|
||||
"ADD",
|
||||
"REPORT",
|
||||
"INVITE",
|
||||
"ACCEPT",
|
||||
"JOIN",
|
||||
"LEAVE",
|
||||
"REMOVE",
|
||||
],
|
||||
)
|
||||
),
|
||||
name="notification_type_valid",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="groupmemberinvitation",
|
||||
name="group",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="user_invitations",
|
||||
to="bookwyrm.group",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="groupmemberinvitation",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="group_invitations",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="groupmember",
|
||||
name="group",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="memberships",
|
||||
to="bookwyrm.group",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="groupmember",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="memberships",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="group",
|
||||
name="user",
|
||||
field=bookwyrm.models.fields.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="list",
|
||||
name="group",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="bookwyrm.group",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="notification",
|
||||
name="related_group",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="notifications",
|
||||
to="bookwyrm.group",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="groupmemberinvitation",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("group", "user"), name="unique_invitation"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="groupmember",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("group", "user"), name="unique_membership"
|
||||
),
|
||||
),
|
||||
]
|
31
bookwyrm/migrations/0108_alter_user_preferred_language.py
Normal file
31
bookwyrm/migrations/0108_alter_user_preferred_language.py
Normal file
|
@ -0,0 +1,31 @@
|
|||
# Generated by Django 3.2.5 on 2021-10-11 17:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0107_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)"),
|
||||
("fr-fr", "Français (French)"),
|
||||
("pt-br", "Português - Brasil (Brazilian Portuguese)"),
|
||||
("zh-hans", "简体中文 (Simplified Chinese)"),
|
||||
("zh-hant", "繁體中文 (Traditional Chinese)"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
19
bookwyrm/migrations/0109_status_edited_date.py
Normal file
19
bookwyrm/migrations/0109_status_edited_date.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 3.2.5 on 2021-10-15 15:54
|
||||
|
||||
import bookwyrm.models.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0108_alter_user_preferred_language"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="status",
|
||||
name="edited_date",
|
||||
field=bookwyrm.models.fields.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
23
bookwyrm/migrations/0110_auto_20211015_1734.py
Normal file
23
bookwyrm/migrations/0110_auto_20211015_1734.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 3.2.5 on 2021-10-15 17:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0109_status_edited_date"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="quotation",
|
||||
name="raw_quote",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="status",
|
||||
name="raw_content",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,13 @@
|
|||
# Generated by Django 3.2.5 on 2021-10-16 19:30
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0107_auto_20211016_0639"),
|
||||
("bookwyrm", "0110_auto_20211015_1734"),
|
||||
]
|
||||
|
||||
operations = []
|
93
bookwyrm/migrations/0112_auto_20211022_0844.py
Normal file
93
bookwyrm/migrations/0112_auto_20211022_0844.py
Normal file
|
@ -0,0 +1,93 @@
|
|||
# Generated by Django 3.2.5 on 2021-10-22 08:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0111_merge_0107_auto_20211016_0639_0110_auto_20211015_1734"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name="notification",
|
||||
name="notification_type_valid",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="notification",
|
||||
name="notification_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("FAVORITE", "Favorite"),
|
||||
("REPLY", "Reply"),
|
||||
("MENTION", "Mention"),
|
||||
("TAG", "Tag"),
|
||||
("FOLLOW", "Follow"),
|
||||
("FOLLOW_REQUEST", "Follow Request"),
|
||||
("BOOST", "Boost"),
|
||||
("IMPORT", "Import"),
|
||||
("ADD", "Add"),
|
||||
("REPORT", "Report"),
|
||||
("INVITE", "Invite"),
|
||||
("ACCEPT", "Accept"),
|
||||
("JOIN", "Join"),
|
||||
("LEAVE", "Leave"),
|
||||
("REMOVE", "Remove"),
|
||||
("GROUP_PRIVACY", "Group Privacy"),
|
||||
("GROUP_NAME", "Group Name"),
|
||||
("GROUP_DESCRIPTION", "Group Description"),
|
||||
],
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="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)"),
|
||||
("pt-br", "Português - Brasil (Brazilian Portuguese)"),
|
||||
("zh-hans", "简体中文 (Simplified Chinese)"),
|
||||
("zh-hant", "繁體中文 (Traditional Chinese)"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="notification",
|
||||
constraint=models.CheckConstraint(
|
||||
check=models.Q(
|
||||
(
|
||||
"notification_type__in",
|
||||
[
|
||||
"FAVORITE",
|
||||
"REPLY",
|
||||
"MENTION",
|
||||
"TAG",
|
||||
"FOLLOW",
|
||||
"FOLLOW_REQUEST",
|
||||
"BOOST",
|
||||
"IMPORT",
|
||||
"ADD",
|
||||
"REPORT",
|
||||
"INVITE",
|
||||
"ACCEPT",
|
||||
"JOIN",
|
||||
"LEAVE",
|
||||
"REMOVE",
|
||||
"GROUP_PRIVACY",
|
||||
"GROUP_NAME",
|
||||
"GROUP_DESCRIPTION",
|
||||
],
|
||||
)
|
||||
),
|
||||
name="notification_type_valid",
|
||||
),
|
||||
),
|
||||
]
|
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,
|
||||
),
|
||||
),
|
||||
]
|
29
bookwyrm/migrations/0120_list_embed_key.py
Normal file
29
bookwyrm/migrations/0120_list_embed_key.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
# Generated by Django 3.2.5 on 2021-12-04 10:55
|
||||
|
||||
from django.db import migrations, models
|
||||
import uuid
|
||||
|
||||
|
||||
def gen_uuid(apps, schema_editor):
|
||||
"""sets an unique UUID for embed_key"""
|
||||
book_lists = apps.get_model("bookwyrm", "List")
|
||||
db_alias = schema_editor.connection.alias
|
||||
for book_list in book_lists.objects.using(db_alias).all():
|
||||
book_list.embed_key = uuid.uuid4()
|
||||
book_list.save(broadcast=False)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0119_user_feed_status_types"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="list",
|
||||
name="embed_key",
|
||||
field=models.UUIDField(editable=False, null=True, unique=True),
|
||||
),
|
||||
migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop),
|
||||
]
|
|
@ -22,6 +22,8 @@ from .relationship import UserFollows, UserFollowRequest, UserBlocks
|
|||
from .report import Report, ReportComment
|
||||
from .federated_server import FederatedServer
|
||||
|
||||
from .group import Group, GroupMember, GroupMemberInvitation
|
||||
|
||||
from .import_job import ImportJob, ImportItem
|
||||
|
||||
from .site import SiteSettings, SiteInvite
|
||||
|
|
|
@ -20,7 +20,7 @@ from django.utils.http import http_date
|
|||
from bookwyrm import activitypub
|
||||
from bookwyrm.settings import USER_AGENT, PAGE_LENGTH
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -29,7 +29,6 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
PropertyField = namedtuple("PropertyField", ("set_activity_from_field"))
|
||||
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def set_activity_from_property_field(activity, obj, field):
|
||||
"""assign a model property value to the activity json"""
|
||||
|
@ -126,12 +125,15 @@ class ActivitypubMixin:
|
|||
# there OUGHT to be only one match
|
||||
return match.first()
|
||||
|
||||
def broadcast(self, activity, sender, software=None):
|
||||
def broadcast(self, activity, sender, software=None, queue=MEDIUM):
|
||||
"""send out an activity"""
|
||||
broadcast_task.delay(
|
||||
sender.id,
|
||||
json.dumps(activity, cls=activitypub.ActivityEncoder),
|
||||
self.get_recipients(software=software),
|
||||
broadcast_task.apply_async(
|
||||
args=(
|
||||
sender.id,
|
||||
json.dumps(activity, cls=activitypub.ActivityEncoder),
|
||||
self.get_recipients(software=software),
|
||||
),
|
||||
queue=queue,
|
||||
)
|
||||
|
||||
def get_recipients(self, software=None):
|
||||
|
@ -195,7 +197,7 @@ class ActivitypubMixin:
|
|||
class ObjectMixin(ActivitypubMixin):
|
||||
"""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 = kwargs.get("broadcast", True)
|
||||
# this bonus kwarg would cause an error in the base save method
|
||||
|
@ -219,15 +221,17 @@ class ObjectMixin(ActivitypubMixin):
|
|||
return
|
||||
|
||||
try:
|
||||
software = None
|
||||
# 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)
|
||||
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"
|
||||
# sends to BW only if we just did a pure version for masto
|
||||
activity = self.to_create_activity(user)
|
||||
self.broadcast(activity, user, software=software)
|
||||
self.broadcast(activity, user, software=software, queue=priority)
|
||||
except AttributeError:
|
||||
# janky as heck, this catches the mutliple inheritence chain
|
||||
# for boosts and ignores this auxilliary broadcast
|
||||
|
@ -241,8 +245,7 @@ class ObjectMixin(ActivitypubMixin):
|
|||
if isinstance(self, user_model):
|
||||
user = self
|
||||
# book data tracks last editor
|
||||
elif hasattr(self, "last_edited_by"):
|
||||
user = self.last_edited_by
|
||||
user = user or getattr(self, "last_edited_by", None)
|
||||
# again, if we don't know the user or they're remote, don't bother
|
||||
if not user or not user.local:
|
||||
return
|
||||
|
@ -252,7 +255,7 @@ class ObjectMixin(ActivitypubMixin):
|
|||
activity = self.to_delete_activity(user)
|
||||
else:
|
||||
activity = self.to_update_activity(user)
|
||||
self.broadcast(activity, user)
|
||||
self.broadcast(activity, user, queue=priority)
|
||||
|
||||
def to_create_activity(self, user, **kwargs):
|
||||
"""returns the object wrapped in a Create activity"""
|
||||
|
@ -375,9 +378,9 @@ class CollectionItemMixin(ActivitypubMixin):
|
|||
|
||||
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"""
|
||||
super().broadcast(activity, sender, software=software)
|
||||
super().broadcast(activity, sender, software=software, queue=queue)
|
||||
|
||||
@property
|
||||
def privacy(self):
|
||||
|
@ -396,7 +399,7 @@ class CollectionItemMixin(ActivitypubMixin):
|
|||
return []
|
||||
return [collection_field.user]
|
||||
|
||||
def save(self, *args, broadcast=True, **kwargs):
|
||||
def save(self, *args, broadcast=True, priority=MEDIUM, **kwargs):
|
||||
"""broadcast updated"""
|
||||
# first off, we want to save normally no matter what
|
||||
super().save(*args, **kwargs)
|
||||
|
@ -407,7 +410,7 @@ class CollectionItemMixin(ActivitypubMixin):
|
|||
|
||||
# adding an obj to the collection
|
||||
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):
|
||||
"""broadcast a remove activity"""
|
||||
|
@ -440,12 +443,12 @@ class CollectionItemMixin(ActivitypubMixin):
|
|||
class ActivityMixin(ActivitypubMixin):
|
||||
"""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"""
|
||||
super().save(*args, **kwargs)
|
||||
user = self.user if hasattr(self, "user") else self.user_subject
|
||||
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):
|
||||
"""nevermind, undo that activity"""
|
||||
|
@ -502,7 +505,7 @@ def unfurl_related_field(related_field, sort_field=None):
|
|||
return related_field.remote_id
|
||||
|
||||
|
||||
@app.task(queue="medium_priority")
|
||||
@app.task(queue=MEDIUM)
|
||||
def broadcast_task(sender_id, activity, recipients):
|
||||
"""the celery task for broadcast"""
|
||||
user_model = apps.get_model("bookwyrm.User", require_ready=True)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
""" database schema for info about authors """
|
||||
import re
|
||||
from django.contrib.postgres.indexes import GinIndex
|
||||
from django.db import models
|
||||
|
||||
|
@ -27,12 +28,23 @@ class Author(BookDataModel):
|
|||
# idk probably other keys would be useful here?
|
||||
born = 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(
|
||||
models.CharField(max_length=255), blank=True, default=list
|
||||
)
|
||||
bio = fields.HtmlField(null=True, blank=True)
|
||||
|
||||
@property
|
||||
def isni_link(self):
|
||||
"""generate the url from the isni id"""
|
||||
clean_isni = re.sub(r"\s", "", self.isni)
|
||||
return f"https://isni.org/isni/{clean_isni}"
|
||||
|
||||
@property
|
||||
def openlibrary_link(self):
|
||||
"""generate the url from the openlibrary id"""
|
||||
return f"https://openlibrary.org/authors/{self.openlibrary_key}"
|
||||
|
||||
def get_remote_id(self):
|
||||
"""editions and works both use "book" instead of model_name"""
|
||||
return f"https://{DOMAIN}/author/{self.id}"
|
||||
|
|
|
@ -67,19 +67,35 @@ class BookWyrmModel(models.Model):
|
|||
return
|
||||
|
||||
# you can see the followers only posts of people you follow
|
||||
if (
|
||||
self.privacy == "followers"
|
||||
and self.user.followers.filter(id=viewer.id).first()
|
||||
if self.privacy == "followers" and (
|
||||
self.user.followers.filter(id=viewer.id).first()
|
||||
):
|
||||
return
|
||||
|
||||
# you can see dms you are tagged in
|
||||
if hasattr(self, "mention_users"):
|
||||
if (
|
||||
self.privacy == "direct"
|
||||
self.privacy in ["direct", "followers"]
|
||||
and self.mention_users.filter(id=viewer.id).first()
|
||||
):
|
||||
|
||||
return
|
||||
|
||||
# you can see groups of which you are a member
|
||||
if (
|
||||
hasattr(self, "memberships")
|
||||
and self.memberships.filter(user=viewer).exists()
|
||||
):
|
||||
return
|
||||
|
||||
# you can see objects which have a group of which you are a member
|
||||
if hasattr(self, "group"):
|
||||
if (
|
||||
hasattr(self.group, "memberships")
|
||||
and self.group.memberships.filter(user=viewer).exists()
|
||||
):
|
||||
return
|
||||
|
||||
raise Http404()
|
||||
|
||||
def raise_not_editable(self, viewer):
|
||||
|
|
|
@ -53,6 +53,16 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
|
|||
null=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def openlibrary_link(self):
|
||||
"""generate the url from the openlibrary id"""
|
||||
return f"https://openlibrary.org/books/{self.openlibrary_key}"
|
||||
|
||||
@property
|
||||
def inventaire_link(self):
|
||||
"""generate the url from the inventaire id"""
|
||||
return f"https://inventaire.io/entity/{self.inventaire_id}"
|
||||
|
||||
class Meta:
|
||||
"""can't initialize this model, that wouldn't make sense"""
|
||||
|
||||
|
@ -67,9 +77,10 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
|
|||
self.remote_id = None
|
||||
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"""
|
||||
super().broadcast(activity, sender, software=software)
|
||||
super().broadcast(activity, sender, software=software, **kwargs)
|
||||
|
||||
|
||||
class Book(BookDataModel):
|
||||
|
|
|
@ -3,6 +3,7 @@ from dataclasses import MISSING
|
|||
import imghdr
|
||||
import re
|
||||
from uuid import uuid4
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import dateutil.parser
|
||||
from dateutil.parser import ParserError
|
||||
|
@ -13,11 +14,12 @@ from django.db import models
|
|||
from django.forms import ClearableFileInput, ImageField as DjangoImageField
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.encoding import filepath_to_uri
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm.connectors import get_image
|
||||
from bookwyrm.sanitize_html import InputHtmlParser
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from bookwyrm.settings import MEDIA_FULL_URL
|
||||
|
||||
|
||||
def validate_remote_id(value):
|
||||
|
@ -294,7 +296,7 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
|
|||
super().__init__(*args, **kwargs)
|
||||
|
||||
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():
|
||||
return False
|
||||
|
||||
|
@ -381,17 +383,6 @@ class CustomImageField(DjangoImageField):
|
|||
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):
|
||||
"""activitypub-aware image field"""
|
||||
|
||||
|
@ -407,7 +398,11 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
|
|||
if formatted is None or formatted is MISSING:
|
||||
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
|
||||
|
||||
getattr(instance, self.name).save(*formatted, save=save)
|
||||
|
@ -424,7 +419,12 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
|
|||
activity[key] = formatted
|
||||
|
||||
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):
|
||||
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):
|
||||
"""activitypub-aware datetime field"""
|
||||
|
||||
|
|
181
bookwyrm/models/group.py
Normal file
181
bookwyrm/models/group.py
Normal file
|
@ -0,0 +1,181 @@
|
|||
""" do book related things with other users """
|
||||
from django.apps import apps
|
||||
from django.db import models, IntegrityError, transaction
|
||||
from django.db.models import Q
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from .base_model import BookWyrmModel
|
||||
from . import fields
|
||||
from .relationship import UserBlocks
|
||||
|
||||
|
||||
class Group(BookWyrmModel):
|
||||
"""A group of users"""
|
||||
|
||||
name = fields.CharField(max_length=100)
|
||||
user = fields.ForeignKey("User", on_delete=models.CASCADE)
|
||||
description = fields.TextField(blank=True, null=True)
|
||||
privacy = fields.PrivacyField()
|
||||
|
||||
def get_remote_id(self):
|
||||
"""don't want the user to be in there in this case"""
|
||||
return f"https://{DOMAIN}/group/{self.id}"
|
||||
|
||||
@classmethod
|
||||
def followers_filter(cls, queryset, viewer):
|
||||
"""Override filter for "followers" privacy level to allow non-following
|
||||
group members to see the existence of group-curated lists"""
|
||||
|
||||
return queryset.exclude(
|
||||
~Q( # user is not a group member
|
||||
Q(user__followers=viewer) | Q(user=viewer) | Q(memberships__user=viewer)
|
||||
),
|
||||
privacy="followers", # and the status of the group is followers only
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def direct_filter(cls, queryset, viewer):
|
||||
"""Override filter for "direct" privacy level to allow group members
|
||||
to see the existence of groups and group lists"""
|
||||
|
||||
return queryset.exclude(~Q(memberships__user=viewer), privacy="direct")
|
||||
|
||||
|
||||
class GroupMember(models.Model):
|
||||
"""Users who are members of a group"""
|
||||
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
updated_date = models.DateTimeField(auto_now=True)
|
||||
group = models.ForeignKey(
|
||||
"Group", on_delete=models.CASCADE, related_name="memberships"
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
"User", on_delete=models.CASCADE, related_name="memberships"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Users can only have one membership per group"""
|
||||
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["group", "user"], name="unique_membership")
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""don't let a user invite someone who blocked them"""
|
||||
# blocking in either direction is a no-go
|
||||
if UserBlocks.objects.filter(
|
||||
Q(
|
||||
user_subject=self.group.user,
|
||||
user_object=self.user,
|
||||
)
|
||||
| Q(
|
||||
user_subject=self.user,
|
||||
user_object=self.group.user,
|
||||
)
|
||||
).exists():
|
||||
raise IntegrityError()
|
||||
# accepts and requests are handled by the GroupMemberInvitation model
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def from_request(cls, join_request):
|
||||
"""converts a join request into a member relationship"""
|
||||
|
||||
# remove the invite
|
||||
join_request.delete()
|
||||
|
||||
# make a group member
|
||||
return cls.objects.create(
|
||||
user=join_request.user,
|
||||
group=join_request.group,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def remove(cls, owner, user):
|
||||
"""remove a user from a group"""
|
||||
|
||||
memberships = cls.objects.filter(group__user=owner, user=user).all()
|
||||
for member in memberships:
|
||||
member.delete()
|
||||
|
||||
|
||||
class GroupMemberInvitation(models.Model):
|
||||
"""adding a user to a group requires manual confirmation"""
|
||||
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
group = models.ForeignKey(
|
||||
"Group", on_delete=models.CASCADE, related_name="user_invitations"
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
"User", on_delete=models.CASCADE, related_name="group_invitations"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Users can only have one outstanding invitation per group"""
|
||||
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["group", "user"], name="unique_invitation")
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""make sure the membership doesn't already exist"""
|
||||
# if there's an invitation for a membership that already exists, accept it
|
||||
# without changing the local database state
|
||||
if GroupMember.objects.filter(user=self.user, group=self.group).exists():
|
||||
self.accept()
|
||||
return
|
||||
|
||||
# blocking in either direction is a no-go
|
||||
if UserBlocks.objects.filter(
|
||||
Q(
|
||||
user_subject=self.group.user,
|
||||
user_object=self.user,
|
||||
)
|
||||
| Q(
|
||||
user_subject=self.user,
|
||||
user_object=self.group.user,
|
||||
)
|
||||
).exists():
|
||||
raise IntegrityError()
|
||||
|
||||
# make an invitation
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# now send the invite
|
||||
model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
notification_type = "INVITE"
|
||||
model.objects.create(
|
||||
user=self.user,
|
||||
related_user=self.group.user,
|
||||
related_group=self.group,
|
||||
notification_type=notification_type,
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def accept(self):
|
||||
"""turn this request into the real deal"""
|
||||
GroupMember.from_request(self)
|
||||
|
||||
model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
# tell the group owner
|
||||
model.objects.create(
|
||||
user=self.group.user,
|
||||
related_user=self.user,
|
||||
related_group=self.group,
|
||||
notification_type="ACCEPT",
|
||||
)
|
||||
|
||||
# let the other members know about it
|
||||
for membership in self.group.memberships.all():
|
||||
member = membership.user
|
||||
if member not in (self.user, self.group.user):
|
||||
model.objects.create(
|
||||
user=member,
|
||||
related_user=self.user,
|
||||
related_group=self.group,
|
||||
notification_type="JOIN",
|
||||
)
|
||||
|
||||
def reject(self):
|
||||
"""generate a Reject for this membership request"""
|
||||
|
||||
self.delete()
|
|
@ -6,20 +6,14 @@ from django.db import models
|
|||
from django.utils import timezone
|
||||
|
||||
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
|
||||
|
||||
|
||||
# Mapping goodreads -> bookwyrm shelf titles.
|
||||
GOODREADS_SHELVES = {
|
||||
"read": "read",
|
||||
"currently-reading": "reading",
|
||||
"to-read": "to-read",
|
||||
}
|
||||
|
||||
|
||||
def unquote_string(text):
|
||||
"""resolve csv quote weirdness"""
|
||||
if not text:
|
||||
return None
|
||||
match = re.match(r'="([^"]*)"', text)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
@ -31,7 +25,7 @@ def construct_search_term(title, author):
|
|||
# Strip brackets (usually series title from search term)
|
||||
title = re.sub(r"\s*\([^)]*\)\s*", "", title)
|
||||
# Open library doesn't like including author initials in search term.
|
||||
author = re.sub(r"(\w\.)+\s*", "", author)
|
||||
author = re.sub(r"(\w\.)+\s*", "", author) if author else ""
|
||||
|
||||
return " ".join([title, author])
|
||||
|
||||
|
@ -41,14 +35,21 @@ class ImportJob(models.Model):
|
|||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
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)
|
||||
mappings = models.JSONField()
|
||||
complete = models.BooleanField(default=False)
|
||||
source = models.CharField(max_length=100)
|
||||
privacy = models.CharField(
|
||||
max_length=255, default="public", choices=PrivacyLevels.choices
|
||||
)
|
||||
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):
|
||||
"""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")
|
||||
index = models.IntegerField()
|
||||
data = models.JSONField()
|
||||
normalized_data = models.JSONField()
|
||||
book = models.ForeignKey(Book, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
book_guess = models.ForeignKey(
|
||||
Book,
|
||||
|
@ -65,11 +67,30 @@ class ImportItem(models.Model):
|
|||
related_name="book_guess",
|
||||
)
|
||||
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):
|
||||
"""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:
|
||||
self.book = self.get_book_from_isbn()
|
||||
self.book = self.get_book_from_identifier()
|
||||
elif self.openlibrary_key:
|
||||
self.book = self.get_book_from_identifier(field="openlibrary_key")
|
||||
else:
|
||||
# don't fall back on title/author search if isbn is present.
|
||||
# you're too likely to mismatch
|
||||
|
@ -79,23 +100,31 @@ class ImportItem(models.Model):
|
|||
else:
|
||||
self.book_guess = book
|
||||
|
||||
def get_book_from_isbn(self):
|
||||
"""search by isbn"""
|
||||
def get_book_from_identifier(self, field="isbn"):
|
||||
"""search by isbn or other unique identifier"""
|
||||
search_result = connector_manager.first_search_result(
|
||||
self.isbn, min_confidence=0.999
|
||||
getattr(self, field), min_confidence=0.999
|
||||
)
|
||||
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
|
||||
return search_result.connector.get_or_create_book(search_result.key)
|
||||
return None
|
||||
|
||||
def get_book_from_title_author(self):
|
||||
"""search by title and author"""
|
||||
if not self.title:
|
||||
return None, 0
|
||||
search_term = construct_search_term(self.title, self.author)
|
||||
search_result = connector_manager.first_search_result(
|
||||
search_term, min_confidence=0.1
|
||||
)
|
||||
if search_result:
|
||||
if isinstance(search_result, Edition):
|
||||
return (search_result, 1)
|
||||
# raises ConnectorException
|
||||
return (
|
||||
search_result.connector.get_or_create_book(search_result.key),
|
||||
|
@ -106,56 +135,69 @@ class ImportItem(models.Model):
|
|||
@property
|
||||
def title(self):
|
||||
"""get the book title"""
|
||||
return self.data["Title"]
|
||||
return self.normalized_data.get("title")
|
||||
|
||||
@property
|
||||
def author(self):
|
||||
"""get the book title"""
|
||||
return self.data["Author"]
|
||||
"""get the book's authors"""
|
||||
return self.normalized_data.get("authors")
|
||||
|
||||
@property
|
||||
def isbn(self):
|
||||
"""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
|
||||
def openlibrary_key(self):
|
||||
"""the edition identifier is preferable to the work key"""
|
||||
return self.normalized_data.get("openlibrary_key") or self.normalized_data.get(
|
||||
"openlibrary_work_key"
|
||||
)
|
||||
|
||||
@property
|
||||
def shelf(self):
|
||||
"""the goodreads shelf field"""
|
||||
if self.data["Exclusive Shelf"]:
|
||||
return GOODREADS_SHELVES.get(self.data["Exclusive Shelf"])
|
||||
return None
|
||||
return self.normalized_data.get("shelf")
|
||||
|
||||
@property
|
||||
def review(self):
|
||||
"""a user-written review, to be imported with the book data"""
|
||||
return self.data["My Review"]
|
||||
return self.normalized_data.get("review_body")
|
||||
|
||||
@property
|
||||
def rating(self):
|
||||
"""x/5 star rating for a book"""
|
||||
if self.data.get("My Rating", None):
|
||||
return int(self.data["My Rating"])
|
||||
if self.normalized_data.get("rating"):
|
||||
return float(self.normalized_data.get("rating"))
|
||||
return None
|
||||
|
||||
@property
|
||||
def date_added(self):
|
||||
"""when the book was added to this dataset"""
|
||||
if self.data["Date Added"]:
|
||||
return timezone.make_aware(dateutil.parser.parse(self.data["Date Added"]))
|
||||
if self.normalized_data.get("date_added"):
|
||||
return timezone.make_aware(
|
||||
dateutil.parser.parse(self.normalized_data.get("date_added"))
|
||||
)
|
||||
return None
|
||||
|
||||
@property
|
||||
def date_started(self):
|
||||
"""when the book was started"""
|
||||
if "Date Started" in self.data and self.data["Date Started"]:
|
||||
return timezone.make_aware(dateutil.parser.parse(self.data["Date Started"]))
|
||||
if self.normalized_data.get("date_started"):
|
||||
return timezone.make_aware(
|
||||
dateutil.parser.parse(self.normalized_data.get("date_started"))
|
||||
)
|
||||
return None
|
||||
|
||||
@property
|
||||
def date_read(self):
|
||||
"""the date a book was completed"""
|
||||
if self.data["Date Read"]:
|
||||
return timezone.make_aware(dateutil.parser.parse(self.data["Date Read"]))
|
||||
if self.normalized_data.get("date_finished"):
|
||||
return timezone.make_aware(
|
||||
dateutil.parser.parse(self.normalized_data.get("date_finished"))
|
||||
)
|
||||
return None
|
||||
|
||||
@property
|
||||
|
@ -174,7 +216,9 @@ class ImportItem(models.Model):
|
|||
if start_date and start_date is not None and not self.date_read:
|
||||
return [ReadThrough(start_date=start_date)]
|
||||
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 [
|
||||
ReadThrough(
|
||||
start_date=start_date,
|
||||
|
@ -185,8 +229,10 @@ class ImportItem(models.Model):
|
|||
|
||||
def __repr__(self):
|
||||
# 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):
|
||||
# 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")
|
||||
)
|
||||
|
|
|
@ -1,22 +1,22 @@
|
|||
""" make a list of books!! """
|
||||
import uuid
|
||||
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm.settings import DOMAIN
|
||||
|
||||
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
|
||||
from .base_model import BookWyrmModel
|
||||
from .group import GroupMember
|
||||
from . import fields
|
||||
|
||||
|
||||
CurationType = models.TextChoices(
|
||||
"Curation",
|
||||
[
|
||||
"closed",
|
||||
"open",
|
||||
"curated",
|
||||
],
|
||||
["closed", "open", "curated", "group"],
|
||||
)
|
||||
|
||||
|
||||
|
@ -32,12 +32,20 @@ class List(OrderedCollectionMixin, BookWyrmModel):
|
|||
curation = fields.CharField(
|
||||
max_length=255, default="closed", choices=CurationType.choices
|
||||
)
|
||||
group = models.ForeignKey(
|
||||
"Group",
|
||||
on_delete=models.SET_NULL,
|
||||
default=None,
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
books = models.ManyToManyField(
|
||||
"Edition",
|
||||
symmetrical=False,
|
||||
through="ListItem",
|
||||
through_fields=("book_list", "book"),
|
||||
)
|
||||
embed_key = models.UUIDField(unique=True, null=True, editable=False)
|
||||
activity_serializer = activitypub.BookList
|
||||
|
||||
def get_remote_id(self):
|
||||
|
@ -54,6 +62,58 @@ class List(OrderedCollectionMixin, BookWyrmModel):
|
|||
|
||||
ordering = ("-updated_date",)
|
||||
|
||||
def raise_not_editable(self, viewer):
|
||||
"""the associated user OR the list owner can edit"""
|
||||
if self.user == viewer:
|
||||
return
|
||||
# group members can edit items in group lists
|
||||
is_group_member = GroupMember.objects.filter(
|
||||
group=self.group, user=viewer
|
||||
).exists()
|
||||
if is_group_member:
|
||||
return
|
||||
super().raise_not_editable(viewer)
|
||||
|
||||
@classmethod
|
||||
def followers_filter(cls, queryset, viewer):
|
||||
"""Override filter for "followers" privacy level to allow non-following
|
||||
group members to see the existence of group lists"""
|
||||
|
||||
return queryset.exclude(
|
||||
~Q( # user isn't following or group member
|
||||
Q(user__followers=viewer)
|
||||
| Q(user=viewer)
|
||||
| Q(group__memberships__user=viewer)
|
||||
),
|
||||
privacy="followers", # and the status (of the list) is followers only
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def direct_filter(cls, queryset, viewer):
|
||||
"""Override filter for "direct" privacy level to allow
|
||||
group members to see the existence of group lists"""
|
||||
|
||||
return queryset.exclude(
|
||||
~Q( # user not self and not in the group if this is a group list
|
||||
Q(user=viewer) | Q(group__memberships__user=viewer)
|
||||
),
|
||||
privacy="direct",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def remove_from_group(cls, owner, user):
|
||||
"""remove a list from a group"""
|
||||
|
||||
cls.objects.filter(group__user=owner, user=user).all().update(
|
||||
group=None, curation="closed"
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""on save, update embed_key and avoid clash with existing code"""
|
||||
if not self.embed_key:
|
||||
self.embed_key = uuid.uuid4()
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class ListItem(CollectionItemMixin, BookWyrmModel):
|
||||
"""ok"""
|
||||
|
@ -82,9 +142,9 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
|
|||
self.book_list.save(broadcast=False)
|
||||
|
||||
list_owner = self.book_list.user
|
||||
model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
# create a notification if somoene ELSE added to a local user's list
|
||||
if created and list_owner.local and list_owner != self.user:
|
||||
model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
model.objects.create(
|
||||
user=list_owner,
|
||||
related_user=self.user,
|
||||
|
@ -92,10 +152,26 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
|
|||
notification_type="ADD",
|
||||
)
|
||||
|
||||
if self.book_list.group:
|
||||
for membership in self.book_list.group.memberships.all():
|
||||
if membership.user != self.user:
|
||||
model.objects.create(
|
||||
user=membership.user,
|
||||
related_user=self.user,
|
||||
related_list_item=self,
|
||||
notification_type="ADD",
|
||||
)
|
||||
|
||||
def raise_not_deletable(self, viewer):
|
||||
"""the associated user OR the list owner can delete"""
|
||||
if self.book_list.user == viewer:
|
||||
return
|
||||
# group members can delete items in group lists
|
||||
is_group_member = GroupMember.objects.filter(
|
||||
group=self.book_list.group, user=viewer
|
||||
).exists()
|
||||
if is_group_member:
|
||||
return
|
||||
super().raise_not_deletable(viewer)
|
||||
|
||||
class Meta:
|
||||
|
|
|
@ -4,10 +4,10 @@ from django.dispatch import receiver
|
|||
from .base_model import BookWyrmModel
|
||||
from . import Boost, Favorite, ImportJob, Report, Status, User
|
||||
|
||||
|
||||
# pylint: disable=line-too-long
|
||||
NotificationType = models.TextChoices(
|
||||
"NotificationType",
|
||||
"FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD REPORT",
|
||||
"FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD REPORT INVITE ACCEPT JOIN LEAVE REMOVE GROUP_PRIVACY GROUP_NAME GROUP_DESCRIPTION",
|
||||
)
|
||||
|
||||
|
||||
|
@ -19,6 +19,9 @@ class Notification(BookWyrmModel):
|
|||
related_user = models.ForeignKey(
|
||||
"User", on_delete=models.CASCADE, null=True, related_name="related_user"
|
||||
)
|
||||
related_group = models.ForeignKey(
|
||||
"Group", on_delete=models.CASCADE, null=True, related_name="notifications"
|
||||
)
|
||||
related_status = models.ForeignKey("Status", on_delete=models.CASCADE, null=True)
|
||||
related_import = models.ForeignKey("ImportJob", on_delete=models.CASCADE, null=True)
|
||||
related_list_item = models.ForeignKey(
|
||||
|
@ -37,6 +40,7 @@ class Notification(BookWyrmModel):
|
|||
user=self.user,
|
||||
related_book=self.related_book,
|
||||
related_user=self.related_user,
|
||||
related_group=self.related_group,
|
||||
related_status=self.related_status,
|
||||
related_import=self.related_import,
|
||||
related_list_item=self.related_list_item,
|
||||
|
@ -153,9 +157,12 @@ def notify_user_on_unboost(sender, instance, *args, **kwargs):
|
|||
|
||||
@receiver(models.signals.post_save, sender=ImportJob)
|
||||
# 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"""
|
||||
if not instance.complete:
|
||||
update_fields = update_fields or []
|
||||
if not instance.complete or "complete" not in update_fields:
|
||||
return
|
||||
Notification.objects.create(
|
||||
user=instance.user,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
""" the particulars for this instance of BookWyrm """
|
||||
import datetime
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from django.db import models, IntegrityError
|
||||
from django.dispatch import receiver
|
||||
|
@ -7,9 +8,10 @@ from django.utils import timezone
|
|||
from model_utils import FieldTracker
|
||||
|
||||
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 .user import User
|
||||
from .fields import get_absolute_url
|
||||
|
||||
|
||||
class SiteSettings(models.Model):
|
||||
|
@ -66,6 +68,28 @@ class SiteSettings(models.Model):
|
|||
default_settings.save()
|
||||
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):
|
||||
"""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 OrderedCollectionPageMixin
|
||||
from .base_model import BookWyrmModel
|
||||
from .fields import image_serializer
|
||||
from .readthrough import ProgressMode
|
||||
from . import fields
|
||||
|
||||
|
@ -31,6 +30,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
"User", on_delete=models.PROTECT, activitypub_field="attributedTo"
|
||||
)
|
||||
content = fields.HtmlField(blank=True, null=True)
|
||||
raw_content = models.TextField(blank=True, null=True)
|
||||
mention_users = fields.TagField("User", related_name="mention_user")
|
||||
mention_books = fields.TagField("Edition", related_name="mention_book")
|
||||
local = models.BooleanField(default=True)
|
||||
|
@ -43,6 +43,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
published_date = fields.DateTimeField(
|
||||
default=timezone.now, activitypub_field="published"
|
||||
)
|
||||
edited_date = fields.DateTimeField(
|
||||
blank=True, null=True, activitypub_field="updated"
|
||||
)
|
||||
deleted = models.BooleanField(default=False)
|
||||
deleted_date = models.DateTimeField(blank=True, null=True)
|
||||
favorites = models.ManyToManyField(
|
||||
|
@ -186,15 +189,26 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
if hasattr(activity, "name"):
|
||||
activity.name = self.pure_name
|
||||
activity.type = self.pure_type
|
||||
activity.attachment = [
|
||||
image_serializer(b.cover, b.alt_text)
|
||||
for b in self.mention_books.all()[:4]
|
||||
if b.cover
|
||||
]
|
||||
if hasattr(self, "book") and self.book.cover:
|
||||
activity.attachment.append(
|
||||
image_serializer(self.book.cover, self.book.alt_text)
|
||||
)
|
||||
book = getattr(self, "book", None)
|
||||
books = [book] if book else []
|
||||
books += list(self.mention_books.all())
|
||||
if len(books) == 1 and getattr(books[0], "preview_image", None):
|
||||
covers = [
|
||||
activitypub.Document(
|
||||
url=fields.get_absolute_url(books[0].preview_image),
|
||||
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
|
||||
|
||||
def to_activity(self, pure=False): # pylint: disable=arguments-differ
|
||||
|
@ -220,6 +234,16 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
~Q(Q(user=viewer) | Q(mention_users=viewer)), privacy="direct"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def followers_filter(cls, queryset, viewer):
|
||||
"""Override-able filter for "followers" privacy level"""
|
||||
return queryset.exclude(
|
||||
~Q( # not yourself, a follower, or someone who is tagged
|
||||
Q(user__followers=viewer) | Q(user=viewer) | Q(mention_users=viewer)
|
||||
),
|
||||
privacy="followers", # and the status is followers only
|
||||
)
|
||||
|
||||
|
||||
class GeneratedNote(Status):
|
||||
"""these are app-generated messages about user activity"""
|
||||
|
@ -292,6 +316,7 @@ class Quotation(BookStatus):
|
|||
"""like a review but without a rating and transient"""
|
||||
|
||||
quote = fields.HtmlField()
|
||||
raw_quote = models.TextField(blank=True, null=True)
|
||||
position = models.IntegerField(
|
||||
validators=[MinValueValidator(0)], null=True, blank=True
|
||||
)
|
||||
|
|
|
@ -4,11 +4,12 @@ from urllib.parse import urlparse
|
|||
|
||||
from django.apps import apps
|
||||
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.dispatch import receiver
|
||||
from django.db import models, transaction
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from model_utils import FieldTracker
|
||||
import pytz
|
||||
|
||||
|
@ -27,6 +28,19 @@ from .federated_server import FederatedServer
|
|||
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():
|
||||
"""helper for generating links to the site"""
|
||||
protocol = "https" if USE_HTTPS else "http"
|
||||
|
@ -128,6 +142,13 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
show_suggested_users = models.BooleanField(default=True)
|
||||
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(
|
||||
choices=[(str(tz), str(tz)) for tz in pytz.all_timezones],
|
||||
default=str(pytz.utc),
|
||||
|
|
|
@ -49,6 +49,28 @@ def get_font(font_name, size=28):
|
|||
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):
|
||||
"""Adds text for images"""
|
||||
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"]:
|
||||
# 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(
|
||||
(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"]:
|
||||
# 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(
|
||||
(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"]:
|
||||
# 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(
|
||||
(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"]:
|
||||
# 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(
|
||||
(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"""
|
||||
if not isinstance(instance, (models.Book, models.User, models.SiteSettings)):
|
||||
return False
|
||||
uuid = uuid4()
|
||||
file_name = f"{instance.id}-{uuid}.jpg"
|
||||
image_buffer = BytesIO()
|
||||
|
||||
try:
|
||||
try:
|
||||
old_path = instance.preview_image.name
|
||||
file_name = instance.preview_image.name
|
||||
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
|
||||
image.save(image_buffer, format="jpeg", quality=75)
|
||||
|
@ -345,10 +379,6 @@ def save_and_cleanup(image, instance=None):
|
|||
else:
|
||||
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:
|
||||
image_buffer.close()
|
||||
return True
|
||||
|
|
|
@ -7,13 +7,14 @@ from django.utils.translation import gettext_lazy as _
|
|||
|
||||
|
||||
env = Env()
|
||||
env.read_env()
|
||||
DOMAIN = env("DOMAIN")
|
||||
VERSION = "0.0.1"
|
||||
VERSION = "0.1.0"
|
||||
|
||||
PAGE_LENGTH = env("PAGE_LENGTH", 15)
|
||||
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
|
||||
|
||||
JS_CACHE = "c02929b1"
|
||||
JS_CACHE = "3891b373"
|
||||
|
||||
# email
|
||||
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
|
||||
|
@ -162,11 +163,14 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||
LANGUAGE_CODE = "en-us"
|
||||
LANGUAGES = [
|
||||
("en-us", _("English")),
|
||||
("de-de", _("Deutsch (German)")), # German
|
||||
("es", _("Español (Spanish)")), # Spanish
|
||||
("fr-fr", _("Français (French)")), # French
|
||||
("zh-hans", _("简体中文 (Simplified Chinese)")), # Simplified Chinese
|
||||
("zh-hant", _("繁體中文 (Traditional Chinese)")), # Traditional Chinese
|
||||
("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)")),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -20,6 +20,10 @@ body {
|
|||
overflow: visible;
|
||||
}
|
||||
|
||||
.card.has-border {
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
|
||||
.scroll-x {
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
|
@ -115,6 +119,34 @@ input[type=file]::file-selector-button:hover {
|
|||
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
|
||||
******************************************************************************/
|
||||
|
||||
|
@ -509,6 +541,20 @@ ol.ordered-list li::before {
|
|||
border-left: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
/* Breadcrumbs
|
||||
******************************************************************************/
|
||||
|
||||
.breadcrumb li:first-child * {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.breadcrumb li > * {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0 0.75em;
|
||||
}
|
||||
|
||||
/* Dimensions
|
||||
* @todo These could be in rem.
|
||||
******************************************************************************/
|
||||
|
|
Binary file not shown.
|
@ -46,4 +46,5 @@
|
|||
<glyph unicode="" glyph-name="star-full" d="M1024 562.95l-353.78 51.408-158.22 320.582-158.216-320.582-353.784-51.408 256-249.538-60.432-352.352 316.432 166.358 316.432-166.358-60.434 352.352 256.002 249.538z" />
|
||||
<glyph unicode="" glyph-name="heart" d="M755.188 896c-107.63 0-200.258-87.554-243.164-179-42.938 91.444-135.578 179-243.216 179-148.382 0-268.808-120.44-268.808-268.832 0-301.846 304.5-380.994 512.022-679.418 196.154 296.576 511.978 387.206 511.978 679.418 0 148.392-120.43 268.832-268.812 268.832z" />
|
||||
<glyph unicode="" glyph-name="plus" d="M992 576h-352v352c0 17.672-14.328 32-32 32h-192c-17.672 0-32-14.328-32-32v-352h-352c-17.672 0-32-14.328-32-32v-192c0-17.672 14.328-32 32-32h352v-352c0-17.672 14.328-32 32-32h192c17.672 0 32 14.328 32 32v352h352c17.672 0 32 14.328 32 32v192c0 17.672-14.328 32-32 32z" />
|
||||
<glyph unicode="" glyph-name="download" d="M512-32l480 480h-288v512h-384v-512h-288z" />
|
||||
</font></defs></svg>
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
Binary file not shown.
Binary file not shown.
13
bookwyrm/static/css/vendor/icons.css
vendored
13
bookwyrm/static/css/vendor/icons.css
vendored
|
@ -1,10 +1,10 @@
|
|||
@font-face {
|
||||
font-family: 'icomoon';
|
||||
src: url('../fonts/icomoon.eot?36x4a3');
|
||||
src: url('../fonts/icomoon.eot?36x4a3#iefix') format('embedded-opentype'),
|
||||
url('../fonts/icomoon.ttf?36x4a3') format('truetype'),
|
||||
url('../fonts/icomoon.woff?36x4a3') format('woff'),
|
||||
url('../fonts/icomoon.svg?36x4a3#icomoon') format('svg');
|
||||
src: url('../fonts/icomoon.eot?r7jc98');
|
||||
src: url('../fonts/icomoon.eot?r7jc98#iefix') format('embedded-opentype'),
|
||||
url('../fonts/icomoon.ttf?r7jc98') format('truetype'),
|
||||
url('../fonts/icomoon.woff?r7jc98') format('woff'),
|
||||
url('../fonts/icomoon.svg?r7jc98#icomoon') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
|
@ -142,3 +142,6 @@
|
|||
.icon-spinner:before {
|
||||
content: "\e97a";
|
||||
}
|
||||
.icon-download:before {
|
||||
content: "\ea36";
|
||||
}
|
||||
|
|
|
@ -28,6 +28,12 @@ let BookWyrm = new class {
|
|||
this.revealForm.bind(this))
|
||||
);
|
||||
|
||||
document.querySelectorAll('[data-hides]')
|
||||
.forEach(button => button.addEventListener(
|
||||
'change',
|
||||
this.hideForm.bind(this))
|
||||
);
|
||||
|
||||
document.querySelectorAll('[data-back]')
|
||||
.forEach(button => button.addEventListener(
|
||||
'click',
|
||||
|
@ -39,6 +45,13 @@ let BookWyrm = new class {
|
|||
'change',
|
||||
this.disableIfTooLarge.bind(this)
|
||||
));
|
||||
|
||||
document.querySelectorAll('[data-duplicate]')
|
||||
.forEach(node => node.addEventListener(
|
||||
'click',
|
||||
this.duplicateInput.bind(this)
|
||||
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -53,6 +66,9 @@ let BookWyrm = new class {
|
|||
document.querySelectorAll('input[type="file"]').forEach(
|
||||
bookwyrm.disableIfTooLarge.bind(bookwyrm)
|
||||
);
|
||||
document.querySelectorAll('[data-copytext]').forEach(
|
||||
bookwyrm.copyText.bind(bookwyrm)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -107,9 +123,44 @@ let BookWyrm = new class {
|
|||
* @return {undefined}
|
||||
*/
|
||||
updateCountElement(counter, data) {
|
||||
let count = data.count;
|
||||
const count_by_type = data.count_by_type;
|
||||
const currentCount = counter.innerText;
|
||||
const count = data.count;
|
||||
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) {
|
||||
this.addRemoveClass(counter.closest('[data-poll-wrapper]'), 'is-hidden', count < 1);
|
||||
|
@ -119,8 +170,8 @@ let BookWyrm = new class {
|
|||
}
|
||||
|
||||
/**
|
||||
* Toggle form.
|
||||
*
|
||||
* Show form.
|
||||
*
|
||||
* @param {Event} event
|
||||
* @return {undefined}
|
||||
*/
|
||||
|
@ -128,7 +179,23 @@ let BookWyrm = new class {
|
|||
let trigger = event.currentTarget;
|
||||
let hidden = trigger.closest('.hidden-form').querySelectorAll('.is-hidden')[0];
|
||||
|
||||
this.addRemoveClass(hidden, 'is-hidden', !hidden);
|
||||
if (hidden) {
|
||||
this.addRemoveClass(hidden, 'is-hidden', !hidden);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide form.
|
||||
*
|
||||
* @param {Event} event
|
||||
* @return {undefined}
|
||||
*/
|
||||
hideForm(event) {
|
||||
let trigger = event.currentTarget;
|
||||
let targetId = trigger.dataset.hides
|
||||
let visible = document.getElementById(targetId)
|
||||
|
||||
this.addRemoveClass(visible, 'is-hidden', true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -227,7 +294,7 @@ let BookWyrm = new class {
|
|||
}
|
||||
|
||||
/**
|
||||
* Check or uncheck a checbox.
|
||||
* Check or uncheck a checkbox.
|
||||
*
|
||||
* @param {string} checkbox - id of the checkbox
|
||||
* @param {boolean} pressed - Is the trigger pressed?
|
||||
|
@ -346,4 +413,73 @@ let BookWyrm = new class {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display pop up window.
|
||||
*
|
||||
* @param {string} url Url to open
|
||||
* @param {string} windowName windowName
|
||||
* @return {undefined}
|
||||
*/
|
||||
displayPopUp(url, windowName) {
|
||||
window.open(
|
||||
url,
|
||||
windowName,
|
||||
"left=100,top=100,width=430,height=600"
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a "click-to-copy" component from a textarea element
|
||||
* with `data-copytext`, `data-copytext-label`, `data-copytext-success`
|
||||
* attributes.
|
||||
*
|
||||
* @param {object} node - DOM node of the text container
|
||||
* @return {undefined}
|
||||
*/
|
||||
|
||||
copyText(textareaEl) {
|
||||
const text = textareaEl.textContent;
|
||||
|
||||
const copyButtonEl = document.createElement('button');
|
||||
|
||||
copyButtonEl.textContent = textareaEl.dataset.copytextLabel;
|
||||
copyButtonEl.classList.add(
|
||||
"mt-2",
|
||||
"button",
|
||||
"is-small",
|
||||
"is-fullwidth",
|
||||
"is-primary",
|
||||
"is-light"
|
||||
);
|
||||
copyButtonEl.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
textareaEl.classList.add('is-success');
|
||||
copyButtonEl.classList.replace('is-primary', 'is-success');
|
||||
copyButtonEl.textContent = textareaEl.dataset.copytextSuccess;
|
||||
});
|
||||
});
|
||||
|
||||
textareaEl.parentNode.appendChild(copyButtonEl)
|
||||
}
|
||||
}();
|
||||
|
|
|
@ -187,6 +187,7 @@ let StatusCache = new class {
|
|||
.forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", false));
|
||||
|
||||
// Remove existing disabled states
|
||||
|
||||
button.querySelectorAll("[data-shelf-dropdown-identifier] button")
|
||||
.forEach(item => item.disabled = false);
|
||||
|
||||
|
@ -209,10 +210,10 @@ let StatusCache = new class {
|
|||
.forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", true));
|
||||
|
||||
// Close menu
|
||||
let menu = button.querySelector(".dropdown-trigger[aria-expanded=true]");
|
||||
let menu = button.querySelector("details[open]");
|
||||
|
||||
if (menu) {
|
||||
menu.click();
|
||||
menu.removeAttribute("open");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -81,7 +81,7 @@ class SuggestedUsers(RedisStore):
|
|||
"""take a user out of someone's suggestions"""
|
||||
self.bulk_remove_objects_from_store([suggested_user], self.store_id(user))
|
||||
|
||||
def get_suggestions(self, user):
|
||||
def get_suggestions(self, user, local=False):
|
||||
"""get suggestions"""
|
||||
values = self.get_store(self.store_id(user), withscores=True)
|
||||
results = []
|
||||
|
@ -97,8 +97,8 @@ class SuggestedUsers(RedisStore):
|
|||
logger.exception(err)
|
||||
continue
|
||||
user.mutuals = counts["mutuals"]
|
||||
# user.shared_books = counts["shared_books"]
|
||||
results.append(user)
|
||||
if (local and user.local) or not local:
|
||||
results.append(user)
|
||||
if len(results) >= 5:
|
||||
break
|
||||
return results
|
||||
|
|
|
@ -9,3 +9,8 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "celerywyrm.settings")
|
|||
app = Celery(
|
||||
"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 markdown %}
|
||||
{% load humanize %}
|
||||
{% load utilities %}
|
||||
|
||||
{% block title %}{{ author.name }}{% endblock %}
|
||||
|
||||
|
@ -13,7 +14,7 @@
|
|||
</div>
|
||||
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
|
||||
<div class="column is-narrow">
|
||||
<a href="{{ author.local_path }}/edit">
|
||||
<a href="{% url 'edit-author' author.id %}">
|
||||
<span class="icon icon-pencil" title="{% trans 'Edit Author' %}" aria-hidden="True"></span>
|
||||
<span class="is-hidden-mobile">{% trans "Edit Author" %}</span>
|
||||
</a>
|
||||
|
@ -22,96 +23,138 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block columns content" itemscope itemtype="https://schema.org/Person">
|
||||
<div class="block columns is-flex-direction-row-reverse" itemscope itemtype="https://schema.org/Person">
|
||||
<meta itemprop="name" content="{{ author.name }}">
|
||||
{% if author.bio %}
|
||||
<div class="column">
|
||||
{% include "snippets/trimmed_text.html" with full=author.bio trim_length=200 %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if author.aliases or author.born or author.died or author.wikipedia_link or author.openlibrary_key or author.inventaire_id %}
|
||||
{% firstof author.aliases author.born author.died as details %}
|
||||
{% firstof author.wikipedia_link author.openlibrary_key author.inventaire_id author.isni as links %}
|
||||
{% if details or links %}
|
||||
<div class="column is-two-fifths">
|
||||
<div class="box py-2">
|
||||
<dl>
|
||||
{% if details %}
|
||||
<section class="block content">
|
||||
<h2 class="title is-4">{% trans "Author details" %}</h2>
|
||||
<dl class="box">
|
||||
{% if author.aliases %}
|
||||
<div class="is-flex is-flex-wrap-wrap my-1">
|
||||
<div class="is-flex is-flex-wrap-wrap mr-1">
|
||||
<dt class="has-text-weight-bold mr-1">{% trans "Aliases:" %}</dt>
|
||||
{% for alias in author.aliases %}
|
||||
<dd itemprop="alternateName" content="{{alias}}">
|
||||
{{alias}}{% if not forloop.last %}, {% endif %}
|
||||
</dd>
|
||||
{% endfor %}
|
||||
<dd>
|
||||
{% include "snippets/trimmed_list.html" with items=author.aliases itemprop="alternateName" %}
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if author.born %}
|
||||
<div class="is-flex my-1">
|
||||
<div class="is-flex mt-1">
|
||||
<dt class="has-text-weight-bold mr-1">{% trans "Born:" %}</dt>
|
||||
<dd itemprop="birthDate">{{ author.born|naturalday }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if author.died %}
|
||||
<div class="is-flex my-1">
|
||||
<div class="is-flex mt-1">
|
||||
<dt class="has-text-weight-bold mr-1">{% trans "Died:" %}</dt>
|
||||
<dd itemprop="deathDate">{{ author.died|naturalday }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if author.wikipedia_link %}
|
||||
<p class="my-1">
|
||||
<a itemprop="sameAs" href="{{ author.wikipedia_link }}" rel="noopener" target="_blank">
|
||||
{% trans "Wikipedia" %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if links %}
|
||||
<section>
|
||||
<h2 class="title is-4">{% trans "External links" %}</h2>
|
||||
<div class="box">
|
||||
{% if author.wikipedia_link %}
|
||||
<div>
|
||||
<a itemprop="sameAs" href="{{ author.wikipedia_link }}" rel="noopener" target="_blank">
|
||||
{% trans "Wikipedia" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if author.openlibrary_key %}
|
||||
<p class="my-1">
|
||||
<a itemprop="sameAs" href="https://openlibrary.org/authors/{{ author.openlibrary_key }}" target="_blank" rel="noopener">
|
||||
{% trans "View on OpenLibrary" %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if author.isni %}
|
||||
<div class="mt-1">
|
||||
<a itemprop="sameAs" href="{{ author.isni_link }}" rel="noopener" target="_blank">
|
||||
{% trans "View ISNI record" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if author.inventaire_id %}
|
||||
<p class="my-1">
|
||||
<a itemprop="sameAs" href="https://inventaire.io/entity/{{ author.inventaire_id }}" target="_blank" rel="noopener">
|
||||
{% trans "View on Inventaire" %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% trans "Load data" as button_text %}
|
||||
{% if author.openlibrary_key %}
|
||||
<div class="mt-1 is-flex">
|
||||
<a class="mr-3" itemprop="sameAs" href="{{ author.openlibrary_link }}" target="_blank" rel="noopener">
|
||||
{% trans "View on OpenLibrary" %}
|
||||
</a>
|
||||
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
|
||||
{% with controls_text="ol_sync" controls_uid=author.id %}
|
||||
{% include 'snippets/toggle/toggle_button.html' with text=button_text focus="modal_title_ol_sync" class="is-small" icon_with_text="download" %}
|
||||
{% include "author/sync_modal.html" with source="openlibrary.org" source_name="OpenLibrary" %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if author.librarything_key %}
|
||||
<p class="my-1">
|
||||
<a itemprop="sameAs" href="https://www.librarything.com/author/{{ author.librarything_key }}" target="_blank" rel="noopener">
|
||||
{% trans "View on LibraryThing" %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if author.inventaire_id %}
|
||||
<div class="mt-1 is-flex">
|
||||
<a class="mr-3" itemprop="sameAs" href="{{ author.inventaire_link }}" target="_blank" rel="noopener">
|
||||
{% trans "View on Inventaire" %}
|
||||
</a>
|
||||
|
||||
{% if author.goodreads_key %}
|
||||
<p class="my-1">
|
||||
<a itemprop="sameAs" href="https://www.goodreads.com/author/show/{{ author.goodreads_key }}" target="_blank" rel="noopener">
|
||||
{% trans "View on Goodreads" %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="column">
|
||||
{% if author.bio %}
|
||||
{{ author.bio|to_markdown|safe }}
|
||||
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
|
||||
{% with controls_text="iv_sync" controls_uid=author.id %}
|
||||
{% include 'snippets/toggle/toggle_button.html' with text=button_text focus="modal_title_iv_sync" class="is-small" icon_with_text="download" %}
|
||||
{% include "author/sync_modal.html" with source="inventaire.io" source_name="Inventaire" %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if author.librarything_key %}
|
||||
<div class="mt-1">
|
||||
<a itemprop="sameAs" href="https://www.librarything.com/author/{{ author.librarything_key }}" target="_blank" rel="noopener">
|
||||
{% trans "View on LibraryThing" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if author.goodreads_key %}
|
||||
<div>
|
||||
<a itemprop="sameAs" href="https://www.goodreads.com/author/show/{{ author.goodreads_key }}" target="_blank" rel="noopener">
|
||||
{% trans "View on Goodreads" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<hr aria-hidden="true">
|
||||
|
||||
<div class="block">
|
||||
<h3 class="title is-4">{% blocktrans with name=author.name %}Books by {{ name }}{% endblocktrans %}</h3>
|
||||
<h2 class="title is-4">{% blocktrans with name=author.name %}Books by {{ name }}{% endblocktrans %}</h2>
|
||||
<div class="columns is-multiline is-mobile">
|
||||
{% for book in books %}
|
||||
<div class="column is-one-fifth">
|
||||
{% include 'landing/small-book.html' with book=book %}
|
||||
<div class="column is-one-fifth-tablet is-half-mobile is-flex is-flex-direction-column">
|
||||
<div class="is-flex-grow-1">
|
||||
{% include 'landing/small-book.html' with book=book %}
|
||||
</div>
|
||||
{% include 'snippets/shelve_button/shelve_button.html' with book=book %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{% include 'snippets/pagination.html' with page=books %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
@ -12,7 +12,9 @@
|
|||
<div>
|
||||
<p>{% trans "Added:" %} {{ author.created_date | naturaltime }}</p>
|
||||
<p>{% trans "Updated:" %} {{ author.updated_date | naturaltime }}</p>
|
||||
{% if author.last_edited_by %}
|
||||
<p>{% trans "Last edited by:" %} <a href="{{ author.last_edited_by.remote_id }}">{{ author.last_edited_by.display_name }}</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
@ -32,47 +34,41 @@
|
|||
<div class="field">
|
||||
<label class="label" for="id_name">{% trans "Name:" %}</label>
|
||||
{{ form.name }}
|
||||
{% for error in form.name.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.name.errors id="desc_name" %}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="id_aliases">{% trans "Aliases:" %}</label>
|
||||
{{ form.aliases }}
|
||||
<span class="help">{% trans "Separate multiple values with commas." %}</span>
|
||||
{% for error in form.aliases.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.aliases.errors id="desc_aliases" %}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="id_bio">{% trans "Bio:" %}</label>
|
||||
{{ form.bio }}
|
||||
{% for error in form.bio.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.bio.errors id="desc_bio" %}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.wikipedia_link.errors id="desc_wikipedia_link" %}
|
||||
|
||||
<div class="field">
|
||||
<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">
|
||||
{% for error in form.born.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.born.errors id="desc_born" %}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<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">
|
||||
{% for error in form.died.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.died.errors id="desc_died" %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
|
@ -80,33 +76,36 @@
|
|||
<div class="field">
|
||||
<label class="label" for="id_openlibrary_key">{% trans "Openlibrary key:" %}</label>
|
||||
{{ form.openlibrary_key }}
|
||||
{% for error in form.openlibrary_key.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.oepnlibrary_key.errors id="desc_oepnlibrary_key" %}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="id_inventaire_id">{% trans "Inventaire ID:" %}</label>
|
||||
{{ form.inventaire_id }}
|
||||
{% for error in form.inventaire_id.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.inventaire_id.errors id="desc_inventaire_id" %}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="id_librarything_key">{% trans "Librarything key:" %}</label>
|
||||
{{ form.librarything_key }}
|
||||
{% for error in form.librarything_key.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.librarything_key.errors id="desc_librarything_key" %}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="id_goodreads_key">{% trans "Goodreads key:" %}</label>
|
||||
{{ form.goodreads_key }}
|
||||
{% for error in form.goodreads_key.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.goodreads_key.errors id="desc_goodreads_key" %}
|
||||
</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>
|
||||
|
|
30
bookwyrm/templates/author/sync_modal.html
Normal file
30
bookwyrm/templates/author/sync_modal.html
Normal file
|
@ -0,0 +1,30 @@
|
|||
{% extends 'components/modal.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block modal-title %}
|
||||
{% trans "Load data" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-form-open %}
|
||||
<form name="{{ source }}-update" method="POST" action="{% url 'author-update-remote' author.id source %}">
|
||||
{% csrf_token %}
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-body %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
Loading data will connect to <strong>{{ source_name }}</strong> and check for any metadata about this author which aren't present here. Existing metadata will not be overwritten.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-footer %}
|
||||
<button class="button is-primary" type="submit">
|
||||
<span>{% trans "Confirm" %}</span>
|
||||
</button>
|
||||
|
||||
{% trans "Cancel" as button_text %}
|
||||
{% include 'snippets/toggle/toggle_button.html' with text=button_text %}
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-form-close %}</form>{% endblock %}
|
|
@ -90,11 +90,28 @@
|
|||
</div>
|
||||
{% endwith %}
|
||||
|
||||
{% trans "Load data" as button_text %}
|
||||
{% if book.openlibrary_key %}
|
||||
<p><a href="https://openlibrary.org/books/{{ book.openlibrary_key }}" target="_blank" rel="noopener">{% trans "View on OpenLibrary" %}</a></p>
|
||||
<p>
|
||||
<a href="{{ book.openlibrary_link }}" target="_blank" rel="noopener">{% trans "View on OpenLibrary" %}</a>
|
||||
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
|
||||
{% with controls_text="ol_sync" controls_uid=book.id %}
|
||||
{% include 'snippets/toggle/toggle_button.html' with text=button_text focus="modal_title_ol_sync" class="is-small" icon_with_text="download" %}
|
||||
{% include "book/sync_modal.html" with source="openlibrary.org" source_name="OpenLibrary" %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if book.inventaire_id %}
|
||||
<p><a href="https://inventaire.io/entity/{{ book.inventaire_id }}" target="_blank" rel="noopener">{% trans "View on Inventaire" %}</a></p>
|
||||
<p>
|
||||
<a href="{{ book.inventaire_link }}" target="_blank" rel="noopener">{% trans "View on Inventaire" %}</a>
|
||||
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
|
||||
{% with controls_text="iv_sync" controls_uid=book.id %}
|
||||
{% include 'snippets/toggle/toggle_button.html' with text=button_text focus="modal_title_iv_sync" class="is-small" icon_with_text="download" %}
|
||||
{% include "book/sync_modal.html" with source="inventaire.io" source_name="Inventaire" %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
@ -153,12 +170,21 @@
|
|||
|
||||
{# user's relationship to the book #}
|
||||
<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 %}
|
||||
<p>
|
||||
{% 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 %}
|
||||
{% include 'snippets/shelf_selector.html' with current=shelf.shelf %}
|
||||
</p>
|
||||
<li class="box">
|
||||
<a href="{{ shelf.shelf.local_path }}">{{ shelf.shelf.name }}</a>
|
||||
<div class="mb-3">
|
||||
{% include 'snippets/shelf_selector.html' with shelf=shelf.shelf class="is-small" readthrough=readthrough %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% for shelf in other_edition_shelves %}
|
||||
<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 %}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{% spaceless %}
|
||||
{% load i18n %}
|
||||
|
||||
{% if book.isbn13 or book.oclc_number or book.asin %}
|
||||
{% if book.isbn_13 or book.oclc_number or book.asin %}
|
||||
<dl>
|
||||
{% if book.isbn_13 %}
|
||||
<div class="is-flex">
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load humanize %}
|
||||
{% load utilities %}
|
||||
|
||||
{% 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 %}
|
||||
<fieldset>
|
||||
<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>
|
||||
{% with forloop.counter0 as counter %}
|
||||
{% for match in author.matches %}
|
||||
<label class="label mb-2">
|
||||
<label class="label">
|
||||
<input type="radio" name="author_match-{{ counter }}" value="{{ match.id }}" required>
|
||||
{{ match.name }}
|
||||
</label>
|
||||
<p class="help">
|
||||
<a href="{{ match.local_path }}" target="_blank">{% blocktrans with book_title=match.book_set.first.title %}Author of <em>{{ book_title }}</em>{% endblocktrans %}</a>
|
||||
<p class="help ml-5 mb-2">
|
||||
{% 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 class="help ml-5">
|
||||
{{ author.existing_isnis|get_isni_bio:match }}
|
||||
</p>
|
||||
{{ author.existing_isnis|get_isni:match }}
|
||||
{% 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" %}
|
||||
</label>
|
||||
{% endwith %}
|
||||
|
@ -108,7 +119,13 @@
|
|||
{% if not confirm_mode %}
|
||||
<div class="block">
|
||||
<button class="button is-primary" type="submit">{% trans "Save" %}</button>
|
||||
{% if book %}
|
||||
<a class="button" href="{{ book.local_path }}">{% trans "Cancel" %}</a>
|
||||
{% else %}
|
||||
<a href="/" class="button" data-back>
|
||||
<span>{% trans "Cancel" %}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
|
|
@ -12,106 +12,125 @@
|
|||
<div class="columns">
|
||||
<div class="column is-half">
|
||||
<section class="block">
|
||||
<h2 class="title is-4">{% trans "Metadata" %}</h2>
|
||||
<h2 class="title is-4">
|
||||
{% trans "Metadata" %}
|
||||
</h2>
|
||||
<div class="box">
|
||||
<div class="field">
|
||||
<label class="label" for="id_title">{% trans "Title:" %}</label>
|
||||
<input type="text" name="title" value="{{ form.title.value|default:'' }}" maxlength="255" class="input" required="" id="id_title">
|
||||
{% for error in form.title.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
<label class="label" for="id_title">
|
||||
{% trans "Title:" %}
|
||||
</label>
|
||||
<input type="text" name="title" value="{{ form.title.value|default:'' }}" maxlength="255" class="input" required="" id="id_title" aria-describedby="desc_title">
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.title.errors id="desc_title" %}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="id_subtitle">{% trans "Subtitle:" %}</label>
|
||||
<input type="text" name="subtitle" value="{{ form.subtitle.value|default:'' }}" maxlength="255" class="input" id="id_subtitle">
|
||||
{% for error in form.subtitle.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
<label class="label" for="id_subtitle">
|
||||
{% trans "Subtitle:" %}
|
||||
</label>
|
||||
<input type="text" name="subtitle" value="{{ form.subtitle.value|default:'' }}" maxlength="255" class="input" id="id_subtitle" aria-describedby="desc_subtitle">
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.subtitle.errors id="desc_subtitle" %}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="id_description">{% trans "Description:" %}</label>
|
||||
<label class="label" for="id_description">
|
||||
{% trans "Description:" %}
|
||||
</label>
|
||||
{{ form.description }}
|
||||
{% for error in form.description.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.description.errors id="desc_description" %}
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-two-thirds">
|
||||
<div class="field">
|
||||
<label class="label" for="id_series">{% trans "Series:" %}</label>
|
||||
<input type="text" class="input" name="series" id="id_series" value="{{ form.series.value|default:'' }}">
|
||||
{% for error in form.series.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
<label class="label" for="id_series">
|
||||
{% trans "Series:" %}
|
||||
</label>
|
||||
<input type="text" class="input" name="series" id="id_series" value="{{ form.series.value|default:'' }}" aria-describedby="desc_series">
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.series.errors id="desc_series" %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-one-third">
|
||||
<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 }}
|
||||
{% for error in form.series_number.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.series_number.errors id="desc_series_number" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="id_languages">{% trans "Languages:" %}</label>
|
||||
<label class="label" for="id_languages">
|
||||
{% trans "Languages:" %}
|
||||
</label>
|
||||
{{ form.languages }}
|
||||
<span class="help">{% trans "Separate multiple values with commas." %}</span>
|
||||
{% for error in form.languages.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
<span class="help" id="desc_languages_help">
|
||||
{% trans "Separate multiple values with commas." %}
|
||||
</span>
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.languages.errors id="desc_languages" %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2 class="title is-4">{% trans "Publication" %}</h2>
|
||||
<h2 class="title is-4">
|
||||
{% trans "Publication" %}
|
||||
</h2>
|
||||
<div class="box">
|
||||
<div class="field">
|
||||
<label class="label" for="id_publishers">{% trans "Publisher:" %}</label>
|
||||
<label class="label" for="id_publishers">
|
||||
{% trans "Publisher:" %}
|
||||
</label>
|
||||
{{ form.publishers }}
|
||||
<span class="help">{% trans "Separate multiple values with commas." %}</span>
|
||||
{% for error in form.publishers.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
<span class="help" id="desc_publishers_help">
|
||||
{% trans "Separate multiple values with commas." %}
|
||||
</span>
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.publishers.errors id="desc_publishers" %}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="id_first_published_date">{% trans "First published date:" %}</label>
|
||||
<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 %}>
|
||||
{% for error in form.first_published_date.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
<label class="label" for="id_first_published_date">
|
||||
{% trans "First published date:" %}
|
||||
</label>
|
||||
<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">
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.first_published_date.errors id="desc_first_published_date" %}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="id_published_date">{% trans "Published date:" %}</label>
|
||||
<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 %}>
|
||||
{% for error in form.published_date.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
<label class="label" for="id_published_date">
|
||||
{% trans "Published date:" %}
|
||||
</label>
|
||||
<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">
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.published_date.errors id="desc_published_date" %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2 class="title is-4">{% trans "Authors" %}</h2>
|
||||
<h2 class="title is-4">
|
||||
{% trans "Authors" %}
|
||||
</h2>
|
||||
<div class="box">
|
||||
{% if book.authors.exists %}
|
||||
<fieldset>
|
||||
{% for author in book.authors.all %}
|
||||
<div class="is-flex is-justify-content-space-between">
|
||||
<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 %}
|
||||
</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>
|
||||
</p>
|
||||
</div>
|
||||
|
@ -119,17 +138,27 @@
|
|||
</fieldset>
|
||||
{% endif %}
|
||||
<div class="field">
|
||||
<label class="label" for="id_add_author">{% trans "Add Authors:" %}</label>
|
||||
<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 %}>
|
||||
<span class="help">{% trans "Separate multiple values with commas." %}</span>
|
||||
<label class="label" for="id_add_author">
|
||||
{% trans "Add Authors:" %}
|
||||
</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>
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="column is-half">
|
||||
<section class="block">
|
||||
<h2 class="title is-4">{% trans "Cover" %}</h2>
|
||||
<h2 class="title is-4">
|
||||
{% trans "Cover" %}
|
||||
</h2>
|
||||
<div class="box">
|
||||
<div class="columns">
|
||||
{% if book.cover %}
|
||||
|
@ -140,108 +169,122 @@
|
|||
|
||||
<div class="column">
|
||||
<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 }}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="id_cover_url">
|
||||
{% trans "Load cover from url:" %}
|
||||
</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>
|
||||
{% for error in form.cover.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.cover.errors id="desc_cover" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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="columns">
|
||||
<div class="column is-one-third">
|
||||
<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">
|
||||
{{ form.physical_format }}
|
||||
</div>
|
||||
{% for error in form.physical_format.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.physical_format.errors id="desc_physical_format" %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<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 }}
|
||||
{% for error in form.physical_format_detail.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.physical_format_detail.errors id="desc_physical_format_detail" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="id_pages">{% trans "Pages:" %}</label>
|
||||
<label class="label" for="id_pages">
|
||||
{% trans "Pages:" %}
|
||||
</label>
|
||||
{{ form.pages }}
|
||||
{% for error in form.pages.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.pages.errors id="desc_pages" %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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="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 }}
|
||||
{% for error in form.isbn_13.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.isbn_13.errors id="desc_isbn_13" %}
|
||||
</div>
|
||||
|
||||
<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 }}
|
||||
{% for error in form.isbn_10.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.isbn_10.errors id="desc_isbn_10" %}
|
||||
</div>
|
||||
|
||||
<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 }}
|
||||
{% for error in form.openlibrary_key.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.openlibrary_key.errors id="desc_openlibrary_key" %}
|
||||
</div>
|
||||
|
||||
<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 }}
|
||||
{% for error in form.inventaire_id.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.inventaire_id.errors id="desc_inventaire_id" %}
|
||||
</div>
|
||||
|
||||
<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 }}
|
||||
{% for error in form.oclc_number.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.oclc_number.errors id="desc_oclc_number" %}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="id_asin">{% trans "ASIN:" %}</label>
|
||||
<label class="label" for="id_asin">
|
||||
{% trans "ASIN:" %}
|
||||
</label>
|
||||
{{ form.asin }}
|
||||
{% for error in form.ASIN.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.ASIN.errors id="desc_ASIN" %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
{% load i18n %}
|
||||
{% load humanize %}
|
||||
|
||||
{% firstof book.physical_format_detail book.physical_format as format %}
|
||||
{% firstof book.physical_format_detail book.get_physical_format_display as format %}
|
||||
{% firstof book.physical_format book.physical_format_detail as format_property %}
|
||||
{% with pages=book.pages %}
|
||||
{% if format or pages %}
|
||||
|
@ -18,7 +18,7 @@
|
|||
|
||||
<p>
|
||||
{% if format and not pages %}
|
||||
{% blocktrans %}{{ format }}{% endblocktrans %}
|
||||
{{ format }}
|
||||
{% elif format and pages %}
|
||||
{% blocktrans %}{{ format }}, {{ pages }} pages{% endblocktrans %}
|
||||
{% elif pages %}
|
||||
|
|
30
bookwyrm/templates/book/sync_modal.html
Normal file
30
bookwyrm/templates/book/sync_modal.html
Normal file
|
@ -0,0 +1,30 @@
|
|||
{% extends 'components/modal.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block modal-title %}
|
||||
{% trans "Load data" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-form-open %}
|
||||
<form name="{{ source }}-update" method="POST" action="{% url 'book-update-remote' book.id source %}">
|
||||
{% csrf_token %}
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-body %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
Loading data will connect to <strong>{{ source_name }}</strong> and check for any metadata about this book which aren't present here. Existing metadata will not be overwritten.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-footer %}
|
||||
<button class="button is-primary" type="submit">
|
||||
<span>{% trans "Confirm" %}</span>
|
||||
</button>
|
||||
|
||||
{% trans "Cancel" as button_text %}
|
||||
{% include 'snippets/toggle/toggle_button.html' with text=button_text %}
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-form-close %}</form>{% endblock %}
|
|
@ -2,25 +2,20 @@
|
|||
{% load utilities %}
|
||||
|
||||
{% with 0|uuid as uuid %}
|
||||
<div
|
||||
<details
|
||||
id="menu_{{ uuid }}"
|
||||
class="
|
||||
dropdown control
|
||||
{% if right %}is-right{% endif %}
|
||||
"
|
||||
>
|
||||
<button
|
||||
class="button dropdown-trigger pulldown-menu {{ class }}"
|
||||
type="button"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-controls="menu_options_{{ uuid }}"
|
||||
data-controls="menu_{{ uuid }}"
|
||||
<summary
|
||||
class="button control dropdown-trigger pulldown-menu {{ class }}"
|
||||
>
|
||||
{% block dropdown-trigger %}{% endblock %}
|
||||
</button>
|
||||
</summary>
|
||||
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-menu control">
|
||||
<ul
|
||||
id="menu_options_{{ uuid }}"
|
||||
class="dropdown-content p-0 is-clipped"
|
||||
|
@ -29,6 +24,6 @@
|
|||
{% block dropdown-list %}{% endblock %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% endwith %}
|
||||
{% endspaceless %}
|
||||
|
|
|
@ -2,10 +2,10 @@
|
|||
{% load i18n %}
|
||||
{% load utilities %}
|
||||
|
||||
{% block title %}{% trans "Compose status" %}{% endblock %}
|
||||
{% block title %}{% trans "Edit status" %}{% endblock %}
|
||||
{% block content %}
|
||||
<header class="block content">
|
||||
<h1>{% trans "Compose status" %}</h1>
|
||||
<h1>{% trans "Edit status" %}</h1>
|
||||
</header>
|
||||
|
||||
{% with 0|uuid as uuid %}
|
||||
|
@ -22,6 +22,10 @@
|
|||
<div class="column">
|
||||
{% if draft.reply_parent %}
|
||||
{% include 'snippets/status/status.html' with status=draft.reply_parent no_interact=True %}
|
||||
{% else %}
|
||||
<div class="block">
|
||||
{% include "snippets/status/header.html" with status=draft %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not draft %}
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
</p>
|
||||
<form name="directory" method="POST" action="{% url 'directory' %}">
|
||||
{% 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">
|
||||
{% 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 %}
|
||||
|
@ -28,7 +28,7 @@
|
|||
<div class="column is-narrow">
|
||||
{% trans "Dismiss message" as button_text %}
|
||||
<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>
|
||||
</div>
|
||||
</div></div>
|
||||
|
|
40
bookwyrm/templates/discover/card-header.html
Normal file
40
bookwyrm/templates/discover/card-header.html
Normal file
|
@ -0,0 +1,40 @@
|
|||
{% load i18n %}
|
||||
{% load utilities %}
|
||||
|
||||
{% 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.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' %}
|
||||
{% blocktrans trimmed %}
|
||||
<a href="{{ user_path}}">{{ username }}</a> rated <a href="{{ book_path }}">{{ book_title }}</a>
|
||||
{% endblocktrans %}
|
||||
{% elif status.status_type == 'Review' %}
|
||||
{% blocktrans trimmed %}
|
||||
<a href="{{ user_path}}">{{ username }}</a> reviewed <a href="{{ book_path }}">{{ book_title }}</a>
|
||||
{% endblocktrans %}
|
||||
{% elif status.status_type == 'Comment' %}
|
||||
{% blocktrans trimmed %}
|
||||
<a href="{{ user_path}}">{{ username }}</a> commented on <a href="{{ book_path }}">{{ book_title }}</a>
|
||||
{% endblocktrans %}
|
||||
{% elif status.status_type == 'Quotation' %}
|
||||
{% blocktrans trimmed %}
|
||||
<a href="{{ user_path}}">{{ username }}</a> quoted <a href="{{ book_path }}">{{ book_title }}</a>
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
|
||||
{% endwith %}
|
|
@ -36,23 +36,7 @@
|
|||
</figure>
|
||||
<div class="media-content">
|
||||
<h3 class="title is-6">
|
||||
<a href="{{ status.user.local_path }}">
|
||||
<span>{{ status.user.display_name }}</span>
|
||||
</a>
|
||||
|
||||
{% if status.status_type == 'GeneratedNote' %}
|
||||
{{ status.content|safe }}
|
||||
{% elif status.status_type == 'Rating' %}
|
||||
{% trans "rated" %}
|
||||
{% elif status.status_type == 'Review' %}
|
||||
{% trans "reviewed" %}
|
||||
{% elif status.status_type == 'Comment' %}
|
||||
{% trans "commented on" %}
|
||||
{% elif status.status_type == 'Quotation' %}
|
||||
{% trans "quoted" %}
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ book.local_path }}">{{ book.title }}</a>
|
||||
{% include "discover/card-header.html" %}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -22,23 +22,7 @@
|
|||
|
||||
<div class="media-content">
|
||||
<h3 class="title is-6">
|
||||
<a href="{{ status.user.local_path }}">
|
||||
<span>{{ status.user.display_name }}</span>
|
||||
</a>
|
||||
|
||||
{% if status.status_type == 'GeneratedNote' %}
|
||||
{{ status.content|safe }}
|
||||
{% elif status.status_type == 'Rating' %}
|
||||
{% trans "rated" %}
|
||||
{% elif status.status_type == 'Review' %}
|
||||
{% trans "reviewed" %}
|
||||
{% elif status.status_type == 'Comment' %}
|
||||
{% trans "commented on" %}
|
||||
{% elif status.status_type == 'Quotation' %}
|
||||
{% trans "quoted" %}
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ book.local_path }}">{{ book.title }}</a>
|
||||
{% include "discover/card-header.html" %}
|
||||
</h3>
|
||||
{% if status.rating %}
|
||||
<p class="subtitle is-6">
|
||||
|
|
|
@ -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="padding: 1rem; overflow: auto;">
|
||||
<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>
|
||||
<a style="color: black; text-decoration: none" href="https://{{ domain }}" style="text-decoration: none;"><strong>{{ site_name }}</strong><br>
|
||||
|
|
|
@ -12,6 +12,6 @@
|
|||
<p>
|
||||
{% url 'code-of-conduct' as coc_path %}
|
||||
{% url 'about' as about_path %}
|
||||
{% blocktrans %}Learn more <a href="https://{{ domain }}{{ about_path }}">about this instance</a>.{% endblocktrans %}
|
||||
{% blocktrans %}Learn more <a href="https://{{ domain }}{{ about_path }}">about {{ site_name }}</a>.{% endblocktrans %}
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
|
|
@ -5,6 +5,6 @@
|
|||
|
||||
{{ invite_link }}
|
||||
|
||||
{% trans "Learn more about this instance:" %} https://{{ domain }}{% url 'about' %}
|
||||
{% blocktrans %}Learn more about {{ site_name }}:{% endblocktrans %} https://{{ domain }}{% url 'about' %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
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>
|
||||
<div>
|
||||
<strong>Subject:</strong> {% include subject_path %}
|
||||
|
|
53
bookwyrm/templates/embed-layout.html
Normal file
53
bookwyrm/templates/embed-layout.html
Normal file
|
@ -0,0 +1,53 @@
|
|||
{% load layout %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="{% get_lang %}">
|
||||
<head>
|
||||
<title>{% block title %}BookWyrm{% endblock %} - {{ site.name }}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="{% static "css/vendor/bulma.min.css" %}">
|
||||
<link rel="stylesheet" href="{% static "css/vendor/icons.css" %}">
|
||||
<link rel="stylesheet" href="{% static "css/bookwyrm.css" %}">
|
||||
|
||||
<base target="_blank">
|
||||
|
||||
<link rel="shortcut icon" type="image/x-icon" href="{% if site.favicon %}{% get_media_prefix %}{{ site.favicon }}{% else %}{% static "images/favicon.ico" %}{% endif %}">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="section py-3">
|
||||
<a href="/" class="is-flex is-align-items-center">
|
||||
<img class="image logo is-flex-shrink-0" style="height: 32px" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="{% blocktrans with site_name=site.name %}{{ site_name }} home page{% endblocktrans %}">
|
||||
<span class="title is-5 ml-2">{{ site.name }}</span>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<main class="section py-3">
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="section py-3">
|
||||
<p>
|
||||
<a href="{% url 'about' %}">
|
||||
{% blocktrans with site_name=site.name %}About {{ site_name }}{% endblocktrans %}
|
||||
</a>
|
||||
</p>
|
||||
{% if site.admin_email %}
|
||||
<p>
|
||||
<a href="mailto:{{ site.admin_email }}">
|
||||
{% trans "Contact site admin" %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
<p>
|
||||
<a href="https://joinbookwyrm.com/">
|
||||
{% trans "Join Bookwyrm" %}
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
|
@ -6,20 +6,62 @@
|
|||
<h1 class="title">
|
||||
{{ tab.name }}
|
||||
</h1>
|
||||
<div class="tabs">
|
||||
<ul>
|
||||
{% for stream in streams %}
|
||||
<li class="{% if tab.key == stream.key %}is-active{% endif %}"{% if tab.key == stream.key %} aria-current="page"{% endif %}>
|
||||
<a href="/{{ stream.key }}#feed">{{ stream.shortname }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="block is-clipped">
|
||||
<div class="is-pulled-left">
|
||||
<div class="tabs">
|
||||
<ul>
|
||||
{% for stream in streams %}
|
||||
<li class="{% if tab.key == stream.key %}is-active{% endif %}"{% if tab.key == stream.key %} aria-current="page"{% endif %}>
|
||||
<a href="/{{ stream.key }}#feed">{{ stream.shortname }}</a>
|
||||
</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>
|
||||
|
||||
{# announcements and system messages #}
|
||||
{% if not activities.number > 1 %}
|
||||
<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 %}
|
||||
{{ allowed_status_types|json_script:"unread-notifications-wrapper" }}
|
||||
</a>
|
||||
|
||||
{% if request.user.show_goal and not goal and tab.key == 'home' %}
|
||||
|
@ -36,6 +78,7 @@
|
|||
{% if not activities %}
|
||||
<div class="block content">
|
||||
<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 %}
|
||||
{# suggested users for when things are very lonely #}
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
{% if user.is_authenticated %}
|
||||
<div class="column is-one-third">
|
||||
<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 %}
|
||||
<p>{% trans "There are no books here right now! Try searching for a book to get started" %}</p>
|
||||
{% else %}
|
||||
|
|
|
@ -2,6 +2,18 @@
|
|||
{% load i18n %}
|
||||
{% 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 %}
|
||||
<header class="block">
|
||||
<a href="/#feed" class="button" data-back>
|
||||
|
|
|
@ -4,9 +4,14 @@
|
|||
|
||||
<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 %}">
|
||||
<option disabled selected value>Add to your books</option>
|
||||
<option disabled selected value>{% trans 'Add to your books' %}</option>
|
||||
{% 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 %}
|
||||
</select>
|
||||
</div>
|
||||
|
|
|
@ -14,16 +14,14 @@
|
|||
<div class="block">
|
||||
<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 %}">
|
||||
{% for error in form.name.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.name.errors id="desc_name" %}
|
||||
</div>
|
||||
<div class="block">
|
||||
<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>
|
||||
{% for error in form.summary.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.summary.errors id="desc_summary" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -31,9 +29,8 @@
|
|||
<div class="block">
|
||||
<label class="label" for="id_avatar">{% trans "Avatar:" %}</label>
|
||||
{{ form.avatar }}
|
||||
{% for error in form.avatar.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.avatar.errors id="desc_avatar" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
12
bookwyrm/templates/groups/create_form.html
Normal file
12
bookwyrm/templates/groups/create_form.html
Normal file
|
@ -0,0 +1,12 @@
|
|||
{% extends 'components/inline_form.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block header %}
|
||||
{% trans "Create Group" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<form name="create-group" method="post" action="{% url 'user-groups' request.user.username %}">
|
||||
{% include 'groups/form.html' with group_form=group_form %}
|
||||
</form>
|
||||
{% endblock %}
|
6
bookwyrm/templates/groups/created_text.html
Normal file
6
bookwyrm/templates/groups/created_text.html
Normal file
|
@ -0,0 +1,6 @@
|
|||
{% load i18n %}
|
||||
{% spaceless %}
|
||||
|
||||
{% blocktrans with username=group.user.display_name path=group.user.local_path %}Managed by <a href="{{ path }}">{{ username }}</a>{% endblocktrans %}
|
||||
|
||||
{% endspaceless %}
|
21
bookwyrm/templates/groups/delete_group_modal.html
Normal file
21
bookwyrm/templates/groups/delete_group_modal.html
Normal file
|
@ -0,0 +1,21 @@
|
|||
{% extends 'components/modal.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block modal-title %}{% trans "Delete this group?" %}{% endblock %}
|
||||
|
||||
{% block modal-body %}
|
||||
{% trans "This action cannot be un-done" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-footer %}
|
||||
<form name="delete-group-{{ group.id }}" action="{% url 'delete-group' group.id %}" method="POST">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="id" value="{{ group.id }}">
|
||||
<button class="button is-danger" type="submit">
|
||||
{% trans "Delete" %}
|
||||
</button>
|
||||
{% trans "Cancel" as button_text %}
|
||||
{% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="delete_group" controls_uid=group.id %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
13
bookwyrm/templates/groups/edit_form.html
Normal file
13
bookwyrm/templates/groups/edit_form.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
{% extends 'components/inline_form.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block header %}
|
||||
{% trans "Edit Group" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<form name="edit-group" method="post" action="{% url 'group' group.id %}">
|
||||
{% include 'groups/form.html' %}
|
||||
</form>
|
||||
{% include "groups/delete_group_modal.html" with controls_text="delete_group" controls_uid=group.id %}
|
||||
{% endblock %}
|
9
bookwyrm/templates/groups/find_users.html
Normal file
9
bookwyrm/templates/groups/find_users.html
Normal file
|
@ -0,0 +1,9 @@
|
|||
{% extends 'groups/group.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block searchresults %}
|
||||
<h2 class="title is-5">
|
||||
{% trans "Add new members!" %}
|
||||
</h2>
|
||||
{% include 'groups/suggested_users.html' with suggested_users=suggested_users %}
|
||||
{% endblock %}
|
34
bookwyrm/templates/groups/form.html
Normal file
34
bookwyrm/templates/groups/form.html
Normal file
|
@ -0,0 +1,34 @@
|
|||
{% load i18n %}
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-two-thirds">
|
||||
<input type="hidden" name="user" value="{{ request.user.id }}" />
|
||||
<div class="field">
|
||||
<label class="label" for="id_name">{% trans "Group Name:" %}</label>
|
||||
{{ group_form.name }}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="id_description">{% trans "Group Description:" %}</label>
|
||||
{{ group_form.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns is-mobile">
|
||||
<div class="column">
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
{% include 'snippets/privacy_select_no_followers.html' with current=group.privacy %}
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if group.id %}
|
||||
<div class="column is-narrow">
|
||||
{% trans "Delete group" as button_text %}
|
||||
{% include 'snippets/toggle/toggle_button.html' with class="is-danger" text=button_text icon_with_text="x" controls_text="delete_group" controls_uid=group.id focus="modal_title_delete_group" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
82
bookwyrm/templates/groups/group.html
Normal file
82
bookwyrm/templates/groups/group.html
Normal file
|
@ -0,0 +1,82 @@
|
|||
{% extends 'groups/layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load bookwyrm_tags %}
|
||||
{% load markdown %}
|
||||
|
||||
{% block panel %}
|
||||
|
||||
<div class="columns mt-3">
|
||||
<section class="column is-three-quarters">
|
||||
|
||||
{% if group.user == request.user %}
|
||||
<div class="block">
|
||||
<form class="field has-addons" method="get" action="{% url 'group-find-users' group.id %}">
|
||||
<div class="control">
|
||||
<input type="text" name="user_query" value="{{ request.GET.user_query }}" class="input" placeholder="{% trans 'Search to add a user' %}" aria-label="{% trans 'Search to add a user' %}">
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button" type="submit">
|
||||
<span class="icon icon-search" title="{% trans 'Search' %}">
|
||||
<span class="is-sr-only">{% trans "Search" %}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block searchresults %}
|
||||
{% endblock %}
|
||||
<div class="mb-2">
|
||||
{% include "groups/members.html" with group=group %}
|
||||
</div>
|
||||
|
||||
<h2 class="title is-5">Lists</h2>
|
||||
{% if not lists %}
|
||||
<p>{% trans "This group has no lists" %}</p>
|
||||
{% else %}
|
||||
|
||||
<div class="columns is-multiline">
|
||||
{% for list in lists %}
|
||||
<div class="column is-one-third">
|
||||
<div class="card is-stretchable">
|
||||
<header class="card-header">
|
||||
<h4 class="card-header-title">
|
||||
<a href="{{ list.local_path }}">{{ list.name }}</a> <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=list %}</span>
|
||||
</h4>
|
||||
</header>
|
||||
|
||||
{% with list_books=list.listitem_set.all|slice:5 %}
|
||||
{% if list_books %}
|
||||
<div class="card-image columns is-mobile is-gapless is-clipped">
|
||||
{% for book in list_books %}
|
||||
<a class="column is-cover" href="{{ book.book.local_path }}">
|
||||
{% include 'snippets/book_cover.html' with book=book.book cover_class='is-h-s' size='small' aria='show' %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="card-content is-flex-grow-0">
|
||||
<div class="is-clipped" {% if list.description %}title="{{ list.description }}"{% endif %}>
|
||||
{% if list.description %}
|
||||
{{ list.description|to_markdown|safe|truncatechars_html:30 }}
|
||||
{% else %}
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="subtitle help">
|
||||
{% include 'lists/created_text.html' with list=list %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
{% include "snippets/pagination.html" with page=items %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
32
bookwyrm/templates/groups/layout.html
Normal file
32
bookwyrm/templates/groups/layout.html
Normal file
|
@ -0,0 +1,32 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{{ group.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="columns content is-mobile">
|
||||
<div class="column">
|
||||
<h1 class="title">{{ group.name }} <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=group %}</span></h1>
|
||||
<p class="subtitle help">
|
||||
{% include 'groups/created_text.html' with group=group %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-narrow is-flex">
|
||||
{% if request.user == group.user %}
|
||||
{% trans "Edit group" as button_text %}
|
||||
{% include 'snippets/toggle/open_button.html' with text=button_text icon_with_text="pencil" controls_text="edit_group" focus="edit_group_header" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="block content">
|
||||
{% include 'snippets/trimmed_text.html' with full=group.description %}
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
{% include 'groups/edit_form.html' with controls_text="edit_group" %}
|
||||
</div>
|
||||
|
||||
{% block panel %}{% endblock %}
|
||||
|
||||
{% endblock %}
|
47
bookwyrm/templates/groups/members.html
Normal file
47
bookwyrm/templates/groups/members.html
Normal file
|
@ -0,0 +1,47 @@
|
|||
{% load i18n %}
|
||||
{% load utilities %}
|
||||
{% load humanize %}
|
||||
{% load bookwyrm_tags %}
|
||||
{% load bookwyrm_group_tags %}
|
||||
|
||||
<h2 class="title is-5">Group Members</h2>
|
||||
<p class="subtitle is-6">{% trans "Members can add and remove books on a group's book lists" %}</p>
|
||||
|
||||
{% if group.user != request.user and group|is_member:request.user %}
|
||||
<form action="{% url 'remove-group-member' %}" method="POST" class="my-4">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="group" value="{{ group.id }}">
|
||||
<input type="hidden" name="user" value="{{ user.username }}">
|
||||
<button id="remove_self_button" class="button is-small is-danger is-light is-hidden" type="submit">
|
||||
{% trans "Confirm" %}
|
||||
</button>
|
||||
<button id="hide_remove_self_button" data-controls="remove_self_button" class="button is-small" type="button" aria-pressed="false">
|
||||
{% trans "Leave group" %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="is-multiline is-flex is-flex-grow-0 is-flex-wrap-wrap">
|
||||
{% for membership in group.memberships.all %}
|
||||
{% with member=membership.user %}
|
||||
<div class="box has-text-centered is-shadowless has-background-white-bis my-2 mx-2 member_{{ member.id }}">
|
||||
<a href="{{ member.local_path }}" class="has-text-black">
|
||||
{% include 'snippets/avatar.html' with user=member large=True %}
|
||||
<span title="{{ member.display_name }}" class="is-block is-6 has-text-weight-bold">{{ member.display_name|truncatechars:10 }}</span>
|
||||
<span title="@{{ member|username }}" class="is-block pb-3">@{{ member|username|truncatechars:8 }}</span>
|
||||
</a>
|
||||
{% if group.user == member %}
|
||||
<span class="icon icon-star-full" title="Manager">
|
||||
<span class="is-sr-only">Manager</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% include 'snippets/remove_from_group_button.html' with user=member group=group %}
|
||||
{% if request.user in member.following.all %}
|
||||
<p class="help">
|
||||
{% trans "Follows you" %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
46
bookwyrm/templates/groups/suggested_users.html
Normal file
46
bookwyrm/templates/groups/suggested_users.html
Normal file
|
@ -0,0 +1,46 @@
|
|||
{% load i18n %}
|
||||
{% load utilities %}
|
||||
{% load humanize %}
|
||||
|
||||
{% if suggested_users %}
|
||||
<div class="column is-flex is-flex-grow-0">
|
||||
{% for user in suggested_users %}
|
||||
<div class="box has-text-centered is-shadowless has-background-white-bis m-2">
|
||||
<a href="{{ user.local_path }}" class="has-text-black">
|
||||
{% include 'snippets/avatar.html' with user=user large=True %}
|
||||
<span title="{{ user.display_name }}" class="is-block is-6 has-text-weight-bold">{{ user.display_name|truncatechars:10 }}</span>
|
||||
<span title="@{{ user|username }}" class="is-block pb-3">@{{ user|username|truncatechars:8 }}</span>
|
||||
</a>
|
||||
{% include 'snippets/add_to_group_button.html' with user=user group=group %}
|
||||
{% if user.mutuals %}
|
||||
<p class="help">
|
||||
{% blocktrans trimmed with mutuals=user.mutuals|intcomma count counter=user.mutuals %}
|
||||
{{ mutuals }} follower you follow
|
||||
{% plural %}
|
||||
{{ mutuals }} followers you follow{% endblocktrans %}
|
||||
</p>
|
||||
{% elif user.shared_books %}
|
||||
<p class="help">
|
||||
{% blocktrans trimmed with shared_books=user.shared_books|intcomma count counter=user.shared_books %}
|
||||
{{ shared_books }} book on your shelves
|
||||
{% plural %}
|
||||
{{ shared_books }} books on your shelves
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% elif request.user in user.following.all %}
|
||||
<p class="help">
|
||||
{% trans "Follows you" %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
No potential members found for "{{ user_query }}"
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<br/>
|
||||
|
||||
{% endif %}
|
35
bookwyrm/templates/groups/user_groups.html
Normal file
35
bookwyrm/templates/groups/user_groups.html
Normal file
|
@ -0,0 +1,35 @@
|
|||
{% load i18n %}
|
||||
{% load markdown %}
|
||||
{% load interaction %}
|
||||
|
||||
<div class="columns is-multiline">
|
||||
{% for group in groups %}
|
||||
<div class="column is-one-quarter">
|
||||
<div class="card is-stretchable">
|
||||
<header class="card-header">
|
||||
<h4 class="card-header-title">
|
||||
<a href="{{ group.local_path }}">{{ group.name }}</a> <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=group %}</span>
|
||||
</h4>
|
||||
{% if group.user == user %}
|
||||
<div class="card-header-icon">
|
||||
{% trans "Manager" as text %}
|
||||
<span class="icon icon-star-full has-text-grey" title="{{ text }}">
|
||||
<span class="is-sr-only">{{ text }}</span>
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
<div class="card-content is-flex-grow-0">
|
||||
<div class="is-clipped" {% if group.description %}title="{{ group.description }}"{% endif %}>
|
||||
{% if group.description %}
|
||||
{{ group.description|to_markdown|safe|truncatechars_html:30 }}
|
||||
{% else %}
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
|
@ -22,8 +22,8 @@
|
|||
|
||||
<div class="select block">
|
||||
<select name="source" id="source">
|
||||
<option value="GoodReads" {% if current == 'GoodReads' %}selected{% endif %}>
|
||||
GoodReads (CSV)
|
||||
<option value="Goodreads" {% if current == 'Goodreads' %}selected{% endif %}>
|
||||
Goodreads (CSV)
|
||||
</option>
|
||||
<option value="Storygraph" {% if current == 'Storygraph' %}selected{% endif %}>
|
||||
Storygraph (CSV)
|
||||
|
@ -31,6 +31,9 @@
|
|||
<option value="LibraryThing" {% if current == 'LibraryThing' %}selected{% endif %}>
|
||||
LibraryThing (TSV)
|
||||
</option>
|
||||
<option value="OpenLibrary" {% if current == 'OpenLibrary' %}selected{% endif %}>
|
||||
OpenLibrary (CSV)
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
|
@ -46,10 +49,10 @@
|
|||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>
|
||||
<span class="label">{% trans "Privacy setting for imported reviews:" %}</span>
|
||||
{% include 'snippets/privacy_select.html' with no_label=True %}
|
||||
<label class="label" for="privacy_import">
|
||||
{% trans "Privacy setting for imported reviews:" %}
|
||||
</label>
|
||||
{% include 'snippets/privacy_select.html' with no_label=True privacy_uuid="import" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -6,153 +6,233 @@
|
|||
{% block title %}{% trans "Import Status" %}{% endblock %}
|
||||
|
||||
{% block content %}{% spaceless %}
|
||||
<div class="block">
|
||||
<h1 class="title">{% trans "Import Status" %}</h1>
|
||||
<a href="{% url 'import' %}" class="has-text-weight-normal help subtitle is-link">{% trans "Back to imports" %}</a>
|
||||
|
||||
<dl>
|
||||
<div class="is-flex">
|
||||
<dt class="has-text-weight-medium">{% trans "Import started:" %}</dt>
|
||||
<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>
|
||||
<header class="block">
|
||||
<h1 class="title">
|
||||
{% block page_title %}
|
||||
{% if job.retry %}
|
||||
{% trans "Retry Status" %}
|
||||
{% else %}
|
||||
{% trans "Import Status" %}
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
{% endblock %}
|
||||
</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 %}
|
||||
<p>
|
||||
{% trans "Import still in progress." %}
|
||||
<br/>
|
||||
{% trans "(Hit reload to update!)" %}
|
||||
</p>
|
||||
<div class="box is-processing">
|
||||
<div class="block">
|
||||
<span class="icon icon-spinner is-pulled-left" aria-hidden="true"></span>
|
||||
<span>{% trans "In progress" %}</span>
|
||||
<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>
|
||||
{% if job.source == "OpenLibrary" %}
|
||||
<th>
|
||||
{% trans "Openlibrary key" %}
|
||||
</th>
|
||||
{% endif %}
|
||||
<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>
|
||||
{% if job.source == "OpenLibrary" %}
|
||||
<td>
|
||||
{{ item.openlibrary_key }}
|
||||
</td>
|
||||
{% endif %}
|
||||
<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 %}
|
||||
</div>
|
||||
|
||||
{% if failed_items %}
|
||||
<div class="block">
|
||||
<h2 class="title is-4">{% trans "Failed to load" %}</h2>
|
||||
{% 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 %}
|
||||
{% if not legacy %}
|
||||
<div>
|
||||
{% include 'snippets/pagination.html' with page=items %}
|
||||
</div>
|
||||
{% 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 %}
|
||||
|
||||
{% 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 %}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue