mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2025-02-17 03:25:19 +00:00
Merge branch 'main' into progress-modal
This commit is contained in:
commit
273198d634
286 changed files with 10940 additions and 4798 deletions
|
@ -16,7 +16,7 @@ DEFAULT_LANGUAGE="English"
|
|||
|
||||
MEDIA_ROOT=images/
|
||||
|
||||
POSTGRES_PORT=5432
|
||||
PGPORT=5432
|
||||
POSTGRES_PASSWORD=securedbypassword123
|
||||
POSTGRES_USER=fedireads
|
||||
POSTGRES_DB=fedireads
|
||||
|
|
|
@ -16,7 +16,7 @@ DEFAULT_LANGUAGE="English"
|
|||
|
||||
MEDIA_ROOT=images/
|
||||
|
||||
POSTGRES_PORT=5432
|
||||
PGPORT=5432
|
||||
POSTGRES_PASSWORD=securedbypassword123
|
||||
POSTGRES_USER=fedireads
|
||||
POSTGRES_DB=fedireads
|
||||
|
|
|
@ -101,7 +101,7 @@ class ActivityObject:
|
|||
except KeyError:
|
||||
if field.default == MISSING and field.default_factory == MISSING:
|
||||
raise ActivitySerializerError(
|
||||
"Missing required field: %s" % field.name
|
||||
f"Missing required field: {field.name}"
|
||||
)
|
||||
value = field.default
|
||||
setattr(self, field.name, value)
|
||||
|
@ -213,14 +213,14 @@ class ActivityObject:
|
|||
return data
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(queue="medium_priority")
|
||||
@transaction.atomic
|
||||
def set_related_field(
|
||||
model_name, origin_model_name, related_field_name, related_remote_id, data
|
||||
):
|
||||
"""load reverse related fields (editions, attachments) without blocking"""
|
||||
model = apps.get_model("bookwyrm.%s" % model_name, require_ready=True)
|
||||
origin_model = apps.get_model("bookwyrm.%s" % origin_model_name, require_ready=True)
|
||||
model = apps.get_model(f"bookwyrm.{model_name}", require_ready=True)
|
||||
origin_model = apps.get_model(f"bookwyrm.{origin_model_name}", require_ready=True)
|
||||
|
||||
with transaction.atomic():
|
||||
if isinstance(data, str):
|
||||
|
@ -234,7 +234,7 @@ def set_related_field(
|
|||
# this must exist because it's the object that triggered this function
|
||||
instance = origin_model.find_existing_by_remote_id(related_remote_id)
|
||||
if not instance:
|
||||
raise ValueError("Invalid related remote id: %s" % related_remote_id)
|
||||
raise ValueError(f"Invalid related remote id: {related_remote_id}")
|
||||
|
||||
# set the origin's remote id on the activity so it will be there when
|
||||
# the model instance is created
|
||||
|
@ -265,7 +265,7 @@ def get_model_from_type(activity_type):
|
|||
]
|
||||
if not model:
|
||||
raise ActivitySerializerError(
|
||||
'No model found for activity type "%s"' % activity_type
|
||||
f'No model found for activity type "{activity_type}"'
|
||||
)
|
||||
return model[0]
|
||||
|
||||
|
@ -275,6 +275,8 @@ def resolve_remote_id(
|
|||
):
|
||||
"""take a remote_id and return an instance, creating if necessary"""
|
||||
if model: # a bonus check we can do if we already know the model
|
||||
if isinstance(model, str):
|
||||
model = apps.get_model(f"bookwyrm.{model}", require_ready=True)
|
||||
result = model.find_existing_by_remote_id(remote_id)
|
||||
if result and not refresh:
|
||||
return result if not get_activity else result.to_activity_dataclass()
|
||||
|
@ -284,7 +286,7 @@ def resolve_remote_id(
|
|||
data = get_data(remote_id)
|
||||
except ConnectorException:
|
||||
raise ActivitySerializerError(
|
||||
"Could not connect to host for remote_id in: %s" % (remote_id)
|
||||
f"Could not connect to host for remote_id: {remote_id}"
|
||||
)
|
||||
# determine the model implicitly, if not provided
|
||||
# or if it's a model with subclasses like Status, check again
|
||||
|
|
|
@ -30,8 +30,8 @@ class Note(ActivityObject):
|
|||
to: List[str] = field(default_factory=lambda: [])
|
||||
cc: List[str] = field(default_factory=lambda: [])
|
||||
replies: Dict = field(default_factory=lambda: {})
|
||||
inReplyTo: str = ""
|
||||
summary: str = ""
|
||||
inReplyTo: str = None
|
||||
summary: str = None
|
||||
tag: List[Link] = field(default_factory=lambda: [])
|
||||
attachment: List[Document] = field(default_factory=lambda: [])
|
||||
sensitive: bool = False
|
||||
|
@ -70,6 +70,8 @@ class Quotation(Comment):
|
|||
"""a quote and commentary on a book"""
|
||||
|
||||
quote: str
|
||||
position: int = None
|
||||
positionMode: str = None
|
||||
type: str = "Quotation"
|
||||
|
||||
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
""" access the activity streams stored in redis """
|
||||
from datetime import timedelta
|
||||
from django.dispatch import receiver
|
||||
from django.db import transaction
|
||||
from django.db.models import signals, Q
|
||||
from django.utils import timezone
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.redis_store import RedisStore, r
|
||||
|
@ -13,11 +16,12 @@ class ActivityStream(RedisStore):
|
|||
|
||||
def stream_id(self, user):
|
||||
"""the redis key for this user's instance of this stream"""
|
||||
return "{}-{}".format(user.id, self.key)
|
||||
return f"{user.id}-{self.key}"
|
||||
|
||||
def unread_id(self, user):
|
||||
"""the redis key for this user's unread count for this stream"""
|
||||
return "{}-unread".format(self.stream_id(user))
|
||||
stream_id = self.stream_id(user)
|
||||
return f"{stream_id}-unread"
|
||||
|
||||
def get_rank(self, obj): # pylint: disable=no-self-use
|
||||
"""statuses are sorted by date published"""
|
||||
|
@ -258,38 +262,31 @@ def add_status_on_create(sender, instance, created, *args, **kwargs):
|
|||
return
|
||||
|
||||
if instance.deleted:
|
||||
for stream in streams.values():
|
||||
stream.remove_object_from_related_stores(instance)
|
||||
remove_status_task.delay(instance.id)
|
||||
return
|
||||
|
||||
for stream in streams.values():
|
||||
stream.add_status(instance, increment_unread=created)
|
||||
|
||||
if sender != models.Boost:
|
||||
return
|
||||
# remove the original post and other, earlier boosts
|
||||
boosted = instance.boost.boosted_status
|
||||
old_versions = models.Boost.objects.filter(
|
||||
boosted_status__id=boosted.id,
|
||||
created_date__lt=instance.created_date,
|
||||
# when creating new things, gotta wait on the transaction
|
||||
transaction.on_commit(
|
||||
lambda: add_status_on_create_command(sender, instance, created)
|
||||
)
|
||||
for stream in streams.values():
|
||||
audience = stream.get_stores_for_object(instance)
|
||||
stream.remove_object_from_related_stores(boosted, stores=audience)
|
||||
for status in old_versions:
|
||||
stream.remove_object_from_related_stores(status, stores=audience)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
if sender == models.Boost:
|
||||
handle_boost_task.delay(instance.id)
|
||||
|
||||
|
||||
@receiver(signals.post_delete, sender=models.Boost)
|
||||
# pylint: disable=unused-argument
|
||||
def remove_boost_on_delete(sender, instance, *args, **kwargs):
|
||||
"""boosts are deleted"""
|
||||
# we're only interested in new statuses
|
||||
for stream in streams.values():
|
||||
# remove the boost
|
||||
stream.remove_object_from_related_stores(instance)
|
||||
# re-add the original status
|
||||
stream.add_status(instance.boosted_status)
|
||||
# remove the boost
|
||||
remove_status_task.delay(instance.id)
|
||||
# re-add the original status
|
||||
add_status_task.delay(instance.boosted_status.id)
|
||||
|
||||
|
||||
@receiver(signals.post_save, sender=models.UserFollows)
|
||||
|
@ -298,7 +295,9 @@ def add_statuses_on_follow(sender, instance, created, *args, **kwargs):
|
|||
"""add a newly followed user's statuses to feeds"""
|
||||
if not created or not instance.user_subject.local:
|
||||
return
|
||||
HomeStream().add_user_statuses(instance.user_subject, instance.user_object)
|
||||
add_user_statuses_task.delay(
|
||||
instance.user_subject.id, instance.user_object.id, stream_list=["home"]
|
||||
)
|
||||
|
||||
|
||||
@receiver(signals.post_delete, sender=models.UserFollows)
|
||||
|
@ -307,7 +306,9 @@ def remove_statuses_on_unfollow(sender, instance, *args, **kwargs):
|
|||
"""remove statuses from a feed on unfollow"""
|
||||
if not instance.user_subject.local:
|
||||
return
|
||||
HomeStream().remove_user_statuses(instance.user_subject, instance.user_object)
|
||||
remove_user_statuses_task.delay(
|
||||
instance.user_subject.id, instance.user_object.id, stream_list=["home"]
|
||||
)
|
||||
|
||||
|
||||
@receiver(signals.post_save, sender=models.UserBlocks)
|
||||
|
@ -316,13 +317,15 @@ def remove_statuses_on_block(sender, instance, *args, **kwargs):
|
|||
"""remove statuses from all feeds on block"""
|
||||
# blocks apply ot all feeds
|
||||
if instance.user_subject.local:
|
||||
for stream in streams.values():
|
||||
stream.remove_user_statuses(instance.user_subject, instance.user_object)
|
||||
remove_user_statuses_task.delay(
|
||||
instance.user_subject.id, instance.user_object.id
|
||||
)
|
||||
|
||||
# and in both directions
|
||||
if instance.user_object.local:
|
||||
for stream in streams.values():
|
||||
stream.remove_user_statuses(instance.user_object, instance.user_subject)
|
||||
remove_user_statuses_task.delay(
|
||||
instance.user_object.id, instance.user_subject.id
|
||||
)
|
||||
|
||||
|
||||
@receiver(signals.post_delete, sender=models.UserBlocks)
|
||||
|
@ -330,15 +333,22 @@ def remove_statuses_on_block(sender, instance, *args, **kwargs):
|
|||
def add_statuses_on_unblock(sender, instance, *args, **kwargs):
|
||||
"""remove statuses from all feeds on block"""
|
||||
public_streams = [v for (k, v) in streams.items() if k != "home"]
|
||||
|
||||
# add statuses back to streams with statuses from anyone
|
||||
if instance.user_subject.local:
|
||||
for stream in public_streams:
|
||||
stream.add_user_statuses(instance.user_subject, instance.user_object)
|
||||
add_user_statuses_task.delay(
|
||||
instance.user_subject.id,
|
||||
instance.user_object.id,
|
||||
stream_list=public_streams,
|
||||
)
|
||||
|
||||
# add statuses back to streams with statuses from anyone
|
||||
if instance.user_object.local:
|
||||
for stream in public_streams:
|
||||
stream.add_user_statuses(instance.user_object, instance.user_subject)
|
||||
add_user_statuses_task.delay(
|
||||
instance.user_object.id,
|
||||
instance.user_subject.id,
|
||||
stream_list=public_streams,
|
||||
)
|
||||
|
||||
|
||||
@receiver(signals.post_save, sender=models.User)
|
||||
|
@ -348,8 +358,8 @@ def populate_streams_on_account_create(sender, instance, created, *args, **kwarg
|
|||
if not created or not instance.local:
|
||||
return
|
||||
|
||||
for stream in streams.values():
|
||||
stream.populate_streams(instance)
|
||||
for stream in streams:
|
||||
populate_stream_task.delay(stream, instance.id)
|
||||
|
||||
|
||||
@receiver(signals.pre_save, sender=models.ShelfBook)
|
||||
|
@ -358,20 +368,14 @@ def add_statuses_on_shelve(sender, instance, *args, **kwargs):
|
|||
"""update books stream when user shelves a book"""
|
||||
if not instance.user.local:
|
||||
return
|
||||
book = None
|
||||
if hasattr(instance, "book"):
|
||||
book = instance.book
|
||||
elif instance.mention_books.exists():
|
||||
book = instance.mention_books.first()
|
||||
if not book:
|
||||
return
|
||||
book = instance.book
|
||||
|
||||
# check if the book is already on the user's shelves
|
||||
editions = book.parent_work.editions.all()
|
||||
if models.ShelfBook.objects.filter(user=instance.user, book__in=editions).exists():
|
||||
return
|
||||
|
||||
BooksStream().add_book_statuses(instance.user, book)
|
||||
add_book_statuses_task.delay(instance.user.id, book.id)
|
||||
|
||||
|
||||
@receiver(signals.post_delete, sender=models.ShelfBook)
|
||||
|
@ -381,24 +385,101 @@ def remove_statuses_on_unshelve(sender, instance, *args, **kwargs):
|
|||
if not instance.user.local:
|
||||
return
|
||||
|
||||
book = None
|
||||
if hasattr(instance, "book"):
|
||||
book = instance.book
|
||||
elif instance.mention_books.exists():
|
||||
book = instance.mention_books.first()
|
||||
if not book:
|
||||
return
|
||||
book = instance.book
|
||||
|
||||
# check if the book is actually unshelved, not just moved
|
||||
editions = book.parent_work.editions.all()
|
||||
if models.ShelfBook.objects.filter(user=instance.user, book__in=editions).exists():
|
||||
return
|
||||
|
||||
BooksStream().remove_book_statuses(instance.user, instance.book)
|
||||
remove_book_statuses_task.delay(instance.user.id, book.id)
|
||||
|
||||
|
||||
@app.task
|
||||
# ---- TASKS
|
||||
|
||||
|
||||
@app.task(queue="low_priority")
|
||||
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)
|
||||
book = models.Edition.objects.get(id=book_id)
|
||||
BooksStream().add_book_statuses(user, book)
|
||||
|
||||
|
||||
@app.task(queue="low_priority")
|
||||
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)
|
||||
book = models.Edition.objects.get(id=book_id)
|
||||
BooksStream().remove_book_statuses(user, book)
|
||||
|
||||
|
||||
@app.task(queue="medium_priority")
|
||||
def populate_stream_task(stream, user_id):
|
||||
"""background task for populating an empty activitystream"""
|
||||
user = models.User.objects.get(id=user_id)
|
||||
stream = streams[stream]
|
||||
stream.populate_streams(user)
|
||||
|
||||
|
||||
@app.task(queue="medium_priority")
|
||||
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
|
||||
if not isinstance(status_ids, list):
|
||||
status_ids = [status_ids]
|
||||
statuses = models.Status.objects.filter(id__in=status_ids)
|
||||
|
||||
for stream in streams.values():
|
||||
for status in statuses:
|
||||
stream.remove_object_from_related_stores(status)
|
||||
|
||||
|
||||
@app.task(queue="high_priority")
|
||||
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)
|
||||
# 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):
|
||||
increment_unread = False
|
||||
for stream in streams.values():
|
||||
stream.add_status(status, increment_unread=increment_unread)
|
||||
|
||||
|
||||
@app.task(queue="medium_priority")
|
||||
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()
|
||||
viewer = models.User.objects.get(id=viewer_id)
|
||||
user = models.User.objects.get(id=user_id)
|
||||
for stream in stream_list:
|
||||
stream.remove_user_statuses(viewer, user)
|
||||
|
||||
|
||||
@app.task(queue="medium_priority")
|
||||
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()
|
||||
viewer = models.User.objects.get(id=viewer_id)
|
||||
user = models.User.objects.get(id=user_id)
|
||||
for stream in stream_list:
|
||||
stream.add_user_statuses(viewer, user)
|
||||
|
||||
|
||||
@app.task(queue="medium_priority")
|
||||
def handle_boost_task(boost_id):
|
||||
"""remove the original post and other, earlier boosts"""
|
||||
instance = models.Status.objects.get(id=boost_id)
|
||||
boosted = instance.boost.boosted_status
|
||||
|
||||
old_versions = models.Boost.objects.filter(
|
||||
boosted_status__id=boosted.id,
|
||||
created_date__lt=instance.created_date,
|
||||
)
|
||||
|
||||
for stream in streams.values():
|
||||
audience = stream.get_stores_for_object(instance)
|
||||
stream.remove_object_from_related_stores(boosted, stores=audience)
|
||||
for status in old_versions:
|
||||
stream.remove_object_from_related_stores(status, stores=audience)
|
||||
|
|
|
@ -43,7 +43,7 @@ class AbstractMinimalConnector(ABC):
|
|||
params["min_confidence"] = min_confidence
|
||||
|
||||
data = self.get_search_data(
|
||||
"%s%s" % (self.search_url, query),
|
||||
f"{self.search_url}{query}",
|
||||
params=params,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
@ -57,7 +57,7 @@ class AbstractMinimalConnector(ABC):
|
|||
"""isbn search"""
|
||||
params = {}
|
||||
data = self.get_search_data(
|
||||
"%s%s" % (self.isbn_search_url, query),
|
||||
f"{self.isbn_search_url}{query}",
|
||||
params=params,
|
||||
)
|
||||
results = []
|
||||
|
@ -131,7 +131,7 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
work_data = data
|
||||
|
||||
if not work_data or not edition_data:
|
||||
raise ConnectorException("Unable to load book data: %s" % remote_id)
|
||||
raise ConnectorException(f"Unable to load book data: {remote_id}")
|
||||
|
||||
with transaction.atomic():
|
||||
# create activitypub object
|
||||
|
@ -222,9 +222,7 @@ def get_data(url, params=None, timeout=10):
|
|||
"""wrapper for request.get"""
|
||||
# check if the url is blocked
|
||||
if models.FederatedServer.is_blocked(url):
|
||||
raise ConnectorException(
|
||||
"Attempting to load data from blocked url: {:s}".format(url)
|
||||
)
|
||||
raise ConnectorException(f"Attempting to load data from blocked url: {url}")
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
|
@ -283,6 +281,7 @@ class SearchResult:
|
|||
confidence: int = 1
|
||||
|
||||
def __repr__(self):
|
||||
# pylint: disable=consider-using-f-string
|
||||
return "<SearchResult key={!r} title={!r} author={!r}>".format(
|
||||
self.key, self.title, self.author
|
||||
)
|
||||
|
|
|
@ -109,17 +109,17 @@ def get_or_create_connector(remote_id):
|
|||
connector_info = models.Connector.objects.create(
|
||||
identifier=identifier,
|
||||
connector_file="bookwyrm_connector",
|
||||
base_url="https://%s" % identifier,
|
||||
books_url="https://%s/book" % identifier,
|
||||
covers_url="https://%s/images/covers" % identifier,
|
||||
search_url="https://%s/search?q=" % identifier,
|
||||
base_url=f"https://{identifier}",
|
||||
books_url=f"https://{identifier}/book",
|
||||
covers_url=f"https://{identifier}/images/covers",
|
||||
search_url=f"https://{identifier}/search?q=",
|
||||
priority=2,
|
||||
)
|
||||
|
||||
return load_connector(connector_info)
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(queue="low_priority")
|
||||
def load_more_data(connector_id, book_id):
|
||||
"""background the work of getting all 10,000 editions of LoTR"""
|
||||
connector_info = models.Connector.objects.get(id=connector_id)
|
||||
|
@ -131,7 +131,7 @@ def load_more_data(connector_id, book_id):
|
|||
def load_connector(connector_info):
|
||||
"""instantiate the connector class"""
|
||||
connector = importlib.import_module(
|
||||
"bookwyrm.connectors.%s" % connector_info.connector_file
|
||||
f"bookwyrm.connectors.{connector_info.connector_file}"
|
||||
)
|
||||
return connector.Connector(connector_info.identifier)
|
||||
|
||||
|
@ -141,4 +141,4 @@ def load_connector(connector_info):
|
|||
def create_connector(sender, instance, created, *args, **kwargs):
|
||||
"""create a connector to an external bookwyrm server"""
|
||||
if instance.application_type == "bookwyrm":
|
||||
get_or_create_connector("https://{:s}".format(instance.server_name))
|
||||
get_or_create_connector(f"https://{instance.server_name}")
|
||||
|
|
|
@ -59,7 +59,7 @@ class Connector(AbstractConnector):
|
|||
|
||||
def get_remote_id(self, value):
|
||||
"""convert an id/uri into a url"""
|
||||
return "{:s}?action=by-uris&uris={:s}".format(self.books_url, value)
|
||||
return f"{self.books_url}?action=by-uris&uris={value}"
|
||||
|
||||
def get_book_data(self, remote_id):
|
||||
data = get_data(remote_id)
|
||||
|
@ -87,11 +87,7 @@ class Connector(AbstractConnector):
|
|||
|
||||
def format_search_result(self, search_result):
|
||||
images = search_result.get("image")
|
||||
cover = (
|
||||
"{:s}/img/entities/{:s}".format(self.covers_url, images[0])
|
||||
if images
|
||||
else None
|
||||
)
|
||||
cover = f"{self.covers_url}/img/entities/{images[0]}" if images else None
|
||||
# a deeply messy translation of inventaire's scores
|
||||
confidence = float(search_result.get("_score", 0.1))
|
||||
confidence = 0.1 if confidence < 150 else 0.999
|
||||
|
@ -99,9 +95,7 @@ class Connector(AbstractConnector):
|
|||
title=search_result.get("label"),
|
||||
key=self.get_remote_id(search_result.get("uri")),
|
||||
author=search_result.get("description"),
|
||||
view_link="{:s}/entity/{:s}".format(
|
||||
self.base_url, search_result.get("uri")
|
||||
),
|
||||
view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
|
||||
cover=cover,
|
||||
confidence=confidence,
|
||||
connector=self,
|
||||
|
@ -123,9 +117,7 @@ class Connector(AbstractConnector):
|
|||
title=title[0],
|
||||
key=self.get_remote_id(search_result.get("uri")),
|
||||
author=search_result.get("description"),
|
||||
view_link="{:s}/entity/{:s}".format(
|
||||
self.base_url, search_result.get("uri")
|
||||
),
|
||||
view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
|
||||
cover=self.get_cover_url(search_result.get("image")),
|
||||
connector=self,
|
||||
)
|
||||
|
@ -135,11 +127,7 @@ class Connector(AbstractConnector):
|
|||
|
||||
def load_edition_data(self, work_uri):
|
||||
"""get a list of editions for a work"""
|
||||
url = (
|
||||
"{:s}?action=reverse-claims&property=wdt:P629&value={:s}&sort=true".format(
|
||||
self.books_url, work_uri
|
||||
)
|
||||
)
|
||||
url = f"{self.books_url}?action=reverse-claims&property=wdt:P629&value={work_uri}&sort=true"
|
||||
return get_data(url)
|
||||
|
||||
def get_edition_from_work_data(self, data):
|
||||
|
@ -195,7 +183,7 @@ class Connector(AbstractConnector):
|
|||
# cover may or may not be an absolute url already
|
||||
if re.match(r"^http", cover_id):
|
||||
return cover_id
|
||||
return "%s%s" % (self.covers_url, cover_id)
|
||||
return f"{self.covers_url}{cover_id}"
|
||||
|
||||
def resolve_keys(self, keys):
|
||||
"""cool, it's "wd:Q3156592" now what the heck does that mean"""
|
||||
|
@ -213,9 +201,7 @@ class Connector(AbstractConnector):
|
|||
link = links.get("enwiki")
|
||||
if not link:
|
||||
return ""
|
||||
url = "{:s}/api/data?action=wp-extract&lang=en&title={:s}".format(
|
||||
self.base_url, link
|
||||
)
|
||||
url = f"{self.base_url}/api/data?action=wp-extract&lang=en&title={link}"
|
||||
try:
|
||||
data = get_data(url)
|
||||
except ConnectorException:
|
||||
|
|
|
@ -71,7 +71,7 @@ class Connector(AbstractConnector):
|
|||
key = data["key"]
|
||||
except KeyError:
|
||||
raise ConnectorException("Invalid book data")
|
||||
return "%s%s" % (self.books_url, key)
|
||||
return f"{self.books_url}{key}"
|
||||
|
||||
def is_work_data(self, data):
|
||||
return bool(re.match(r"^[\/\w]+OL\d+W$", data["key"]))
|
||||
|
@ -81,7 +81,7 @@ class Connector(AbstractConnector):
|
|||
key = data["key"]
|
||||
except KeyError:
|
||||
raise ConnectorException("Invalid book data")
|
||||
url = "%s%s/editions" % (self.books_url, key)
|
||||
url = f"{self.books_url}{key}/editions"
|
||||
data = self.get_book_data(url)
|
||||
edition = pick_default_edition(data["entries"])
|
||||
if not edition:
|
||||
|
@ -93,7 +93,7 @@ class Connector(AbstractConnector):
|
|||
key = data["works"][0]["key"]
|
||||
except (IndexError, KeyError):
|
||||
raise ConnectorException("No work found for edition")
|
||||
url = "%s%s" % (self.books_url, key)
|
||||
url = f"{self.books_url}{key}"
|
||||
return self.get_book_data(url)
|
||||
|
||||
def get_authors_from_data(self, data):
|
||||
|
@ -102,7 +102,7 @@ class Connector(AbstractConnector):
|
|||
author_blob = author_blob.get("author", author_blob)
|
||||
# this id is "/authors/OL1234567A"
|
||||
author_id = author_blob["key"]
|
||||
url = "%s%s" % (self.base_url, author_id)
|
||||
url = f"{self.base_url}{author_id}"
|
||||
author = self.get_or_create_author(url)
|
||||
if not author:
|
||||
continue
|
||||
|
@ -113,8 +113,8 @@ class Connector(AbstractConnector):
|
|||
if not cover_blob:
|
||||
return None
|
||||
cover_id = cover_blob[0]
|
||||
image_name = "%s-%s.jpg" % (cover_id, size)
|
||||
return "%s/b/id/%s" % (self.covers_url, image_name)
|
||||
image_name = f"{cover_id}-{size}.jpg"
|
||||
return f"{self.covers_url}/b/id/{image_name}"
|
||||
|
||||
def parse_search_data(self, data):
|
||||
return data.get("docs")
|
||||
|
@ -152,7 +152,7 @@ class Connector(AbstractConnector):
|
|||
|
||||
def load_edition_data(self, olkey):
|
||||
"""query openlibrary for editions of a work"""
|
||||
url = "%s/works/%s/editions" % (self.books_url, olkey)
|
||||
url = f"{self.books_url}/works/{olkey}/editions"
|
||||
return self.get_book_data(url)
|
||||
|
||||
def expand_book_data(self, book):
|
||||
|
|
|
@ -71,7 +71,7 @@ class Connector(AbstractConnector):
|
|||
def format_search_result(self, search_result):
|
||||
cover = None
|
||||
if search_result.cover:
|
||||
cover = "%s%s" % (self.covers_url, search_result.cover)
|
||||
cover = f"{self.covers_url}{search_result.cover}"
|
||||
|
||||
return SearchResult(
|
||||
title=search_result.title,
|
||||
|
|
|
@ -15,4 +15,5 @@ def site_settings(request): # pylint: disable=unused-argument
|
|||
"media_full_url": settings.MEDIA_FULL_URL,
|
||||
"preview_images_enabled": settings.ENABLE_PREVIEW_IMAGES,
|
||||
"request_protocol": request_protocol,
|
||||
"js_cache": settings.JS_CACHE,
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ def email_data():
|
|||
"""fields every email needs"""
|
||||
site = models.SiteSettings.objects.get()
|
||||
if site.logo_small:
|
||||
logo_path = "/images/{}".format(site.logo_small.url)
|
||||
logo_path = f"/images/{site.logo_small.url}"
|
||||
else:
|
||||
logo_path = "/static/images/logo-small.png"
|
||||
|
||||
|
@ -48,23 +48,17 @@ def password_reset_email(reset_code):
|
|||
|
||||
def format_email(email_name, data):
|
||||
"""render the email templates"""
|
||||
subject = (
|
||||
get_template("email/{}/subject.html".format(email_name)).render(data).strip()
|
||||
)
|
||||
subject = get_template(f"email/{email_name}/subject.html").render(data).strip()
|
||||
html_content = (
|
||||
get_template("email/{}/html_content.html".format(email_name))
|
||||
.render(data)
|
||||
.strip()
|
||||
get_template(f"email/{email_name}/html_content.html").render(data).strip()
|
||||
)
|
||||
text_content = (
|
||||
get_template("email/{}/text_content.html".format(email_name))
|
||||
.render(data)
|
||||
.strip()
|
||||
get_template(f"email/{email_name}/text_content.html").render(data).strip()
|
||||
)
|
||||
return (subject, html_content, text_content)
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(queue="high_priority")
|
||||
def send_email(recipient, subject, html_content, text_content):
|
||||
"""use a task to send the email"""
|
||||
email = EmailMultiAlternatives(
|
||||
|
|
|
@ -101,6 +101,8 @@ class QuotationForm(CustomForm):
|
|||
"content_warning",
|
||||
"sensitive",
|
||||
"privacy",
|
||||
"position",
|
||||
"position_mode",
|
||||
]
|
||||
|
||||
|
||||
|
@ -123,6 +125,12 @@ class StatusForm(CustomForm):
|
|||
fields = ["user", "content", "content_warning", "sensitive", "privacy"]
|
||||
|
||||
|
||||
class DirectForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Status
|
||||
fields = ["user", "content", "content_warning", "sensitive", "privacy"]
|
||||
|
||||
|
||||
class EditUserForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.User
|
||||
|
@ -132,6 +140,7 @@ class EditUserForm(CustomForm):
|
|||
"email",
|
||||
"summary",
|
||||
"show_goal",
|
||||
"show_suggested_users",
|
||||
"manually_approves_followers",
|
||||
"default_post_privacy",
|
||||
"discoverable",
|
||||
|
@ -219,7 +228,7 @@ class ExpiryWidget(widgets.Select):
|
|||
elif selected_string == "forever":
|
||||
return None
|
||||
else:
|
||||
return selected_string # "This will raise
|
||||
return selected_string # This will raise
|
||||
|
||||
return timezone.now() + interval
|
||||
|
||||
|
@ -251,10 +260,7 @@ class CreateInviteForm(CustomForm):
|
|||
]
|
||||
),
|
||||
"use_limit": widgets.Select(
|
||||
choices=[
|
||||
(i, _("%(count)d uses" % {"count": i}))
|
||||
for i in [1, 5, 10, 25, 50, 100]
|
||||
]
|
||||
choices=[(i, _(f"{i} uses")) for i in [1, 5, 10, 25, 50, 100]]
|
||||
+ [(None, _("Unlimited"))]
|
||||
),
|
||||
}
|
||||
|
@ -296,6 +302,18 @@ class ReportForm(CustomForm):
|
|||
fields = ["user", "reporter", "statuses", "note"]
|
||||
|
||||
|
||||
class EmailBlocklistForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.EmailBlocklist
|
||||
fields = ["domain"]
|
||||
|
||||
|
||||
class IPBlocklistForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.IPBlocklist
|
||||
fields = ["address"]
|
||||
|
||||
|
||||
class ServerForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.FederatedServer
|
||||
|
|
|
@ -3,6 +3,7 @@ import csv
|
|||
import logging
|
||||
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.models import ImportJob, ImportItem
|
||||
|
@ -61,7 +62,7 @@ class Importer:
|
|||
job.save()
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(queue="low_priority")
|
||||
def import_data(source, job_id):
|
||||
"""does the actual lookup work in a celery task"""
|
||||
job = ImportJob.objects.get(id=job_id)
|
||||
|
@ -71,19 +72,20 @@ def import_data(source, job_id):
|
|||
item.resolve()
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
logger.exception(err)
|
||||
item.fail_reason = "Error loading book"
|
||||
item.fail_reason = _("Error loading book")
|
||||
item.save()
|
||||
continue
|
||||
|
||||
if item.book:
|
||||
if item.book or item.book_guess:
|
||||
item.save()
|
||||
|
||||
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.fail_reason = _("Could not find a match for book")
|
||||
item.save()
|
||||
finally:
|
||||
job.complete = True
|
||||
|
@ -125,6 +127,7 @@ def handle_imported_book(source, user, item, include_reviews, privacy):
|
|||
# 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,
|
||||
|
|
3
bookwyrm/middleware/__init__.py
Normal file
3
bookwyrm/middleware/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
""" look at all this nice middleware! """
|
||||
from .timezone_middleware import TimezoneMiddleware
|
||||
from .ip_middleware import IPBlocklistMiddleware
|
16
bookwyrm/middleware/ip_middleware.py
Normal file
16
bookwyrm/middleware/ip_middleware.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
""" Block IP addresses """
|
||||
from django.http import Http404
|
||||
from bookwyrm import models
|
||||
|
||||
|
||||
class IPBlocklistMiddleware:
|
||||
"""check incoming traffic against an IP block-list"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
address = request.META.get("REMOTE_ADDR")
|
||||
if models.IPBlocklist.objects.filter(address=address).exists():
|
||||
raise Http404()
|
||||
return self.get_response(request)
|
40
bookwyrm/migrations/0086_auto_20210827_1727.py
Normal file
40
bookwyrm/migrations/0086_auto_20210827_1727.py
Normal file
|
@ -0,0 +1,40 @@
|
|||
# Generated by Django 3.2.4 on 2021-08-27 17:27
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.expressions
|
||||
|
||||
|
||||
def normalize_readthrough_dates(app_registry, schema_editor):
|
||||
"""Find any invalid dates and reset them"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
app_registry.get_model("bookwyrm", "ReadThrough").objects.using(db_alias).filter(
|
||||
start_date__gt=models.F("finish_date")
|
||||
).update(start_date=models.F("finish_date"))
|
||||
|
||||
|
||||
def reverse_func(apps, schema_editor):
|
||||
"""nothing to do here"""
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0085_user_saved_lists"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(normalize_readthrough_dates, reverse_func),
|
||||
migrations.AlterModelOptions(
|
||||
name="readthrough",
|
||||
options={"ordering": ("-start_date",)},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="readthrough",
|
||||
constraint=models.CheckConstraint(
|
||||
check=models.Q(
|
||||
("finish_date__gte", django.db.models.expressions.F("start_date"))
|
||||
),
|
||||
name="chronology",
|
||||
),
|
||||
),
|
||||
]
|
49
bookwyrm/migrations/0086_auto_20210828_1724.py
Normal file
49
bookwyrm/migrations/0086_auto_20210828_1724.py
Normal file
|
@ -0,0 +1,49 @@
|
|||
# Generated by Django 3.2.4 on 2021-08-28 17:24
|
||||
|
||||
import bookwyrm.models.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
from django.db.models import F, Value, CharField
|
||||
from django.db.models.functions import Concat
|
||||
|
||||
|
||||
def forwards_func(apps, schema_editor):
|
||||
"""generate followers url"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
apps.get_model("bookwyrm", "User").objects.using(db_alias).annotate(
|
||||
generated_url=Concat(
|
||||
F("remote_id"), Value("/followers"), output_field=CharField()
|
||||
)
|
||||
).update(followers_url=models.F("generated_url"))
|
||||
|
||||
|
||||
def reverse_func(apps, schema_editor):
|
||||
"""noop"""
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0085_user_saved_lists"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="followers_url",
|
||||
field=bookwyrm.models.fields.CharField(
|
||||
default="/followers", max_length=255
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.RunPython(forwards_func, reverse_func),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="followers",
|
||||
field=models.ManyToManyField(
|
||||
related_name="following",
|
||||
through="bookwyrm.UserFollows",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,13 @@
|
|||
# Generated by Django 3.2.4 on 2021-08-29 18:19
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0086_auto_20210827_1727"),
|
||||
("bookwyrm", "0086_auto_20210828_1724"),
|
||||
]
|
||||
|
||||
operations = []
|
34
bookwyrm/migrations/0088_auto_20210905_2233.py
Normal file
34
bookwyrm/migrations/0088_auto_20210905_2233.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-05 22:33
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0087_merge_0086_auto_20210827_1727_0086_auto_20210828_1724"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="quotation",
|
||||
name="position",
|
||||
field=models.IntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[django.core.validators.MinValueValidator(0)],
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="quotation",
|
||||
name="position_mode",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[("PG", "page"), ("PCT", "percent")],
|
||||
default="PG",
|
||||
max_length=3,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
18
bookwyrm/migrations/0089_user_show_suggested_users.py
Normal file
18
bookwyrm/migrations/0089_user_show_suggested_users.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-08 16:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0088_auto_20210905_2233"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="show_suggested_users",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
45
bookwyrm/migrations/0090_auto_20210908_2346.py
Normal file
45
bookwyrm/migrations/0090_auto_20210908_2346.py
Normal file
|
@ -0,0 +1,45 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-08 23:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0089_user_show_suggested_users"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="connector",
|
||||
name="deactivation_reason",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("self_deletion", "Self Deletion"),
|
||||
("moderator_suspension", "Moderator Suspension"),
|
||||
("moderator_deletion", "Moderator Deletion"),
|
||||
("domain_block", "Domain Block"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="deactivation_reason",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("self_deletion", "Self Deletion"),
|
||||
("moderator_suspension", "Moderator Suspension"),
|
||||
("moderator_deletion", "Moderator Deletion"),
|
||||
("domain_block", "Domain Block"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
32
bookwyrm/migrations/0090_emailblocklist.py
Normal file
32
bookwyrm/migrations/0090_emailblocklist.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-08 22:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0089_user_show_suggested_users"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="EmailBlocklist",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_date", models.DateTimeField(auto_now_add=True)),
|
||||
("domain", models.CharField(max_length=255, unique=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ("-created_date",),
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,13 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-09 00:43
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0090_auto_20210908_2346"),
|
||||
("bookwyrm", "0090_emailblocklist"),
|
||||
]
|
||||
|
||||
operations = []
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-10 18:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0091_merge_0090_auto_20210908_2346_0090_emailblocklist"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="sitesettings",
|
||||
name="instance_short_description",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-10 19:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0092_sitesettings_instance_short_description"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="sitesettings",
|
||||
name="instance_short_description",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
39
bookwyrm/migrations/0094_auto_20210911_1550.py
Normal file
39
bookwyrm/migrations/0094_auto_20210911_1550.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-11 15:50
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db.models import F, Value, CharField
|
||||
|
||||
|
||||
def set_deactivate_date(apps, schema_editor):
|
||||
"""best-guess for deactivation date"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
apps.get_model("bookwyrm", "User").objects.using(db_alias).filter(
|
||||
is_active=False
|
||||
).update(deactivation_date=models.F("last_active_date"))
|
||||
|
||||
|
||||
def reverse_func(apps, schema_editor):
|
||||
"""noop"""
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0093_alter_sitesettings_instance_short_description"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="deactivation_date",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="saved_lists",
|
||||
field=models.ManyToManyField(
|
||||
blank=True, related_name="saved_lists", to="bookwyrm.List"
|
||||
),
|
||||
),
|
||||
migrations.RunPython(set_deactivate_date, reverse_func),
|
||||
]
|
25
bookwyrm/migrations/0094_importitem_book_guess.py
Normal file
25
bookwyrm/migrations/0094_importitem_book_guess.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-11 14:22
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0093_alter_sitesettings_instance_short_description"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="importitem",
|
||||
name="book_guess",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="book_guess",
|
||||
to="bookwyrm.book",
|
||||
),
|
||||
),
|
||||
]
|
45
bookwyrm/migrations/0095_auto_20210911_2053.py
Normal file
45
bookwyrm/migrations/0095_auto_20210911_2053.py
Normal file
|
@ -0,0 +1,45 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-11 20:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0094_auto_20210911_1550"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="connector",
|
||||
name="deactivation_reason",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("self_deletion", "Self deletion"),
|
||||
("moderator_suspension", "Moderator suspension"),
|
||||
("moderator_deletion", "Moderator deletion"),
|
||||
("domain_block", "Domain block"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="deactivation_reason",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("self_deletion", "Self deletion"),
|
||||
("moderator_suspension", "Moderator suspension"),
|
||||
("moderator_deletion", "Moderator deletion"),
|
||||
("domain_block", "Domain block"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
13
bookwyrm/migrations/0095_merge_20210911_2143.py
Normal file
13
bookwyrm/migrations/0095_merge_20210911_2143.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-11 21:43
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0094_auto_20210911_1550"),
|
||||
("bookwyrm", "0094_importitem_book_guess"),
|
||||
]
|
||||
|
||||
operations = []
|
13
bookwyrm/migrations/0096_merge_20210912_0044.py
Normal file
13
bookwyrm/migrations/0096_merge_20210912_0044.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-12 00:44
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0095_auto_20210911_2053"),
|
||||
("bookwyrm", "0095_merge_20210911_2143"),
|
||||
]
|
||||
|
||||
operations = []
|
38
bookwyrm/migrations/0097_auto_20210917_1858.py
Normal file
38
bookwyrm/migrations/0097_auto_20210917_1858.py
Normal file
|
@ -0,0 +1,38 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-17 18:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0096_merge_20210912_0044"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="IPBlocklist",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_date", models.DateTimeField(auto_now_add=True)),
|
||||
("address", models.CharField(max_length=255, unique=True)),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ("-created_date",),
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="emailblocklist",
|
||||
name="is_active",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
27
bookwyrm/migrations/0098_auto_20210918_2238.py
Normal file
27
bookwyrm/migrations/0098_auto_20210918_2238.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-18 22:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0097_auto_20210917_1858"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="sitesettings",
|
||||
name="invite_request_text",
|
||||
field=models.TextField(
|
||||
default="If your request is approved, you will receive an email with a registration link."
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="sitesettings",
|
||||
name="registration_closed_text",
|
||||
field=models.TextField(
|
||||
default='We aren\'t taking new users at this time. You can find an open instance at <a href="https://joinbookwyrm.com/instances">joinbookwyrm.com/instances</a>.'
|
||||
),
|
||||
),
|
||||
]
|
37
bookwyrm/migrations/0099_readthrough_is_active.py
Normal file
37
bookwyrm/migrations/0099_readthrough_is_active.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-22 16:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def set_active_readthrough(apps, schema_editor):
|
||||
"""best-guess for deactivation date"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
apps.get_model("bookwyrm", "ReadThrough").objects.using(db_alias).filter(
|
||||
start_date__isnull=False,
|
||||
finish_date__isnull=True,
|
||||
).update(is_active=True)
|
||||
|
||||
|
||||
def reverse_func(apps, schema_editor):
|
||||
"""noop"""
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0098_auto_20210918_2238"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="readthrough",
|
||||
name="is_active",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.RunPython(set_active_readthrough, reverse_func),
|
||||
migrations.AlterField(
|
||||
model_name="readthrough",
|
||||
name="is_active",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
|
@ -14,7 +14,6 @@ from .status import Review, ReviewRating
|
|||
from .status import Boost
|
||||
from .attachment import Image
|
||||
from .favorite import Favorite
|
||||
from .notification import Notification
|
||||
from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
|
||||
|
||||
from .user import User, KeyPair, AnnualGoal
|
||||
|
@ -24,8 +23,12 @@ from .federated_server import FederatedServer
|
|||
|
||||
from .import_job import ImportJob, ImportItem
|
||||
|
||||
from .site import SiteSettings, SiteInvite, PasswordReset, InviteRequest
|
||||
from .site import SiteSettings, SiteInvite
|
||||
from .site import PasswordReset, InviteRequest
|
||||
from .announcement import Announcement
|
||||
from .antispam import EmailBlocklist, IPBlocklist
|
||||
|
||||
from .notification import Notification
|
||||
|
||||
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
|
||||
activity_models = {
|
||||
|
|
|
@ -266,7 +266,7 @@ class ObjectMixin(ActivitypubMixin):
|
|||
signed_message = signer.sign(SHA256.new(content.encode("utf8")))
|
||||
|
||||
signature = activitypub.Signature(
|
||||
creator="%s#main-key" % user.remote_id,
|
||||
creator=f"{user.remote_id}#main-key",
|
||||
created=activity_object.published,
|
||||
signatureValue=b64encode(signed_message).decode("utf8"),
|
||||
)
|
||||
|
@ -285,16 +285,16 @@ class ObjectMixin(ActivitypubMixin):
|
|||
return activitypub.Delete(
|
||||
id=self.remote_id + "/activity",
|
||||
actor=user.remote_id,
|
||||
to=["%s/followers" % user.remote_id],
|
||||
to=[f"{user.remote_id}/followers"],
|
||||
cc=["https://www.w3.org/ns/activitystreams#Public"],
|
||||
object=self,
|
||||
).serialize()
|
||||
|
||||
def to_update_activity(self, user):
|
||||
"""wrapper for Updates to an activity"""
|
||||
activity_id = "%s#update/%s" % (self.remote_id, uuid4())
|
||||
uuid = uuid4()
|
||||
return activitypub.Update(
|
||||
id=activity_id,
|
||||
id=f"{self.remote_id}#update/{uuid}",
|
||||
actor=user.remote_id,
|
||||
to=["https://www.w3.org/ns/activitystreams#Public"],
|
||||
object=self,
|
||||
|
@ -337,8 +337,8 @@ class OrderedCollectionPageMixin(ObjectMixin):
|
|||
paginated = Paginator(queryset, PAGE_LENGTH)
|
||||
# add computed fields specific to orderd collections
|
||||
activity["totalItems"] = paginated.count
|
||||
activity["first"] = "%s?page=1" % remote_id
|
||||
activity["last"] = "%s?page=%d" % (remote_id, paginated.num_pages)
|
||||
activity["first"] = f"{remote_id}?page=1"
|
||||
activity["last"] = f"{remote_id}?page={paginated.num_pages}"
|
||||
|
||||
return serializer(**activity)
|
||||
|
||||
|
@ -362,6 +362,13 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin):
|
|||
self.collection_queryset, **kwargs
|
||||
).serialize()
|
||||
|
||||
def delete(self, *args, broadcast=True, **kwargs):
|
||||
"""Delete the object"""
|
||||
activity = self.to_delete_activity(self.user)
|
||||
super().delete(*args, **kwargs)
|
||||
if self.user.local and broadcast:
|
||||
self.broadcast(activity, self.user)
|
||||
|
||||
|
||||
class CollectionItemMixin(ActivitypubMixin):
|
||||
"""for items that are part of an (Ordered)Collection"""
|
||||
|
@ -413,7 +420,7 @@ class CollectionItemMixin(ActivitypubMixin):
|
|||
"""AP for shelving a book"""
|
||||
collection_field = getattr(self, self.collection_field)
|
||||
return activitypub.Add(
|
||||
id="{:s}#add".format(collection_field.remote_id),
|
||||
id=f"{collection_field.remote_id}#add",
|
||||
actor=user.remote_id,
|
||||
object=self.to_activity_dataclass(),
|
||||
target=collection_field.remote_id,
|
||||
|
@ -423,7 +430,7 @@ class CollectionItemMixin(ActivitypubMixin):
|
|||
"""AP for un-shelving a book"""
|
||||
collection_field = getattr(self, self.collection_field)
|
||||
return activitypub.Remove(
|
||||
id="{:s}#remove".format(collection_field.remote_id),
|
||||
id=f"{collection_field.remote_id}#remove",
|
||||
actor=user.remote_id,
|
||||
object=self.to_activity_dataclass(),
|
||||
target=collection_field.remote_id,
|
||||
|
@ -451,7 +458,7 @@ class ActivityMixin(ActivitypubMixin):
|
|||
"""undo an action"""
|
||||
user = self.user if hasattr(self, "user") else self.user_subject
|
||||
return activitypub.Undo(
|
||||
id="%s#undo" % self.remote_id,
|
||||
id=f"{self.remote_id}#undo",
|
||||
actor=user.remote_id,
|
||||
object=self,
|
||||
).serialize()
|
||||
|
@ -495,7 +502,7 @@ def unfurl_related_field(related_field, sort_field=None):
|
|||
return related_field.remote_id
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(queue="medium_priority")
|
||||
def broadcast_task(sender_id, activity, recipients):
|
||||
"""the celery task for broadcast"""
|
||||
user_model = apps.get_model("bookwyrm.User", require_ready=True)
|
||||
|
@ -548,11 +555,11 @@ def to_ordered_collection_page(
|
|||
|
||||
prev_page = next_page = None
|
||||
if activity_page.has_next():
|
||||
next_page = "%s?page=%d" % (remote_id, activity_page.next_page_number())
|
||||
next_page = f"{remote_id}?page={activity_page.next_page_number()}"
|
||||
if activity_page.has_previous():
|
||||
prev_page = "%s?page=%d" % (remote_id, activity_page.previous_page_number())
|
||||
prev_page = f"{remote_id}?page=%d{activity_page.previous_page_number()}"
|
||||
return activitypub.OrderedCollectionPage(
|
||||
id="%s?page=%s" % (remote_id, page),
|
||||
id=f"{remote_id}?page={page}",
|
||||
partOf=remote_id,
|
||||
orderedItems=items,
|
||||
next=next_page,
|
||||
|
|
35
bookwyrm/models/antispam.py
Normal file
35
bookwyrm/models/antispam.py
Normal file
|
@ -0,0 +1,35 @@
|
|||
""" Lets try NOT to sell viagra """
|
||||
from django.db import models
|
||||
|
||||
from .user import User
|
||||
|
||||
|
||||
class EmailBlocklist(models.Model):
|
||||
"""blocked email addresses"""
|
||||
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
domain = models.CharField(max_length=255, unique=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
"""default sorting"""
|
||||
|
||||
ordering = ("-created_date",)
|
||||
|
||||
@property
|
||||
def users(self):
|
||||
"""find the users associated with this address"""
|
||||
return User.objects.filter(email__endswith=f"@{self.domain}")
|
||||
|
||||
|
||||
class IPBlocklist(models.Model):
|
||||
"""blocked ip addresses"""
|
||||
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
address = models.CharField(max_length=255, unique=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
"""default sorting"""
|
||||
|
||||
ordering = ("-created_date",)
|
|
@ -35,7 +35,7 @@ class Author(BookDataModel):
|
|||
|
||||
def get_remote_id(self):
|
||||
"""editions and works both use "book" instead of model_name"""
|
||||
return "https://%s/author/%s" % (DOMAIN, self.id)
|
||||
return f"https://{DOMAIN}/author/{self.id}"
|
||||
|
||||
activity_serializer = activitypub.Author
|
||||
|
||||
|
|
|
@ -1,22 +1,24 @@
|
|||
""" base model with default fields """
|
||||
import base64
|
||||
from Crypto import Random
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import models
|
||||
from django.dispatch import receiver
|
||||
from django.http import Http404
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from .fields import RemoteIdField
|
||||
|
||||
|
||||
DeactivationReason = models.TextChoices(
|
||||
"DeactivationReason",
|
||||
[
|
||||
"pending",
|
||||
"self_deletion",
|
||||
"moderator_deletion",
|
||||
"domain_block",
|
||||
],
|
||||
)
|
||||
DeactivationReason = [
|
||||
("pending", _("Pending")),
|
||||
("self_deletion", _("Self deletion")),
|
||||
("moderator_suspension", _("Moderator suspension")),
|
||||
("moderator_deletion", _("Moderator deletion")),
|
||||
("domain_block", _("Domain block")),
|
||||
]
|
||||
|
||||
|
||||
def new_access_code():
|
||||
|
@ -33,11 +35,11 @@ class BookWyrmModel(models.Model):
|
|||
|
||||
def get_remote_id(self):
|
||||
"""generate a url that resolves to the local object"""
|
||||
base_path = "https://%s" % DOMAIN
|
||||
base_path = f"https://{DOMAIN}"
|
||||
if hasattr(self, "user"):
|
||||
base_path = "%s%s" % (base_path, self.user.local_path)
|
||||
base_path = f"{base_path}{self.user.local_path}"
|
||||
model_name = type(self).__name__.lower()
|
||||
return "%s/%s/%d" % (base_path, model_name, self.id)
|
||||
return f"{base_path}/{model_name}/{self.id}"
|
||||
|
||||
class Meta:
|
||||
"""this is just here to provide default fields for other models"""
|
||||
|
@ -47,28 +49,28 @@ class BookWyrmModel(models.Model):
|
|||
@property
|
||||
def local_path(self):
|
||||
"""how to link to this object in the local app"""
|
||||
return self.get_remote_id().replace("https://%s" % DOMAIN, "")
|
||||
return self.get_remote_id().replace(f"https://{DOMAIN}", "")
|
||||
|
||||
def visible_to_user(self, viewer):
|
||||
def raise_visible_to_user(self, viewer):
|
||||
"""is a user authorized to view an object?"""
|
||||
# make sure this is an object with privacy owned by a user
|
||||
if not hasattr(self, "user") or not hasattr(self, "privacy"):
|
||||
return None
|
||||
return
|
||||
|
||||
# viewer can't see it if the object's owner blocked them
|
||||
if viewer in self.user.blocks.all():
|
||||
return False
|
||||
raise Http404()
|
||||
|
||||
# you can see your own posts and any public or unlisted posts
|
||||
if viewer == self.user or self.privacy in ["public", "unlisted"]:
|
||||
return True
|
||||
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()
|
||||
):
|
||||
return True
|
||||
return
|
||||
|
||||
# you can see dms you are tagged in
|
||||
if hasattr(self, "mention_users"):
|
||||
|
@ -76,8 +78,32 @@ class BookWyrmModel(models.Model):
|
|||
self.privacy == "direct"
|
||||
and self.mention_users.filter(id=viewer.id).first()
|
||||
):
|
||||
return True
|
||||
return False
|
||||
return
|
||||
raise Http404()
|
||||
|
||||
def raise_not_editable(self, viewer):
|
||||
"""does this user have permission to edit this object? liable to be overwritten
|
||||
by models that inherit this base model class"""
|
||||
if not hasattr(self, "user"):
|
||||
return
|
||||
|
||||
# generally moderators shouldn't be able to edit other people's stuff
|
||||
if self.user == viewer:
|
||||
return
|
||||
|
||||
raise PermissionDenied()
|
||||
|
||||
def raise_not_deletable(self, viewer):
|
||||
"""does this user have permission to delete this object? liable to be
|
||||
overwritten by models that inherit this base model class"""
|
||||
if not hasattr(self, "user"):
|
||||
return
|
||||
|
||||
# but generally moderators can delete other people's stuff
|
||||
if self.user == viewer or viewer.has_perm("moderate_post"):
|
||||
return
|
||||
|
||||
raise PermissionDenied()
|
||||
|
||||
|
||||
@receiver(models.signals.post_save)
|
||||
|
|
|
@ -3,7 +3,8 @@ import re
|
|||
|
||||
from django.contrib.postgres.search import SearchVectorField
|
||||
from django.contrib.postgres.indexes import GinIndex
|
||||
from django.db import models
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Prefetch
|
||||
from django.dispatch import receiver
|
||||
from model_utils import FieldTracker
|
||||
from model_utils.managers import InheritanceManager
|
||||
|
@ -163,9 +164,9 @@ class Book(BookDataModel):
|
|||
@property
|
||||
def alt_text(self):
|
||||
"""image alt test"""
|
||||
text = "%s" % self.title
|
||||
text = self.title
|
||||
if self.edition_info:
|
||||
text += " (%s)" % self.edition_info
|
||||
text += f" ({self.edition_info})"
|
||||
return text
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
@ -176,9 +177,10 @@ class Book(BookDataModel):
|
|||
|
||||
def get_remote_id(self):
|
||||
"""editions and works both use "book" instead of model_name"""
|
||||
return "https://%s/book/%d" % (DOMAIN, self.id)
|
||||
return f"https://{DOMAIN}/book/{self.id}"
|
||||
|
||||
def __repr__(self):
|
||||
# pylint: disable=consider-using-f-string
|
||||
return "<{} key={!r} title={!r}>".format(
|
||||
self.__class__,
|
||||
self.openlibrary_key,
|
||||
|
@ -215,7 +217,7 @@ class Work(OrderedCollectionPageMixin, Book):
|
|||
"""an ordered collection of editions"""
|
||||
return self.to_ordered_collection(
|
||||
self.editions.order_by("-edition_rank").all(),
|
||||
remote_id="%s/editions" % self.remote_id,
|
||||
remote_id=f"{self.remote_id}/editions",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
@ -305,6 +307,27 @@ class Edition(Book):
|
|||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def viewer_aware_objects(cls, viewer):
|
||||
"""annotate a book query with metadata related to the user"""
|
||||
queryset = cls.objects
|
||||
if not viewer or not viewer.is_authenticated:
|
||||
return queryset
|
||||
|
||||
queryset = queryset.prefetch_related(
|
||||
Prefetch(
|
||||
"shelfbook_set",
|
||||
queryset=viewer.shelfbook_set.all(),
|
||||
to_attr="current_shelves",
|
||||
),
|
||||
Prefetch(
|
||||
"readthrough_set",
|
||||
queryset=viewer.readthrough_set.filter(is_active=True).all(),
|
||||
to_attr="active_readthroughs",
|
||||
),
|
||||
)
|
||||
return queryset
|
||||
|
||||
|
||||
def isbn_10_to_13(isbn_10):
|
||||
"""convert an isbn 10 into an isbn 13"""
|
||||
|
@ -361,4 +384,6 @@ def preview_image(instance, *args, **kwargs):
|
|||
changed_fields = instance.field_tracker.changed()
|
||||
|
||||
if len(changed_fields) > 0:
|
||||
generate_edition_preview_image_task.delay(instance.id)
|
||||
transaction.on_commit(
|
||||
lambda: generate_edition_preview_image_task.delay(instance.id)
|
||||
)
|
||||
|
|
|
@ -19,7 +19,7 @@ class Connector(BookWyrmModel):
|
|||
api_key = models.CharField(max_length=255, null=True, blank=True)
|
||||
active = models.BooleanField(default=True)
|
||||
deactivation_reason = models.CharField(
|
||||
max_length=255, choices=DeactivationReason.choices, null=True, blank=True
|
||||
max_length=255, choices=DeactivationReason, null=True, blank=True
|
||||
)
|
||||
|
||||
base_url = models.CharField(max_length=255)
|
||||
|
@ -29,7 +29,4 @@ class Connector(BookWyrmModel):
|
|||
isbn_search_url = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return "{} ({})".format(
|
||||
self.identifier,
|
||||
self.id,
|
||||
)
|
||||
return f"{self.identifier} ({self.id})"
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
""" like/fav/star a status """
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from .activitypub_mixin import ActivityMixin
|
||||
|
@ -29,38 +27,9 @@ class Favorite(ActivityMixin, BookWyrmModel):
|
|||
|
||||
def save(self, *args, **kwargs):
|
||||
"""update user active time"""
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save(broadcast=False, update_fields=["last_active_date"])
|
||||
self.user.update_active_date()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if self.status.user.local and self.status.user != self.user:
|
||||
notification_model = apps.get_model(
|
||||
"bookwyrm.Notification", require_ready=True
|
||||
)
|
||||
notification_model.objects.create(
|
||||
user=self.status.user,
|
||||
notification_type="FAVORITE",
|
||||
related_user=self.user,
|
||||
related_status=self.status,
|
||||
)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""delete and delete notifications"""
|
||||
# check for notification
|
||||
if self.status.user.local:
|
||||
notification_model = apps.get_model(
|
||||
"bookwyrm.Notification", require_ready=True
|
||||
)
|
||||
notification = notification_model.objects.filter(
|
||||
user=self.status.user,
|
||||
related_user=self.user,
|
||||
related_status=self.status,
|
||||
notification_type="FAVORITE",
|
||||
).first()
|
||||
if notification:
|
||||
notification.delete()
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
"""can't fav things twice"""
|
||||
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
""" connections to external ActivityPub servers """
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .base_model import BookWyrmModel
|
||||
|
||||
FederationStatus = models.TextChoices(
|
||||
"Status",
|
||||
[
|
||||
"federated",
|
||||
"blocked",
|
||||
],
|
||||
)
|
||||
FederationStatus = [
|
||||
("federated", _("Federated")),
|
||||
("blocked", _("Blocked")),
|
||||
]
|
||||
|
||||
|
||||
class FederatedServer(BookWyrmModel):
|
||||
|
@ -18,7 +18,7 @@ class FederatedServer(BookWyrmModel):
|
|||
|
||||
server_name = models.CharField(max_length=255, unique=True)
|
||||
status = models.CharField(
|
||||
max_length=255, default="federated", choices=FederationStatus.choices
|
||||
max_length=255, default="federated", choices=FederationStatus
|
||||
)
|
||||
# is it mastodon, bookwyrm, etc
|
||||
application_type = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
@ -28,7 +28,7 @@ class FederatedServer(BookWyrmModel):
|
|||
def block(self):
|
||||
"""block a server"""
|
||||
self.status = "blocked"
|
||||
self.save()
|
||||
self.save(update_fields=["status"])
|
||||
|
||||
# deactivate all associated users
|
||||
self.user_set.filter(is_active=True).update(
|
||||
|
@ -45,7 +45,7 @@ class FederatedServer(BookWyrmModel):
|
|||
def unblock(self):
|
||||
"""unblock a server"""
|
||||
self.status = "federated"
|
||||
self.save()
|
||||
self.save(update_fields=["status"])
|
||||
|
||||
self.user_set.filter(deactivation_reason="domain_block").update(
|
||||
is_active=True, deactivation_reason=None
|
||||
|
|
|
@ -56,7 +56,7 @@ class ActivitypubFieldMixin:
|
|||
activitypub_field=None,
|
||||
activitypub_wrapper=None,
|
||||
deduplication_field=False,
|
||||
**kwargs
|
||||
**kwargs,
|
||||
):
|
||||
self.deduplication_field = deduplication_field
|
||||
if activitypub_wrapper:
|
||||
|
@ -224,8 +224,20 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
|
|||
original = getattr(instance, self.name)
|
||||
to = data.to
|
||||
cc = data.cc
|
||||
|
||||
# we need to figure out who this is to get their followers link
|
||||
for field in ["attributedTo", "owner", "actor"]:
|
||||
if hasattr(data, field):
|
||||
user_field = field
|
||||
break
|
||||
if not user_field:
|
||||
raise ValidationError("No user field found for privacy", data)
|
||||
user = activitypub.resolve_remote_id(getattr(data, user_field), model="User")
|
||||
|
||||
if to == [self.public]:
|
||||
setattr(instance, self.name, "public")
|
||||
elif to == [user.followers_url]:
|
||||
setattr(instance, self.name, "followers")
|
||||
elif cc == []:
|
||||
setattr(instance, self.name, "direct")
|
||||
elif self.public in cc:
|
||||
|
@ -241,9 +253,7 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
|
|||
mentions = [u.remote_id for u in instance.mention_users.all()]
|
||||
# this is a link to the followers list
|
||||
# pylint: disable=protected-access
|
||||
followers = instance.user.__class__._meta.get_field(
|
||||
"followers"
|
||||
).field_to_activity(instance.user.followers)
|
||||
followers = instance.user.followers_url
|
||||
if instance.privacy == "public":
|
||||
activity["to"] = [self.public]
|
||||
activity["cc"] = [followers] + mentions
|
||||
|
@ -298,7 +308,7 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
|
|||
|
||||
def field_to_activity(self, value):
|
||||
if self.link_only:
|
||||
return "%s/%s" % (value.instance.remote_id, self.name)
|
||||
return f"{value.instance.remote_id}/{self.name}"
|
||||
return [i.remote_id for i in value.all()]
|
||||
|
||||
def field_from_activity(self, value):
|
||||
|
@ -378,7 +388,7 @@ def image_serializer(value, alt):
|
|||
else:
|
||||
return None
|
||||
if not url[:4] == "http":
|
||||
url = "https://{:s}{:s}".format(DOMAIN, url)
|
||||
url = f"https://{DOMAIN}{url}"
|
||||
return activitypub.Document(url=url, name=alt)
|
||||
|
||||
|
||||
|
@ -438,7 +448,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
|
|||
|
||||
image_content = ContentFile(response.content)
|
||||
extension = imghdr.what(None, image_content.read()) or ""
|
||||
image_name = "{:s}.{:s}".format(str(uuid4()), extension)
|
||||
image_name = f"{uuid4()}.{extension}"
|
||||
return [image_name, image_content]
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
import re
|
||||
import dateutil.parser
|
||||
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
@ -50,19 +49,6 @@ class ImportJob(models.Model):
|
|||
)
|
||||
retry = models.BooleanField(default=False)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""save and notify"""
|
||||
super().save(*args, **kwargs)
|
||||
if self.complete:
|
||||
notification_model = apps.get_model(
|
||||
"bookwyrm.Notification", require_ready=True
|
||||
)
|
||||
notification_model.objects.create(
|
||||
user=self.user,
|
||||
notification_type="IMPORT",
|
||||
related_import=self,
|
||||
)
|
||||
|
||||
|
||||
class ImportItem(models.Model):
|
||||
"""a single line of a csv being imported"""
|
||||
|
@ -71,6 +57,13 @@ class ImportItem(models.Model):
|
|||
index = models.IntegerField()
|
||||
data = models.JSONField()
|
||||
book = models.ForeignKey(Book, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
book_guess = models.ForeignKey(
|
||||
Book,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="book_guess",
|
||||
)
|
||||
fail_reason = models.TextField(null=True)
|
||||
|
||||
def resolve(self):
|
||||
|
@ -78,9 +71,13 @@ class ImportItem(models.Model):
|
|||
if self.isbn:
|
||||
self.book = self.get_book_from_isbn()
|
||||
else:
|
||||
# don't fall back on title/author search is isbn is present.
|
||||
# don't fall back on title/author search if isbn is present.
|
||||
# you're too likely to mismatch
|
||||
self.book = self.get_book_from_title_author()
|
||||
book, confidence = self.get_book_from_title_author()
|
||||
if confidence > 0.999:
|
||||
self.book = book
|
||||
else:
|
||||
self.book_guess = book
|
||||
|
||||
def get_book_from_isbn(self):
|
||||
"""search by isbn"""
|
||||
|
@ -96,12 +93,15 @@ class ImportItem(models.Model):
|
|||
"""search by title and author"""
|
||||
search_term = construct_search_term(self.title, self.author)
|
||||
search_result = connector_manager.first_search_result(
|
||||
search_term, min_confidence=0.999
|
||||
search_term, min_confidence=0.1
|
||||
)
|
||||
if search_result:
|
||||
# raises ConnectorException
|
||||
return search_result.connector.get_or_create_book(search_result.key)
|
||||
return None
|
||||
return (
|
||||
search_result.connector.get_or_create_book(search_result.key),
|
||||
search_result.confidence,
|
||||
)
|
||||
return None, 0
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
|
@ -174,6 +174,7 @@ 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
|
||||
return [
|
||||
ReadThrough(
|
||||
start_date=start_date,
|
||||
|
@ -183,7 +184,9 @@ class ImportItem(models.Model):
|
|||
return []
|
||||
|
||||
def __repr__(self):
|
||||
# pylint: disable=consider-using-f-string
|
||||
return "<{!r}Item {!r}>".format(self.data["import_source"], self.data["Title"])
|
||||
|
||||
def __str__(self):
|
||||
# pylint: disable=consider-using-f-string
|
||||
return "{} by {}".format(self.data["Title"], self.data["Author"])
|
||||
|
|
|
@ -42,7 +42,7 @@ class List(OrderedCollectionMixin, BookWyrmModel):
|
|||
|
||||
def get_remote_id(self):
|
||||
"""don't want the user to be in there in this case"""
|
||||
return "https://%s/list/%d" % (DOMAIN, self.id)
|
||||
return f"https://{DOMAIN}/list/{self.id}"
|
||||
|
||||
@property
|
||||
def collection_queryset(self):
|
||||
|
@ -92,6 +92,12 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
|
|||
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
|
||||
super().raise_not_deletable(viewer)
|
||||
|
||||
class Meta:
|
||||
"""A book may only be placed into a list once,
|
||||
and each order in the list may be used only once"""
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
""" alert a user to activity """
|
||||
from django.db import models
|
||||
from django.dispatch import receiver
|
||||
from .base_model import BookWyrmModel
|
||||
from . import Boost, Favorite, ImportJob, Report, Status, User
|
||||
|
||||
|
||||
NotificationType = models.TextChoices(
|
||||
|
@ -53,3 +55,127 @@ class Notification(BookWyrmModel):
|
|||
name="notification_type_valid",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=Favorite)
|
||||
# pylint: disable=unused-argument
|
||||
def notify_on_fav(sender, instance, *args, **kwargs):
|
||||
"""someone liked your content, you ARE loved"""
|
||||
if not instance.status.user.local or instance.status.user == instance.user:
|
||||
return
|
||||
Notification.objects.create(
|
||||
user=instance.status.user,
|
||||
notification_type="FAVORITE",
|
||||
related_user=instance.user,
|
||||
related_status=instance.status,
|
||||
)
|
||||
|
||||
|
||||
@receiver(models.signals.post_delete, sender=Favorite)
|
||||
# pylint: disable=unused-argument
|
||||
def notify_on_unfav(sender, instance, *args, **kwargs):
|
||||
"""oops, didn't like that after all"""
|
||||
if not instance.status.user.local:
|
||||
return
|
||||
Notification.objects.filter(
|
||||
user=instance.status.user,
|
||||
related_user=instance.user,
|
||||
related_status=instance.status,
|
||||
notification_type="FAVORITE",
|
||||
).delete()
|
||||
|
||||
|
||||
@receiver(models.signals.post_save)
|
||||
# pylint: disable=unused-argument
|
||||
def notify_user_on_mention(sender, instance, *args, **kwargs):
|
||||
"""creating and deleting statuses with @ mentions and replies"""
|
||||
if not issubclass(sender, Status):
|
||||
return
|
||||
|
||||
if instance.deleted:
|
||||
Notification.objects.filter(related_status=instance).delete()
|
||||
return
|
||||
|
||||
if (
|
||||
instance.reply_parent
|
||||
and instance.reply_parent.user != instance.user
|
||||
and instance.reply_parent.user.local
|
||||
):
|
||||
Notification.objects.create(
|
||||
user=instance.reply_parent.user,
|
||||
notification_type="REPLY",
|
||||
related_user=instance.user,
|
||||
related_status=instance,
|
||||
)
|
||||
for mention_user in instance.mention_users.all():
|
||||
# avoid double-notifying about this status
|
||||
if not mention_user.local or (
|
||||
instance.reply_parent and mention_user == instance.reply_parent.user
|
||||
):
|
||||
continue
|
||||
Notification.objects.create(
|
||||
user=mention_user,
|
||||
notification_type="MENTION",
|
||||
related_user=instance.user,
|
||||
related_status=instance,
|
||||
)
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=Boost)
|
||||
# pylint: disable=unused-argument
|
||||
def notify_user_on_boost(sender, instance, *args, **kwargs):
|
||||
"""boosting a status"""
|
||||
if (
|
||||
not instance.boosted_status.user.local
|
||||
or instance.boosted_status.user == instance.user
|
||||
):
|
||||
return
|
||||
|
||||
Notification.objects.create(
|
||||
user=instance.boosted_status.user,
|
||||
related_status=instance.boosted_status,
|
||||
related_user=instance.user,
|
||||
notification_type="BOOST",
|
||||
)
|
||||
|
||||
|
||||
@receiver(models.signals.post_delete, sender=Boost)
|
||||
# pylint: disable=unused-argument
|
||||
def notify_user_on_unboost(sender, instance, *args, **kwargs):
|
||||
"""unboosting a status"""
|
||||
Notification.objects.filter(
|
||||
user=instance.boosted_status.user,
|
||||
related_status=instance.boosted_status,
|
||||
related_user=instance.user,
|
||||
notification_type="BOOST",
|
||||
).delete()
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=ImportJob)
|
||||
# pylint: disable=unused-argument
|
||||
def notify_user_on_import_complete(sender, instance, *args, **kwargs):
|
||||
"""we imported your books! aren't you proud of us"""
|
||||
if not instance.complete:
|
||||
return
|
||||
Notification.objects.create(
|
||||
user=instance.user,
|
||||
notification_type="IMPORT",
|
||||
related_import=instance,
|
||||
)
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=Report)
|
||||
# pylint: disable=unused-argument
|
||||
def notify_admins_on_report(sender, instance, *args, **kwargs):
|
||||
"""something is up, make sure the admins know"""
|
||||
# moderators and superusers should be notified
|
||||
admins = User.objects.filter(
|
||||
models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
|
||||
| models.Q(is_superuser=True)
|
||||
).all()
|
||||
for admin in admins:
|
||||
Notification.objects.create(
|
||||
user=admin,
|
||||
related_report=instance,
|
||||
notification_type="REPORT",
|
||||
)
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
""" progress in a book """
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.core import validators
|
||||
from django.db import models
|
||||
from django.db.models import F, Q
|
||||
|
||||
from .base_model import BookWyrmModel
|
||||
|
||||
|
@ -26,11 +26,14 @@ class ReadThrough(BookWyrmModel):
|
|||
)
|
||||
start_date = models.DateTimeField(blank=True, null=True)
|
||||
finish_date = models.DateTimeField(blank=True, null=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""update user active time"""
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save(broadcast=False, update_fields=["last_active_date"])
|
||||
self.user.update_active_date()
|
||||
# an active readthrough must have an unset finish date
|
||||
if self.finish_date:
|
||||
self.is_active = False
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def create_update(self):
|
||||
|
@ -41,6 +44,16 @@ class ReadThrough(BookWyrmModel):
|
|||
)
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
"""Don't let readthroughs end before they start"""
|
||||
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=Q(finish_date__gte=F("start_date")), name="chronology"
|
||||
)
|
||||
]
|
||||
ordering = ("-start_date",)
|
||||
|
||||
|
||||
class ProgressUpdate(BookWyrmModel):
|
||||
"""Store progress through a book in the database."""
|
||||
|
@ -54,6 +67,5 @@ class ProgressUpdate(BookWyrmModel):
|
|||
|
||||
def save(self, *args, **kwargs):
|
||||
"""update user active time"""
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save(broadcast=False, update_fields=["last_active_date"])
|
||||
self.user.update_active_date()
|
||||
super().save(*args, **kwargs)
|
||||
|
|
|
@ -53,7 +53,7 @@ class UserRelationship(BookWyrmModel):
|
|||
def get_remote_id(self):
|
||||
"""use shelf identifier in remote_id"""
|
||||
base_path = self.user_subject.remote_id
|
||||
return "%s#follows/%d" % (base_path, self.id)
|
||||
return f"{base_path}#follows/{self.id}"
|
||||
|
||||
|
||||
class UserFollows(ActivityMixin, UserRelationship):
|
||||
|
@ -144,7 +144,8 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
|||
"""get id for sending an accept or reject of a local user"""
|
||||
|
||||
base_path = self.user_object.remote_id
|
||||
return "%s#%s/%d" % (base_path, status, self.id or 0)
|
||||
status_id = self.id or 0
|
||||
return f"{base_path}#{status}/{status_id}"
|
||||
|
||||
def accept(self, broadcast_only=False):
|
||||
"""turn this request into the real deal"""
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
""" flagged for moderation """
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.db.models import F, Q
|
||||
from .base_model import BookWyrmModel
|
||||
|
@ -16,23 +15,6 @@ class Report(BookWyrmModel):
|
|||
statuses = models.ManyToManyField("Status", blank=True)
|
||||
resolved = models.BooleanField(default=False)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""notify admins when a report is created"""
|
||||
super().save(*args, **kwargs)
|
||||
user_model = apps.get_model("bookwyrm.User", require_ready=True)
|
||||
# moderators and superusers should be notified
|
||||
admins = user_model.objects.filter(
|
||||
Q(user_permissions__name__in=["moderate_user", "moderate_post"])
|
||||
| Q(is_superuser=True)
|
||||
).all()
|
||||
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
for admin in admins:
|
||||
notification_model.objects.create(
|
||||
user=admin,
|
||||
related_report=self,
|
||||
notification_type="REPORT",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""don't let users report themselves"""
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
""" puttin' books on shelves """
|
||||
import re
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
@ -44,7 +45,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
|||
def get_identifier(self):
|
||||
"""custom-shelf-123 for the url"""
|
||||
slug = re.sub(r"[^\w]", "", self.name).lower()
|
||||
return "{:s}-{:d}".format(slug, self.id)
|
||||
return f"{slug}-{self.id}"
|
||||
|
||||
@property
|
||||
def collection_queryset(self):
|
||||
|
@ -55,7 +56,13 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
|||
"""shelf identifier instead of id"""
|
||||
base_path = self.user.remote_id
|
||||
identifier = self.identifier or self.get_identifier()
|
||||
return "%s/books/%s" % (base_path, identifier)
|
||||
return f"{base_path}/books/{identifier}"
|
||||
|
||||
def raise_not_deletable(self, viewer):
|
||||
"""don't let anyone delete a default shelf"""
|
||||
super().raise_not_deletable(viewer)
|
||||
if not self.editable:
|
||||
raise PermissionDenied()
|
||||
|
||||
class Meta:
|
||||
"""user/shelf unqiueness"""
|
||||
|
|
|
@ -20,10 +20,17 @@ class SiteSettings(models.Model):
|
|||
max_length=150, default="Social Reading and Reviewing"
|
||||
)
|
||||
instance_description = models.TextField(default="This instance has no description.")
|
||||
instance_short_description = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
# about page
|
||||
registration_closed_text = models.TextField(
|
||||
default="Contact an administrator to get an invite"
|
||||
default="We aren't taking new users at this time. You can find an open "
|
||||
'instance at <a href="https://joinbookwyrm.com/instances">'
|
||||
"joinbookwyrm.com/instances</a>."
|
||||
)
|
||||
invite_request_text = models.TextField(
|
||||
default="If your request is approved, you will receive an email with a "
|
||||
"registration link."
|
||||
)
|
||||
code_of_conduct = models.TextField(default="Add a code of conduct here.")
|
||||
privacy_policy = models.TextField(default="Add a privacy policy here.")
|
||||
|
@ -80,7 +87,7 @@ class SiteInvite(models.Model):
|
|||
@property
|
||||
def link(self):
|
||||
"""formats the invite link"""
|
||||
return "https://{}/invite/{}".format(DOMAIN, self.code)
|
||||
return f"https://{DOMAIN}/invite/{self.code}"
|
||||
|
||||
|
||||
class InviteRequest(BookWyrmModel):
|
||||
|
@ -120,7 +127,7 @@ class PasswordReset(models.Model):
|
|||
@property
|
||||
def link(self):
|
||||
"""formats the invite link"""
|
||||
return "https://{}/password-reset/{}".format(DOMAIN, self.code)
|
||||
return f"https://{DOMAIN}/password-reset/{self.code}"
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
|
|
@ -3,6 +3,7 @@ from dataclasses import MISSING
|
|||
import re
|
||||
|
||||
from django.apps import apps
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.dispatch import receiver
|
||||
|
@ -67,40 +68,6 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
|
||||
ordering = ("-published_date",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""save and notify"""
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
|
||||
if self.deleted:
|
||||
notification_model.objects.filter(related_status=self).delete()
|
||||
return
|
||||
|
||||
if (
|
||||
self.reply_parent
|
||||
and self.reply_parent.user != self.user
|
||||
and self.reply_parent.user.local
|
||||
):
|
||||
notification_model.objects.create(
|
||||
user=self.reply_parent.user,
|
||||
notification_type="REPLY",
|
||||
related_user=self.user,
|
||||
related_status=self,
|
||||
)
|
||||
for mention_user in self.mention_users.all():
|
||||
# avoid double-notifying about this status
|
||||
if not mention_user.local or (
|
||||
self.reply_parent and mention_user == self.reply_parent.user
|
||||
):
|
||||
continue
|
||||
notification_model.objects.create(
|
||||
user=mention_user,
|
||||
notification_type="MENTION",
|
||||
related_user=self.user,
|
||||
related_status=self,
|
||||
)
|
||||
|
||||
def delete(self, *args, **kwargs): # pylint: disable=unused-argument
|
||||
""" "delete" a status"""
|
||||
if hasattr(self, "boosted_status"):
|
||||
|
@ -108,6 +75,10 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
super().delete(*args, **kwargs)
|
||||
return
|
||||
self.deleted = True
|
||||
# clear user content
|
||||
self.content = None
|
||||
if hasattr(self, "quotation"):
|
||||
self.quotation = None # pylint: disable=attribute-defined-outside-init
|
||||
self.deleted_date = timezone.now()
|
||||
self.save()
|
||||
|
||||
|
@ -179,9 +150,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
"""helper function for loading AP serialized replies to a status"""
|
||||
return self.to_ordered_collection(
|
||||
self.replies(self),
|
||||
remote_id="%s/replies" % self.remote_id,
|
||||
remote_id=f"{self.remote_id}/replies",
|
||||
collection_only=True,
|
||||
**kwargs
|
||||
**kwargs,
|
||||
).serialize()
|
||||
|
||||
def to_activity_dataclass(self, pure=False): # pylint: disable=arguments-differ
|
||||
|
@ -217,6 +188,13 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
"""json serialized activitypub class"""
|
||||
return self.to_activity_dataclass(pure=pure).serialize()
|
||||
|
||||
def raise_not_editable(self, viewer):
|
||||
"""certain types of status aren't editable"""
|
||||
# first, the standard raise
|
||||
super().raise_not_editable(viewer)
|
||||
if isinstance(self, (GeneratedNote, ReviewRating)):
|
||||
raise PermissionDenied()
|
||||
|
||||
|
||||
class GeneratedNote(Status):
|
||||
"""these are app-generated messages about user activity"""
|
||||
|
@ -226,10 +204,10 @@ class GeneratedNote(Status):
|
|||
"""indicate the book in question for mastodon (or w/e) users"""
|
||||
message = self.content
|
||||
books = ", ".join(
|
||||
'<a href="%s">"%s"</a>' % (book.remote_id, book.title)
|
||||
f'<a href="{book.remote_id}">"{book.title}"</a>'
|
||||
for book in self.mention_books.all()
|
||||
)
|
||||
return "%s %s %s" % (self.user.display_name, message, books)
|
||||
return f"{self.user.display_name} {message} {books}"
|
||||
|
||||
activity_serializer = activitypub.GeneratedNote
|
||||
pure_type = "Note"
|
||||
|
@ -277,10 +255,9 @@ class Comment(BookStatus):
|
|||
@property
|
||||
def pure_content(self):
|
||||
"""indicate the book in question for mastodon (or w/e) users"""
|
||||
return '%s<p>(comment on <a href="%s">"%s"</a>)</p>' % (
|
||||
self.content,
|
||||
self.book.remote_id,
|
||||
self.book.title,
|
||||
return (
|
||||
f'{self.content}<p>(comment on <a href="{self.book.remote_id}">'
|
||||
f'"{self.book.title}"</a>)</p>'
|
||||
)
|
||||
|
||||
activity_serializer = activitypub.Comment
|
||||
|
@ -290,17 +267,25 @@ class Quotation(BookStatus):
|
|||
"""like a review but without a rating and transient"""
|
||||
|
||||
quote = fields.HtmlField()
|
||||
position = models.IntegerField(
|
||||
validators=[MinValueValidator(0)], null=True, blank=True
|
||||
)
|
||||
position_mode = models.CharField(
|
||||
max_length=3,
|
||||
choices=ProgressMode.choices,
|
||||
default=ProgressMode.PAGE,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def pure_content(self):
|
||||
"""indicate the book in question for mastodon (or w/e) users"""
|
||||
quote = re.sub(r"^<p>", '<p>"', self.quote)
|
||||
quote = re.sub(r"</p>$", '"</p>', quote)
|
||||
return '%s <p>-- <a href="%s">"%s"</a></p>%s' % (
|
||||
quote,
|
||||
self.book.remote_id,
|
||||
self.book.title,
|
||||
self.content,
|
||||
return (
|
||||
f'{quote} <p>-- <a href="{self.book.remote_id}">'
|
||||
f'"{self.book.title}"</a></p>{self.content}'
|
||||
)
|
||||
|
||||
activity_serializer = activitypub.Quotation
|
||||
|
@ -379,27 +364,6 @@ class Boost(ActivityMixin, Status):
|
|||
return
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
if not self.boosted_status.user.local or self.boosted_status.user == self.user:
|
||||
return
|
||||
|
||||
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
notification_model.objects.create(
|
||||
user=self.boosted_status.user,
|
||||
related_status=self.boosted_status,
|
||||
related_user=self.user,
|
||||
notification_type="BOOST",
|
||||
)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""delete and un-notify"""
|
||||
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
notification_model.objects.filter(
|
||||
user=self.boosted_status.user,
|
||||
related_status=self.boosted_status,
|
||||
related_user=self.user,
|
||||
notification_type="BOOST",
|
||||
).delete()
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""the user field is "actor" here instead of "attributedTo" """
|
||||
|
@ -412,10 +376,6 @@ class Boost(ActivityMixin, Status):
|
|||
self.image_fields = []
|
||||
self.deserialize_reverse_fields = []
|
||||
|
||||
# This constraint can't work as it would cross tables.
|
||||
# class Meta:
|
||||
# unique_together = ('user', 'boosted_status')
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@receiver(models.signals.post_save)
|
||||
|
|
|
@ -82,9 +82,9 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
preview_image = models.ImageField(
|
||||
upload_to="previews/avatars/", blank=True, null=True
|
||||
)
|
||||
followers = fields.ManyToManyField(
|
||||
followers_url = fields.CharField(max_length=255, activitypub_field="followers")
|
||||
followers = models.ManyToManyField(
|
||||
"self",
|
||||
link_only=True,
|
||||
symmetrical=False,
|
||||
through="UserFollows",
|
||||
through_fields=("user_object", "user_subject"),
|
||||
|
@ -105,7 +105,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
related_name="blocked_by",
|
||||
)
|
||||
saved_lists = models.ManyToManyField(
|
||||
"List", symmetrical=False, related_name="saved_lists"
|
||||
"List", symmetrical=False, related_name="saved_lists", blank=True
|
||||
)
|
||||
favorites = models.ManyToManyField(
|
||||
"Status",
|
||||
|
@ -122,16 +122,21 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
updated_date = models.DateTimeField(auto_now=True)
|
||||
last_active_date = models.DateTimeField(default=timezone.now)
|
||||
manually_approves_followers = fields.BooleanField(default=False)
|
||||
|
||||
# options to turn features on and off
|
||||
show_goal = models.BooleanField(default=True)
|
||||
show_suggested_users = models.BooleanField(default=True)
|
||||
discoverable = fields.BooleanField(default=False)
|
||||
|
||||
preferred_timezone = models.CharField(
|
||||
choices=[(str(tz), str(tz)) for tz in pytz.all_timezones],
|
||||
default=str(pytz.utc),
|
||||
max_length=255,
|
||||
)
|
||||
deactivation_reason = models.CharField(
|
||||
max_length=255, choices=DeactivationReason.choices, null=True, blank=True
|
||||
max_length=255, choices=DeactivationReason, null=True, blank=True
|
||||
)
|
||||
deactivation_date = models.DateTimeField(null=True, blank=True)
|
||||
confirmation_code = models.CharField(max_length=32, default=new_access_code)
|
||||
|
||||
name_field = "username"
|
||||
|
@ -147,12 +152,13 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
@property
|
||||
def following_link(self):
|
||||
"""just how to find out the following info"""
|
||||
return "{:s}/following".format(self.remote_id)
|
||||
return f"{self.remote_id}/following"
|
||||
|
||||
@property
|
||||
def alt_text(self):
|
||||
"""alt text with username"""
|
||||
return "avatar for %s" % (self.localname or self.username)
|
||||
# pylint: disable=consider-using-f-string
|
||||
return "avatar for {:s}".format(self.localname or self.username)
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
|
@ -189,12 +195,15 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
queryset = queryset.exclude(blocks=viewer)
|
||||
return queryset
|
||||
|
||||
def update_active_date(self):
|
||||
"""this user is here! they are doing things!"""
|
||||
self.last_active_date = timezone.now()
|
||||
self.save(broadcast=False, update_fields=["last_active_date"])
|
||||
|
||||
def to_outbox(self, filter_type=None, **kwargs):
|
||||
"""an ordered collection of statuses"""
|
||||
if filter_type:
|
||||
filter_class = apps.get_model(
|
||||
"bookwyrm.%s" % filter_type, require_ready=True
|
||||
)
|
||||
filter_class = apps.get_model(f"bookwyrm.{filter_type}", require_ready=True)
|
||||
if not issubclass(filter_class, Status):
|
||||
raise TypeError(
|
||||
"filter_status_class must be a subclass of models.Status"
|
||||
|
@ -218,7 +227,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
|
||||
def to_following_activity(self, **kwargs):
|
||||
"""activitypub following list"""
|
||||
remote_id = "%s/following" % self.remote_id
|
||||
remote_id = f"{self.remote_id}/following"
|
||||
return self.to_ordered_collection(
|
||||
self.following.order_by("-updated_date").all(),
|
||||
remote_id=remote_id,
|
||||
|
@ -228,7 +237,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
|
||||
def to_followers_activity(self, **kwargs):
|
||||
"""activitypub followers list"""
|
||||
remote_id = "%s/followers" % self.remote_id
|
||||
remote_id = self.followers_url
|
||||
return self.to_ordered_collection(
|
||||
self.followers.order_by("-updated_date").all(),
|
||||
remote_id=remote_id,
|
||||
|
@ -261,10 +270,15 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
if not self.local and not re.match(regex.FULL_USERNAME, self.username):
|
||||
# generate a username that uses the domain (webfinger format)
|
||||
actor_parts = urlparse(self.remote_id)
|
||||
self.username = "%s@%s" % (self.username, actor_parts.netloc)
|
||||
self.username = f"{self.username}@{actor_parts.netloc}"
|
||||
|
||||
# this user already exists, no need to populate fields
|
||||
if not created:
|
||||
if self.is_active:
|
||||
self.deactivation_date = None
|
||||
elif not self.deactivation_date:
|
||||
self.deactivation_date = timezone.now()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
return
|
||||
|
||||
|
@ -274,28 +288,47 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
transaction.on_commit(lambda: set_remote_server.delay(self.id))
|
||||
return
|
||||
|
||||
# populate fields for local users
|
||||
self.remote_id = "%s/user/%s" % (site_link(), self.localname)
|
||||
self.inbox = "%s/inbox" % self.remote_id
|
||||
self.shared_inbox = "%s/inbox" % site_link()
|
||||
self.outbox = "%s/outbox" % self.remote_id
|
||||
with transaction.atomic():
|
||||
# populate fields for local users
|
||||
link = site_link()
|
||||
self.remote_id = f"{link}/user/{self.localname}"
|
||||
self.followers_url = f"{self.remote_id}/followers"
|
||||
self.inbox = f"{self.remote_id}/inbox"
|
||||
self.shared_inbox = f"{link}/inbox"
|
||||
self.outbox = f"{self.remote_id}/outbox"
|
||||
|
||||
# an id needs to be set before we can proceed with related models
|
||||
# an id needs to be set before we can proceed with related models
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# make users editors by default
|
||||
try:
|
||||
self.groups.add(Group.objects.get(name="editor"))
|
||||
except Group.DoesNotExist:
|
||||
# this should only happen in tests
|
||||
pass
|
||||
|
||||
# create keys and shelves for new local users
|
||||
self.key_pair = KeyPair.objects.create(
|
||||
remote_id=f"{self.remote_id}/#main-key"
|
||||
)
|
||||
self.save(broadcast=False, update_fields=["key_pair"])
|
||||
|
||||
self.create_shelves()
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""deactivate rather than delete a user"""
|
||||
self.is_active = False
|
||||
# skip the logic in this class's save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# make users editors by default
|
||||
try:
|
||||
self.groups.add(Group.objects.get(name="editor"))
|
||||
except Group.DoesNotExist:
|
||||
# this should only happen in tests
|
||||
pass
|
||||
|
||||
# create keys and shelves for new local users
|
||||
self.key_pair = KeyPair.objects.create(
|
||||
remote_id="%s/#main-key" % self.remote_id
|
||||
)
|
||||
self.save(broadcast=False, update_fields=["key_pair"])
|
||||
@property
|
||||
def local_path(self):
|
||||
"""this model doesn't inherit bookwyrm model, so here we are"""
|
||||
# pylint: disable=consider-using-f-string
|
||||
return "/user/{:s}".format(self.localname or self.username)
|
||||
|
||||
def create_shelves(self):
|
||||
"""default shelves for a new user"""
|
||||
shelves = [
|
||||
{
|
||||
"name": "To Read",
|
||||
|
@ -319,17 +352,6 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
editable=False,
|
||||
).save(broadcast=False)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""deactivate rather than delete a user"""
|
||||
self.is_active = False
|
||||
# skip the logic in this class's save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def local_path(self):
|
||||
"""this model doesn't inherit bookwyrm model, so here we are"""
|
||||
return "/user/%s" % (self.localname or self.username)
|
||||
|
||||
|
||||
class KeyPair(ActivitypubMixin, BookWyrmModel):
|
||||
"""public and private keys for a user"""
|
||||
|
@ -344,7 +366,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
|
|||
|
||||
def get_remote_id(self):
|
||||
# self.owner is set by the OneToOneField on User
|
||||
return "%s/#main-key" % self.owner.remote_id
|
||||
return f"{self.owner.remote_id}/#main-key"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""create a key pair"""
|
||||
|
@ -381,7 +403,7 @@ class AnnualGoal(BookWyrmModel):
|
|||
|
||||
def get_remote_id(self):
|
||||
"""put the year in the path"""
|
||||
return "{:s}/goal/{:d}".format(self.user.remote_id, self.year)
|
||||
return f"{self.user.remote_id}/goal/{self.year}"
|
||||
|
||||
@property
|
||||
def books(self):
|
||||
|
@ -418,7 +440,7 @@ class AnnualGoal(BookWyrmModel):
|
|||
}
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(queue="low_priority")
|
||||
def set_remote_server(user_id):
|
||||
"""figure out the user's remote server in the background"""
|
||||
user = User.objects.get(id=user_id)
|
||||
|
@ -437,7 +459,7 @@ def get_or_create_remote_server(domain):
|
|||
pass
|
||||
|
||||
try:
|
||||
data = get_data("https://%s/.well-known/nodeinfo" % domain)
|
||||
data = get_data(f"https://{domain}/.well-known/nodeinfo")
|
||||
try:
|
||||
nodeinfo_url = data.get("links")[0].get("href")
|
||||
except (TypeError, KeyError):
|
||||
|
@ -457,7 +479,7 @@ def get_or_create_remote_server(domain):
|
|||
return server
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(queue="low_priority")
|
||||
def get_remote_reviews(outbox):
|
||||
"""ingest reviews by a new remote bookwyrm user"""
|
||||
outbox_page = outbox + "?page=true&type=Review"
|
||||
|
|
|
@ -220,6 +220,7 @@ def generate_default_inner_img():
|
|||
|
||||
|
||||
# pylint: disable=too-many-locals
|
||||
# pylint: disable=too-many-statements
|
||||
def generate_preview_image(
|
||||
texts=None, picture=None, rating=None, show_instance_layer=True
|
||||
):
|
||||
|
@ -237,7 +238,8 @@ def generate_preview_image(
|
|||
|
||||
# Color
|
||||
if BG_COLOR in ["use_dominant_color_light", "use_dominant_color_dark"]:
|
||||
image_bg_color = "rgb(%s, %s, %s)" % dominant_color
|
||||
red, green, blue = dominant_color
|
||||
image_bg_color = f"rgb({red}, {green}, {blue})"
|
||||
|
||||
# Adjust color
|
||||
image_bg_color_rgb = [x / 255.0 for x in ImageColor.getrgb(image_bg_color)]
|
||||
|
@ -315,7 +317,8 @@ def save_and_cleanup(image, instance=None):
|
|||
"""Save and close the file"""
|
||||
if not isinstance(instance, (models.Book, models.User, models.SiteSettings)):
|
||||
return False
|
||||
file_name = "%s-%s.jpg" % (str(instance.id), str(uuid4()))
|
||||
uuid = uuid4()
|
||||
file_name = f"{instance.id}-{uuid}.jpg"
|
||||
image_buffer = BytesIO()
|
||||
|
||||
try:
|
||||
|
@ -352,7 +355,7 @@ def save_and_cleanup(image, instance=None):
|
|||
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@app.task
|
||||
@app.task(queue="low_priority")
|
||||
def generate_site_preview_image_task():
|
||||
"""generate preview_image for the website"""
|
||||
if not settings.ENABLE_PREVIEW_IMAGES:
|
||||
|
@ -377,7 +380,7 @@ def generate_site_preview_image_task():
|
|||
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@app.task
|
||||
@app.task(queue="low_priority")
|
||||
def generate_edition_preview_image_task(book_id):
|
||||
"""generate preview_image for a book"""
|
||||
if not settings.ENABLE_PREVIEW_IMAGES:
|
||||
|
@ -402,7 +405,7 @@ def generate_edition_preview_image_task(book_id):
|
|||
save_and_cleanup(image, instance=book)
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(queue="low_priority")
|
||||
def generate_user_preview_image_task(user_id):
|
||||
"""generate preview_image for a book"""
|
||||
if not settings.ENABLE_PREVIEW_IMAGES:
|
||||
|
@ -412,7 +415,7 @@ def generate_user_preview_image_task(user_id):
|
|||
|
||||
texts = {
|
||||
"text_one": user.display_name,
|
||||
"text_three": "@{}@{}".format(user.localname, settings.DOMAIN),
|
||||
"text_three": f"@{user.localname}@{settings.DOMAIN}",
|
||||
}
|
||||
|
||||
if user.avatar:
|
||||
|
|
|
@ -48,7 +48,7 @@ class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method
|
|||
return
|
||||
|
||||
self.tag_stack = self.tag_stack[:-1]
|
||||
self.output.append(("tag", "</%s>" % tag))
|
||||
self.output.append(("tag", f"</{tag}>"))
|
||||
|
||||
def handle_data(self, data):
|
||||
"""extract the answer, if we're in an answer tag"""
|
||||
|
|
|
@ -13,16 +13,7 @@ VERSION = "0.0.1"
|
|||
PAGE_LENGTH = env("PAGE_LENGTH", 15)
|
||||
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
|
||||
|
||||
# celery
|
||||
CELERY_BROKER = "redis://:{}@redis_broker:{}/0".format(
|
||||
requests.utils.quote(env("REDIS_BROKER_PASSWORD", "")), env("REDIS_BROKER_PORT")
|
||||
)
|
||||
CELERY_RESULT_BACKEND = "redis://:{}@redis_broker:{}/0".format(
|
||||
requests.utils.quote(env("REDIS_BROKER_PASSWORD", "")), env("REDIS_BROKER_PORT")
|
||||
)
|
||||
CELERY_ACCEPT_CONTENT = ["application/json"]
|
||||
CELERY_TASK_SERIALIZER = "json"
|
||||
CELERY_RESULT_SERIALIZER = "json"
|
||||
JS_CACHE = "7f2343cf"
|
||||
|
||||
# email
|
||||
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
|
||||
|
@ -32,7 +23,7 @@ EMAIL_HOST_USER = env("EMAIL_HOST_USER")
|
|||
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
|
||||
EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", True)
|
||||
EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", False)
|
||||
DEFAULT_FROM_EMAIL = "admin@{:s}".format(env("DOMAIN"))
|
||||
DEFAULT_FROM_EMAIL = f"admin@{DOMAIN}"
|
||||
|
||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
@ -86,7 +77,8 @@ MIDDLEWARE = [
|
|||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"bookwyrm.timezone_middleware.TimezoneMiddleware",
|
||||
"bookwyrm.middleware.TimezoneMiddleware",
|
||||
"bookwyrm.middleware.IPBlocklistMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
@ -135,7 +127,7 @@ DATABASES = {
|
|||
"USER": env("POSTGRES_USER", "fedireads"),
|
||||
"PASSWORD": env("POSTGRES_PASSWORD", "fedireads"),
|
||||
"HOST": env("POSTGRES_HOST", ""),
|
||||
"PORT": env("POSTGRES_PORT", 5432),
|
||||
"PORT": env("PGPORT", 5432),
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -186,11 +178,8 @@ USE_L10N = True
|
|||
USE_TZ = True
|
||||
|
||||
|
||||
USER_AGENT = "%s (BookWyrm/%s; +https://%s/)" % (
|
||||
requests.utils.default_user_agent(),
|
||||
VERSION,
|
||||
DOMAIN,
|
||||
)
|
||||
agent = requests.utils.default_user_agent()
|
||||
USER_AGENT = f"{agent} (BookWyrm/{VERSION}; +https://{DOMAIN}/)"
|
||||
|
||||
# Imagekit generated thumbnails
|
||||
ENABLE_THUMBNAIL_GENERATION = env.bool("ENABLE_THUMBNAIL_GENERATION", False)
|
||||
|
@ -221,11 +210,11 @@ if USE_S3:
|
|||
AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"}
|
||||
# S3 Static settings
|
||||
STATIC_LOCATION = "static"
|
||||
STATIC_URL = "https://%s/%s/" % (AWS_S3_CUSTOM_DOMAIN, STATIC_LOCATION)
|
||||
STATIC_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/{STATIC_LOCATION}/"
|
||||
STATICFILES_STORAGE = "bookwyrm.storage_backends.StaticStorage"
|
||||
# S3 Media settings
|
||||
MEDIA_LOCATION = "images"
|
||||
MEDIA_URL = "https://%s/%s/" % (AWS_S3_CUSTOM_DOMAIN, MEDIA_LOCATION)
|
||||
MEDIA_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/{MEDIA_LOCATION}/"
|
||||
MEDIA_FULL_URL = MEDIA_URL
|
||||
DEFAULT_FILE_STORAGE = "bookwyrm.storage_backends.ImagesStorage"
|
||||
# I don't know if it's used, but the site crashes without it
|
||||
|
@ -235,5 +224,5 @@ else:
|
|||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static"))
|
||||
MEDIA_URL = "/images/"
|
||||
MEDIA_FULL_URL = "%s://%s%s" % (PROTOCOL, DOMAIN, MEDIA_URL)
|
||||
MEDIA_FULL_URL = f"{PROTOCOL}://{DOMAIN}{MEDIA_URL}"
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images"))
|
||||
|
|
|
@ -26,21 +26,21 @@ def make_signature(sender, destination, date, digest):
|
|||
"""uses a private key to sign an outgoing message"""
|
||||
inbox_parts = urlparse(destination)
|
||||
signature_headers = [
|
||||
"(request-target): post %s" % inbox_parts.path,
|
||||
"host: %s" % inbox_parts.netloc,
|
||||
"date: %s" % date,
|
||||
"digest: %s" % digest,
|
||||
f"(request-target): post {inbox_parts.path}",
|
||||
f"host: {inbox_parts.netloc}",
|
||||
f"date: {date}",
|
||||
f"digest: {digest}",
|
||||
]
|
||||
message_to_sign = "\n".join(signature_headers)
|
||||
signer = pkcs1_15.new(RSA.import_key(sender.key_pair.private_key))
|
||||
signed_message = signer.sign(SHA256.new(message_to_sign.encode("utf8")))
|
||||
signature = {
|
||||
"keyId": "%s#main-key" % sender.remote_id,
|
||||
"keyId": f"{sender.remote_id}#main-key",
|
||||
"algorithm": "rsa-sha256",
|
||||
"headers": "(request-target) host date digest",
|
||||
"signature": b64encode(signed_message).decode("utf8"),
|
||||
}
|
||||
return ",".join('%s="%s"' % (k, v) for (k, v) in signature.items())
|
||||
return ",".join(f'{k}="{v}"' for (k, v) in signature.items())
|
||||
|
||||
|
||||
def make_digest(data):
|
||||
|
@ -58,7 +58,7 @@ def verify_digest(request):
|
|||
elif algorithm == "SHA-512":
|
||||
hash_function = hashlib.sha512
|
||||
else:
|
||||
raise ValueError("Unsupported hash function: {}".format(algorithm))
|
||||
raise ValueError(f"Unsupported hash function: {algorithm}")
|
||||
|
||||
expected = hash_function(request.body).digest()
|
||||
if b64decode(digest) != expected:
|
||||
|
@ -95,18 +95,18 @@ class Signature:
|
|||
def verify(self, public_key, request):
|
||||
"""verify rsa signature"""
|
||||
if http_date_age(request.headers["date"]) > MAX_SIGNATURE_AGE:
|
||||
raise ValueError("Request too old: %s" % (request.headers["date"],))
|
||||
raise ValueError(f"Request too old: {request.headers['date']}")
|
||||
public_key = RSA.import_key(public_key)
|
||||
|
||||
comparison_string = []
|
||||
for signed_header_name in self.headers.split(" "):
|
||||
if signed_header_name == "(request-target)":
|
||||
comparison_string.append("(request-target): post %s" % request.path)
|
||||
comparison_string.append(f"(request-target): post {request.path}")
|
||||
else:
|
||||
if signed_header_name == "digest":
|
||||
verify_digest(request)
|
||||
comparison_string.append(
|
||||
"%s: %s" % (signed_header_name, request.headers[signed_header_name])
|
||||
f"{signed_header_name}: {request.headers[signed_header_name]}"
|
||||
)
|
||||
comparison_string = "\n".join(comparison_string)
|
||||
|
||||
|
|
|
@ -89,6 +89,32 @@ body {
|
|||
display: inline !important;
|
||||
}
|
||||
|
||||
input[type=file]::file-selector-button {
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-color: #fff;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #dbdbdb;
|
||||
box-shadow: none;
|
||||
color: #363636;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
height: 2.5em;
|
||||
justify-content: center;
|
||||
line-height: 1.5;
|
||||
padding-bottom: calc(0.5em - 1px);
|
||||
padding-left: 1em;
|
||||
padding-right: 1em;
|
||||
padding-top: calc(0.5em - 1px);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
input[type=file]::file-selector-button:hover {
|
||||
border-color: #b5b5b5;
|
||||
color: #363636;
|
||||
}
|
||||
|
||||
/** Shelving
|
||||
******************************************************************************/
|
||||
|
||||
|
@ -96,7 +122,7 @@ body {
|
|||
@see https://www.youtube.com/watch?v=9xXBYcWgCHA */
|
||||
.shelf-option:disabled > *::after {
|
||||
font-family: "icomoon"; /* stylelint-disable font-family-no-missing-generic-family-keyword */
|
||||
content: "\e918";
|
||||
content: "\e919"; /* icon-check */
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
|
@ -167,21 +193,36 @@ body {
|
|||
|
||||
/* All stars are visually filled by default. */
|
||||
.form-rate-stars .icon::before {
|
||||
content: '\e9d9';
|
||||
content: '\e9d9'; /* icon-star-full */
|
||||
}
|
||||
|
||||
/* Icons directly following half star inputs are marked as half */
|
||||
.form-rate-stars input.half:checked ~ .icon::before {
|
||||
content: '\e9d8'; /* icon-star-half */
|
||||
}
|
||||
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
.form-rate-stars input.half:checked + input + .icon:hover::before {
|
||||
content: '\e9d8' !important; /* icon-star-half */
|
||||
}
|
||||
|
||||
/* Icons directly following half check inputs that follow the checked input are emptied. */
|
||||
.form-rate-stars input.half:checked + input + .icon ~ .icon::before {
|
||||
content: '\e9d7'; /* icon-star-empty */
|
||||
}
|
||||
|
||||
/* Icons directly following inputs that follow the checked input are emptied. */
|
||||
.form-rate-stars input:checked ~ input + .icon::before {
|
||||
content: '\e9d7';
|
||||
content: '\e9d7'; /* icon-star-empty */
|
||||
}
|
||||
|
||||
/* When a label is hovered, repeat the fill-all-then-empty-following pattern. */
|
||||
.form-rate-stars:hover .icon.icon::before {
|
||||
content: '\e9d9';
|
||||
content: '\e9d9' !important; /* icon-star-full */
|
||||
}
|
||||
|
||||
.form-rate-stars .icon:hover ~ .icon::before {
|
||||
content: '\e9d7';
|
||||
content: '\e9d7' !important; /* icon-star-empty */
|
||||
}
|
||||
|
||||
/** Book covers
|
||||
|
@ -292,17 +333,59 @@ body {
|
|||
}
|
||||
|
||||
.quote > blockquote::before {
|
||||
content: "\e906";
|
||||
content: "\e907"; /* icon-quote-open */
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.quote > blockquote::after {
|
||||
content: "\e905";
|
||||
content: "\e906"; /* icon-quote-close */
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* States
|
||||
/** Animations and transitions
|
||||
******************************************************************************/
|
||||
|
||||
@keyframes turning {
|
||||
from { transform: rotateZ(0deg); }
|
||||
to { transform: rotateZ(360deg); }
|
||||
}
|
||||
|
||||
.is-processing .icon-spinner::before {
|
||||
animation: turning 1.5s infinite linear;
|
||||
}
|
||||
|
||||
.icon-spinner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.is-processing .icon-spinner {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.is-processing .icon::before {
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/** Transient notification
|
||||
******************************************************************************/
|
||||
|
||||
#live-messages {
|
||||
position: fixed;
|
||||
bottom: 1em;
|
||||
right: 1em;
|
||||
}
|
||||
|
||||
/** Tooltips
|
||||
******************************************************************************/
|
||||
|
||||
.tooltip {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/** States
|
||||
******************************************************************************/
|
||||
|
||||
/* "disabled" for non-buttons */
|
||||
|
|
Binary file not shown.
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 34 KiB |
Binary file not shown.
Binary file not shown.
196
bookwyrm/static/css/vendor/icons.css
vendored
196
bookwyrm/static/css/vendor/icons.css
vendored
|
@ -1,10 +1,10 @@
|
|||
@font-face {
|
||||
font-family: 'icomoon';
|
||||
src: url('../fonts/icomoon.eot?19nagi');
|
||||
src: url('../fonts/icomoon.eot?19nagi#iefix') format('embedded-opentype'),
|
||||
url('../fonts/icomoon.ttf?19nagi') format('truetype'),
|
||||
url('../fonts/icomoon.woff?19nagi') format('woff'),
|
||||
url('../fonts/icomoon.svg?19nagi#icomoon') format('svg');
|
||||
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');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
|
@ -25,6 +25,90 @@
|
|||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-book:before {
|
||||
content: "\e901";
|
||||
}
|
||||
.icon-envelope:before {
|
||||
content: "\e902";
|
||||
}
|
||||
.icon-arrow-right:before {
|
||||
content: "\e903";
|
||||
}
|
||||
.icon-bell:before {
|
||||
content: "\e904";
|
||||
}
|
||||
.icon-x:before {
|
||||
content: "\e905";
|
||||
}
|
||||
.icon-quote-close:before {
|
||||
content: "\e906";
|
||||
}
|
||||
.icon-quote-open:before {
|
||||
content: "\e907";
|
||||
}
|
||||
.icon-image:before {
|
||||
content: "\e908";
|
||||
}
|
||||
.icon-pencil:before {
|
||||
content: "\e909";
|
||||
}
|
||||
.icon-list:before {
|
||||
content: "\e90a";
|
||||
}
|
||||
.icon-unlock:before {
|
||||
content: "\e90b";
|
||||
}
|
||||
.icon-globe:before {
|
||||
content: "\e90c";
|
||||
}
|
||||
.icon-lock:before {
|
||||
content: "\e90d";
|
||||
}
|
||||
.icon-chain-broken:before {
|
||||
content: "\e90e";
|
||||
}
|
||||
.icon-chain:before {
|
||||
content: "\e90f";
|
||||
}
|
||||
.icon-comments:before {
|
||||
content: "\e910";
|
||||
}
|
||||
.icon-comment:before {
|
||||
content: "\e911";
|
||||
}
|
||||
.icon-boost:before {
|
||||
content: "\e912";
|
||||
}
|
||||
.icon-arrow-left:before {
|
||||
content: "\e913";
|
||||
}
|
||||
.icon-arrow-up:before {
|
||||
content: "\e914";
|
||||
}
|
||||
.icon-arrow-down:before {
|
||||
content: "\e915";
|
||||
}
|
||||
.icon-local:before {
|
||||
content: "\e917";
|
||||
}
|
||||
.icon-dots-three:before {
|
||||
content: "\e918";
|
||||
}
|
||||
.icon-check:before {
|
||||
content: "\e919";
|
||||
}
|
||||
.icon-dots-three-vertical:before {
|
||||
content: "\e91a";
|
||||
}
|
||||
.icon-bookmark:before {
|
||||
content: "\e91b";
|
||||
}
|
||||
.icon-warning:before {
|
||||
content: "\e91c";
|
||||
}
|
||||
.icon-rss:before {
|
||||
content: "\e91d";
|
||||
}
|
||||
.icon-graphic-heart:before {
|
||||
content: "\e91e";
|
||||
}
|
||||
|
@ -34,102 +118,6 @@
|
|||
.icon-graphic-banknote:before {
|
||||
content: "\e920";
|
||||
}
|
||||
.icon-warning:before {
|
||||
content: "\e91b";
|
||||
}
|
||||
.icon-book:before {
|
||||
content: "\e900";
|
||||
}
|
||||
.icon-bookmark:before {
|
||||
content: "\e91a";
|
||||
}
|
||||
.icon-rss:before {
|
||||
content: "\e91d";
|
||||
}
|
||||
.icon-envelope:before {
|
||||
content: "\e901";
|
||||
}
|
||||
.icon-arrow-right:before {
|
||||
content: "\e902";
|
||||
}
|
||||
.icon-bell:before {
|
||||
content: "\e903";
|
||||
}
|
||||
.icon-x:before {
|
||||
content: "\e904";
|
||||
}
|
||||
.icon-quote-close:before {
|
||||
content: "\e905";
|
||||
}
|
||||
.icon-quote-open:before {
|
||||
content: "\e906";
|
||||
}
|
||||
.icon-image:before {
|
||||
content: "\e907";
|
||||
}
|
||||
.icon-pencil:before {
|
||||
content: "\e908";
|
||||
}
|
||||
.icon-list:before {
|
||||
content: "\e909";
|
||||
}
|
||||
.icon-unlock:before {
|
||||
content: "\e90a";
|
||||
}
|
||||
.icon-unlisted:before {
|
||||
content: "\e90a";
|
||||
}
|
||||
.icon-globe:before {
|
||||
content: "\e90b";
|
||||
}
|
||||
.icon-public:before {
|
||||
content: "\e90b";
|
||||
}
|
||||
.icon-lock:before {
|
||||
content: "\e90c";
|
||||
}
|
||||
.icon-followers:before {
|
||||
content: "\e90c";
|
||||
}
|
||||
.icon-chain-broken:before {
|
||||
content: "\e90d";
|
||||
}
|
||||
.icon-chain:before {
|
||||
content: "\e90e";
|
||||
}
|
||||
.icon-comments:before {
|
||||
content: "\e90f";
|
||||
}
|
||||
.icon-comment:before {
|
||||
content: "\e910";
|
||||
}
|
||||
.icon-boost:before {
|
||||
content: "\e911";
|
||||
}
|
||||
.icon-arrow-left:before {
|
||||
content: "\e912";
|
||||
}
|
||||
.icon-arrow-up:before {
|
||||
content: "\e913";
|
||||
}
|
||||
.icon-arrow-down:before {
|
||||
content: "\e914";
|
||||
}
|
||||
.icon-home:before {
|
||||
content: "\e915";
|
||||
}
|
||||
.icon-local:before {
|
||||
content: "\e916";
|
||||
}
|
||||
.icon-dots-three:before {
|
||||
content: "\e917";
|
||||
}
|
||||
.icon-check:before {
|
||||
content: "\e918";
|
||||
}
|
||||
.icon-dots-three-vertical:before {
|
||||
content: "\e919";
|
||||
}
|
||||
.icon-search:before {
|
||||
content: "\e986";
|
||||
}
|
||||
|
@ -148,3 +136,9 @@
|
|||
.icon-plus:before {
|
||||
content: "\ea0a";
|
||||
}
|
||||
.icon-question-circle:before {
|
||||
content: "\e900";
|
||||
}
|
||||
.icon-spinner:before {
|
||||
content: "\e97a";
|
||||
}
|
||||
|
|
|
@ -301,7 +301,10 @@ let BookWyrm = new class {
|
|||
ajaxPost(form) {
|
||||
return fetch(form.action, {
|
||||
method : "POST",
|
||||
body: new FormData(form)
|
||||
body: new FormData(form),
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
236
bookwyrm/static/js/status_cache.js
Normal file
236
bookwyrm/static/js/status_cache.js
Normal file
|
@ -0,0 +1,236 @@
|
|||
/* exported StatusCache */
|
||||
/* globals BookWyrm */
|
||||
|
||||
let StatusCache = new class {
|
||||
constructor() {
|
||||
document.querySelectorAll('[data-cache-draft]')
|
||||
.forEach(t => t.addEventListener('change', this.updateDraft.bind(this)));
|
||||
|
||||
document.querySelectorAll('[data-cache-draft]')
|
||||
.forEach(t => this.populateDraft(t));
|
||||
|
||||
document.querySelectorAll('.submit-status')
|
||||
.forEach(button => button.addEventListener(
|
||||
'submit',
|
||||
this.submitStatus.bind(this))
|
||||
);
|
||||
|
||||
document.querySelectorAll('.form-rate-stars label.icon')
|
||||
.forEach(button => button.addEventListener('click', this.toggleStar.bind(this)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update localStorage copy of drafted status
|
||||
*
|
||||
* @param {Event} event
|
||||
* @return {undefined}
|
||||
*/
|
||||
updateDraft(event) {
|
||||
// Used in set reading goal
|
||||
let key = event.target.dataset.cacheDraft;
|
||||
let value = event.target.value;
|
||||
|
||||
if (!value) {
|
||||
window.localStorage.removeItem(key);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle display of a DOM node based on its value in the localStorage.
|
||||
*
|
||||
* @param {object} node - DOM node to toggle.
|
||||
* @return {undefined}
|
||||
*/
|
||||
populateDraft(node) {
|
||||
// Used in set reading goal
|
||||
let key = node.dataset.cacheDraft;
|
||||
let value = window.localStorage.getItem(key);
|
||||
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
node.value = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a status with ajax
|
||||
*
|
||||
* @param {} event
|
||||
* @return {undefined}
|
||||
*/
|
||||
submitStatus(event) {
|
||||
const form = event.currentTarget;
|
||||
let trigger = event.submitter;
|
||||
|
||||
// Safari doesn't understand "submitter"
|
||||
if (!trigger) {
|
||||
trigger = event.currentTarget.querySelector("button[type=submit]");
|
||||
}
|
||||
|
||||
// This allows the form to submit in the old fashioned way if there's a problem
|
||||
|
||||
if (!trigger || !form) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
BookWyrm.addRemoveClass(form, 'is-processing', true);
|
||||
trigger.setAttribute('disabled', null);
|
||||
|
||||
BookWyrm.ajaxPost(form).finally(() => {
|
||||
// Change icon to remove ongoing activity on the current UI.
|
||||
// Enable back the element used to submit the form.
|
||||
BookWyrm.addRemoveClass(form, 'is-processing', false);
|
||||
trigger.removeAttribute('disabled');
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error();
|
||||
}
|
||||
this.submitStatusSuccess(form);
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn(error);
|
||||
this.announceMessage('status-error-message');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a message in the live region
|
||||
*
|
||||
* @param {String} the id of the message dom element
|
||||
* @return {undefined}
|
||||
*/
|
||||
announceMessage(message_id) {
|
||||
const element = document.getElementById(message_id);
|
||||
let copy = element.cloneNode(true);
|
||||
|
||||
copy.id = null;
|
||||
element.insertAdjacentElement('beforebegin', copy);
|
||||
|
||||
BookWyrm.addRemoveClass(copy, 'is-hidden', false);
|
||||
setTimeout(function() {
|
||||
copy.remove();
|
||||
}, 10000, copy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Success state for a posted status
|
||||
*
|
||||
* @param {Object} the html form that was submitted
|
||||
* @return {undefined}
|
||||
*/
|
||||
submitStatusSuccess(form) {
|
||||
// Clear form data
|
||||
form.reset();
|
||||
|
||||
// Clear localstorage
|
||||
form.querySelectorAll('[data-cache-draft]')
|
||||
.forEach(node => window.localStorage.removeItem(node.dataset.cacheDraft));
|
||||
|
||||
// Close modals
|
||||
let modal = form.closest(".modal.is-active");
|
||||
|
||||
if (modal) {
|
||||
modal.getElementsByClassName("modal-close")[0].click();
|
||||
|
||||
// Update shelve buttons
|
||||
document.querySelectorAll("[data-shelve-button-book='" + form.book.value +"']")
|
||||
.forEach(button => this.cycleShelveButtons(button, form.reading_status.value));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Close reply panel
|
||||
let reply = form.closest(".reply-panel");
|
||||
|
||||
if (reply) {
|
||||
document.querySelector("[data-controls=" + reply.id + "]").click();
|
||||
}
|
||||
|
||||
this.announceMessage('status-success-message');
|
||||
}
|
||||
|
||||
/**
|
||||
* Change which buttons are available for a shelf
|
||||
*
|
||||
* @param {Object} html button dom element
|
||||
* @param {String} the identifier of the selected shelf
|
||||
* @return {undefined}
|
||||
*/
|
||||
cycleShelveButtons(button, identifier) {
|
||||
// Pressed button
|
||||
let shelf = button.querySelector("[data-shelf-identifier='" + identifier + "']");
|
||||
let next_identifier = shelf.dataset.shelfNext;
|
||||
|
||||
// Set all buttons to hidden
|
||||
button.querySelectorAll("[data-shelf-identifier]")
|
||||
.forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", true));
|
||||
|
||||
// Button that should be visible now
|
||||
let next = button.querySelector("[data-shelf-identifier=" + next_identifier + "]");
|
||||
|
||||
// Show the desired button
|
||||
BookWyrm.addRemoveClass(next, "is-hidden", false);
|
||||
|
||||
// ------ update the dropdown buttons
|
||||
// Remove existing hidden class
|
||||
button.querySelectorAll("[data-shelf-dropdown-identifier]")
|
||||
.forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", false));
|
||||
|
||||
// Remove existing disabled states
|
||||
button.querySelectorAll("[data-shelf-dropdown-identifier] button")
|
||||
.forEach(item => item.disabled = false);
|
||||
|
||||
next_identifier = next_identifier == 'complete' ? 'read' : next_identifier;
|
||||
|
||||
// Disable the current state
|
||||
button.querySelector(
|
||||
"[data-shelf-dropdown-identifier=" + identifier + "] button"
|
||||
).disabled = true;
|
||||
|
||||
let main_button = button.querySelector(
|
||||
"[data-shelf-dropdown-identifier=" + next_identifier + "]"
|
||||
);
|
||||
|
||||
// Hide the option that's shown as the main button
|
||||
BookWyrm.addRemoveClass(main_button, "is-hidden", true);
|
||||
|
||||
// Just hide the other two menu options, idk what to do with them
|
||||
button.querySelectorAll("[data-extra-options]")
|
||||
.forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", true));
|
||||
|
||||
// Close menu
|
||||
let menu = button.querySelector(".dropdown-trigger[aria-expanded=true]");
|
||||
|
||||
if (menu) {
|
||||
menu.click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal half-stars
|
||||
*
|
||||
* @param {Event} event
|
||||
* @return {undefined}
|
||||
*/
|
||||
toggleStar(event) {
|
||||
const label = event.currentTarget;
|
||||
let wholeStar = document.getElementById(label.getAttribute("for"));
|
||||
|
||||
if (wholeStar.checked) {
|
||||
event.preventDefault();
|
||||
let halfStar = document.getElementById(label.dataset.forHalf);
|
||||
|
||||
wholeStar.checked = null;
|
||||
halfStar.checked = "checked";
|
||||
}
|
||||
}
|
||||
}();
|
||||
|
|
@ -24,8 +24,8 @@ class SuggestedUsers(RedisStore):
|
|||
def store_id(self, user): # pylint: disable=no-self-use
|
||||
"""the key used to store this user's recs"""
|
||||
if isinstance(user, int):
|
||||
return "{:d}-suggestions".format(user)
|
||||
return "{:d}-suggestions".format(user.id)
|
||||
return f"{user}-suggestions"
|
||||
return f"{user.id}-suggestions"
|
||||
|
||||
def get_counts_from_rank(self, rank): # pylint: disable=no-self-use
|
||||
"""calculate mutuals count and shared books count from rank"""
|
||||
|
@ -86,10 +86,12 @@ class SuggestedUsers(RedisStore):
|
|||
values = self.get_store(self.store_id(user), withscores=True)
|
||||
results = []
|
||||
# annotate users with mutuals and shared book counts
|
||||
for user_id, rank in values[:5]:
|
||||
for user_id, rank in values:
|
||||
counts = self.get_counts_from_rank(rank)
|
||||
try:
|
||||
user = models.User.objects.get(id=user_id)
|
||||
user = models.User.objects.get(
|
||||
id=user_id, is_active=True, bookwyrm_user=True
|
||||
)
|
||||
except models.User.DoesNotExist as err:
|
||||
# if this happens, the suggestions are janked way up
|
||||
logger.exception(err)
|
||||
|
@ -97,6 +99,8 @@ class SuggestedUsers(RedisStore):
|
|||
user.mutuals = counts["mutuals"]
|
||||
# user.shared_books = counts["shared_books"]
|
||||
results.append(user)
|
||||
if len(results) >= 5:
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
|
@ -178,13 +182,21 @@ def update_suggestions_on_unfollow(sender, instance, **kwargs):
|
|||
|
||||
@receiver(signals.post_save, sender=models.User)
|
||||
# pylint: disable=unused-argument, too-many-arguments
|
||||
def add_new_user(sender, instance, created, update_fields=None, **kwargs):
|
||||
"""a new user, wow how cool"""
|
||||
def update_user(sender, instance, created, update_fields=None, **kwargs):
|
||||
"""an updated user, neat"""
|
||||
# a new user is found, create suggestions for them
|
||||
if created and instance.local:
|
||||
rerank_suggestions_task.delay(instance.id)
|
||||
|
||||
if update_fields and not "discoverable" in update_fields:
|
||||
# we know what fields were updated and discoverability didn't change
|
||||
if not instance.bookwyrm_user or (
|
||||
update_fields and not "discoverable" in update_fields
|
||||
):
|
||||
return
|
||||
|
||||
# deleted the user
|
||||
if not created and not instance.is_active:
|
||||
remove_user_task.delay(instance.id)
|
||||
return
|
||||
|
||||
# this happens on every save, not just when discoverability changes, annoyingly
|
||||
|
@ -194,28 +206,61 @@ def add_new_user(sender, instance, created, update_fields=None, **kwargs):
|
|||
remove_user_task.delay(instance.id)
|
||||
|
||||
|
||||
@app.task
|
||||
@receiver(signals.post_save, sender=models.FederatedServer)
|
||||
def domain_level_update(sender, instance, created, update_fields=None, **kwargs):
|
||||
"""remove users on a domain block"""
|
||||
if (
|
||||
not update_fields
|
||||
or "status" not in update_fields
|
||||
or instance.application_type != "bookwyrm"
|
||||
):
|
||||
return
|
||||
|
||||
if instance.status == "blocked":
|
||||
bulk_remove_instance_task.delay(instance.id)
|
||||
return
|
||||
bulk_add_instance_task.delay(instance.id)
|
||||
|
||||
|
||||
# ------------------- TASKS
|
||||
|
||||
|
||||
@app.task(queue="low_priority")
|
||||
def rerank_suggestions_task(user_id):
|
||||
"""do the hard work in celery"""
|
||||
suggested_users.rerank_user_suggestions(user_id)
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(queue="low_priority")
|
||||
def rerank_user_task(user_id, update_only=False):
|
||||
"""do the hard work in celery"""
|
||||
user = models.User.objects.get(id=user_id)
|
||||
suggested_users.rerank_obj(user, update_only=update_only)
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(queue="low_priority")
|
||||
def remove_user_task(user_id):
|
||||
"""do the hard work in celery"""
|
||||
user = models.User.objects.get(id=user_id)
|
||||
suggested_users.remove_object_from_related_stores(user)
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(queue="medium_priority")
|
||||
def remove_suggestion_task(user_id, suggested_user_id):
|
||||
"""remove a specific user from a specific user's suggestions"""
|
||||
suggested_user = models.User.objects.get(id=suggested_user_id)
|
||||
suggested_users.remove_suggestion(user_id, suggested_user)
|
||||
|
||||
|
||||
@app.task(queue="low_priority")
|
||||
def bulk_remove_instance_task(instance_id):
|
||||
"""remove a bunch of users from recs"""
|
||||
for user in models.User.objects.filter(federated_server__id=instance_id):
|
||||
suggested_users.remove_object_from_related_stores(user)
|
||||
|
||||
|
||||
@app.task(queue="low_priority")
|
||||
def bulk_add_instance_task(instance_id):
|
||||
"""remove a bunch of users from recs"""
|
||||
for user in models.User.objects.filter(federated_server__id=instance_id):
|
||||
suggested_users.rerank_obj(user, update_only=False)
|
||||
|
|
|
@ -2,10 +2,10 @@
|
|||
import os
|
||||
from celery import Celery
|
||||
|
||||
from bookwyrm import settings
|
||||
from celerywyrm import settings
|
||||
|
||||
# set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "celerywyrm.settings")
|
||||
app = Celery(
|
||||
"tasks", broker=settings.CELERY_BROKER, backend=settings.CELERY_RESULT_BACKEND
|
||||
"tasks", broker=settings.CELERY_BROKER_URL, backend=settings.CELERY_RESULT_BACKEND
|
||||
)
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
{% load humanize %}
|
||||
{% load utilities %}
|
||||
{% load static %}
|
||||
{% load layout %}
|
||||
|
||||
{% block title %}{{ book|book_title }}{% endblock %}
|
||||
|
||||
|
@ -43,7 +42,7 @@
|
|||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if book.authors %}
|
||||
{% if book.authors.exists %}
|
||||
<div class="subtitle">
|
||||
{% trans "by" %} {% include 'snippets/authors.html' with book=book %}
|
||||
</div>
|
||||
|
@ -326,5 +325,5 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{% static "js/vendor/tabs.js" %}"></script>
|
||||
<script src="{% static "js/vendor/tabs.js" %}?v={{ js_cache }}"></script>
|
||||
{% endblock %}
|
||||
|
|
|
@ -14,7 +14,11 @@
|
|||
<h2 class="modal-card-title is-flex-shrink-1" id="modal_card_title_{{ controls_text }}_{{ controls_uid }}">
|
||||
{% block modal-title %}{% endblock %}
|
||||
</h2>
|
||||
{% include 'snippets/toggle/toggle_button.html' with label=label class="delete" nonbutton=True %}
|
||||
{% if static %}
|
||||
<a href="/" class="delete">{{ label }}</a>
|
||||
{% else %}
|
||||
{% include 'snippets/toggle/toggle_button.html' with label=label class="delete" nonbutton=True %}
|
||||
{% endif %}
|
||||
</header>
|
||||
{% block modal-form-open %}{% endblock %}
|
||||
{% if not no_body %}
|
||||
|
@ -27,6 +31,10 @@
|
|||
</footer>
|
||||
{% block modal-form-close %}{% endblock %}
|
||||
</div>
|
||||
{% include 'snippets/toggle/toggle_button.html' with label=label class="modal-close is-large" nonbutton=True %}
|
||||
{% if static %}
|
||||
<a href="/" class="modal-close is-large">{{ label }}</a>
|
||||
{% else %}
|
||||
{% include 'snippets/toggle/toggle_button.html' with label=label class="modal-close is-large" nonbutton=True %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
|
11
bookwyrm/templates/components/tooltip.html
Normal file
11
bookwyrm/templates/components/tooltip.html
Normal file
|
@ -0,0 +1,11 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% trans "Help" as button_text %}
|
||||
{% include 'snippets/toggle/open_button.html' with text=button_text class="ml-3 is-rounded is-small is-white p-0 pb-1" icon="question-circle is-size-6" controls_text=controls_text controls_uid=controls_uid %}
|
||||
|
||||
<aside class="tooltip notification is-hidden transition-y is-pulled-left mb-2" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}">
|
||||
{% trans "Close" as button_text %}
|
||||
{% include 'snippets/toggle/close_button.html' with label=button_text class="delete" nonbutton=True controls_text=controls_text controls_uid=controls_uid %}
|
||||
|
||||
{% block tooltip_content %}{% endblock %}
|
||||
</aside>
|
|
@ -27,7 +27,7 @@
|
|||
{% if not draft %}
|
||||
{% include 'snippets/create_status.html' %}
|
||||
{% else %}
|
||||
{% include 'snippets/create_status/status.html' %}
|
||||
{% include 'snippets/create_status/status.html' with no_script=True %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -15,14 +15,15 @@
|
|||
</p>
|
||||
</header>
|
||||
|
||||
{% with tile_classes="tile is-child box has-background-white-ter is-clipped" %}
|
||||
<div class="tile is-ancestor">
|
||||
<div class="tile is-6 is-parent">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
<div class="{{ tile_classes }}">
|
||||
{% include 'discover/large-book.html' with status=large_activities.0 %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-6 is-parent">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
<div class="{{ tile_classes }}">
|
||||
{% include 'discover/large-book.html' with status=large_activities.1 %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -31,18 +32,18 @@
|
|||
<div class="tile is-ancestor">
|
||||
<div class="tile is-vertical is-6">
|
||||
<div class="tile is-parent">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
<div class="{{ tile_classes }}">
|
||||
{% include 'discover/large-book.html' with status=large_activities.2 %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<div class="tile is-parent is-6">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
<div class="{{ tile_classes }}">
|
||||
{% include 'discover/small-book.html' with status=small_activities.0 %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-parent is-6">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
<div class="{{ tile_classes }}">
|
||||
{% include 'discover/small-book.html' with status=small_activities.1 %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -51,18 +52,18 @@
|
|||
<div class="tile is-vertical is-6">
|
||||
<div class="tile">
|
||||
<div class="tile is-parent is-6">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
<div class="{{ tile_classes }}">
|
||||
{% include 'discover/small-book.html' with status=small_activities.2 %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-parent is-6">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
<div class="{{ tile_classes }}">
|
||||
{% include 'discover/small-book.html' with status=small_activities.3 %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-parent">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
<div class="{{ tile_classes }}">
|
||||
{% include 'discover/large-book.html' with status=large_activities.3 %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -71,16 +72,17 @@
|
|||
|
||||
<div class="tile is-ancestor">
|
||||
<div class="tile is-6 is-parent">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
<div class="{{ tile_classes }}">
|
||||
{% include 'discover/large-book.html' with status=large_activities.4 %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-6 is-parent">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
<div class="{{ tile_classes }}">
|
||||
{% include 'discover/large-book.html' with status=large_activities.5 %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
</section>
|
||||
|
||||
<div class="block">
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
</header>
|
||||
|
||||
<div class="box">
|
||||
{% include 'snippets/create_status/status.html' with type="direct" uuid=1 mention=partner %}
|
||||
{% include 'snippets/create_status/status.html' with type="direct" uuid=1 mention=partner no_script=True %}
|
||||
</div>
|
||||
|
||||
<section class="block">
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
{% blocktrans with tab_key=tab.key %}load <span data-poll="stream/{{ tab_key }}">0</span> unread status(es){% endblocktrans %}
|
||||
</a>
|
||||
|
||||
{% if request.user.show_goal and not goal and tab.key == streams.first.key %}
|
||||
{% if request.user.show_goal and not goal and tab.key == 'home' %}
|
||||
{% now 'Y' as year %}
|
||||
<section class="block">
|
||||
{% include 'snippets/goal_card.html' with year=year %}
|
||||
|
@ -37,7 +37,7 @@
|
|||
<div class="block content">
|
||||
<p>{% trans "There aren't any activities right now! Try following a user to get started" %}</p>
|
||||
|
||||
{% if suggested_users %}
|
||||
{% if request.user.show_suggested_users and suggested_users %}
|
||||
{# suggested users for when things are very lonely #}
|
||||
{% include 'feed/suggested_users.html' with suggested_users=suggested_users %}
|
||||
{% endif %}
|
||||
|
@ -46,7 +46,7 @@
|
|||
|
||||
{% for activity in activities %}
|
||||
|
||||
{% if not activities.number > 1 and forloop.counter0 == 2 and suggested_users %}
|
||||
{% if request.user.show_suggested_users and not activities.number > 1 and forloop.counter0 == 2 and suggested_users %}
|
||||
{# suggested users on the first page, two statuses down #}
|
||||
{% include 'feed/suggested_users.html' with suggested_users=suggested_users %}
|
||||
{% endif %}
|
||||
|
|
|
@ -106,5 +106,5 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{% static "js/vendor/tabs.js" %}"></script>
|
||||
<script src="{% static "js/vendor/tabs.js" %}?v={{ js_cache }}"></script>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,6 +1,15 @@
|
|||
{% load i18n %}
|
||||
<section class="block">
|
||||
<h2 class="title is-5">{% trans "Who to follow" %}</h2>
|
||||
<header class="columns">
|
||||
<div class="column">
|
||||
<h2 class="title is-5">{% trans "Who to follow" %}</h2>
|
||||
</div>
|
||||
<form class="column is-narrow" action="{% url 'hide-suggestions' %}" method="POST">
|
||||
{% csrf_token %}
|
||||
{% trans "Don't show suggested users" as button_text %}
|
||||
<button type="submit" class="delete" title="{{ button_text }}">{{ button_text }}</button>
|
||||
</form>
|
||||
</header>
|
||||
{% include 'snippets/suggested_users.html' with suggested_users=suggested_users %}
|
||||
<a class="help" href="{% url 'directory' %}">{% trans "View directory" %} <span class="icon icon-arrow-right"></span></a>
|
||||
</section>
|
||||
|
|
|
@ -12,9 +12,14 @@
|
|||
|
||||
<div class="columns">
|
||||
<div class="column is-half">
|
||||
<label class="label" for="source">
|
||||
{% trans "Data source:" %}
|
||||
</label>
|
||||
|
||||
<div class="field">
|
||||
<label class="label is-pulled-left" for="source">
|
||||
{% trans "Data source:" %}
|
||||
</label>
|
||||
{% include 'import/tooltip.html' with controls_text="goodreads-tooltip" %}
|
||||
</div>
|
||||
|
||||
<div class="select block">
|
||||
<select name="source" id="source">
|
||||
<option value="GoodReads" {% if current == 'GoodReads' %}selected{% endif %}>
|
|
@ -40,7 +40,7 @@
|
|||
<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">
|
||||
<form name="retry" action="/import/{{ job.id }}" method="post" class="box">
|
||||
{% csrf_token %}
|
||||
|
||||
{% with failed_count=failed_items|length %}
|
||||
|
@ -77,7 +77,7 @@
|
|||
class="checkbox"
|
||||
type="checkbox"
|
||||
data-action="toggle-all"
|
||||
data-target="failed-imports"
|
||||
data-target="failed_imports"
|
||||
/>
|
||||
{% trans "Select all" %}
|
||||
</label>
|
||||
|
@ -156,5 +156,5 @@
|
|||
{% endspaceless %}{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{% static "js/check_all.js" %}"></script>
|
||||
<script src="{% static "js/check_all.js" %}?v={{ js_cache }}"></script>
|
||||
{% endblock %}
|
8
bookwyrm/templates/import/tooltip.html
Normal file
8
bookwyrm/templates/import/tooltip.html
Normal file
|
@ -0,0 +1,8 @@
|
|||
{% extends 'components/tooltip.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block tooltip_content %}
|
||||
|
||||
{% trans 'You can download your GoodReads data from the <a href="https://www.goodreads.com/review/import" target="_blank" rel="noopener">Import/Export page</a> of your GoodReads account.' %}
|
||||
|
||||
{% endblock %}
|
|
@ -5,11 +5,11 @@
|
|||
|
||||
{% block content %}
|
||||
|
||||
<h1 class="title">{% trans "Create an Account" %}</h1>
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<div class="block">
|
||||
{% if valid %}
|
||||
<h1 class="title">{% trans "Create an Account" %}</h1>
|
||||
<div>
|
||||
<form name="register" method="post" action="/register">
|
||||
<input type=hidden name="invite_code" value="{{ invite.code }}">
|
||||
|
@ -25,7 +25,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="block">
|
||||
<div class="box">
|
||||
{% include 'snippets/about.html' %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'landing/landing_layout.html' %}
|
||||
{% extends 'landing/layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block panel %}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'landing/landing_layout.html' %}
|
||||
{% extends 'landing/layout.html' %}
|
||||
{% load i18n %}
|
||||
{% block panel %}
|
||||
|
||||
|
|
|
@ -40,38 +40,41 @@
|
|||
<div class="tile is-5 is-parent">
|
||||
{% if not request.user.is_authenticated %}
|
||||
<div class="tile is-child box has-background-primary-light content">
|
||||
<h2 class="title">
|
||||
{% if site.allow_registration %}
|
||||
{% blocktrans with name=site.name %}Join {{ name }}{% endblocktrans %}
|
||||
{% elif site.allow_invite_requests %}
|
||||
{% trans "Request an Invitation" %}
|
||||
{% else %}
|
||||
{% blocktrans with name=site.name%}{{ name}} registration is closed{% endblocktrans %}
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
{% if site.allow_registration %}
|
||||
<h2 class="title">{% blocktrans with name=site.name %}Join {{ name }}{% endblocktrans %}</h2>
|
||||
<form name="register" method="post" action="/register">
|
||||
{% include 'snippets/register_form.html' %}
|
||||
</form>
|
||||
|
||||
<form name="register" method="post" action="/register">
|
||||
{% include 'snippets/register_form.html' %}
|
||||
</form>
|
||||
{% elif site.allow_invite_requests %}
|
||||
{% if request_received %}
|
||||
<p>
|
||||
{% trans "Thank you! Your request has been received." %}
|
||||
</p>
|
||||
{% else %}
|
||||
<p>{{ site.invite_request_text }}</p>
|
||||
<form name="invite-request" action="{% url 'invite-request' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="block">
|
||||
<label for="id_request_email" class="label">{% trans "Email address:" %}</label>
|
||||
<input type="email" name="email" maxlength="255" class="input" required="" id="id_request_email">
|
||||
{% for error in request_form.email.errors %}
|
||||
<p class="help is-danger">{{ error|escape }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="submit" class="button is-link">{% trans "Submit" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
|
||||
<h2 class="title">{% trans "This instance is closed" %}</h2>
|
||||
<p>{{ site.registration_closed_text|safe}}</p>
|
||||
|
||||
{% if site.allow_invite_requests %}
|
||||
{% if request_received %}
|
||||
<p>
|
||||
{% trans "Thank you! Your request has been received." %}
|
||||
</p>
|
||||
{% else %}
|
||||
<h3>{% trans "Request an Invitation" %}</h3>
|
||||
<form name="invite-request" action="{% url 'invite-request' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="block">
|
||||
<label for="id_request_email" class="label">{% trans "Email address:" %}</label>
|
||||
<input type="email" name="email" maxlength="255" class="input" required="" id="id_request_email">
|
||||
{% for error in request_form.email.errors %}
|
||||
<p class="help is-danger">{{ error|escape }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="submit" class="button is-link">{% trans "Submit" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<p>{{ site.registration_closed_text|safe}}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
|
@ -4,12 +4,14 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="{% get_lang %}">
|
||||
<head>
|
||||
<title>{% block title %}BookWyrm{% endblock %} | {{ site.name }}</title>
|
||||
<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" %}">
|
||||
|
||||
<link rel="search" type="application/opensearchdescription+xml" href="{% url 'opensearch' %}" title="{% blocktrans with site_name=site.name %}{{ site_name }} search{% endblocktrans %}" />
|
||||
|
||||
<link rel="shortcut icon" type="image/x-icon" href="{% if site.favicon %}{% get_media_prefix %}{{ site.favicon }}{% else %}{% static "images/favicon.ico" %}{% endif %}">
|
||||
|
||||
{% if preview_images_enabled is True %}
|
||||
|
@ -17,8 +19,8 @@
|
|||
{% else %}
|
||||
<meta name="twitter:card" content="summary">
|
||||
{% endif %}
|
||||
<meta name="twitter:title" content="{% if title %}{{ title }} | {% endif %}{{ site.name }}">
|
||||
<meta name="og:title" content="{% if title %}{{ title }} | {% endif %}{{ site.name }}">
|
||||
<meta name="twitter:title" content="{% if title %}{{ title }} - {% endif %}{{ site.name }}">
|
||||
<meta name="og:title" content="{% if title %}{{ title }} - {% endif %}{{ site.name }}">
|
||||
<meta name="twitter:description" content="{{ site.instance_tagline }}">
|
||||
<meta name="og:description" content="{{ site.instance_tagline }}">
|
||||
|
||||
|
@ -34,10 +36,15 @@
|
|||
<a class="navbar-item" href="/">
|
||||
<img class="image logo" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="Home page">
|
||||
</a>
|
||||
<form class="navbar-item column" action="/search/">
|
||||
<form class="navbar-item column" action="{% url 'search' %}">
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
<input aria-label="{% trans 'Search for a book or user' %}" id="search_input" class="input" type="text" name="q" placeholder="{% trans 'Search for a book or user' %}" value="{{ query }}">
|
||||
{% if user.is_authenticated %}
|
||||
{% trans "Search for a book, user, or list" as search_placeholder %}
|
||||
{% else %}
|
||||
{% trans "Search for a book" as search_placeholder %}
|
||||
{% endif %}
|
||||
<input aria-label="{{ search_placeholder }}" id="search_input" class="input" type="text" name="q" placeholder="{{ search_placeholder }}" value="{{ query }}">
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button" type="submit">
|
||||
|
@ -109,19 +116,19 @@
|
|||
{% trans 'Settings' %}
|
||||
</a>
|
||||
</li>
|
||||
{% if perms.bookwyrm.create_invites or perms.moderate_users %}
|
||||
{% if perms.bookwyrm.create_invites or perms.moderate_user %}
|
||||
<li class="navbar-divider" role="presentation"></li>
|
||||
{% endif %}
|
||||
{% if perms.bookwyrm.create_invites %}
|
||||
{% if perms.bookwyrm.create_invites and not site.allow_registration %}
|
||||
<li>
|
||||
<a href="{% url 'settings-invite-requests' %}" class="navbar-item">
|
||||
{% trans 'Invites' %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.bookwyrm.moderate_users %}
|
||||
{% if perms.bookwyrm.moderate_user %}
|
||||
<li>
|
||||
<a href="{% url 'settings-users' %}" class="navbar-item">
|
||||
<a href="{% url 'settings-dashboard' %}" class="navbar-item">
|
||||
{% trans 'Admin' %}
|
||||
</a>
|
||||
</li>
|
||||
|
@ -210,6 +217,11 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div role="region" aria-live="polite" id="live-messages">
|
||||
<p id="status-success-message" class="live-message is-sr-only is-hidden">{% trans "Successfully posted status" %}</p>
|
||||
<p id="status-error-message" class="live-message notification is-danger p-3 pr-5 pl-5 is-hidden">{% trans "Error posting status" %}</p>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="columns">
|
||||
|
@ -249,8 +261,11 @@
|
|||
<script>
|
||||
var csrf_token = '{{ csrf_token }}';
|
||||
</script>
|
||||
<script src="{% static "js/bookwyrm.js" %}"></script>
|
||||
<script src="{% static "js/localstorage.js" %}"></script>
|
||||
|
||||
<script src="{% static "js/bookwyrm.js" %}?v={{ js_cache }}"></script>
|
||||
<script src="{% static "js/localstorage.js" %}?v={{ js_cache }}"></script>
|
||||
<script src="{% static "js/status_cache.js" %}?v={{ js_cache }}"></script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'lists/list_layout.html' %}
|
||||
{% extends 'lists/layout.html' %}
|
||||
{% load i18n %}
|
||||
{% block panel %}
|
||||
|
||||
|
|
21
bookwyrm/templates/lists/delete_list_modal.html
Normal file
21
bookwyrm/templates/lists/delete_list_modal.html
Normal file
|
@ -0,0 +1,21 @@
|
|||
{% extends 'components/modal.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block modal-title %}{% trans "Delete this list?" %}{% endblock %}
|
||||
|
||||
{% block modal-body %}
|
||||
{% trans "This action cannot be un-done" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-footer %}
|
||||
<form name="delete-list-{{ list.id }}" action="{% url 'delete-list' list.id %}" method="POST">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="id" value="{{ list.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_list" controls_uid=list.id %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
@ -9,4 +9,5 @@
|
|||
<form name="edit-list" method="post" action="{% url 'list' list.id %}">
|
||||
{% include 'lists/form.html' %}
|
||||
</form>
|
||||
{% include "lists/delete_list_modal.html" with controls_text="delete_list" controls_uid=list.id %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<input type="hidden" name="user" value="{{ request.user.id }}">
|
||||
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<div class="column is-two-thirds">
|
||||
<div class="field">
|
||||
<label class="label" for="id_name">{% trans "Name:" %}</label>
|
||||
{{ list_form.name }}
|
||||
|
@ -34,12 +34,21 @@
|
|||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
{% include 'snippets/privacy_select.html' with current=list.privacy %}
|
||||
<div class="columns is-mobile">
|
||||
<div class="column">
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
{% include 'snippets/privacy_select.html' with current=list.privacy %}
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
|
||||
{% if list.id %}
|
||||
<div class="column is-narrow">
|
||||
{% trans "Delete list" as button_text %}
|
||||
{% include 'snippets/toggle/toggle_button.html' with class="is-danger" text=button_text icon_with_text="x" controls_text="delete_list" controls_uid=list.id focus="modal_title_delete_list" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'lists/list_layout.html' %}
|
||||
{% extends 'lists/layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load bookwyrm_tags %}
|
||||
{% load markdown %}
|
||||
|
@ -66,14 +66,14 @@
|
|||
<p>{% blocktrans with username=item.user.display_name user_path=item.user.local_path %}Added by <a href="{{ user_path }}">{{ username }}</a>{% endblocktrans %}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if list.user == request.user or list.curation == 'open' and item.user == request.user %}
|
||||
{% if list.user == request.user %}
|
||||
<div class="card-footer-item">
|
||||
<form name="set-position" method="post" action="{% url 'list-set-book-position' item.id %}">
|
||||
{% csrf_token %}
|
||||
<div class="field has-addons mb-0">
|
||||
<div class="control">
|
||||
<label for="input-list-position" class="button is-transparent is-small">{% trans "List position" %}</label>
|
||||
</div>
|
||||
{% csrf_token %}
|
||||
<div class="control">
|
||||
<input id="input_list_position" class="input is-small" type="number" min="1" name="position" value="{{ item.order }}">
|
||||
</div>
|
||||
|
@ -83,7 +83,9 @@
|
|||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form name="add-book" method="post" action="{% url 'list-remove-book' list.id %}" class="card-footer-item">
|
||||
{% endif %}
|
||||
{% if list.user == request.user or list.curation == 'open' and item.user == request.user %}
|
||||
<form name="remove-book" method="post" action="{% url 'list-remove-book' list.id %}" class="card-footer-item">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="item" value="{{ item.id }}">
|
||||
<button type="submit" class="button is-small is-danger">{% trans "Remove" %}</button>
|
||||
|
|
|
@ -4,69 +4,65 @@
|
|||
{% block title %}{% trans "Login" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<div class="box">
|
||||
<h1 class="title">{% trans "Log in" %}</h1>
|
||||
{% if login_form.non_field_errors %}
|
||||
<p class="notification is-danger">{{ login_form.non_field_errors }}</p>
|
||||
{% endif %}
|
||||
<h1 class="title">{% trans "Log in" %}</h1>
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-half">
|
||||
{% if login_form.non_field_errors %}
|
||||
<p class="notification is-danger">{{ login_form.non_field_errors }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if show_confirmed_email %}
|
||||
<p class="notification is-success">{% trans "Success! Email address confirmed." %}</p>
|
||||
{% endif %}
|
||||
<form name="login" method="post" action="/login">
|
||||
{% csrf_token %}
|
||||
{% if show_confirmed_email %}<input type="hidden" name="first_login" value="true">{% endif %}
|
||||
<div class="field">
|
||||
<label class="label" for="id_localname">{% trans "Username:" %}</label>
|
||||
<div class="control">
|
||||
{{ login_form.localname }}
|
||||
</div>
|
||||
{% if show_confirmed_email %}
|
||||
<p class="notification is-success">{% trans "Success! Email address confirmed." %}</p>
|
||||
{% endif %}
|
||||
<form name="login" method="post" action="/login">
|
||||
{% csrf_token %}
|
||||
{% if show_confirmed_email %}<input type="hidden" name="first_login" value="true">{% endif %}
|
||||
<div class="field">
|
||||
<label class="label" for="id_localname">{% trans "Username:" %}</label>
|
||||
<div class="control">
|
||||
{{ login_form.localname }}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="id_password">{% trans "Password:" %}</label>
|
||||
<div class="control">
|
||||
{{ login_form.password }}
|
||||
</div>
|
||||
{% for error in login_form.password.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="id_password">{% trans "Password:" %}</label>
|
||||
<div class="control">
|
||||
{{ login_form.password }}
|
||||
</div>
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">{% trans "Log in" %}</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<small><a href="{% url 'password-reset' %}">{% trans "Forgot your password?" %}</a></small>
|
||||
</div>
|
||||
{% for error in login_form.password.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">{% trans "Log in" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="control">
|
||||
<small><a href="{% url 'password-reset' %}">{% trans "Forgot your password?" %}</a></small>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="column">
|
||||
{% if site.allow_registration %}
|
||||
<div class="column is-half">
|
||||
<div class="box has-background-primary-light">
|
||||
{% if site.allow_registration %}
|
||||
<h2 class="title">{% trans "Create an Account" %}</h2>
|
||||
<form name="register" method="post" action="/register">
|
||||
{% include 'snippets/register_form.html' %}
|
||||
</form>
|
||||
{% else %}
|
||||
<h2 class="title">{% trans "This instance is closed" %}</h2>
|
||||
<p>{% trans "Contact an administrator to get an invite" %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="column">
|
||||
<div class="box">
|
||||
{% include 'snippets/about.html' %}
|
||||
|
||||
<p class="block">
|
||||
<a href="{% url 'about' %}">{% trans "More about this site" %}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
<div class="box">
|
||||
{% include 'snippets/about.html' %}
|
||||
|
||||
<p class="block">
|
||||
<a href="{% url 'about' %}">{% trans "More about this site" %}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'settings/admin_layout.html' %}
|
||||
{% extends 'settings/layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load humanize %}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'settings/admin_layout.html' %}
|
||||
{% extends 'settings/layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}
|
||||
|
|
16
bookwyrm/templates/opensearch.xml
Normal file
16
bookwyrm/templates/opensearch.xml
Normal file
|
@ -0,0 +1,16 @@
|
|||
{% load i18n %}{% load static %}<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OpenSearchDescription
|
||||
xmlns="http://a9.com/-/spec/opensearch/1.1/"
|
||||
xmlns:moz="http://www.mozilla.org/2006/browser/search/"
|
||||
>
|
||||
<ShortName>BW</ShortName>
|
||||
<Description>{% blocktrans trimmed with site_name=site.name %}
|
||||
{{ site_name }} search
|
||||
{% endblocktrans %}</Description>
|
||||
<Image width="16" height="16" type="image/x-icon">{{ image }}</Image>
|
||||
<Url
|
||||
type="text/html"
|
||||
method="get"
|
||||
template="https://{{ DOMAIN }}{% url 'search' %}?q={searchTerms}"
|
||||
/>
|
||||
</OpenSearchDescription>
|
|
@ -43,9 +43,19 @@
|
|||
</div>
|
||||
<div class="block">
|
||||
<label class="checkbox label" for="id_show_goal">
|
||||
{% trans "Show set reading goal prompt in feed:" %}
|
||||
{% trans "Show reading goal prompt in feed:" %}
|
||||
{{ form.show_goal }}
|
||||
</label>
|
||||
<label class="checkbox label" for="id_show_goal">
|
||||
{% trans "Show suggested users:" %}
|
||||
{{ form.show_suggested_users }}
|
||||
</label>
|
||||
<label class="checkbox label" for="id_discoverable">
|
||||
{% trans "Show this account in suggested users:" %}
|
||||
{{ form.discoverable }}
|
||||
</label>
|
||||
{% url 'directory' as path %}
|
||||
<p class="help">{% blocktrans %}Your account will show up in the <a href="{{ path }}">directory</a>, and may be recommended to other BookWyrm users.{% endblocktrans %}</p>
|
||||
</div>
|
||||
<div class="block">
|
||||
<label class="checkbox label" for="id_manually_approves_followers">
|
||||
|
@ -61,14 +71,6 @@
|
|||
{{ form.default_post_privacy }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="block">
|
||||
<label class="checkbox label" for="id_discoverable">
|
||||
{% trans "Show this account in suggested users:" %}
|
||||
{{ form.discoverable }}
|
||||
</label>
|
||||
{% url 'directory' as path %}
|
||||
<p class="help">{% blocktrans %}Your account will show up in the <a href="{{ path }}">directory</a>, and may be recommended to other BookWyrm users.{% endblocktrans %}</p>
|
||||
</div>
|
||||
<div class="block">
|
||||
<label class="label" for="id_preferred_timezone">{% trans "Preferred Timezone: " %}</label>
|
||||
<div class="select">
|
||||
|
|
|
@ -9,6 +9,6 @@ Finish "{{ book_title }}"
|
|||
|
||||
{% block content %}
|
||||
|
||||
{% include "snippets/reading_modals/finish_reading_modal.html" with book=book active=True %}
|
||||
{% include "snippets/reading_modals/finish_reading_modal.html" with book=book active=True static=True %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
@ -9,6 +9,6 @@ Start "{{ book_title }}"
|
|||
|
||||
{% block content %}
|
||||
|
||||
{% include "snippets/reading_modals/start_reading_modal.html" with book=book active=True %}
|
||||
{% include "snippets/reading_modals/start_reading_modal.html" with book=book active=True static=True %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
@ -9,6 +9,6 @@ Want to Read "{{ book_title }}"
|
|||
|
||||
{% block content %}
|
||||
|
||||
{% include "snippets/reading_modals/want_to_read_modal.html" with book=book active=True %}
|
||||
{% include "snippets/reading_modals/want_to_read_modal.html" with book=book active=True static=True %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue