diff --git a/.env.dev.example b/.env.dev.example
index d4476fd24..1e4fb9812 100644
--- a/.env.dev.example
+++ b/.env.dev.example
@@ -16,7 +16,7 @@ DEFAULT_LANGUAGE="English"
MEDIA_ROOT=images/
-POSTGRES_PORT=5432
+PGPORT=5432
POSTGRES_PASSWORD=securedbypassword123
POSTGRES_USER=fedireads
POSTGRES_DB=fedireads
diff --git a/.env.prod.example b/.env.prod.example
index 99520916a..49729d533 100644
--- a/.env.prod.example
+++ b/.env.prod.example
@@ -16,7 +16,7 @@ DEFAULT_LANGUAGE="English"
MEDIA_ROOT=images/
-POSTGRES_PORT=5432
+PGPORT=5432
POSTGRES_PASSWORD=securedbypassword123
POSTGRES_USER=fedireads
POSTGRES_DB=fedireads
diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py
index 52b1b1f27..24d383ac7 100644
--- a/bookwyrm/activitypub/base_activity.py
+++ b/bookwyrm/activitypub/base_activity.py
@@ -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]
@@ -286,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
diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py
index 6dedd7176..5e0969e56 100644
--- a/bookwyrm/activitystreams.py
+++ b/bookwyrm/activitystreams.py
@@ -1,7 +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
@@ -14,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"""
@@ -355,7 +358,7 @@ def populate_streams_on_account_create(sender, instance, created, *args, **kwarg
if not created or not instance.local:
return
- for stream in streams.values():
+ for stream in streams:
populate_stream_task.delay(stream, instance.id)
@@ -395,7 +398,7 @@ def remove_statuses_on_unshelve(sender, instance, *args, **kwargs):
# ---- TASKS
-@app.task
+@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)
@@ -403,7 +406,7 @@ def add_book_statuses_task(user_id, book_id):
BooksStream().add_book_statuses(user, book)
-@app.task
+@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)
@@ -411,7 +414,7 @@ def remove_book_statuses_task(user_id, book_id):
BooksStream().remove_book_statuses(user, book)
-@app.task
+@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)
@@ -419,7 +422,7 @@ def populate_stream_task(stream, user_id):
stream.populate_streams(user)
-@app.task
+@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
@@ -432,15 +435,19 @@ def remove_status_task(status_ids):
stream.remove_object_from_related_stores(status)
-@app.task
+@app.task(queue="high_priority")
def add_status_task(status_id, increment_unread=False):
- """remove a status from any stream it might be in"""
+ """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
+@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()
@@ -450,9 +457,9 @@ def remove_user_statuses_task(viewer_id, user_id, stream_list=None):
stream.remove_user_statuses(viewer, user)
-@app.task
+@app.task(queue="medium_priority")
def add_user_statuses_task(viewer_id, user_id, stream_list=None):
- """remove all statuses by a user from a viewer's stream"""
+ """add all statuses by a user to a viewer's stream"""
stream_list = [streams[s] for s in stream_list] if stream_list else streams.values()
viewer = models.User.objects.get(id=viewer_id)
user = models.User.objects.get(id=user_id)
@@ -460,7 +467,7 @@ def add_user_statuses_task(viewer_id, user_id, stream_list=None):
stream.add_user_statuses(viewer, user)
-@app.task
+@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)
@@ -469,7 +476,7 @@ def handle_boost_task(boost_id):
old_versions = models.Boost.objects.filter(
boosted_status__id=boosted.id,
created_date__lt=instance.created_date,
- ).values_list("id", flat=True)
+ )
for stream in streams.values():
audience = stream.get_stores_for_object(instance)
diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py
index ffacffdf0..455241cca 100644
--- a/bookwyrm/connectors/abstract_connector.py
+++ b/bookwyrm/connectors/abstract_connector.py
@@ -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 "".format(
self.key, self.title, self.author
)
diff --git a/bookwyrm/connectors/connector_manager.py b/bookwyrm/connectors/connector_manager.py
index 1a615c9b2..b676e9aa5 100644
--- a/bookwyrm/connectors/connector_manager.py
+++ b/bookwyrm/connectors/connector_manager.py
@@ -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}")
diff --git a/bookwyrm/connectors/inventaire.py b/bookwyrm/connectors/inventaire.py
index d2a7b9faa..704554880 100644
--- a/bookwyrm/connectors/inventaire.py
+++ b/bookwyrm/connectors/inventaire.py
@@ -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:
diff --git a/bookwyrm/connectors/openlibrary.py b/bookwyrm/connectors/openlibrary.py
index e58749c13..fca5d0f71 100644
--- a/bookwyrm/connectors/openlibrary.py
+++ b/bookwyrm/connectors/openlibrary.py
@@ -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):
diff --git a/bookwyrm/connectors/self_connector.py b/bookwyrm/connectors/self_connector.py
index 8d5a7614e..cdb586cb3 100644
--- a/bookwyrm/connectors/self_connector.py
+++ b/bookwyrm/connectors/self_connector.py
@@ -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,
diff --git a/bookwyrm/context_processors.py b/bookwyrm/context_processors.py
index 0610a8b9a..309e84ed1 100644
--- a/bookwyrm/context_processors.py
+++ b/bookwyrm/context_processors.py
@@ -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,
}
diff --git a/bookwyrm/emailing.py b/bookwyrm/emailing.py
index fff3985ef..c6a197f29 100644
--- a/bookwyrm/emailing.py
+++ b/bookwyrm/emailing.py
@@ -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(
diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py
index 690526050..23063ff7c 100644
--- a/bookwyrm/forms.py
+++ b/bookwyrm/forms.py
@@ -125,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
@@ -134,6 +140,7 @@ class EditUserForm(CustomForm):
"email",
"summary",
"show_goal",
+ "show_suggested_users",
"manually_approves_followers",
"default_post_privacy",
"discoverable",
@@ -253,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"))]
),
}
@@ -298,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
diff --git a/bookwyrm/importers/importer.py b/bookwyrm/importers/importer.py
index d5f1449ca..a10b4060c 100644
--- a/bookwyrm/importers/importer.py
+++ b/bookwyrm/importers/importer.py
@@ -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,
diff --git a/bookwyrm/middleware/__init__.py b/bookwyrm/middleware/__init__.py
new file mode 100644
index 000000000..03843c5a3
--- /dev/null
+++ b/bookwyrm/middleware/__init__.py
@@ -0,0 +1,3 @@
+""" look at all this nice middleware! """
+from .timezone_middleware import TimezoneMiddleware
+from .ip_middleware import IPBlocklistMiddleware
diff --git a/bookwyrm/middleware/ip_middleware.py b/bookwyrm/middleware/ip_middleware.py
new file mode 100644
index 000000000..8063dd1f6
--- /dev/null
+++ b/bookwyrm/middleware/ip_middleware.py
@@ -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)
diff --git a/bookwyrm/timezone_middleware.py b/bookwyrm/middleware/timezone_middleware.py
similarity index 100%
rename from bookwyrm/timezone_middleware.py
rename to bookwyrm/middleware/timezone_middleware.py
diff --git a/bookwyrm/migrations/0089_user_show_suggested_users.py b/bookwyrm/migrations/0089_user_show_suggested_users.py
new file mode 100644
index 000000000..047bb974c
--- /dev/null
+++ b/bookwyrm/migrations/0089_user_show_suggested_users.py
@@ -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),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0090_auto_20210908_2346.py b/bookwyrm/migrations/0090_auto_20210908_2346.py
new file mode 100644
index 000000000..7c8708573
--- /dev/null
+++ b/bookwyrm/migrations/0090_auto_20210908_2346.py
@@ -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,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0090_emailblocklist.py b/bookwyrm/migrations/0090_emailblocklist.py
new file mode 100644
index 000000000..6934e51e9
--- /dev/null
+++ b/bookwyrm/migrations/0090_emailblocklist.py
@@ -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",),
+ },
+ ),
+ ]
diff --git a/bookwyrm/migrations/0091_merge_0090_auto_20210908_2346_0090_emailblocklist.py b/bookwyrm/migrations/0091_merge_0090_auto_20210908_2346_0090_emailblocklist.py
new file mode 100644
index 000000000..7aae2c7a6
--- /dev/null
+++ b/bookwyrm/migrations/0091_merge_0090_auto_20210908_2346_0090_emailblocklist.py
@@ -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 = []
diff --git a/bookwyrm/migrations/0092_sitesettings_instance_short_description.py b/bookwyrm/migrations/0092_sitesettings_instance_short_description.py
new file mode 100644
index 000000000..4c62dd7be
--- /dev/null
+++ b/bookwyrm/migrations/0092_sitesettings_instance_short_description.py
@@ -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),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0093_alter_sitesettings_instance_short_description.py b/bookwyrm/migrations/0093_alter_sitesettings_instance_short_description.py
new file mode 100644
index 000000000..165199be7
--- /dev/null
+++ b/bookwyrm/migrations/0093_alter_sitesettings_instance_short_description.py
@@ -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),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0094_auto_20210911_1550.py b/bookwyrm/migrations/0094_auto_20210911_1550.py
new file mode 100644
index 000000000..8c3be9f89
--- /dev/null
+++ b/bookwyrm/migrations/0094_auto_20210911_1550.py
@@ -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),
+ ]
diff --git a/bookwyrm/migrations/0094_importitem_book_guess.py b/bookwyrm/migrations/0094_importitem_book_guess.py
new file mode 100644
index 000000000..be703cdde
--- /dev/null
+++ b/bookwyrm/migrations/0094_importitem_book_guess.py
@@ -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",
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0095_auto_20210911_2053.py b/bookwyrm/migrations/0095_auto_20210911_2053.py
new file mode 100644
index 000000000..06d15f5e6
--- /dev/null
+++ b/bookwyrm/migrations/0095_auto_20210911_2053.py
@@ -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,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0095_merge_20210911_2143.py b/bookwyrm/migrations/0095_merge_20210911_2143.py
new file mode 100644
index 000000000..ea6b5a346
--- /dev/null
+++ b/bookwyrm/migrations/0095_merge_20210911_2143.py
@@ -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 = []
diff --git a/bookwyrm/migrations/0096_merge_20210912_0044.py b/bookwyrm/migrations/0096_merge_20210912_0044.py
new file mode 100644
index 000000000..0d3b69a20
--- /dev/null
+++ b/bookwyrm/migrations/0096_merge_20210912_0044.py
@@ -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 = []
diff --git a/bookwyrm/migrations/0097_auto_20210917_1858.py b/bookwyrm/migrations/0097_auto_20210917_1858.py
new file mode 100644
index 000000000..28cf94e25
--- /dev/null
+++ b/bookwyrm/migrations/0097_auto_20210917_1858.py
@@ -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),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0098_auto_20210918_2238.py b/bookwyrm/migrations/0098_auto_20210918_2238.py
new file mode 100644
index 000000000..09fdba317
--- /dev/null
+++ b/bookwyrm/migrations/0098_auto_20210918_2238.py
@@ -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 joinbookwyrm.com/instances .'
+ ),
+ ),
+ ]
diff --git a/bookwyrm/models/__init__.py b/bookwyrm/models/__init__.py
index 6f378e83c..bffd62b45 100644
--- a/bookwyrm/models/__init__.py
+++ b/bookwyrm/models/__init__.py
@@ -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 = {
diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py
index f287b752f..3a88c5249 100644
--- a/bookwyrm/models/activitypub_mixin.py
+++ b/bookwyrm/models/activitypub_mixin.py
@@ -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)
@@ -420,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,
@@ -430,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,
@@ -458,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()
@@ -502,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)
@@ -555,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,
diff --git a/bookwyrm/models/antispam.py b/bookwyrm/models/antispam.py
new file mode 100644
index 000000000..7a85bbcf0
--- /dev/null
+++ b/bookwyrm/models/antispam.py
@@ -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",)
diff --git a/bookwyrm/models/author.py b/bookwyrm/models/author.py
index 6da80b176..53cf94ff4 100644
--- a/bookwyrm/models/author.py
+++ b/bookwyrm/models/author.py
@@ -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
diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py
index 5b55ea50f..aa174a143 100644
--- a/bookwyrm/models/base_model.py
+++ b/bookwyrm/models/base_model.py
@@ -3,20 +3,19 @@ import base64
from Crypto import Random
from django.db import models
from django.dispatch import receiver
+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 +32,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,7 +46,7 @@ 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):
"""is a user authorized to view an object?"""
diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py
index 5888ea07a..70df3bd47 100644
--- a/bookwyrm/models/book.py
+++ b/bookwyrm/models/book.py
@@ -4,6 +4,7 @@ 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 transaction
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
from model_utils import FieldTracker
@@ -164,9 +165,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):
@@ -177,9 +178,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,
@@ -216,7 +218,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,
)
@@ -375,4 +377,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)
+ )
diff --git a/bookwyrm/models/connector.py b/bookwyrm/models/connector.py
index 2d6717908..9d2c6aeb3 100644
--- a/bookwyrm/models/connector.py
+++ b/bookwyrm/models/connector.py
@@ -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})"
diff --git a/bookwyrm/models/favorite.py b/bookwyrm/models/favorite.py
index 9ab300b3c..4c3675219 100644
--- a/bookwyrm/models/favorite.py
+++ b/bookwyrm/models/favorite.py
@@ -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"""
diff --git a/bookwyrm/models/federated_server.py b/bookwyrm/models/federated_server.py
index e297c46c4..eb03d457e 100644
--- a/bookwyrm/models/federated_server.py
+++ b/bookwyrm/models/federated_server.py
@@ -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
diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py
index cc5a7bb55..ccd669cbf 100644
--- a/bookwyrm/models/fields.py
+++ b/bookwyrm/models/fields.py
@@ -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:
@@ -308,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):
@@ -388,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)
@@ -448,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):
diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py
index 05aada161..22253fef7 100644
--- a/bookwyrm/models/import_job.py
+++ b/bookwyrm/models/import_job.py
@@ -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"])
diff --git a/bookwyrm/models/list.py b/bookwyrm/models/list.py
index bbad5ba9b..49fb53757 100644
--- a/bookwyrm/models/list.py
+++ b/bookwyrm/models/list.py
@@ -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):
diff --git a/bookwyrm/models/notification.py b/bookwyrm/models/notification.py
index ff0b4e5a6..a4968f61f 100644
--- a/bookwyrm/models/notification.py
+++ b/bookwyrm/models/notification.py
@@ -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",
+ )
diff --git a/bookwyrm/models/readthrough.py b/bookwyrm/models/readthrough.py
index 343d3c115..e1090f415 100644
--- a/bookwyrm/models/readthrough.py
+++ b/bookwyrm/models/readthrough.py
@@ -2,7 +2,6 @@
from django.core import validators
from django.db import models
from django.db.models import F, Q
-from django.utils import timezone
from .base_model import BookWyrmModel
@@ -30,8 +29,7 @@ class ReadThrough(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)
def create_update(self):
@@ -65,6 +63,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)
diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py
index edb89d13c..fc7a9df83 100644
--- a/bookwyrm/models/relationship.py
+++ b/bookwyrm/models/relationship.py
@@ -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"""
diff --git a/bookwyrm/models/report.py b/bookwyrm/models/report.py
index 7ff4c9091..636817cb2 100644
--- a/bookwyrm/models/report.py
+++ b/bookwyrm/models/report.py
@@ -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"""
diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py
index c4e907d27..6f1344022 100644
--- a/bookwyrm/models/shelf.py
+++ b/bookwyrm/models/shelf.py
@@ -44,7 +44,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 +55,7 @@ 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}"
class Meta:
"""user/shelf unqiueness"""
diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py
index ef3f7c3ca..8338fff88 100644
--- a/bookwyrm/models/site.py
+++ b/bookwyrm/models/site.py
@@ -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 '
+ "joinbookwyrm.com/instances ."
+ )
+ 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
diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py
index d0f094d22..3a0fad5e5 100644
--- a/bookwyrm/models/status.py
+++ b/bookwyrm/models/status.py
@@ -67,40 +67,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 +74,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 +149,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
@@ -226,10 +196,10 @@ class GeneratedNote(Status):
"""indicate the book in question for mastodon (or w/e) users"""
message = self.content
books = ", ".join(
- '"%s" ' % (book.remote_id, book.title)
+ f'"{book.title}" '
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 +247,9 @@ class Comment(BookStatus):
@property
def pure_content(self):
"""indicate the book in question for mastodon (or w/e) users"""
- return '%s(comment on "%s" )
' % (
- self.content,
- self.book.remote_id,
- self.book.title,
+ return (
+ f'{self.content}(comment on '
+ f'"{self.book.title}" )
'
)
activity_serializer = activitypub.Comment
@@ -306,11 +275,9 @@ class Quotation(BookStatus):
"""indicate the book in question for mastodon (or w/e) users"""
quote = re.sub(r"^", '
"', self.quote)
quote = re.sub(r"
$", '"
', quote)
- return '%s -- "%s"
%s' % (
- quote,
- self.book.remote_id,
- self.book.title,
- self.content,
+ return (
+ f'{quote} -- '
+ f'"{self.book.title}"
{self.content}'
)
activity_serializer = activitypub.Quotation
@@ -389,27 +356,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" """
@@ -422,10 +368,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)
diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py
index 0745dffa2..637baa6ee 100644
--- a/bookwyrm/models/user.py
+++ b/bookwyrm/models/user.py
@@ -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,
@@ -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,30 +288,47 @@ class User(OrderedCollectionPageMixin, AbstractUser):
transaction.on_commit(lambda: set_remote_server.delay(self.id))
return
- # 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"
+ 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",
@@ -321,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"""
@@ -346,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"""
@@ -383,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):
@@ -420,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)
@@ -439,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):
@@ -459,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"
diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py
index 4f85bb56e..8224a2787 100644
--- a/bookwyrm/preview_images.py
+++ b/bookwyrm/preview_images.py
@@ -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:
diff --git a/bookwyrm/sanitize_html.py b/bookwyrm/sanitize_html.py
index 0be64c58c..8b0e3c4cb 100644
--- a/bookwyrm/sanitize_html.py
+++ b/bookwyrm/sanitize_html.py
@@ -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"""
diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py
index c1f900794..31c2edf80 100644
--- a/bookwyrm/settings.py
+++ b/bookwyrm/settings.py
@@ -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"))
diff --git a/bookwyrm/signatures.py b/bookwyrm/signatures.py
index c8c900283..61cafe71f 100644
--- a/bookwyrm/signatures.py
+++ b/bookwyrm/signatures.py
@@ -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)
diff --git a/bookwyrm/static/css/bookwyrm.css b/bookwyrm/static/css/bookwyrm.css
index 0724c7f14..e1012c2fd 100644
--- a/bookwyrm/static/css/bookwyrm.css
+++ b/bookwyrm/static/css/bookwyrm.css
@@ -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 */
diff --git a/bookwyrm/static/css/fonts/icomoon.eot b/bookwyrm/static/css/fonts/icomoon.eot
index 2c801b2b6..8eba8692f 100644
Binary files a/bookwyrm/static/css/fonts/icomoon.eot and b/bookwyrm/static/css/fonts/icomoon.eot differ
diff --git a/bookwyrm/static/css/fonts/icomoon.svg b/bookwyrm/static/css/fonts/icomoon.svg
index 6327b19e6..82e413294 100644
--- a/bookwyrm/static/css/fonts/icomoon.svg
+++ b/bookwyrm/static/css/fonts/icomoon.svg
@@ -7,38 +7,39 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bookwyrm/static/css/fonts/icomoon.ttf b/bookwyrm/static/css/fonts/icomoon.ttf
index 242ca7392..5bf90a0a7 100644
Binary files a/bookwyrm/static/css/fonts/icomoon.ttf and b/bookwyrm/static/css/fonts/icomoon.ttf differ
diff --git a/bookwyrm/static/css/fonts/icomoon.woff b/bookwyrm/static/css/fonts/icomoon.woff
index 67b0f0a69..6ce6834d5 100644
Binary files a/bookwyrm/static/css/fonts/icomoon.woff and b/bookwyrm/static/css/fonts/icomoon.woff differ
diff --git a/bookwyrm/static/css/vendor/icons.css b/bookwyrm/static/css/vendor/icons.css
index db783c24f..b43224e34 100644
--- a/bookwyrm/static/css/vendor/icons.css
+++ b/bookwyrm/static/css/vendor/icons.css
@@ -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";
+}
diff --git a/bookwyrm/static/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js
index 894b1fb69..f000fd082 100644
--- a/bookwyrm/static/js/bookwyrm.js
+++ b/bookwyrm/static/js/bookwyrm.js
@@ -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',
+ }
});
}
diff --git a/bookwyrm/static/js/status_cache.js b/bookwyrm/static/js/status_cache.js
new file mode 100644
index 000000000..b3e345b19
--- /dev/null
+++ b/bookwyrm/static/js/status_cache.js
@@ -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";
+ }
+ }
+}();
+
diff --git a/bookwyrm/suggested_users.py b/bookwyrm/suggested_users.py
index 9c42d79d8..e8f236324 100644
--- a/bookwyrm/suggested_users.py
+++ b/bookwyrm/suggested_users.py
@@ -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)
diff --git a/bookwyrm/tasks.py b/bookwyrm/tasks.py
index 6d1992a77..b860e0184 100644
--- a/bookwyrm/tasks.py
+++ b/bookwyrm/tasks.py
@@ -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
)
diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html
index 6fa2e441a..fc94c7de8 100644
--- a/bookwyrm/templates/book/book.html
+++ b/bookwyrm/templates/book/book.html
@@ -325,5 +325,5 @@
{% endblock %}
{% block scripts %}
-
+
{% endblock %}
diff --git a/bookwyrm/templates/components/tooltip.html b/bookwyrm/templates/components/tooltip.html
new file mode 100644
index 000000000..b1a8f56c1
--- /dev/null
+++ b/bookwyrm/templates/components/tooltip.html
@@ -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 %}
+
+
+ {% 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 %}
+
diff --git a/bookwyrm/templates/compose.html b/bookwyrm/templates/compose.html
index e37ec170e..3a222cf6a 100644
--- a/bookwyrm/templates/compose.html
+++ b/bookwyrm/templates/compose.html
@@ -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 %}
diff --git a/bookwyrm/templates/discover/discover.html b/bookwyrm/templates/discover/discover.html
index 01ef21869..f55f81a09 100644
--- a/bookwyrm/templates/discover/discover.html
+++ b/bookwyrm/templates/discover/discover.html
@@ -15,14 +15,15 @@
+ {% with tile_classes="tile is-child box has-background-white-ter is-clipped" %}
-
+
{% include 'discover/large-book.html' with status=large_activities.0 %}
-
+
{% include 'discover/large-book.html' with status=large_activities.1 %}
@@ -31,18 +32,18 @@
-
+
{% include 'discover/large-book.html' with status=large_activities.2 %}
-
+
{% include 'discover/small-book.html' with status=small_activities.0 %}
-
+
{% include 'discover/small-book.html' with status=small_activities.1 %}
@@ -51,18 +52,18 @@
-
+
{% include 'discover/small-book.html' with status=small_activities.2 %}
-
+
{% include 'discover/small-book.html' with status=small_activities.3 %}
-
+
{% include 'discover/large-book.html' with status=large_activities.3 %}
@@ -71,16 +72,17 @@
-
+
{% include 'discover/large-book.html' with status=large_activities.4 %}
-
+
{% include 'discover/large-book.html' with status=large_activities.5 %}
+ {% endwith %}
diff --git a/bookwyrm/templates/feed/direct_messages.html b/bookwyrm/templates/feed/direct_messages.html
index 115e1e6f4..77f9aac19 100644
--- a/bookwyrm/templates/feed/direct_messages.html
+++ b/bookwyrm/templates/feed/direct_messages.html
@@ -14,7 +14,7 @@
- {% 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 %}
diff --git a/bookwyrm/templates/feed/feed.html b/bookwyrm/templates/feed/feed.html
index 265a467a7..b8e351c9f 100644
--- a/bookwyrm/templates/feed/feed.html
+++ b/bookwyrm/templates/feed/feed.html
@@ -22,7 +22,7 @@
{% blocktrans with tab_key=tab.key %}load 0 unread status(es){% endblocktrans %}
-{% 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 %}
{% include 'snippets/goal_card.html' with year=year %}
@@ -37,7 +37,7 @@
{% trans "There aren't any activities right now! Try following a user to get started" %}
- {% 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 %}
diff --git a/bookwyrm/templates/feed/layout.html b/bookwyrm/templates/feed/layout.html
index 746f4fd09..8d79781b3 100644
--- a/bookwyrm/templates/feed/layout.html
+++ b/bookwyrm/templates/feed/layout.html
@@ -106,5 +106,5 @@
{% endblock %}
{% block scripts %}
-
+
{% endblock %}
diff --git a/bookwyrm/templates/feed/suggested_users.html b/bookwyrm/templates/feed/suggested_users.html
index 1de1ae139..4e9f822b5 100644
--- a/bookwyrm/templates/feed/suggested_users.html
+++ b/bookwyrm/templates/feed/suggested_users.html
@@ -1,6 +1,15 @@
{% load i18n %}
- {% trans "Who to follow" %}
+
{% include 'snippets/suggested_users.html' with suggested_users=suggested_users %}
{% trans "View directory" %}
diff --git a/bookwyrm/templates/import.html b/bookwyrm/templates/import/import.html
similarity index 89%
rename from bookwyrm/templates/import.html
rename to bookwyrm/templates/import/import.html
index d2e407486..cc296b75b 100644
--- a/bookwyrm/templates/import.html
+++ b/bookwyrm/templates/import/import.html
@@ -12,9 +12,14 @@
-
- {% trans "Data source:" %}
-
+
+
+
+ {% trans "Data source:" %}
+
+ {% include 'import/tooltip.html' with controls_text="goodreads-tooltip" %}
+
+
diff --git a/bookwyrm/templates/import_status.html b/bookwyrm/templates/import/import_status.html
similarity index 97%
rename from bookwyrm/templates/import_status.html
rename to bookwyrm/templates/import/import_status.html
index db7330bb2..1c0739444 100644
--- a/bookwyrm/templates/import_status.html
+++ b/bookwyrm/templates/import/import_status.html
@@ -40,7 +40,7 @@
{% trans "Failed to load" %}
{% if not job.retry %}
-