Merge branch 'main' into progress-modal

This commit is contained in:
Mouse Reeve 2021-09-28 08:00:45 -07:00
commit 273198d634
286 changed files with 10940 additions and 4798 deletions

View file

@ -16,7 +16,7 @@ DEFAULT_LANGUAGE="English"
MEDIA_ROOT=images/
POSTGRES_PORT=5432
PGPORT=5432
POSTGRES_PASSWORD=securedbypassword123
POSTGRES_USER=fedireads
POSTGRES_DB=fedireads

View file

@ -16,7 +16,7 @@ DEFAULT_LANGUAGE="English"
MEDIA_ROOT=images/
POSTGRES_PORT=5432
PGPORT=5432
POSTGRES_PASSWORD=securedbypassword123
POSTGRES_USER=fedireads
POSTGRES_DB=fedireads

View file

@ -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

View file

@ -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"

View file

@ -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)

View file

@ -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
)

View file

@ -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}")

View file

@ -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:

View file

@ -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):

View file

@ -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,

View file

@ -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,
}

View file

@ -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(

View file

@ -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

View file

@ -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,

View file

@ -0,0 +1,3 @@
""" look at all this nice middleware! """
from .timezone_middleware import TimezoneMiddleware
from .ip_middleware import IPBlocklistMiddleware

View 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)

View 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",
),
),
]

View 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,
),
),
]

View file

@ -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 = []

View 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,
),
),
]

View 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),
),
]

View 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,
),
),
]

View 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",),
},
),
]

View file

@ -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 = []

View file

@ -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),
),
]

View file

@ -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),
),
]

View 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),
]

View 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",
),
),
]

View 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,
),
),
]

View 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 = []

View 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 = []

View 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),
),
]

View 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>.'
),
),
]

View 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),
),
]

View file

@ -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 = {

View file

@ -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,

View 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",)

View file

@ -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

View file

@ -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)

View file

@ -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)
)

View file

@ -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})"

View file

@ -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"""

View file

@ -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

View file

@ -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):

View file

@ -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"])

View file

@ -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"""

View file

@ -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",
)

View file

@ -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)

View file

@ -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"""

View file

@ -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"""

View file

@ -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"""

View file

@ -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

View file

@ -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)

View file

@ -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"

View file

@ -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:

View file

@ -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"""

View file

@ -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"))

View file

@ -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)

View file

@ -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 */

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View file

@ -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";
}

View file

@ -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',
}
});
}

View 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";
}
}
}();

View file

@ -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)

View file

@ -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
)

View file

@ -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 %}

View file

@ -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>

View 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>

View file

@ -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>

View file

@ -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">

View file

@ -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">

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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>

View file

@ -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 %}>

View file

@ -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 %}

View 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 %}

View file

@ -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>

View file

@ -1,4 +1,4 @@
{% extends 'landing/landing_layout.html' %}
{% extends 'landing/layout.html' %}
{% load i18n %}
{% block panel %}

View file

@ -1,4 +1,4 @@
{% extends 'landing/landing_layout.html' %}
{% extends 'landing/layout.html' %}
{% load i18n %}
{% block panel %}

View file

@ -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 %}

View file

@ -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>

View file

@ -1,4 +1,4 @@
{% extends 'lists/list_layout.html' %}
{% extends 'lists/layout.html' %}
{% load i18n %}
{% block panel %}

View 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 %}

View file

@ -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 %}

View file

@ -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>

View file

@ -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>

View file

@ -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 %}

View file

@ -1,4 +1,4 @@
{% extends 'settings/admin_layout.html' %}
{% extends 'settings/layout.html' %}
{% load i18n %}
{% load humanize %}

View file

@ -1,4 +1,4 @@
{% extends 'settings/admin_layout.html' %}
{% extends 'settings/layout.html' %}
{% load i18n %}
{% block title %}

View 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>

View file

@ -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">

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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