Merge branch 'main' into search-refactor

This commit is contained in:
Mouse Reeve 2021-09-30 10:40:57 -07:00
commit 5dd2aac600
218 changed files with 8892 additions and 6942 deletions

View file

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

View file

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

View file

@ -24,5 +24,5 @@ jobs:
pip install pylint pip install pylint
- name: Analysing the code with pylint - name: Analysing the code with pylint
run: | run: |
pylint bookwyrm/ --ignore=migrations,tests --disable=E1101,E1135,E1136,R0903,R0901,R0902,W0707,W0511,W0406,R0401,R0801,C0209 pylint bookwyrm/ --ignore=migrations,tests --disable=E1101,E1135,E1136,R0903,R0901,R0902,W0707,W0511,W0406,R0401,R0801

View file

@ -8,4 +8,4 @@ WORKDIR /app
COPY requirements.txt /app/ COPY requirements.txt /app/
RUN pip install -r requirements.txt --no-cache-dir RUN pip install -r requirements.txt --no-cache-dir
RUN apt-get update && apt-get install -y gettext libgettextpo-dev && apt-get clean RUN apt-get update && apt-get install -y gettext libgettextpo-dev tidy && apt-get clean

View file

@ -101,7 +101,7 @@ class ActivityObject:
except KeyError: except KeyError:
if field.default == MISSING and field.default_factory == MISSING: if field.default == MISSING and field.default_factory == MISSING:
raise ActivitySerializerError( raise ActivitySerializerError(
"Missing required field: %s" % field.name f"Missing required field: {field.name}"
) )
value = field.default value = field.default
setattr(self, field.name, value) setattr(self, field.name, value)
@ -219,8 +219,8 @@ def set_related_field(
model_name, origin_model_name, related_field_name, related_remote_id, data model_name, origin_model_name, related_field_name, related_remote_id, data
): ):
"""load reverse related fields (editions, attachments) without blocking""" """load reverse related fields (editions, attachments) without blocking"""
model = apps.get_model("bookwyrm.%s" % model_name, require_ready=True) model = apps.get_model(f"bookwyrm.{model_name}", require_ready=True)
origin_model = apps.get_model("bookwyrm.%s" % origin_model_name, require_ready=True) origin_model = apps.get_model(f"bookwyrm.{origin_model_name}", require_ready=True)
with transaction.atomic(): with transaction.atomic():
if isinstance(data, str): if isinstance(data, str):
@ -234,7 +234,7 @@ def set_related_field(
# this must exist because it's the object that triggered this function # this must exist because it's the object that triggered this function
instance = origin_model.find_existing_by_remote_id(related_remote_id) instance = origin_model.find_existing_by_remote_id(related_remote_id)
if not instance: 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 # set the origin's remote id on the activity so it will be there when
# the model instance is created # the model instance is created
@ -265,7 +265,7 @@ def get_model_from_type(activity_type):
] ]
if not model: if not model:
raise ActivitySerializerError( raise ActivitySerializerError(
'No model found for activity type "%s"' % activity_type f'No model found for activity type "{activity_type}"'
) )
return model[0] return model[0]
@ -286,7 +286,7 @@ def resolve_remote_id(
data = get_data(remote_id) data = get_data(remote_id)
except ConnectorException: except ConnectorException:
raise ActivitySerializerError( 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 # determine the model implicitly, if not provided
# or if it's a model with subclasses like Status, check again # or if it's a model with subclasses like Status, check again

View file

@ -54,6 +54,7 @@ class Edition(Book):
asin: str = "" asin: str = ""
pages: int = None pages: int = None
physicalFormat: str = "" physicalFormat: str = ""
physicalFormatDetail: str = ""
publishers: List[str] = field(default_factory=lambda: []) publishers: List[str] = field(default_factory=lambda: [])
editionRank: int = 0 editionRank: int = 0

View file

@ -16,11 +16,12 @@ class ActivityStream(RedisStore):
def stream_id(self, user): def stream_id(self, user):
"""the redis key for this user's instance of this stream""" """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): def unread_id(self, user):
"""the redis key for this user's unread count for this stream""" """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 def get_rank(self, obj): # pylint: disable=no-self-use
"""statuses are sorted by date published""" """statuses are sorted by date published"""

View file

@ -8,6 +8,7 @@ from requests.exceptions import RequestException
from bookwyrm import activitypub, models, settings from bookwyrm import activitypub, models, settings
from .connector_manager import load_more_data, ConnectorException from .connector_manager import load_more_data, ConnectorException
from .format_mappings import format_mappings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -41,7 +42,7 @@ class AbstractMinimalConnector(ABC):
params["min_confidence"] = min_confidence params["min_confidence"] = min_confidence
data = self.get_search_data( data = self.get_search_data(
"%s%s" % (self.search_url, query), f"{self.search_url}{query}",
params=params, params=params,
timeout=timeout, timeout=timeout,
) )
@ -55,7 +56,7 @@ class AbstractMinimalConnector(ABC):
"""isbn search""" """isbn search"""
params = {} params = {}
data = self.get_search_data( data = self.get_search_data(
"%s%s" % (self.isbn_search_url, query), f"{self.isbn_search_url}{query}",
params=params, params=params,
) )
results = [] results = []
@ -129,7 +130,7 @@ class AbstractConnector(AbstractMinimalConnector):
work_data = data work_data = data
if not work_data or not edition_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(): with transaction.atomic():
# create activitypub object # create activitypub object
@ -220,9 +221,7 @@ def get_data(url, params=None, timeout=10):
"""wrapper for request.get""" """wrapper for request.get"""
# check if the url is blocked # check if the url is blocked
if models.FederatedServer.is_blocked(url): if models.FederatedServer.is_blocked(url):
raise ConnectorException( raise ConnectorException(f"Attempting to load data from blocked url: {url}")
"Attempting to load data from blocked url: {:s}".format(url)
)
try: try:
resp = requests.get( resp = requests.get(
@ -286,3 +285,25 @@ class Mapping:
return self.formatter(value) return self.formatter(value)
except: # pylint: disable=bare-except except: # pylint: disable=bare-except
return None return None
def infer_physical_format(format_text):
"""try to figure out what the standardized format is from the free value"""
format_text = format_text.lower()
if format_text in format_mappings:
# try a direct match
return format_mappings[format_text]
# failing that, try substring
matches = [v for k, v in format_mappings.items() if k in format_text]
if not matches:
return None
return matches[0]
def unique_physical_format(format_text):
"""only store the format if it isn't diretly in the format mappings"""
format_text = format_text.lower()
if format_text in format_mappings:
# try a direct match, so saving this would be redundant
return None
return format_text

View file

@ -100,10 +100,10 @@ def get_or_create_connector(remote_id):
connector_info = models.Connector.objects.create( connector_info = models.Connector.objects.create(
identifier=identifier, identifier=identifier,
connector_file="bookwyrm_connector", connector_file="bookwyrm_connector",
base_url="https://%s" % identifier, base_url=f"https://{identifier}",
books_url="https://%s/book" % identifier, books_url=f"https://{identifier}/book",
covers_url="https://%s/images/covers" % identifier, covers_url=f"https://{identifier}/images/covers",
search_url="https://%s/search?q=" % identifier, search_url=f"https://{identifier}/search?q=",
priority=2, priority=2,
) )
@ -122,7 +122,7 @@ def load_more_data(connector_id, book_id):
def load_connector(connector_info): def load_connector(connector_info):
"""instantiate the connector class""" """instantiate the connector class"""
connector = importlib.import_module( connector = importlib.import_module(
"bookwyrm.connectors.%s" % connector_info.connector_file f"bookwyrm.connectors.{connector_info.connector_file}"
) )
return connector.Connector(connector_info.identifier) return connector.Connector(connector_info.identifier)
@ -132,4 +132,4 @@ def load_connector(connector_info):
def create_connector(sender, instance, created, *args, **kwargs): def create_connector(sender, instance, created, *args, **kwargs):
"""create a connector to an external bookwyrm server""" """create a connector to an external bookwyrm server"""
if instance.application_type == "bookwyrm": if instance.application_type == "bookwyrm":
get_or_create_connector("https://{:s}".format(instance.server_name)) get_or_create_connector(f"https://{instance.server_name}")

View file

@ -0,0 +1,43 @@
""" comparing a free text format to the standardized one """
format_mappings = {
"paperback": "Paperback",
"soft": "Paperback",
"pamphlet": "Paperback",
"peperback": "Paperback",
"tapa blanda": "Paperback",
"turtleback": "Paperback",
"pocket": "Paperback",
"spiral": "Paperback",
"ring": "Paperback",
"平装": "Paperback",
"简装": "Paperback",
"hardcover": "Hardcover",
"hardcocer": "Hardcover",
"hardover": "Hardcover",
"hardback": "Hardcover",
"library": "Hardcover",
"tapa dura": "Hardcover",
"leather": "Hardcover",
"clothbound": "Hardcover",
"精装": "Hardcover",
"ebook": "EBook",
"e-book": "EBook",
"digital": "EBook",
"computer file": "EBook",
"epub": "EBook",
"online": "EBook",
"pdf": "EBook",
"elektronische": "EBook",
"electronic": "EBook",
"audiobook": "AudiobookFormat",
"audio": "AudiobookFormat",
"cd": "AudiobookFormat",
"dvd": "AudiobookFormat",
"mp3": "AudiobookFormat",
"cassette": "AudiobookFormat",
"kindle": "AudiobookFormat",
"talking": "AudiobookFormat",
"sound": "AudiobookFormat",
"comic": "GraphicNovel",
"graphic": "GraphicNovel",
}

View file

@ -9,7 +9,7 @@ from .connector_manager import ConnectorException
class Connector(AbstractConnector): class Connector(AbstractConnector):
"""instantiate a connector for OL""" """instantiate a connector for inventaire"""
def __init__(self, identifier): def __init__(self, identifier):
super().__init__(identifier) super().__init__(identifier)
@ -60,7 +60,7 @@ class Connector(AbstractConnector):
def get_remote_id(self, value): def get_remote_id(self, value):
"""convert an id/uri into a url""" """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): def get_book_data(self, remote_id):
data = get_data(remote_id) data = get_data(remote_id)
@ -88,11 +88,7 @@ class Connector(AbstractConnector):
def format_search_result(self, search_result): def format_search_result(self, search_result):
images = search_result.get("image") images = search_result.get("image")
cover = ( cover = f"{self.covers_url}/img/entities/{images[0]}" if images else None
"{:s}/img/entities/{:s}".format(self.covers_url, images[0])
if images
else None
)
# a deeply messy translation of inventaire's scores # a deeply messy translation of inventaire's scores
confidence = float(search_result.get("_score", 0.1)) confidence = float(search_result.get("_score", 0.1))
confidence = 0.1 if confidence < 150 else 0.999 confidence = 0.1 if confidence < 150 else 0.999
@ -100,9 +96,7 @@ class Connector(AbstractConnector):
title=search_result.get("label"), title=search_result.get("label"),
key=self.get_remote_id(search_result.get("uri")), key=self.get_remote_id(search_result.get("uri")),
author=search_result.get("description"), author=search_result.get("description"),
view_link="{:s}/entity/{:s}".format( view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
self.base_url, search_result.get("uri")
),
cover=cover, cover=cover,
confidence=confidence, confidence=confidence,
connector=self, connector=self,
@ -124,9 +118,7 @@ class Connector(AbstractConnector):
title=title[0], title=title[0],
key=self.get_remote_id(search_result.get("uri")), key=self.get_remote_id(search_result.get("uri")),
author=search_result.get("description"), author=search_result.get("description"),
view_link="{:s}/entity/{:s}".format( view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
self.base_url, search_result.get("uri")
),
cover=self.get_cover_url(search_result.get("image")), cover=self.get_cover_url(search_result.get("image")),
connector=self, connector=self,
) )
@ -136,11 +128,7 @@ class Connector(AbstractConnector):
def load_edition_data(self, work_uri): def load_edition_data(self, work_uri):
"""get a list of editions for a work""" """get a list of editions for a work"""
url = ( url = f"{self.books_url}?action=reverse-claims&property=wdt:P629&value={work_uri}&sort=true"
"{:s}?action=reverse-claims&property=wdt:P629&value={:s}&sort=true".format(
self.books_url, work_uri
)
)
return get_data(url) return get_data(url)
def get_edition_from_work_data(self, data): def get_edition_from_work_data(self, data):
@ -196,7 +184,7 @@ class Connector(AbstractConnector):
# cover may or may not be an absolute url already # cover may or may not be an absolute url already
if re.match(r"^http", cover_id): if re.match(r"^http", cover_id):
return 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): def resolve_keys(self, keys):
"""cool, it's "wd:Q3156592" now what the heck does that mean""" """cool, it's "wd:Q3156592" now what the heck does that mean"""
@ -214,9 +202,7 @@ class Connector(AbstractConnector):
link = links.get("enwiki") link = links.get("enwiki")
if not link: if not link:
return "" return ""
url = "{:s}/api/data?action=wp-extract&lang=en&title={:s}".format( url = f"{self.base_url}/api/data?action=wp-extract&lang=en&title={link}"
self.base_url, link
)
try: try:
data = get_data(url) data = get_data(url)
except ConnectorException: except ConnectorException:

View file

@ -4,7 +4,7 @@ import re
from bookwyrm import models from bookwyrm import models
from bookwyrm.book_search import SearchResult from bookwyrm.book_search import SearchResult
from .abstract_connector import AbstractConnector, Mapping from .abstract_connector import AbstractConnector, Mapping
from .abstract_connector import get_data from .abstract_connector import get_data, infer_physical_format, unique_physical_format
from .connector_manager import ConnectorException from .connector_manager import ConnectorException
from .openlibrary_languages import languages from .openlibrary_languages import languages
@ -44,7 +44,16 @@ class Connector(AbstractConnector):
), ),
Mapping("publishedDate", remote_field="publish_date"), Mapping("publishedDate", remote_field="publish_date"),
Mapping("pages", remote_field="number_of_pages"), Mapping("pages", remote_field="number_of_pages"),
Mapping("physicalFormat", remote_field="physical_format"), Mapping(
"physicalFormat",
remote_field="physical_format",
formatter=infer_physical_format,
),
Mapping(
"physicalFormatDetail",
remote_field="physical_format",
formatter=unique_physical_format,
),
Mapping("publishers"), Mapping("publishers"),
] ]
@ -72,7 +81,7 @@ class Connector(AbstractConnector):
key = data["key"] key = data["key"]
except KeyError: except KeyError:
raise ConnectorException("Invalid book data") raise ConnectorException("Invalid book data")
return "%s%s" % (self.books_url, key) return f"{self.books_url}{key}"
def is_work_data(self, data): def is_work_data(self, data):
return bool(re.match(r"^[\/\w]+OL\d+W$", data["key"])) return bool(re.match(r"^[\/\w]+OL\d+W$", data["key"]))
@ -82,7 +91,7 @@ class Connector(AbstractConnector):
key = data["key"] key = data["key"]
except KeyError: except KeyError:
raise ConnectorException("Invalid book data") 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) data = self.get_book_data(url)
edition = pick_default_edition(data["entries"]) edition = pick_default_edition(data["entries"])
if not edition: if not edition:
@ -94,7 +103,7 @@ class Connector(AbstractConnector):
key = data["works"][0]["key"] key = data["works"][0]["key"]
except (IndexError, KeyError): except (IndexError, KeyError):
raise ConnectorException("No work found for edition") 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) return self.get_book_data(url)
def get_authors_from_data(self, data): def get_authors_from_data(self, data):
@ -103,7 +112,7 @@ class Connector(AbstractConnector):
author_blob = author_blob.get("author", author_blob) author_blob = author_blob.get("author", author_blob)
# this id is "/authors/OL1234567A" # this id is "/authors/OL1234567A"
author_id = author_blob["key"] 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) author = self.get_or_create_author(url)
if not author: if not author:
continue continue
@ -114,8 +123,8 @@ class Connector(AbstractConnector):
if not cover_blob: if not cover_blob:
return None return None
cover_id = cover_blob[0] cover_id = cover_blob[0]
image_name = "%s-%s.jpg" % (cover_id, size) image_name = f"{cover_id}-{size}.jpg"
return "%s/b/id/%s" % (self.covers_url, image_name) return f"{self.covers_url}/b/id/{image_name}"
def parse_search_data(self, data): def parse_search_data(self, data):
return data.get("docs") return data.get("docs")
@ -153,7 +162,7 @@ class Connector(AbstractConnector):
def load_edition_data(self, olkey): def load_edition_data(self, olkey):
"""query openlibrary for editions of a work""" """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) return self.get_book_data(url)
def expand_book_data(self, book): def expand_book_data(self, book):

View file

@ -11,7 +11,7 @@ def email_data():
"""fields every email needs""" """fields every email needs"""
site = models.SiteSettings.objects.get() site = models.SiteSettings.objects.get()
if site.logo_small: if site.logo_small:
logo_path = "/images/{}".format(site.logo_small.url) logo_path = f"/images/{site.logo_small.url}"
else: else:
logo_path = "/static/images/logo-small.png" logo_path = "/static/images/logo-small.png"
@ -48,18 +48,12 @@ def password_reset_email(reset_code):
def format_email(email_name, data): def format_email(email_name, data):
"""render the email templates""" """render the email templates"""
subject = ( subject = get_template(f"email/{email_name}/subject.html").render(data).strip()
get_template("email/{}/subject.html".format(email_name)).render(data).strip()
)
html_content = ( html_content = (
get_template("email/{}/html_content.html".format(email_name)) get_template(f"email/{email_name}/html_content.html").render(data).strip()
.render(data)
.strip()
) )
text_content = ( text_content = (
get_template("email/{}/text_content.html".format(email_name)) get_template(f"email/{email_name}/text_content.html").render(data).strip()
.render(data)
.strip()
) )
return (subject, html_content, text_content) return (subject, html_content, text_content)

View file

@ -29,8 +29,7 @@ class CustomForm(ModelForm):
input_type = visible.field.widget.input_type input_type = visible.field.widget.input_type
if isinstance(visible.field.widget, Textarea): if isinstance(visible.field.widget, Textarea):
input_type = "textarea" input_type = "textarea"
visible.field.widget.attrs["cols"] = None visible.field.widget.attrs["rows"] = 5
visible.field.widget.attrs["rows"] = None
visible.field.widget.attrs["class"] = css_classes[input_type] visible.field.widget.attrs["class"] = css_classes[input_type]
@ -228,7 +227,7 @@ class ExpiryWidget(widgets.Select):
elif selected_string == "forever": elif selected_string == "forever":
return None return None
else: else:
return selected_string # "This will raise return selected_string # This will raise
return timezone.now() + interval return timezone.now() + interval
@ -260,10 +259,7 @@ class CreateInviteForm(CustomForm):
] ]
), ),
"use_limit": widgets.Select( "use_limit": widgets.Select(
choices=[ choices=[(i, _(f"{i} uses")) for i in [1, 5, 10, 25, 50, 100]]
(i, _("%(count)d uses" % {"count": i}))
for i in [1, 5, 10, 25, 50, 100]
]
+ [(None, _("Unlimited"))] + [(None, _("Unlimited"))]
), ),
} }
@ -272,7 +268,7 @@ class CreateInviteForm(CustomForm):
class ShelfForm(CustomForm): class ShelfForm(CustomForm):
class Meta: class Meta:
model = models.Shelf model = models.Shelf
fields = ["user", "name", "privacy"] fields = ["user", "name", "privacy", "description"]
class GoalForm(CustomForm): class GoalForm(CustomForm):
@ -311,6 +307,12 @@ class EmailBlocklistForm(CustomForm):
fields = ["domain"] fields = ["domain"]
class IPBlocklistForm(CustomForm):
class Meta:
model = models.IPBlocklist
fields = ["address"]
class ServerForm(CustomForm): class ServerForm(CustomForm):
class Meta: class Meta:
model = models.FederatedServer model = models.FederatedServer

View file

@ -127,6 +127,7 @@ def handle_imported_book(source, user, item, include_reviews, privacy):
# but "now" is a bad guess # but "now" is a bad guess
published_date_guess = item.date_read or item.date_added published_date_guess = item.date_read or item.date_added
if item.review: if item.review:
# pylint: disable=consider-using-f-string
review_title = ( review_title = (
"Review of {!r} on {!r}".format( "Review of {!r} on {!r}".format(
item.book.title, item.book.title,

View file

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

View file

@ -0,0 +1,16 @@
""" Block IP addresses """
from django.http import Http404
from bookwyrm import models
class IPBlocklistMiddleware:
"""check incoming traffic against an IP block-list"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
address = request.META.get("REMOTE_ADDR")
if models.IPBlocklist.objects.filter(address=address).exists():
raise Http404()
return self.get_response(request)

View file

@ -0,0 +1,38 @@
# Generated by Django 3.2.4 on 2021-09-17 18:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0096_merge_20210912_0044"),
]
operations = [
migrations.CreateModel(
name="IPBlocklist",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
("address", models.CharField(max_length=255, unique=True)),
("is_active", models.BooleanField(default=True)),
],
options={
"ordering": ("-created_date",),
},
),
migrations.AddField(
model_name="emailblocklist",
name="is_active",
field=models.BooleanField(default=True),
),
]

View file

@ -0,0 +1,27 @@
# Generated by Django 3.2.4 on 2021-09-18 22:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0097_auto_20210917_1858"),
]
operations = [
migrations.AddField(
model_name="sitesettings",
name="invite_request_text",
field=models.TextField(
default="If your request is approved, you will receive an email with a registration link."
),
),
migrations.AlterField(
model_name="sitesettings",
name="registration_closed_text",
field=models.TextField(
default='We aren\'t taking new users at this time. You can find an open instance at <a href="https://joinbookwyrm.com/instances">joinbookwyrm.com/instances</a>.'
),
),
]

View file

@ -0,0 +1,37 @@
# Generated by Django 3.2.4 on 2021-09-22 16:53
from django.db import migrations, models
def set_active_readthrough(apps, schema_editor):
"""best-guess for deactivation date"""
db_alias = schema_editor.connection.alias
apps.get_model("bookwyrm", "ReadThrough").objects.using(db_alias).filter(
start_date__isnull=False,
finish_date__isnull=True,
).update(is_active=True)
def reverse_func(apps, schema_editor):
"""noop"""
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0098_auto_20210918_2238"),
]
operations = [
migrations.AddField(
model_name="readthrough",
name="is_active",
field=models.BooleanField(default=False),
),
migrations.RunPython(set_active_readthrough, reverse_func),
migrations.AlterField(
model_name="readthrough",
name="is_active",
field=models.BooleanField(default=True),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.5 on 2021-09-28 23:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0099_readthrough_is_active"),
]
operations = [
migrations.AddField(
model_name="shelf",
name="description",
field=models.TextField(blank=True, max_length=500, null=True),
),
]

View file

@ -0,0 +1,56 @@
# Generated by Django 3.2 on 2021-05-21 00:17
from django.db import migrations
import bookwyrm
from bookwyrm.connectors.abstract_connector import infer_physical_format
def infer_format(app_registry, schema_editor):
"""set the new phsyical format field based on existing format data"""
db_alias = schema_editor.connection.alias
editions = (
app_registry.get_model("bookwyrm", "Edition")
.objects.using(db_alias)
.filter(physical_format_detail__isnull=False)
)
for edition in editions:
free_format = edition.physical_format_detail.lower()
edition.physical_format = infer_physical_format(free_format)
edition.save()
def reverse(app_registry, schema_editor):
"""doesn't need to do anything"""
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0100_shelf_description"),
]
operations = [
migrations.RenameField(
model_name="edition",
old_name="physical_format",
new_name="physical_format_detail",
),
migrations.AddField(
model_name="edition",
name="physical_format",
field=bookwyrm.models.fields.CharField(
blank=True,
choices=[
("AudiobookFormat", "Audiobook"),
("EBook", "eBook"),
("GraphicNovel", "Graphic novel"),
("Hardcover", "Hardcover"),
("Paperback", "Paperback"),
],
max_length=255,
null=True,
),
),
migrations.RunPython(infer_format, reverse),
]

View file

@ -14,7 +14,6 @@ from .status import Review, ReviewRating
from .status import Boost from .status import Boost
from .attachment import Image from .attachment import Image
from .favorite import Favorite from .favorite import Favorite
from .notification import Notification
from .readthrough import ReadThrough, ProgressUpdate, ProgressMode from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
from .user import User, KeyPair, AnnualGoal from .user import User, KeyPair, AnnualGoal
@ -26,8 +25,10 @@ from .import_job import ImportJob, ImportItem
from .site import SiteSettings, SiteInvite from .site import SiteSettings, SiteInvite
from .site import PasswordReset, InviteRequest from .site import PasswordReset, InviteRequest
from .site import EmailBlocklist
from .announcement import Announcement from .announcement import Announcement
from .antispam import EmailBlocklist, IPBlocklist
from .notification import Notification
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass) cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
activity_models = { activity_models = {

View file

@ -266,7 +266,7 @@ class ObjectMixin(ActivitypubMixin):
signed_message = signer.sign(SHA256.new(content.encode("utf8"))) signed_message = signer.sign(SHA256.new(content.encode("utf8")))
signature = activitypub.Signature( signature = activitypub.Signature(
creator="%s#main-key" % user.remote_id, creator=f"{user.remote_id}#main-key",
created=activity_object.published, created=activity_object.published,
signatureValue=b64encode(signed_message).decode("utf8"), signatureValue=b64encode(signed_message).decode("utf8"),
) )
@ -285,16 +285,16 @@ class ObjectMixin(ActivitypubMixin):
return activitypub.Delete( return activitypub.Delete(
id=self.remote_id + "/activity", id=self.remote_id + "/activity",
actor=user.remote_id, actor=user.remote_id,
to=["%s/followers" % user.remote_id], to=[f"{user.remote_id}/followers"],
cc=["https://www.w3.org/ns/activitystreams#Public"], cc=["https://www.w3.org/ns/activitystreams#Public"],
object=self, object=self,
).serialize() ).serialize()
def to_update_activity(self, user): def to_update_activity(self, user):
"""wrapper for Updates to an activity""" """wrapper for Updates to an activity"""
activity_id = "%s#update/%s" % (self.remote_id, uuid4()) uuid = uuid4()
return activitypub.Update( return activitypub.Update(
id=activity_id, id=f"{self.remote_id}#update/{uuid}",
actor=user.remote_id, actor=user.remote_id,
to=["https://www.w3.org/ns/activitystreams#Public"], to=["https://www.w3.org/ns/activitystreams#Public"],
object=self, object=self,
@ -337,8 +337,8 @@ class OrderedCollectionPageMixin(ObjectMixin):
paginated = Paginator(queryset, PAGE_LENGTH) paginated = Paginator(queryset, PAGE_LENGTH)
# add computed fields specific to orderd collections # add computed fields specific to orderd collections
activity["totalItems"] = paginated.count activity["totalItems"] = paginated.count
activity["first"] = "%s?page=1" % remote_id activity["first"] = f"{remote_id}?page=1"
activity["last"] = "%s?page=%d" % (remote_id, paginated.num_pages) activity["last"] = f"{remote_id}?page={paginated.num_pages}"
return serializer(**activity) return serializer(**activity)
@ -420,7 +420,7 @@ class CollectionItemMixin(ActivitypubMixin):
"""AP for shelving a book""" """AP for shelving a book"""
collection_field = getattr(self, self.collection_field) collection_field = getattr(self, self.collection_field)
return activitypub.Add( return activitypub.Add(
id="{:s}#add".format(collection_field.remote_id), id=f"{collection_field.remote_id}#add",
actor=user.remote_id, actor=user.remote_id,
object=self.to_activity_dataclass(), object=self.to_activity_dataclass(),
target=collection_field.remote_id, target=collection_field.remote_id,
@ -430,7 +430,7 @@ class CollectionItemMixin(ActivitypubMixin):
"""AP for un-shelving a book""" """AP for un-shelving a book"""
collection_field = getattr(self, self.collection_field) collection_field = getattr(self, self.collection_field)
return activitypub.Remove( return activitypub.Remove(
id="{:s}#remove".format(collection_field.remote_id), id=f"{collection_field.remote_id}#remove",
actor=user.remote_id, actor=user.remote_id,
object=self.to_activity_dataclass(), object=self.to_activity_dataclass(),
target=collection_field.remote_id, target=collection_field.remote_id,
@ -458,7 +458,7 @@ class ActivityMixin(ActivitypubMixin):
"""undo an action""" """undo an action"""
user = self.user if hasattr(self, "user") else self.user_subject user = self.user if hasattr(self, "user") else self.user_subject
return activitypub.Undo( return activitypub.Undo(
id="%s#undo" % self.remote_id, id=f"{self.remote_id}#undo",
actor=user.remote_id, actor=user.remote_id,
object=self, object=self,
).serialize() ).serialize()
@ -555,11 +555,11 @@ def to_ordered_collection_page(
prev_page = next_page = None prev_page = next_page = None
if activity_page.has_next(): 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(): 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( return activitypub.OrderedCollectionPage(
id="%s?page=%s" % (remote_id, page), id=f"{remote_id}?page={page}",
partOf=remote_id, partOf=remote_id,
orderedItems=items, orderedItems=items,
next=next_page, next=next_page,

View file

@ -0,0 +1,35 @@
""" Lets try NOT to sell viagra """
from django.db import models
from .user import User
class EmailBlocklist(models.Model):
"""blocked email addresses"""
created_date = models.DateTimeField(auto_now_add=True)
domain = models.CharField(max_length=255, unique=True)
is_active = models.BooleanField(default=True)
class Meta:
"""default sorting"""
ordering = ("-created_date",)
@property
def users(self):
"""find the users associated with this address"""
return User.objects.filter(email__endswith=f"@{self.domain}")
class IPBlocklist(models.Model):
"""blocked ip addresses"""
created_date = models.DateTimeField(auto_now_add=True)
address = models.CharField(max_length=255, unique=True)
is_active = models.BooleanField(default=True)
class Meta:
"""default sorting"""
ordering = ("-created_date",)

View file

@ -35,7 +35,7 @@ class Author(BookDataModel):
def get_remote_id(self): def get_remote_id(self):
"""editions and works both use "book" instead of model_name""" """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 activity_serializer = activitypub.Author

View file

@ -1,8 +1,11 @@
""" base model with default fields """ """ base model with default fields """
import base64 import base64
from Crypto import Random from Crypto import Random
from django.core.exceptions import PermissionDenied
from django.db import models from django.db import models
from django.dispatch import receiver from django.dispatch import receiver
from django.http import Http404
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from bookwyrm.settings import DOMAIN from bookwyrm.settings import DOMAIN
@ -32,11 +35,11 @@ class BookWyrmModel(models.Model):
def get_remote_id(self): def get_remote_id(self):
"""generate a url that resolves to the local object""" """generate a url that resolves to the local object"""
base_path = "https://%s" % DOMAIN base_path = f"https://{DOMAIN}"
if hasattr(self, "user"): 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() 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: class Meta:
"""this is just here to provide default fields for other models""" """this is just here to provide default fields for other models"""
@ -46,28 +49,28 @@ class BookWyrmModel(models.Model):
@property @property
def local_path(self): def local_path(self):
"""how to link to this object in the local app""" """how to link to this object in the local app"""
return self.get_remote_id().replace("https://%s" % DOMAIN, "") return self.get_remote_id().replace(f"https://{DOMAIN}", "")
def visible_to_user(self, viewer): def raise_visible_to_user(self, viewer):
"""is a user authorized to view an object?""" """is a user authorized to view an object?"""
# make sure this is an object with privacy owned by a user # make sure this is an object with privacy owned by a user
if not hasattr(self, "user") or not hasattr(self, "privacy"): if not hasattr(self, "user") or not hasattr(self, "privacy"):
return None return
# viewer can't see it if the object's owner blocked them # viewer can't see it if the object's owner blocked them
if viewer in self.user.blocks.all(): if viewer in self.user.blocks.all():
return False raise Http404()
# you can see your own posts and any public or unlisted posts # you can see your own posts and any public or unlisted posts
if viewer == self.user or self.privacy in ["public", "unlisted"]: if viewer == self.user or self.privacy in ["public", "unlisted"]:
return True return
# you can see the followers only posts of people you follow # you can see the followers only posts of people you follow
if ( if (
self.privacy == "followers" self.privacy == "followers"
and self.user.followers.filter(id=viewer.id).first() and self.user.followers.filter(id=viewer.id).first()
): ):
return True return
# you can see dms you are tagged in # you can see dms you are tagged in
if hasattr(self, "mention_users"): if hasattr(self, "mention_users"):
@ -75,8 +78,32 @@ class BookWyrmModel(models.Model):
self.privacy == "direct" self.privacy == "direct"
and self.mention_users.filter(id=viewer.id).first() and self.mention_users.filter(id=viewer.id).first()
): ):
return True return
return False raise Http404()
def raise_not_editable(self, viewer):
"""does this user have permission to edit this object? liable to be overwritten
by models that inherit this base model class"""
if not hasattr(self, "user"):
return
# generally moderators shouldn't be able to edit other people's stuff
if self.user == viewer:
return
raise PermissionDenied()
def raise_not_deletable(self, viewer):
"""does this user have permission to delete this object? liable to be
overwritten by models that inherit this base model class"""
if not hasattr(self, "user"):
return
# but generally moderators can delete other people's stuff
if self.user == viewer or viewer.has_perm("moderate_post"):
return
raise PermissionDenied()
@receiver(models.signals.post_save) @receiver(models.signals.post_save)

View file

@ -3,9 +3,10 @@ import re
from django.contrib.postgres.search import SearchVectorField from django.contrib.postgres.search import SearchVectorField
from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.indexes import GinIndex
from django.db import models from django.db import models, transaction
from django.db import transaction from django.db.models import Prefetch
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
from model_utils import FieldTracker from model_utils import FieldTracker
from model_utils.managers import InheritanceManager from model_utils.managers import InheritanceManager
from imagekit.models import ImageSpecField from imagekit.models import ImageSpecField
@ -164,9 +165,9 @@ class Book(BookDataModel):
@property @property
def alt_text(self): def alt_text(self):
"""image alt test""" """image alt test"""
text = "%s" % self.title text = self.title
if self.edition_info: if self.edition_info:
text += " (%s)" % self.edition_info text += f" ({self.edition_info})"
return text return text
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
@ -177,9 +178,10 @@ class Book(BookDataModel):
def get_remote_id(self): def get_remote_id(self):
"""editions and works both use "book" instead of model_name""" """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): def __repr__(self):
# pylint: disable=consider-using-f-string
return "<{} key={!r} title={!r}>".format( return "<{} key={!r} title={!r}>".format(
self.__class__, self.__class__,
self.openlibrary_key, self.openlibrary_key,
@ -216,7 +218,7 @@ class Work(OrderedCollectionPageMixin, Book):
"""an ordered collection of editions""" """an ordered collection of editions"""
return self.to_ordered_collection( return self.to_ordered_collection(
self.editions.order_by("-edition_rank").all(), self.editions.order_by("-edition_rank").all(),
remote_id="%s/editions" % self.remote_id, remote_id=f"{self.remote_id}/editions",
**kwargs, **kwargs,
) )
@ -225,6 +227,16 @@ class Work(OrderedCollectionPageMixin, Book):
deserialize_reverse_fields = [("editions", "editions")] deserialize_reverse_fields = [("editions", "editions")]
# https://schema.org/BookFormatType
FormatChoices = [
("AudiobookFormat", _("Audiobook")),
("EBook", _("eBook")),
("GraphicNovel", _("Graphic novel")),
("Hardcover", _("Hardcover")),
("Paperback", _("Paperback")),
]
class Edition(Book): class Edition(Book):
"""an edition of a book""" """an edition of a book"""
@ -242,7 +254,10 @@ class Edition(Book):
max_length=255, blank=True, null=True, deduplication_field=True max_length=255, blank=True, null=True, deduplication_field=True
) )
pages = fields.IntegerField(blank=True, null=True) pages = fields.IntegerField(blank=True, null=True)
physical_format = fields.CharField(max_length=255, blank=True, null=True) physical_format = fields.CharField(
max_length=255, choices=FormatChoices, null=True, blank=True
)
physical_format_detail = fields.CharField(max_length=255, blank=True, null=True)
publishers = fields.ArrayField( publishers = fields.ArrayField(
models.CharField(max_length=255), blank=True, default=list models.CharField(max_length=255), blank=True, default=list
) )
@ -306,6 +321,27 @@ class Edition(Book):
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
@classmethod
def viewer_aware_objects(cls, viewer):
"""annotate a book query with metadata related to the user"""
queryset = cls.objects
if not viewer or not viewer.is_authenticated:
return queryset
queryset = queryset.prefetch_related(
Prefetch(
"shelfbook_set",
queryset=viewer.shelfbook_set.all(),
to_attr="current_shelves",
),
Prefetch(
"readthrough_set",
queryset=viewer.readthrough_set.filter(is_active=True).all(),
to_attr="active_readthroughs",
),
)
return queryset
def isbn_10_to_13(isbn_10): def isbn_10_to_13(isbn_10):
"""convert an isbn 10 into an isbn 13""" """convert an isbn 10 into an isbn 13"""

View file

@ -28,7 +28,4 @@ class Connector(BookWyrmModel):
isbn_search_url = models.CharField(max_length=255, null=True, blank=True) isbn_search_url = models.CharField(max_length=255, null=True, blank=True)
def __str__(self): def __str__(self):
return "{} ({})".format( return f"{self.identifier} ({self.id})"
self.identifier,
self.id,
)

View file

@ -1,7 +1,5 @@
""" like/fav/star a status """ """ like/fav/star a status """
from django.apps import apps
from django.db import models from django.db import models
from django.utils import timezone
from bookwyrm import activitypub from bookwyrm import activitypub
from .activitypub_mixin import ActivityMixin from .activitypub_mixin import ActivityMixin
@ -29,38 +27,9 @@ class Favorite(ActivityMixin, BookWyrmModel):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""update user active time""" """update user active time"""
self.user.last_active_date = timezone.now() self.user.update_active_date()
self.user.save(broadcast=False, update_fields=["last_active_date"])
super().save(*args, **kwargs) 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: class Meta:
"""can't fav things twice""" """can't fav things twice"""

View file

@ -56,7 +56,7 @@ class ActivitypubFieldMixin:
activitypub_field=None, activitypub_field=None,
activitypub_wrapper=None, activitypub_wrapper=None,
deduplication_field=False, deduplication_field=False,
**kwargs **kwargs,
): ):
self.deduplication_field = deduplication_field self.deduplication_field = deduplication_field
if activitypub_wrapper: if activitypub_wrapper:
@ -308,7 +308,7 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
def field_to_activity(self, value): def field_to_activity(self, value):
if self.link_only: 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()] return [i.remote_id for i in value.all()]
def field_from_activity(self, value): def field_from_activity(self, value):
@ -388,7 +388,7 @@ def image_serializer(value, alt):
else: else:
return None return None
if not url[:4] == "http": if not url[:4] == "http":
url = "https://{:s}{:s}".format(DOMAIN, url) url = f"https://{DOMAIN}{url}"
return activitypub.Document(url=url, name=alt) return activitypub.Document(url=url, name=alt)
@ -448,7 +448,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
image_content = ContentFile(response.content) image_content = ContentFile(response.content)
extension = imghdr.what(None, image_content.read()) or "" 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] return [image_name, image_content]
def formfield(self, **kwargs): def formfield(self, **kwargs):

View file

@ -2,7 +2,6 @@
import re import re
import dateutil.parser import dateutil.parser
from django.apps import apps
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
@ -50,19 +49,6 @@ class ImportJob(models.Model):
) )
retry = models.BooleanField(default=False) 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): class ImportItem(models.Model):
"""a single line of a csv being imported""" """a single line of a csv being imported"""
@ -198,7 +184,9 @@ class ImportItem(models.Model):
return [] return []
def __repr__(self): def __repr__(self):
# pylint: disable=consider-using-f-string
return "<{!r}Item {!r}>".format(self.data["import_source"], self.data["Title"]) return "<{!r}Item {!r}>".format(self.data["import_source"], self.data["Title"])
def __str__(self): def __str__(self):
# pylint: disable=consider-using-f-string
return "{} by {}".format(self.data["Title"], self.data["Author"]) return "{} by {}".format(self.data["Title"], self.data["Author"])

View file

@ -42,7 +42,7 @@ class List(OrderedCollectionMixin, BookWyrmModel):
def get_remote_id(self): def get_remote_id(self):
"""don't want the user to be in there in this case""" """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 @property
def collection_queryset(self): def collection_queryset(self):
@ -92,6 +92,12 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
notification_type="ADD", notification_type="ADD",
) )
def raise_not_deletable(self, viewer):
"""the associated user OR the list owner can delete"""
if self.book_list.user == viewer:
return
super().raise_not_deletable(viewer)
class Meta: class Meta:
"""A book may only be placed into a list once, """A book may only be placed into a list once,
and each order in the list may be used only once""" and each order in the list may be used only once"""

View file

@ -1,6 +1,8 @@
""" alert a user to activity """ """ alert a user to activity """
from django.db import models from django.db import models
from django.dispatch import receiver
from .base_model import BookWyrmModel from .base_model import BookWyrmModel
from . import Boost, Favorite, ImportJob, Report, Status, User
NotificationType = models.TextChoices( NotificationType = models.TextChoices(
@ -53,3 +55,127 @@ class Notification(BookWyrmModel):
name="notification_type_valid", name="notification_type_valid",
) )
] ]
@receiver(models.signals.post_save, sender=Favorite)
# pylint: disable=unused-argument
def notify_on_fav(sender, instance, *args, **kwargs):
"""someone liked your content, you ARE loved"""
if not instance.status.user.local or instance.status.user == instance.user:
return
Notification.objects.create(
user=instance.status.user,
notification_type="FAVORITE",
related_user=instance.user,
related_status=instance.status,
)
@receiver(models.signals.post_delete, sender=Favorite)
# pylint: disable=unused-argument
def notify_on_unfav(sender, instance, *args, **kwargs):
"""oops, didn't like that after all"""
if not instance.status.user.local:
return
Notification.objects.filter(
user=instance.status.user,
related_user=instance.user,
related_status=instance.status,
notification_type="FAVORITE",
).delete()
@receiver(models.signals.post_save)
# pylint: disable=unused-argument
def notify_user_on_mention(sender, instance, *args, **kwargs):
"""creating and deleting statuses with @ mentions and replies"""
if not issubclass(sender, Status):
return
if instance.deleted:
Notification.objects.filter(related_status=instance).delete()
return
if (
instance.reply_parent
and instance.reply_parent.user != instance.user
and instance.reply_parent.user.local
):
Notification.objects.create(
user=instance.reply_parent.user,
notification_type="REPLY",
related_user=instance.user,
related_status=instance,
)
for mention_user in instance.mention_users.all():
# avoid double-notifying about this status
if not mention_user.local or (
instance.reply_parent and mention_user == instance.reply_parent.user
):
continue
Notification.objects.create(
user=mention_user,
notification_type="MENTION",
related_user=instance.user,
related_status=instance,
)
@receiver(models.signals.post_save, sender=Boost)
# pylint: disable=unused-argument
def notify_user_on_boost(sender, instance, *args, **kwargs):
"""boosting a status"""
if (
not instance.boosted_status.user.local
or instance.boosted_status.user == instance.user
):
return
Notification.objects.create(
user=instance.boosted_status.user,
related_status=instance.boosted_status,
related_user=instance.user,
notification_type="BOOST",
)
@receiver(models.signals.post_delete, sender=Boost)
# pylint: disable=unused-argument
def notify_user_on_unboost(sender, instance, *args, **kwargs):
"""unboosting a status"""
Notification.objects.filter(
user=instance.boosted_status.user,
related_status=instance.boosted_status,
related_user=instance.user,
notification_type="BOOST",
).delete()
@receiver(models.signals.post_save, sender=ImportJob)
# pylint: disable=unused-argument
def notify_user_on_import_complete(sender, instance, *args, **kwargs):
"""we imported your books! aren't you proud of us"""
if not instance.complete:
return
Notification.objects.create(
user=instance.user,
notification_type="IMPORT",
related_import=instance,
)
@receiver(models.signals.post_save, sender=Report)
# pylint: disable=unused-argument
def notify_admins_on_report(sender, instance, *args, **kwargs):
"""something is up, make sure the admins know"""
# moderators and superusers should be notified
admins = User.objects.filter(
models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
| models.Q(is_superuser=True)
).all()
for admin in admins:
Notification.objects.create(
user=admin,
related_report=instance,
notification_type="REPORT",
)

View file

@ -2,7 +2,6 @@
from django.core import validators from django.core import validators
from django.db import models from django.db import models
from django.db.models import F, Q from django.db.models import F, Q
from django.utils import timezone
from .base_model import BookWyrmModel from .base_model import BookWyrmModel
@ -27,11 +26,14 @@ class ReadThrough(BookWyrmModel):
) )
start_date = models.DateTimeField(blank=True, null=True) start_date = models.DateTimeField(blank=True, null=True)
finish_date = models.DateTimeField(blank=True, null=True) finish_date = models.DateTimeField(blank=True, null=True)
is_active = models.BooleanField(default=True)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""update user active time""" """update user active time"""
self.user.last_active_date = timezone.now() self.user.update_active_date()
self.user.save(broadcast=False, update_fields=["last_active_date"]) # an active readthrough must have an unset finish date
if self.finish_date:
self.is_active = False
super().save(*args, **kwargs) super().save(*args, **kwargs)
def create_update(self): def create_update(self):
@ -65,6 +67,5 @@ class ProgressUpdate(BookWyrmModel):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""update user active time""" """update user active time"""
self.user.last_active_date = timezone.now() self.user.update_active_date()
self.user.save(broadcast=False, update_fields=["last_active_date"])
super().save(*args, **kwargs) super().save(*args, **kwargs)

View file

@ -53,7 +53,7 @@ class UserRelationship(BookWyrmModel):
def get_remote_id(self): def get_remote_id(self):
"""use shelf identifier in remote_id""" """use shelf identifier in remote_id"""
base_path = self.user_subject.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): class UserFollows(ActivityMixin, UserRelationship):
@ -144,7 +144,8 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
"""get id for sending an accept or reject of a local user""" """get id for sending an accept or reject of a local user"""
base_path = self.user_object.remote_id 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): def accept(self, broadcast_only=False):
"""turn this request into the real deal""" """turn this request into the real deal"""

View file

@ -1,5 +1,4 @@
""" flagged for moderation """ """ flagged for moderation """
from django.apps import apps
from django.db import models from django.db import models
from django.db.models import F, Q from django.db.models import F, Q
from .base_model import BookWyrmModel from .base_model import BookWyrmModel
@ -16,23 +15,6 @@ class Report(BookWyrmModel):
statuses = models.ManyToManyField("Status", blank=True) statuses = models.ManyToManyField("Status", blank=True)
resolved = models.BooleanField(default=False) 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: class Meta:
"""don't let users report themselves""" """don't let users report themselves"""

View file

@ -1,5 +1,6 @@
""" puttin' books on shelves """ """ puttin' books on shelves """
import re import re
from django.core.exceptions import PermissionDenied
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
@ -20,6 +21,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
name = fields.CharField(max_length=100) name = fields.CharField(max_length=100)
identifier = models.CharField(max_length=100) identifier = models.CharField(max_length=100)
description = models.TextField(blank=True, null=True, max_length=500)
user = fields.ForeignKey( user = fields.ForeignKey(
"User", on_delete=models.PROTECT, activitypub_field="owner" "User", on_delete=models.PROTECT, activitypub_field="owner"
) )
@ -44,18 +46,29 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
def get_identifier(self): def get_identifier(self):
"""custom-shelf-123 for the url""" """custom-shelf-123 for the url"""
slug = re.sub(r"[^\w]", "", self.name).lower() slug = re.sub(r"[^\w]", "", self.name).lower()
return "{:s}-{:d}".format(slug, self.id) return f"{slug}-{self.id}"
@property @property
def collection_queryset(self): def collection_queryset(self):
"""list of books for this shelf, overrides OrderedCollectionMixin""" """list of books for this shelf, overrides OrderedCollectionMixin"""
return self.books.order_by("shelfbook") return self.books.order_by("shelfbook")
@property
def deletable(self):
"""can the shelf be safely deleted?"""
return self.editable and not self.shelfbook_set.exists()
def get_remote_id(self): def get_remote_id(self):
"""shelf identifier instead of id""" """shelf identifier instead of id"""
base_path = self.user.remote_id base_path = self.user.remote_id
identifier = self.identifier or self.get_identifier() identifier = self.identifier or self.get_identifier()
return "%s/books/%s" % (base_path, identifier) return f"{base_path}/books/{identifier}"
def raise_not_deletable(self, viewer):
"""don't let anyone delete a default shelf"""
super().raise_not_deletable(viewer)
if not self.deletable:
raise PermissionDenied()
class Meta: class Meta:
"""user/shelf unqiueness""" """user/shelf unqiueness"""

View file

@ -24,7 +24,13 @@ class SiteSettings(models.Model):
# about page # about page
registration_closed_text = models.TextField( registration_closed_text = models.TextField(
default="Contact an administrator to get an invite" default="We aren't taking new users at this time. You can find an open "
'instance at <a href="https://joinbookwyrm.com/instances">'
"joinbookwyrm.com/instances</a>."
)
invite_request_text = models.TextField(
default="If your request is approved, you will receive an email with a "
"registration link."
) )
code_of_conduct = models.TextField(default="Add a code of conduct here.") code_of_conduct = models.TextField(default="Add a code of conduct here.")
privacy_policy = models.TextField(default="Add a privacy policy here.") privacy_policy = models.TextField(default="Add a privacy policy here.")
@ -81,7 +87,7 @@ class SiteInvite(models.Model):
@property @property
def link(self): def link(self):
"""formats the invite link""" """formats the invite link"""
return "https://{}/invite/{}".format(DOMAIN, self.code) return f"https://{DOMAIN}/invite/{self.code}"
class InviteRequest(BookWyrmModel): class InviteRequest(BookWyrmModel):
@ -121,24 +127,7 @@ class PasswordReset(models.Model):
@property @property
def link(self): def link(self):
"""formats the invite link""" """formats the invite link"""
return "https://{}/password-reset/{}".format(DOMAIN, self.code) return f"https://{DOMAIN}/password-reset/{self.code}"
class EmailBlocklist(models.Model):
"""blocked email addresses"""
created_date = models.DateTimeField(auto_now_add=True)
domain = models.CharField(max_length=255, unique=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}")
# pylint: disable=unused-argument # pylint: disable=unused-argument

View file

@ -3,6 +3,7 @@ from dataclasses import MISSING
import re import re
from django.apps import apps from django.apps import apps
from django.core.exceptions import PermissionDenied
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.dispatch import receiver from django.dispatch import receiver
@ -67,40 +68,6 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
ordering = ("-published_date",) 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 def delete(self, *args, **kwargs): # pylint: disable=unused-argument
""" "delete" a status""" """ "delete" a status"""
if hasattr(self, "boosted_status"): if hasattr(self, "boosted_status"):
@ -108,6 +75,10 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
return return
self.deleted = True 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.deleted_date = timezone.now()
self.save() self.save()
@ -179,9 +150,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
"""helper function for loading AP serialized replies to a status""" """helper function for loading AP serialized replies to a status"""
return self.to_ordered_collection( return self.to_ordered_collection(
self.replies(self), self.replies(self),
remote_id="%s/replies" % self.remote_id, remote_id=f"{self.remote_id}/replies",
collection_only=True, collection_only=True,
**kwargs **kwargs,
).serialize() ).serialize()
def to_activity_dataclass(self, pure=False): # pylint: disable=arguments-differ def to_activity_dataclass(self, pure=False): # pylint: disable=arguments-differ
@ -217,6 +188,13 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
"""json serialized activitypub class""" """json serialized activitypub class"""
return self.to_activity_dataclass(pure=pure).serialize() return self.to_activity_dataclass(pure=pure).serialize()
def raise_not_editable(self, viewer):
"""certain types of status aren't editable"""
# first, the standard raise
super().raise_not_editable(viewer)
if isinstance(self, (GeneratedNote, ReviewRating)):
raise PermissionDenied()
class GeneratedNote(Status): class GeneratedNote(Status):
"""these are app-generated messages about user activity""" """these are app-generated messages about user activity"""
@ -226,10 +204,10 @@ class GeneratedNote(Status):
"""indicate the book in question for mastodon (or w/e) users""" """indicate the book in question for mastodon (or w/e) users"""
message = self.content message = self.content
books = ", ".join( books = ", ".join(
'<a href="%s">"%s"</a>' % (book.remote_id, book.title) f'<a href="{book.remote_id}">"{book.title}"</a>'
for book in self.mention_books.all() 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 activity_serializer = activitypub.GeneratedNote
pure_type = "Note" pure_type = "Note"
@ -277,10 +255,9 @@ class Comment(BookStatus):
@property @property
def pure_content(self): def pure_content(self):
"""indicate the book in question for mastodon (or w/e) users""" """indicate the book in question for mastodon (or w/e) users"""
return '%s<p>(comment on <a href="%s">"%s"</a>)</p>' % ( return (
self.content, f'{self.content}<p>(comment on <a href="{self.book.remote_id}">'
self.book.remote_id, f'"{self.book.title}"</a>)</p>'
self.book.title,
) )
activity_serializer = activitypub.Comment activity_serializer = activitypub.Comment
@ -306,11 +283,9 @@ class Quotation(BookStatus):
"""indicate the book in question for mastodon (or w/e) users""" """indicate the book in question for mastodon (or w/e) users"""
quote = re.sub(r"^<p>", '<p>"', self.quote) quote = re.sub(r"^<p>", '<p>"', self.quote)
quote = re.sub(r"</p>$", '"</p>', quote) quote = re.sub(r"</p>$", '"</p>', quote)
return '%s <p>-- <a href="%s">"%s"</a></p>%s' % ( return (
quote, f'{quote} <p>-- <a href="{self.book.remote_id}">'
self.book.remote_id, f'"{self.book.title}"</a></p>{self.content}'
self.book.title,
self.content,
) )
activity_serializer = activitypub.Quotation activity_serializer = activitypub.Quotation
@ -389,27 +364,6 @@ class Boost(ActivityMixin, Status):
return return
super().save(*args, **kwargs) 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): def __init__(self, *args, **kwargs):
"""the user field is "actor" here instead of "attributedTo" """ """the user field is "actor" here instead of "attributedTo" """
@ -422,10 +376,6 @@ class Boost(ActivityMixin, Status):
self.image_fields = [] self.image_fields = []
self.deserialize_reverse_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 # pylint: disable=unused-argument
@receiver(models.signals.post_save) @receiver(models.signals.post_save)

View file

@ -152,12 +152,13 @@ class User(OrderedCollectionPageMixin, AbstractUser):
@property @property
def following_link(self): def following_link(self):
"""just how to find out the following info""" """just how to find out the following info"""
return "{:s}/following".format(self.remote_id) return f"{self.remote_id}/following"
@property @property
def alt_text(self): def alt_text(self):
"""alt text with username""" """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 @property
def display_name(self): def display_name(self):
@ -194,12 +195,15 @@ class User(OrderedCollectionPageMixin, AbstractUser):
queryset = queryset.exclude(blocks=viewer) queryset = queryset.exclude(blocks=viewer)
return queryset 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): def to_outbox(self, filter_type=None, **kwargs):
"""an ordered collection of statuses""" """an ordered collection of statuses"""
if filter_type: if filter_type:
filter_class = apps.get_model( filter_class = apps.get_model(f"bookwyrm.{filter_type}", require_ready=True)
"bookwyrm.%s" % filter_type, require_ready=True
)
if not issubclass(filter_class, Status): if not issubclass(filter_class, Status):
raise TypeError( raise TypeError(
"filter_status_class must be a subclass of models.Status" "filter_status_class must be a subclass of models.Status"
@ -223,7 +227,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
def to_following_activity(self, **kwargs): def to_following_activity(self, **kwargs):
"""activitypub following list""" """activitypub following list"""
remote_id = "%s/following" % self.remote_id remote_id = f"{self.remote_id}/following"
return self.to_ordered_collection( return self.to_ordered_collection(
self.following.order_by("-updated_date").all(), self.following.order_by("-updated_date").all(),
remote_id=remote_id, remote_id=remote_id,
@ -266,7 +270,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
if not self.local and not re.match(regex.FULL_USERNAME, self.username): if not self.local and not re.match(regex.FULL_USERNAME, self.username):
# generate a username that uses the domain (webfinger format) # generate a username that uses the domain (webfinger format)
actor_parts = urlparse(self.remote_id) 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 # this user already exists, no need to populate fields
if not created: if not created:
@ -320,7 +324,8 @@ class User(OrderedCollectionPageMixin, AbstractUser):
@property @property
def local_path(self): def local_path(self):
"""this model doesn't inherit bookwyrm model, so here we are""" """this model doesn't inherit bookwyrm model, so here we are"""
return "/user/%s" % (self.localname or self.username) # pylint: disable=consider-using-f-string
return "/user/{:s}".format(self.localname or self.username)
def create_shelves(self): def create_shelves(self):
"""default shelves for a new user""" """default shelves for a new user"""
@ -361,7 +366,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
def get_remote_id(self): def get_remote_id(self):
# self.owner is set by the OneToOneField on User # 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): def save(self, *args, **kwargs):
"""create a key pair""" """create a key pair"""
@ -398,7 +403,7 @@ class AnnualGoal(BookWyrmModel):
def get_remote_id(self): def get_remote_id(self):
"""put the year in the path""" """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 @property
def books(self): def books(self):
@ -454,7 +459,7 @@ def get_or_create_remote_server(domain):
pass pass
try: try:
data = get_data("https://%s/.well-known/nodeinfo" % domain) data = get_data(f"https://{domain}/.well-known/nodeinfo")
try: try:
nodeinfo_url = data.get("links")[0].get("href") nodeinfo_url = data.get("links")[0].get("href")
except (TypeError, KeyError): except (TypeError, KeyError):

View file

@ -220,6 +220,7 @@ def generate_default_inner_img():
# pylint: disable=too-many-locals # pylint: disable=too-many-locals
# pylint: disable=too-many-statements
def generate_preview_image( def generate_preview_image(
texts=None, picture=None, rating=None, show_instance_layer=True texts=None, picture=None, rating=None, show_instance_layer=True
): ):
@ -237,7 +238,8 @@ def generate_preview_image(
# Color # Color
if BG_COLOR in ["use_dominant_color_light", "use_dominant_color_dark"]: 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 # Adjust color
image_bg_color_rgb = [x / 255.0 for x in ImageColor.getrgb(image_bg_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""" """Save and close the file"""
if not isinstance(instance, (models.Book, models.User, models.SiteSettings)): if not isinstance(instance, (models.Book, models.User, models.SiteSettings)):
return False return False
file_name = "%s-%s.jpg" % (str(instance.id), str(uuid4())) uuid = uuid4()
file_name = f"{instance.id}-{uuid}.jpg"
image_buffer = BytesIO() image_buffer = BytesIO()
try: try:
@ -412,7 +415,7 @@ def generate_user_preview_image_task(user_id):
texts = { texts = {
"text_one": user.display_name, "text_one": user.display_name,
"text_three": "@{}@{}".format(user.localname, settings.DOMAIN), "text_three": f"@{user.localname}@{settings.DOMAIN}",
} }
if user.avatar: if user.avatar:

View file

@ -48,7 +48,7 @@ class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method
return return
self.tag_stack = self.tag_stack[:-1] self.tag_stack = self.tag_stack[:-1]
self.output.append(("tag", "</%s>" % tag)) self.output.append(("tag", f"</{tag}>"))
def handle_data(self, data): def handle_data(self, data):
"""extract the answer, if we're in an answer tag""" """extract the answer, if we're in an answer tag"""

View file

@ -13,7 +13,7 @@ VERSION = "0.0.1"
PAGE_LENGTH = env("PAGE_LENGTH", 15) PAGE_LENGTH = env("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English") DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
JS_CACHE = "e5832a26" JS_CACHE = "e2bc0653"
# email # email
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend") EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
@ -23,7 +23,7 @@ EMAIL_HOST_USER = env("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD") EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", True) EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", True)
EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", False) 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, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -77,7 +77,8 @@ MIDDLEWARE = [
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"bookwyrm.timezone_middleware.TimezoneMiddleware", "bookwyrm.middleware.TimezoneMiddleware",
"bookwyrm.middleware.IPBlocklistMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
] ]
@ -126,7 +127,7 @@ DATABASES = {
"USER": env("POSTGRES_USER", "fedireads"), "USER": env("POSTGRES_USER", "fedireads"),
"PASSWORD": env("POSTGRES_PASSWORD", "fedireads"), "PASSWORD": env("POSTGRES_PASSWORD", "fedireads"),
"HOST": env("POSTGRES_HOST", ""), "HOST": env("POSTGRES_HOST", ""),
"PORT": env("POSTGRES_PORT", 5432), "PORT": env("PGPORT", 5432),
}, },
} }
@ -177,11 +178,8 @@ USE_L10N = True
USE_TZ = True USE_TZ = True
USER_AGENT = "%s (BookWyrm/%s; +https://%s/)" % ( agent = requests.utils.default_user_agent()
requests.utils.default_user_agent(), USER_AGENT = f"{agent} (BookWyrm/{VERSION}; +https://{DOMAIN}/)"
VERSION,
DOMAIN,
)
# Imagekit generated thumbnails # Imagekit generated thumbnails
ENABLE_THUMBNAIL_GENERATION = env.bool("ENABLE_THUMBNAIL_GENERATION", False) ENABLE_THUMBNAIL_GENERATION = env.bool("ENABLE_THUMBNAIL_GENERATION", False)
@ -212,11 +210,11 @@ if USE_S3:
AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"} AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"}
# S3 Static settings # S3 Static settings
STATIC_LOCATION = "static" 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" STATICFILES_STORAGE = "bookwyrm.storage_backends.StaticStorage"
# S3 Media settings # S3 Media settings
MEDIA_LOCATION = "images" 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 MEDIA_FULL_URL = MEDIA_URL
DEFAULT_FILE_STORAGE = "bookwyrm.storage_backends.ImagesStorage" DEFAULT_FILE_STORAGE = "bookwyrm.storage_backends.ImagesStorage"
# I don't know if it's used, but the site crashes without it # I don't know if it's used, but the site crashes without it
@ -226,5 +224,5 @@ else:
STATIC_URL = "/static/" STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static")) STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static"))
MEDIA_URL = "/images/" 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")) MEDIA_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images"))

View file

@ -26,21 +26,21 @@ def make_signature(sender, destination, date, digest):
"""uses a private key to sign an outgoing message""" """uses a private key to sign an outgoing message"""
inbox_parts = urlparse(destination) inbox_parts = urlparse(destination)
signature_headers = [ signature_headers = [
"(request-target): post %s" % inbox_parts.path, f"(request-target): post {inbox_parts.path}",
"host: %s" % inbox_parts.netloc, f"host: {inbox_parts.netloc}",
"date: %s" % date, f"date: {date}",
"digest: %s" % digest, f"digest: {digest}",
] ]
message_to_sign = "\n".join(signature_headers) message_to_sign = "\n".join(signature_headers)
signer = pkcs1_15.new(RSA.import_key(sender.key_pair.private_key)) signer = pkcs1_15.new(RSA.import_key(sender.key_pair.private_key))
signed_message = signer.sign(SHA256.new(message_to_sign.encode("utf8"))) signed_message = signer.sign(SHA256.new(message_to_sign.encode("utf8")))
signature = { signature = {
"keyId": "%s#main-key" % sender.remote_id, "keyId": f"{sender.remote_id}#main-key",
"algorithm": "rsa-sha256", "algorithm": "rsa-sha256",
"headers": "(request-target) host date digest", "headers": "(request-target) host date digest",
"signature": b64encode(signed_message).decode("utf8"), "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): def make_digest(data):
@ -58,7 +58,7 @@ def verify_digest(request):
elif algorithm == "SHA-512": elif algorithm == "SHA-512":
hash_function = hashlib.sha512 hash_function = hashlib.sha512
else: else:
raise ValueError("Unsupported hash function: {}".format(algorithm)) raise ValueError(f"Unsupported hash function: {algorithm}")
expected = hash_function(request.body).digest() expected = hash_function(request.body).digest()
if b64decode(digest) != expected: if b64decode(digest) != expected:
@ -95,18 +95,18 @@ class Signature:
def verify(self, public_key, request): def verify(self, public_key, request):
"""verify rsa signature""" """verify rsa signature"""
if http_date_age(request.headers["date"]) > MAX_SIGNATURE_AGE: 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) public_key = RSA.import_key(public_key)
comparison_string = [] comparison_string = []
for signed_header_name in self.headers.split(" "): for signed_header_name in self.headers.split(" "):
if signed_header_name == "(request-target)": 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: else:
if signed_header_name == "digest": if signed_header_name == "digest":
verify_digest(request) verify_digest(request)
comparison_string.append( 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) comparison_string = "\n".join(comparison_string)

View file

@ -378,6 +378,13 @@ input[type=file]::file-selector-button:hover {
right: 1em; right: 1em;
} }
/** Tooltips
******************************************************************************/
.tooltip {
width: 100%;
}
/** States /** States
******************************************************************************/ ******************************************************************************/

View file

@ -141,8 +141,10 @@ let StatusCache = new class {
modal.getElementsByClassName("modal-close")[0].click(); modal.getElementsByClassName("modal-close")[0].click();
// Update shelve buttons // Update shelve buttons
document.querySelectorAll("[data-shelve-button-book='" + form.book.value +"']") if (form.reading_status) {
.forEach(button => this.cycleShelveButtons(button, form.reading_status.value)); document.querySelectorAll("[data-shelve-button-book='" + form.book.value +"']")
.forEach(button => this.cycleShelveButtons(button, form.reading_status.value));
}
return; return;
} }

View file

@ -24,8 +24,8 @@ class SuggestedUsers(RedisStore):
def store_id(self, user): # pylint: disable=no-self-use def store_id(self, user): # pylint: disable=no-self-use
"""the key used to store this user's recs""" """the key used to store this user's recs"""
if isinstance(user, int): if isinstance(user, int):
return "{:d}-suggestions".format(user) return f"{user}-suggestions"
return "{:d}-suggestions".format(user.id) return f"{user.id}-suggestions"
def get_counts_from_rank(self, rank): # pylint: disable=no-self-use def get_counts_from_rank(self, rank): # pylint: disable=no-self-use
"""calculate mutuals count and shared books count from rank""" """calculate mutuals count and shared books count from rank"""

View file

@ -236,14 +236,12 @@
<label class="label" for="id_cover">{% trans "Upload cover:" %}</label> <label class="label" for="id_cover">{% trans "Upload cover:" %}</label>
{{ form.cover }} {{ form.cover }}
</div> </div>
{% if book %}
<div class="field"> <div class="field">
<label class="label" for="id_cover_url"> <label class="label" for="id_cover_url">
{% trans "Load cover from url:" %} {% trans "Load cover from url:" %}
</label> </label>
<input class="input" name="cover-url" id="id_cover_url"> <input class="input" name="cover-url" id="id_cover_url" type="url" value="{{ cover_url|default:'' }}">
</div> </div>
{% endif %}
{% for error in form.cover.errors %} {% for error in form.cover.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}
@ -253,12 +251,27 @@
<div class="block"> <div class="block">
<h2 class="title is-4">{% trans "Physical Properties" %}</h2> <h2 class="title is-4">{% trans "Physical Properties" %}</h2>
<div class="field"> <div class="columns">
<label class="label" for="id_physical_format">{% trans "Format:" %}</label> <div class="column is-one-third">
{{ form.physical_format }} <div class="field">
{% for error in form.physical_format.errors %} <label class="label" for="id_physical_format">{% trans "Format:" %}</label>
<p class="help is-danger">{{ error | escape }}</p> <div class="select">
{% endfor %} {{ form.physical_format }}
</div>
{% for error in form.physical_format.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
</div>
<div class="column">
<div class="field">
<label class="label" for="id_physical_format_detail">{% trans "Format details:" %}</label>
{{ form.physical_format_detail }}
{% for error in form.physical_format_detail.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
</div>
</div> </div>
<div class="field"> <div class="field">

View file

@ -4,13 +4,15 @@
{% load humanize %} {% load humanize %}
<p> <p>
{% with format=book.physical_format pages=book.pages %} {% firstof book.physical_format_detail book.physical_format as format %}
{% firstof book.physical_format book.physical_format_detail as format_property %}
{% with pages=book.pages %}
{% if format %} {% if format %}
{% comment %} {% comment %}
@todo The bookFormat property is limited to a list of values whereas the book edition is free text. @todo The bookFormat property is limited to a list of values whereas the book edition is free text.
@see https://schema.org/bookFormat @see https://schema.org/bookFormat
{% endcomment %} {% endcomment %}
<meta itemprop="bookFormat" content="{{ format }}"> <meta itemprop="bookFormat" content="{{ format_property }}">
{% endif %} {% endif %}
{% if pages %} {% if pages %}

View file

@ -1,5 +1,5 @@
{% load i18n %} {% load i18n %}
<section class="card is-hidden {{ class }}" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}"> <section class="card {% if not visible %}is-hidden {% endif %}{{ class }}" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}">
<header class="card-header has-background-white-ter"> <header class="card-header has-background-white-ter">
<h2 class="card-header-title" tabindex="0" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}_header"> <h2 class="card-header-title" tabindex="0" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}_header">
{% block header %}{% endblock %} {% block header %}{% endblock %}

View file

@ -3,7 +3,7 @@
{% trans "Help" as button_text %} {% 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 %} {% include 'snippets/toggle/open_button.html' with text=button_text class="ml-3 is-rounded is-small is-white p-0 pb-1" icon="question-circle is-size-6" controls_text=controls_text controls_uid=controls_uid %}
<aside class="notification is-hidden transition-y is-pulled-left mb-2" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}"> <aside class="tooltip notification is-hidden transition-y is-pulled-left mb-2" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}">
{% trans "Close" as button_text %} {% 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 %} {% include 'snippets/toggle/close_button.html' with label=button_text class="delete" nonbutton=True controls_text=controls_text controls_uid=controls_uid %}

View file

@ -27,7 +27,7 @@
{% if not draft %} {% if not draft %}
{% include 'snippets/create_status.html' %} {% include 'snippets/create_status.html' %}
{% else %} {% else %}
{% include 'snippets/create_status/status.html' %} {% include 'snippets/create_status/status.html' with no_script=True %}
{% endif %} {% endif %}
</div> </div>
</div> </div>

View file

@ -8,7 +8,7 @@
{% trans "Local users" %} {% trans "Local users" %}
</label> </label>
<label class="is-block"> <label class="is-block">
<input type="radio" class="radio" name="scope" value="federated" {% if not request.GET.sort or request.GET.scope == "federated" %}checked{% endif %}> <input type="radio" class="radio" name="scope" value="federated" {% if request.GET.scope == "federated" %}checked{% endif %}>
{% trans "Federated community" %} {% trans "Federated community" %}
</label> </label>
{% endblock %} {% endblock %}

View file

@ -5,8 +5,8 @@
<label class="label" for="id_sort">{% trans "Order by" %}</label> <label class="label" for="id_sort">{% trans "Order by" %}</label>
<div class="select"> <div class="select">
<select name="sort" id="id_sort"> <select name="sort" id="id_sort">
<option value="suggested" {% if not request.GET.sort or request.GET.sort == "suggested" %}checked{% endif %}>{% trans "Suggested" %}</option> <option value="recent" {% if request.GET.sort == "recent" %}selected{% endif %}>{% trans "Recently active" %}</option>
<option value="recent" {% if request.GET.sort == "suggested" %}checked{% endif %}>{% trans "Recently active" %}</option> <option value="suggested" {% if request.GET.sort == "suggested" %}selected{% endif %}>{% trans "Suggested" %}</option>
</select> </select>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -15,14 +15,15 @@
</p> </p>
</header> </header>
{% with tile_classes="tile is-child box has-background-white-ter is-clipped" %}
<div class="tile is-ancestor"> <div class="tile is-ancestor">
<div class="tile is-6 is-parent"> <div class="tile is-6 is-parent">
<div class="tile is-child box has-background-white-ter"> <div class="{{ tile_classes }}">
{% include 'discover/large-book.html' with status=large_activities.0 %} {% include 'discover/large-book.html' with status=large_activities.0 %}
</div> </div>
</div> </div>
<div class="tile is-6 is-parent"> <div class="tile is-6 is-parent">
<div class="tile is-child box has-background-white-ter"> <div class="{{ tile_classes }}">
{% include 'discover/large-book.html' with status=large_activities.1 %} {% include 'discover/large-book.html' with status=large_activities.1 %}
</div> </div>
</div> </div>
@ -31,18 +32,18 @@
<div class="tile is-ancestor"> <div class="tile is-ancestor">
<div class="tile is-vertical is-6"> <div class="tile is-vertical is-6">
<div class="tile is-parent"> <div class="tile is-parent">
<div class="tile is-child box has-background-white-ter"> <div class="{{ tile_classes }}">
{% include 'discover/large-book.html' with status=large_activities.2 %} {% include 'discover/large-book.html' with status=large_activities.2 %}
</div> </div>
</div> </div>
<div class="tile"> <div class="tile">
<div class="tile is-parent is-6"> <div class="tile is-parent is-6">
<div class="tile is-child box has-background-white-ter"> <div class="{{ tile_classes }}">
{% include 'discover/small-book.html' with status=small_activities.0 %} {% include 'discover/small-book.html' with status=small_activities.0 %}
</div> </div>
</div> </div>
<div class="tile is-parent is-6"> <div class="tile is-parent is-6">
<div class="tile is-child box has-background-white-ter"> <div class="{{ tile_classes }}">
{% include 'discover/small-book.html' with status=small_activities.1 %} {% include 'discover/small-book.html' with status=small_activities.1 %}
</div> </div>
</div> </div>
@ -51,18 +52,18 @@
<div class="tile is-vertical is-6"> <div class="tile is-vertical is-6">
<div class="tile"> <div class="tile">
<div class="tile is-parent is-6"> <div class="tile is-parent is-6">
<div class="tile is-child box has-background-white-ter"> <div class="{{ tile_classes }}">
{% include 'discover/small-book.html' with status=small_activities.2 %} {% include 'discover/small-book.html' with status=small_activities.2 %}
</div> </div>
</div> </div>
<div class="tile is-parent is-6"> <div class="tile is-parent is-6">
<div class="tile is-child box has-background-white-ter"> <div class="{{ tile_classes }}">
{% include 'discover/small-book.html' with status=small_activities.3 %} {% include 'discover/small-book.html' with status=small_activities.3 %}
</div> </div>
</div> </div>
</div> </div>
<div class="tile is-parent"> <div class="tile is-parent">
<div class="tile is-child box has-background-white-ter"> <div class="{{ tile_classes }}">
{% include 'discover/large-book.html' with status=large_activities.3 %} {% include 'discover/large-book.html' with status=large_activities.3 %}
</div> </div>
</div> </div>
@ -71,16 +72,17 @@
<div class="tile is-ancestor"> <div class="tile is-ancestor">
<div class="tile is-6 is-parent"> <div class="tile is-6 is-parent">
<div class="tile is-child box has-background-white-ter"> <div class="{{ tile_classes }}">
{% include 'discover/large-book.html' with status=large_activities.4 %} {% include 'discover/large-book.html' with status=large_activities.4 %}
</div> </div>
</div> </div>
<div class="tile is-6 is-parent"> <div class="tile is-6 is-parent">
<div class="tile is-child box has-background-white-ter"> <div class="{{ tile_classes }}">
{% include 'discover/large-book.html' with status=large_activities.5 %} {% include 'discover/large-book.html' with status=large_activities.5 %}
</div> </div>
</div> </div>
</div> </div>
{% endwith %}
</section> </section>
<div class="block"> <div class="block">

View file

@ -14,7 +14,7 @@
</header> </header>
<div class="box"> <div class="box">
{% include 'snippets/create_status/status.html' with type="direct" uuid=1 mention=partner %} {% include 'snippets/create_status/status.html' with type="direct" uuid=1 mention=partner no_script=True %}
</div> </div>
<section class="block"> <section class="block">

View file

@ -25,7 +25,7 @@
{% if request.user.show_goal and not goal and tab.key == 'home' %} {% if request.user.show_goal and not goal and tab.key == 'home' %}
{% now 'Y' as year %} {% now 'Y' as year %}
<section class="block"> <section class="block">
{% include 'snippets/goal_card.html' with year=year %} {% include 'feed/goal_card.html' with year=year %}
<hr> <hr>
</section> </section>
{% endif %} {% endif %}

View file

@ -7,13 +7,8 @@
</h3> </h3>
{% endblock %} {% endblock %}
{% block card-content %} {% block card-content %}
<div class="content"> {% include 'snippets/goal_form.html' %}
<p>{% blocktrans %}Set a goal for how many books you'll finish reading in {{ year }}, and track your progress throughout the year.{% endblocktrans %}</p>
{% include 'snippets/goal_form.html' %}
</div>
{% endblock %} {% endblock %}
{% block card-footer %} {% block card-footer %}

View file

@ -77,7 +77,7 @@
class="checkbox" class="checkbox"
type="checkbox" type="checkbox"
data-action="toggle-all" data-action="toggle-all"
data-target="failed-imports" data-target="failed_imports"
/> />
{% trans "Select all" %} {% trans "Select all" %}
</label> </label>

View file

@ -5,11 +5,11 @@
{% block content %} {% block content %}
<h1 class="title">{% trans "Create an Account" %}</h1>
<div class="columns"> <div class="columns">
<div class="column"> <div class="column">
<div class="block"> <div class="block">
{% if valid %} {% if valid %}
<h1 class="title">{% trans "Create an Account" %}</h1>
<div> <div>
<form name="register" method="post" action="/register"> <form name="register" method="post" action="/register">
<input type=hidden name="invite_code" value="{{ invite.code }}"> <input type=hidden name="invite_code" value="{{ invite.code }}">
@ -25,7 +25,7 @@
</div> </div>
</div> </div>
<div class="column"> <div class="column">
<div class="block"> <div class="box">
{% include 'snippets/about.html' %} {% include 'snippets/about.html' %}
</div> </div>
</div> </div>

View file

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

View file

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

View file

@ -40,38 +40,41 @@
<div class="tile is-5 is-parent"> <div class="tile is-5 is-parent">
{% if not request.user.is_authenticated %} {% if not request.user.is_authenticated %}
<div class="tile is-child box has-background-primary-light content"> <div class="tile is-child box has-background-primary-light content">
<h2 class="title">
{% if site.allow_registration %}
{% blocktrans with name=site.name %}Join {{ name }}{% endblocktrans %}
{% elif site.allow_invite_requests %}
{% trans "Request an Invitation" %}
{% else %}
{% blocktrans with name=site.name%}{{ name}} registration is closed{% endblocktrans %}
{% endif %}
</h2>
{% if site.allow_registration %} {% if site.allow_registration %}
<h2 class="title">{% blocktrans with name=site.name %}Join {{ name }}{% endblocktrans %}</h2> <form name="register" method="post" action="/register">
<form name="register" method="post" action="/register"> {% include 'snippets/register_form.html' %}
{% include 'snippets/register_form.html' %} </form>
</form> {% elif site.allow_invite_requests %}
{% if request_received %}
<p>
{% trans "Thank you! Your request has been received." %}
</p>
{% else %}
<p>{{ site.invite_request_text }}</p>
<form name="invite-request" action="{% url 'invite-request' %}" method="post">
{% csrf_token %}
<div class="block">
<label for="id_request_email" class="label">{% trans "Email address:" %}</label>
<input type="email" name="email" maxlength="255" class="input" required="" id="id_request_email">
{% for error in request_form.email.errors %}
<p class="help is-danger">{{ error|escape }}</p>
{% endfor %}
</div>
<button type="submit" class="button is-link">{% trans "Submit" %}</button>
</form>
{% endif %}
{% else %} {% else %}
<p>{{ site.registration_closed_text|safe}}</p>
<h2 class="title">{% trans "This instance is closed" %}</h2>
<p>{{ site.registration_closed_text|safe}}</p>
{% if site.allow_invite_requests %}
{% if request_received %}
<p>
{% trans "Thank you! Your request has been received." %}
</p>
{% else %}
<h3>{% trans "Request an Invitation" %}</h3>
<form name="invite-request" action="{% url 'invite-request' %}" method="post">
{% csrf_token %}
<div class="block">
<label for="id_request_email" class="label">{% trans "Email address:" %}</label>
<input type="email" name="email" maxlength="255" class="input" required="" id="id_request_email">
{% for error in request_form.email.errors %}
<p class="help is-danger">{{ error|escape }}</p>
{% endfor %}
</div>
<button type="submit" class="button is-link">{% trans "Submit" %}</button>
</form>
{% endif %}
{% endif %}
{% endif %} {% endif %}
</div> </div>
{% else %} {% else %}

View file

@ -4,12 +4,14 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{% get_lang %}"> <html lang="{% get_lang %}">
<head> <head>
<title>{% block title %}BookWyrm{% endblock %} | {{ site.name }}</title> <title>{% block title %}BookWyrm{% endblock %} - {{ site.name }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="{% static "css/vendor/bulma.min.css" %}"> <link rel="stylesheet" href="{% static "css/vendor/bulma.min.css" %}">
<link rel="stylesheet" href="{% static "css/vendor/icons.css" %}"> <link rel="stylesheet" href="{% static "css/vendor/icons.css" %}">
<link rel="stylesheet" href="{% static "css/bookwyrm.css" %}"> <link rel="stylesheet" href="{% static "css/bookwyrm.css" %}">
<link rel="search" type="application/opensearchdescription+xml" href="{% url 'opensearch' %}" title="{% blocktrans with site_name=site.name %}{{ site_name }} search{% endblocktrans %}" />
<link rel="shortcut icon" type="image/x-icon" href="{% if site.favicon %}{% get_media_prefix %}{{ site.favicon }}{% else %}{% static "images/favicon.ico" %}{% endif %}"> <link rel="shortcut icon" type="image/x-icon" href="{% if site.favicon %}{% get_media_prefix %}{{ site.favicon }}{% else %}{% static "images/favicon.ico" %}{% endif %}">
{% if preview_images_enabled is True %} {% if preview_images_enabled is True %}
@ -17,8 +19,8 @@
{% else %} {% else %}
<meta name="twitter:card" content="summary"> <meta name="twitter:card" content="summary">
{% endif %} {% endif %}
<meta name="twitter:title" content="{% if title %}{{ title }} | {% endif %}{{ site.name }}"> <meta name="twitter:title" content="{% if title %}{{ title }} - {% endif %}{{ site.name }}">
<meta name="og:title" content="{% if title %}{{ title }} | {% endif %}{{ site.name }}"> <meta name="og:title" content="{% if title %}{{ title }} - {% endif %}{{ site.name }}">
<meta name="twitter:description" content="{{ site.instance_tagline }}"> <meta name="twitter:description" content="{{ site.instance_tagline }}">
<meta name="og:description" content="{{ site.instance_tagline }}"> <meta name="og:description" content="{{ site.instance_tagline }}">
@ -34,10 +36,15 @@
<a class="navbar-item" href="/"> <a class="navbar-item" href="/">
<img class="image logo" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="Home page"> <img class="image logo" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="Home page">
</a> </a>
<form class="navbar-item column" action="/search/"> <form class="navbar-item column" action="{% url 'search' %}">
<div class="field has-addons"> <div class="field has-addons">
<div class="control"> <div class="control">
<input aria-label="{% trans 'Search for a book or user' %}" id="search_input" class="input" type="text" name="q" placeholder="{% trans 'Search for a book or user' %}" value="{{ query }}"> {% if user.is_authenticated %}
{% trans "Search for a book, user, or list" as search_placeholder %}
{% else %}
{% trans "Search for a book" as search_placeholder %}
{% endif %}
<input aria-label="{{ search_placeholder }}" id="search_input" class="input" type="text" name="q" placeholder="{{ search_placeholder }}" value="{{ query }}">
</div> </div>
<div class="control"> <div class="control">
<button class="button" type="submit"> <button class="button" type="submit">
@ -110,7 +117,7 @@
</a> </a>
</li> </li>
{% if perms.bookwyrm.create_invites or perms.moderate_user %} {% if perms.bookwyrm.create_invites or perms.moderate_user %}
<li class="navbar-divider" role="presentation"></li> <li class="navbar-divider" role="presentation">&nbsp;</li>
{% endif %} {% endif %}
{% if perms.bookwyrm.create_invites and not site.allow_registration %} {% if perms.bookwyrm.create_invites and not site.allow_registration %}
<li> <li>
@ -126,7 +133,7 @@
</a> </a>
</li> </li>
{% endif %} {% endif %}
<li class="navbar-divider" role="presentation"></li> <li class="navbar-divider" role="presentation">&nbsp;</li>
<li> <li>
<a href="{% url 'logout' %}" class="navbar-item"> <a href="{% url 'logout' %}" class="navbar-item">
{% trans 'Log out' %} {% trans 'Log out' %}

View file

@ -45,8 +45,10 @@
</div> </div>
</div> </div>
</div> </div>
{% if list.id %}
<div class="column is-narrow"> <div class="column is-narrow">
{% trans "Delete list" as button_text %} {% trans "Delete list" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with class="is-danger" text=button_text icon_with_text="x" controls_text="delete_list" controls_uid=list.id focus="modal_title_delete_list" %} {% include 'snippets/toggle/toggle_button.html' with class="is-danger" text=button_text icon_with_text="x" controls_text="delete_list" controls_uid=list.id focus="modal_title_delete_list" %}
</div> </div>
{% endif %}
</div> </div>

View file

@ -66,14 +66,14 @@
<p>{% blocktrans with username=item.user.display_name user_path=item.user.local_path %}Added by <a href="{{ user_path }}">{{ username }}</a>{% endblocktrans %}</p> <p>{% blocktrans with username=item.user.display_name user_path=item.user.local_path %}Added by <a href="{{ user_path }}">{{ username }}</a>{% endblocktrans %}</p>
</div> </div>
</div> </div>
{% if list.user == request.user or list.curation == 'open' and item.user == request.user %} {% if list.user == request.user %}
<div class="card-footer-item"> <div class="card-footer-item">
<form name="set-position" method="post" action="{% url 'list-set-book-position' item.id %}"> <form name="set-position" method="post" action="{% url 'list-set-book-position' item.id %}">
{% csrf_token %}
<div class="field has-addons mb-0"> <div class="field has-addons mb-0">
<div class="control"> <div class="control">
<label for="input-list-position" class="button is-transparent is-small">{% trans "List position" %}</label> <label for="input-list-position" class="button is-transparent is-small">{% trans "List position" %}</label>
</div> </div>
{% csrf_token %}
<div class="control"> <div class="control">
<input id="input_list_position" class="input is-small" type="number" min="1" name="position" value="{{ item.order }}"> <input id="input_list_position" class="input is-small" type="number" min="1" name="position" value="{{ item.order }}">
</div> </div>
@ -83,7 +83,9 @@
</div> </div>
</form> </form>
</div> </div>
<form name="add-book" method="post" action="{% url 'list-remove-book' list.id %}" class="card-footer-item"> {% endif %}
{% if list.user == request.user or list.curation == 'open' and item.user == request.user %}
<form name="remove-book" method="post" action="{% url 'list-remove-book' list.id %}" class="card-footer-item">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="item" value="{{ item.id }}"> <input type="hidden" name="item" value="{{ item.id }}">
<button type="submit" class="button is-small is-danger">{% trans "Remove" %}</button> <button type="submit" class="button is-small is-danger">{% trans "Remove" %}</button>

View file

@ -4,69 +4,65 @@
{% block title %}{% trans "Login" %}{% endblock %} {% block title %}{% trans "Login" %}{% endblock %}
{% block content %} {% block content %}
<div class="columns"> <h1 class="title">{% trans "Log in" %}</h1>
<div class="column"> <div class="columns is-multiline">
<div class="box"> <div class="column is-half">
<h1 class="title">{% trans "Log in" %}</h1> {% if login_form.non_field_errors %}
{% if login_form.non_field_errors %} <p class="notification is-danger">{{ login_form.non_field_errors }}</p>
<p class="notification is-danger">{{ login_form.non_field_errors }}</p> {% endif %}
{% endif %}
{% if show_confirmed_email %} {% if show_confirmed_email %}
<p class="notification is-success">{% trans "Success! Email address confirmed." %}</p> <p class="notification is-success">{% trans "Success! Email address confirmed." %}</p>
{% endif %} {% endif %}
<form name="login" method="post" action="/login"> <form name="login" method="post" action="/login">
{% csrf_token %} {% csrf_token %}
{% if show_confirmed_email %}<input type="hidden" name="first_login" value="true">{% endif %} {% if show_confirmed_email %}<input type="hidden" name="first_login" value="true">{% endif %}
<div class="field"> <div class="field">
<label class="label" for="id_localname">{% trans "Username:" %}</label> <label class="label" for="id_localname">{% trans "Username:" %}</label>
<div class="control"> <div class="control">
{{ login_form.localname }} {{ login_form.localname }}
</div>
</div> </div>
<div class="field"> </div>
<label class="label" for="id_password">{% trans "Password:" %}</label> <div class="field">
<div class="control"> <label class="label" for="id_password">{% trans "Password:" %}</label>
{{ login_form.password }} <div class="control">
</div> {{ login_form.password }}
{% for error in login_form.password.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div> </div>
<div class="field is-grouped"> {% for error in login_form.password.errors %}
<div class="control"> <p class="help is-danger">{{ error | escape }}</p>
<button class="button is-primary" type="submit">{% trans "Log in" %}</button> {% endfor %}
</div> </div>
<div class="control"> <div class="field is-grouped">
<small><a href="{% url 'password-reset' %}">{% trans "Forgot your password?" %}</a></small> <div class="control">
</div> <button class="button is-primary" type="submit">{% trans "Log in" %}</button>
</div> </div>
</form> <div class="control">
</div> <small><a href="{% url 'password-reset' %}">{% trans "Forgot your password?" %}</a></small>
</div>
</div>
</form>
</div> </div>
<div class="column"> {% if site.allow_registration %}
<div class="column is-half">
<div class="box has-background-primary-light"> <div class="box has-background-primary-light">
{% if site.allow_registration %}
<h2 class="title">{% trans "Create an Account" %}</h2> <h2 class="title">{% trans "Create an Account" %}</h2>
<form name="register" method="post" action="/register"> <form name="register" method="post" action="/register">
{% include 'snippets/register_form.html' %} {% include 'snippets/register_form.html' %}
</form> </form>
{% else %} </div>
<h2 class="title">{% trans "This instance is closed" %}</h2> </div>
<p>{% trans "Contact an administrator to get an invite" %}</p> {% endif %}
{% endif %}
<div class="column">
<div class="box">
{% include 'snippets/about.html' %}
<p class="block">
<a href="{% url 'about' %}">{% trans "More about this site" %}</a>
</p>
</div> </div>
</div> </div>
</div> </div>
<div class="block">
<div class="box">
{% include 'snippets/about.html' %}
<p class="block">
<a href="{% url 'about' %}">{% trans "More about this site" %}</a>
</p>
</div>
</div>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,16 @@
{% load i18n %}{% load static %}<?xml version="1.0" encoding="UTF-8"?>
<OpenSearchDescription
xmlns="http://a9.com/-/spec/opensearch/1.1/"
xmlns:moz="http://www.mozilla.org/2006/browser/search/"
>
<ShortName>BW</ShortName>
<Description>{% blocktrans trimmed with site_name=site.name %}
{{ site_name }} search
{% endblocktrans %}</Description>
<Image width="16" height="16" type="image/x-icon">{{ image }}</Image>
<Url
type="text/html"
method="get"
template="https://{{ DOMAIN }}{% url 'search' %}?q={searchTerms}"
/>
</OpenSearchDescription>

View file

@ -9,7 +9,7 @@
{% block panel %} {% block panel %}
{% if not request.user.blocks.exists %} {% if not request.user.blocks.exists %}
<p>{% trans "No users currently blocked." %}</p> <p><em>{% trans "No users currently blocked." %}</em></p>
{% else %} {% else %}
<ul> <ul>
{% for user in request.user.blocks.all %} {% for user in request.user.blocks.all %}

View file

@ -10,11 +10,11 @@
{% block panel %} {% block panel %}
<form name="edit-profile" action="{% url 'prefs-password' %}" method="post" enctype="multipart/form-data"> <form name="edit-profile" action="{% url 'prefs-password' %}" method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<div class="block"> <div class="field">
<label class="label" for="id_password">{% trans "New password:" %}</label> <label class="label" for="id_password">{% trans "New password:" %}</label>
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password"> <input type="password" name="password" maxlength="128" class="input" required="" id="id_password">
</div> </div>
<div class="block"> <div class="field">
<label class="label" for="id_confirm_password">{% trans "Confirm password:" %}</label> <label class="label" for="id_confirm_password">{% trans "Confirm password:" %}</label>
<input type="password" name="confirm-password" maxlength="128" class="input" required="" id="id_confirm_password"> <input type="password" name="confirm-password" maxlength="128" class="input" required="" id="id_confirm_password">
</div> </div>

View file

@ -7,76 +7,114 @@
{% trans "Edit Profile" %} {% trans "Edit Profile" %}
{% endblock %} {% endblock %}
{% block profile-tabs %}
<ul class="menu-list">
<li><a href="#profile">{% trans "Profile" %}</a></li>
<li><a href="#display-preferences">{% trans "Display preferences" %}</a></li>
<li><a href="#privacy">{% trans "Privacy" %}</a></li>
</ul>
{% endblock %}
{% block panel %} {% block panel %}
{% if form.non_field_errors %} {% if form.non_field_errors %}
<p class="notification is-danger">{{ form.non_field_errors }}</p> <p class="notification is-danger">{{ form.non_field_errors }}</p>
{% endif %} {% endif %}
<form name="edit-profile" action="{% url 'prefs-profile' %}" method="post" enctype="multipart/form-data"> <form name="edit-profile" action="{% url 'prefs-profile' %}" method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<div class="block"> <section class="block" id="profile">
<label class="label" for="id_avatar">{% trans "Avatar:" %}</label> <h2 class="title is-4">{% trans "Profile" %}</h2>
{{ form.avatar }} <div class="box">
{% for error in form.avatar.errors %} <label class="label" for="id_avatar">{% trans "Avatar:" %}</label>
<p class="help is-danger">{{ error | escape }}</p> <div class="field columns is-mobile">
{% endfor %} {% if request.user.avatar %}
</div> <div class="column is-narrow">
<div class="block"> {% include 'snippets/avatar.html' with user=request.user large=True %}
<label class="label" for="id_name">{% trans "Display name:" %}</label> </div>
{{ form.name }} {% endif %}
{% for error in form.name.errors %} <div class="column">
<p class="help is-danger">{{ error | escape }}</p> {{ form.avatar }}
{% endfor %} {% for error in form.avatar.errors %}
</div> <p class="help is-danger">{{ error | escape }}</p>
<div class="block"> {% endfor %}
<label class="label" for="id_summary">{% trans "Summary:" %}</label> </div>
{{ form.summary }} </div>
{% for error in form.summary.errors %} <div class="field">
<p class="help is-danger">{{ error | escape }}</p> <label class="label" for="id_name">{% trans "Display name:" %}</label>
{% endfor %} {{ form.name }}
</div> {% for error in form.name.errors %}
<div class="block"> <p class="help is-danger">{{ error | escape }}</p>
<label class="label" for="id_email">{% trans "Email address:" %}</label> {% endfor %}
{{ form.email }} </div>
{% for error in form.email.errors %} <div class="field">
<p class="help is-danger">{{ error | escape }}</p> <label class="label" for="id_summary">{% trans "Summary:" %}</label>
{% endfor %} {{ form.summary }}
</div> {% for error in form.summary.errors %}
<div class="block"> <p class="help is-danger">{{ error | escape }}</p>
<label class="checkbox label" for="id_show_goal"> {% endfor %}
{% trans "Show reading goal prompt in feed:" %} </div>
{{ form.show_goal }} <div class="field">
</label> <label class="label" for="id_email">{% trans "Email address:" %}</label>
<label class="checkbox label" for="id_show_goal"> {{ form.email }}
{% trans "Show suggested users:" %} {% for error in form.email.errors %}
{{ form.show_suggested_users }} <p class="help is-danger">{{ error | escape }}</p>
</label> {% endfor %}
<label class="checkbox label" for="id_discoverable"> </div>
{% trans "Show this account in suggested users:" %}
{{ form.discoverable }}
</label>
{% url 'directory' as path %}
<p class="help">{% blocktrans %}Your account will show up in the <a href="{{ path }}">directory</a>, and may be recommended to other BookWyrm users.{% endblocktrans %}</p>
</div>
<div class="block">
<label class="checkbox label" for="id_manually_approves_followers">
{% trans "Manually approve followers:" %}
{{ form.manually_approves_followers }}
</label>
</div>
<div class="block">
<label class="label" for="id_default_post_privacy">
{% trans "Default post privacy:" %}
</label>
<div class="select">
{{ form.default_post_privacy }}
</div> </div>
</div> </section>
<div class="block">
<label class="label" for="id_preferred_timezone">{% trans "Preferred Timezone: " %}</label> <hr aria-hidden="true">
<div class="select">
{{ form.preferred_timezone }} <section class="block" id="display-preferences">
<h2 class="title is-4">{% trans "Display preferences" %}</h2>
<div class="box">
<div class="field">
<label class="checkbox label" for="id_show_goal">
{% trans "Show reading goal prompt in feed:" %}
{{ form.show_goal }}
</label>
<label class="checkbox label" for="id_show_suggested_users">
{% trans "Show suggested users:" %}
{{ form.show_suggested_users }}
</label>
<label class="checkbox label" for="id_discoverable">
{% trans "Show this account in suggested users:" %}
{{ form.discoverable }}
</label>
{% url 'directory' as path %}
<p class="help">
{% blocktrans %}Your account will show up in the <a href="{{ path }}">directory</a>, and may be recommended to other BookWyrm users.{% endblocktrans %}
</p>
</div>
<div class="field">
<label class="label" for="id_preferred_timezone">{% trans "Preferred Timezone: " %}</label>
<div class="select">
{{ form.preferred_timezone }}
</div>
</div>
</div> </div>
</div> </section>
<div class="block"><button class="button is-primary" type="submit">{% trans "Save" %}</button></div>
<hr aria-hidden="true">
<section class="block" id="privacy">
<h2 class="title is-4">{% trans "Privacy" %}</h2>
<div class="box">
<div class="field">
<label class="checkbox label" for="id_manually_approves_followers">
{% trans "Manually approve followers:" %}
{{ form.manually_approves_followers }}
</label>
</div>
<div class="field">
<label class="label" for="id_default_post_privacy">
{% trans "Default post privacy:" %}
</label>
<div class="select">
{{ form.default_post_privacy }}
</div>
</div>
</div>
</section>
<div class="field"><button class="button is-primary" type="submit">{% trans "Save" %}</button></div>
</form> </form>
{% endblock %} {% endblock %}

View file

@ -12,7 +12,8 @@
<ul class="menu-list"> <ul class="menu-list">
<li> <li>
{% url 'prefs-profile' as url %} {% url 'prefs-profile' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Profile" %}</a> <a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Edit Profile" %}</a>
{% block profile-tabs %}{% endblock %}
</li> </li>
<li> <li>
{% url 'prefs-password' as url %} {% url 'prefs-password' as url %}

View file

@ -26,7 +26,7 @@
{% block panel %} {% block panel %}
<form name="edit-announcement" method="post" action="{% url 'settings-announcements' announcement.id %}" class="block"> <form name="edit-announcement" method="post" action="{% url 'settings-announcements' announcement.id %}" class="block">
{% include 'settings/announcement_form.html' with controls_text="edit_announcement" %} {% include 'settings/announcements/announcement_form.html' with controls_text="edit_announcement" %}
</form> </form>
<div class="block content"> <div class="block content">

View file

@ -11,7 +11,7 @@
{% block panel %} {% block panel %}
<form name="create-announcement" method="post" action="{% url 'settings-announcements' %}" class="block"> <form name="create-announcement" method="post" action="{% url 'settings-announcements' %}" class="block">
{% include 'settings/announcement_form.html' with controls_text="create_announcement" %} {% include 'settings/announcements/announcement_form.html' with controls_text="create_announcement" %}
</form> </form>
<div class="block"> <div class="block">
@ -48,11 +48,10 @@
<td>{% if announcement.active %}{% trans "active" %}{% else %}{% trans "inactive" %}{% endif %}</td> <td>{% if announcement.active %}{% trans "active" %}{% else %}{% trans "inactive" %}{% endif %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
{% if not announcements %}
<tr><td colspan="5"><em>{% trans "No announcements found" %}</em></td></tr>
{% endif %}
</table> </table>
{% if not announcements %}
<p><em>{% trans "No announcements found." %}</em></p>
{% endif %}
</div> </div>
{% include 'snippets/pagination.html' with page=announcements path=request.path %} {% include 'snippets/pagination.html' with page=announcements path=request.path %}

View file

@ -67,27 +67,27 @@
<form method="get" action="{% url 'settings-dashboard' %}" class="notification has-background-white-bis"> <form method="get" action="{% url 'settings-dashboard' %}" class="notification has-background-white-bis">
<div class="is-flex is-align-items-flex-end"> <div class="is-flex is-align-items-flex-end">
<div class="ml-1 mr-1"> <div class="ml-1 mr-1">
<label class="label"> <label class="label" for="id_start">
{% trans "Start date:" %} {% trans "Start date:" %}
<input class="input" type="date" name="start" value="{{ start }}">
</label> </label>
<input class="input" type="date" name="start" value="{{ start }}" id="id_start">
</div> </div>
<div class="ml-1 mr-1"> <div class="ml-1 mr-1">
<label class="label"> <label class="label" for="id_end">
{% trans "End date:" %} {% trans "End date:" %}
<input class="input" type="date" name="end" value="{{ end }}">
</label> </label>
<input class="input" type="date" name="end" value="{{ end }}" id="id_end">
</div> </div>
<div class="ml-1 mr-1"> <div class="ml-1 mr-1">
<label class="label"> <label class="label" for="id_interval">
{% trans "Interval:" %} {% trans "Interval:" %}
<div class="select">
<select name="days">
<option value="1" {% if interval == 1 %}selected{% endif %}>{% trans "Days" %}</option>
<option value="7" {% if interval == 7 %}selected{% endif %}>{% trans "Weeks" %}</option>
</select>
</div>
</label> </label>
<div class="select">
<select name="days" id="id_interval">
<option value="1" {% if interval == 1 %}selected{% endif %}>{% trans "Days" %}</option>
<option value="7" {% if interval == 7 %}selected{% endif %}>{% trans "Weeks" %}</option>
</select>
</div>
</div> </div>
<div class="ml-1 mr-1"> <div class="ml-1 mr-1">
<button class="button is-link" type="submit">{% trans "Submit" %}</button> <button class="button is-link" type="submit">{% trans "Submit" %}</button>
@ -115,6 +115,6 @@
{% block scripts %} {% block scripts %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.5.1/dist/chart.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@3.5.1/dist/chart.min.js"></script>
{% include 'settings/dashboard_user_chart.html' %} {% include 'settings/dashboard/dashboard_user_chart.html' %}
{% include 'settings/dashboard_status_chart.html' %} {% include 'settings/dashboard/dashboard_status_chart.html' %}
{% endblock %} {% endblock %}

View file

@ -8,7 +8,7 @@
{% block form %} {% block form %}
<form name="add-domain" method="post" action="{% url 'settings-email-blocks' %}"> <form name="add-domain" method="post" action="{% url 'settings-email-blocks' %}">
{% csrf_token %} {% csrf_token %}
<label class="label" for="id_event_date">{% trans "Domain:" %}</label> <label class="label" for="id_domain">{% trans "Domain:" %}</label>
<div class="field has-addons"> <div class="field has-addons">
<div class="control"> <div class="control">
<div class="button is-disabled">@</div> <div class="button is-disabled">@</div>

View file

@ -12,7 +12,7 @@
{% endblock %} {% endblock %}
{% block panel %} {% block panel %}
{% include 'settings/domain_form.html' with controls_text="add_domain" class="block" %} {% include 'settings/email_blocklist/domain_form.html' with controls_text="add_domain" class="block" %}
<p class="notification block"> <p class="notification block">
{% trans "When someone tries to register with an email from this domain, no account will be created. The registration process will appear to have worked." %} {% trans "When someone tries to register with an email from this domain, no account will be created. The registration process will appear to have worked." %}
@ -55,7 +55,11 @@
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
{% if not domains.exists %}
<tr><td colspan="5"><em>{% trans "No email domains currently blocked" %}</em></td></tr>
{% endif %}
</table> </table>
{% endblock %} {% endblock %}

View file

@ -33,6 +33,8 @@
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}
</div> </div>
</div>
<div class="column is-half">
<div class="field"> <div class="field">
<label class="label" for="id_status">{% trans "Status:" %}</label> <label class="label" for="id_status">{% trans "Status:" %}</label>
<div class="select"> <div class="select">
@ -43,6 +45,8 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="columns">
<div class="column is-half"> <div class="column is-half">
<div class="field"> <div class="field">
<label class="label" for="id_application_type">{% trans "Software:" %}</label> <label class="label" for="id_application_type">{% trans "Software:" %}</label>
@ -51,6 +55,8 @@
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}
</div> </div>
</div>
<div class="column is-half">
<div class="field"> <div class="field">
<label class="label" for="id_application_version">{% trans "Version:" %}</label> <label class="label" for="id_application_version">{% trans "Version:" %}</label>
<input type="text" name="application_version" maxlength="255" class="input" id="id_application_version" value="{{ form.application_version.value|default:'' }}"> <input type="text" name="application_version" maxlength="255" class="input" id="id_application_version" value="{{ form.application_version.value|default:'' }}">
@ -62,7 +68,7 @@
</div> </div>
<div class="field"> <div class="field">
<label class="label" for="id_notes">{% trans "Notes:" %}</label> <label class="label" for="id_notes">{% trans "Notes:" %}</label>
<textarea name="notes" cols="None" rows="None" class="textarea" id="id_notes">{{ form.notes.value|default:'' }}</textarea> <textarea name="notes" cols="40" rows="5" class="textarea" id="id_notes">{{ form.notes.value|default:'' }}</textarea>
</div> </div>
<button type="submit" class="button is-primary">{% trans "Save" %}</button> <button type="submit" class="button is-primary">{% trans "Save" %}</button>

View file

@ -19,18 +19,14 @@
<h2 class="title is-4">{% trans "Details" %}</h2> <h2 class="title is-4">{% trans "Details" %}</h2>
<div class="box is-flex-grow-1 content"> <div class="box is-flex-grow-1 content">
<dl> <dl>
<div class="is-flex"> <dt class="is-pulled-left mr-5">{% trans "Software:" %}</dt>
<dt>{% trans "Software:" %}</dt> <dd>{{ server.application_type }}</dd>
<dd>{{ server.application_type }}</dd>
</div> <dt class="is-pulled-left mr-5">{% trans "Version:" %}</dt>
<div class="is-flex"> <dd>{{ server.application_version }}</dd>
<dt>{% trans "Version:" %}</dt>
<dd>{{ server.application_version }}</dd> <dt class="is-pulled-left mr-5">{% trans "Status:" %}</dt>
</div> <dd>{{ server.get_status_display }}</dd>
<div class="is-flex">
<dt>{% trans "Status:" %}</dt>
<dd>{{ server.get_status_display }}</dd>
</div>
</dl> </dl>
</div> </div>
</section> </section>
@ -39,38 +35,32 @@
<h2 class="title is-4">{% trans "Activity" %}</h2> <h2 class="title is-4">{% trans "Activity" %}</h2>
<div class="box is-flex-grow-1 content"> <div class="box is-flex-grow-1 content">
<dl> <dl>
<div class="is-flex"> <dt class="is-pulled-left mr-5">{% trans "Users:" %}</dt>
<dt>{% trans "Users:" %}</dt> <dd>
<dd> {{ users.count }}
{{ users.count }} {% if server.user_set.count %}(<a href="{% url 'settings-users' %}?server={{ server.server_name }}">{% trans "View all" %}</a>){% endif %}
{% if server.user_set.count %}(<a href="{% url 'settings-users' %}?server={{ server.server_name }}">{% trans "View all" %}</a>){% endif %} </dd>
</dd>
</div> <dt class="is-pulled-left mr-5">{% trans "Reports:" %}</dt>
<div class="is-flex"> <dd>
<dt>{% trans "Reports:" %}</dt> {{ reports.count }}
<dd> {% if reports.count %}(<a href="{% url 'settings-reports' %}?server={{ server.server_name }}">{% trans "View all" %}</a>){% endif %}
{{ reports.count }} </dd>
{% if reports.count %}(<a href="{% url 'settings-reports' %}?server={{ server.server_name }}">{% trans "View all" %}</a>){% endif %}
</dd> <dt class="is-pulled-left mr-5">{% trans "Followed by us:" %}</dt>
</div> <dd>
<div class="is-flex"> {{ followed_by_us.count }}
<dt>{% trans "Followed by us:" %}</dt> </dd>
<dd>
{{ followed_by_us.count }} <dt class="is-pulled-left mr-5">{% trans "Followed by them:" %}</dt>
</dd> <dd>
</div> {{ followed_by_them.count }}
<div class="is-flex"> </dd>
<dt>{% trans "Followed by them:" %}</dt>
<dd> <dt class="is-pulled-left mr-5">{% trans "Blocked by us:" %}</dt>
{{ followed_by_them.count }} <dd>
</dd> {{ blocked_by_us.count }}
</div> </dd>
<div class="is-flex">
<dt>{% trans "Blocked by us:" %}</dt>
<dd>
{{ blocked_by_us.count }}
</dd>
</div>
</dl> </dl>
</div> </div>
</section> </section>
@ -86,14 +76,13 @@
{% include 'snippets/toggle/open_button.html' with text=button_text icon_with_text="pencil" controls_text="edit_notes" %} {% include 'snippets/toggle/open_button.html' with text=button_text icon_with_text="pencil" controls_text="edit_notes" %}
</div> </div>
</header> </header>
{% if server.notes %} {% trans "<em>No notes</em>" as null_text %}
<div class="box" id="hide_edit_notes">{{ server.notes|to_markdown|safe }}</div> <div class="box" id="hide_edit_notes">{{ server.notes|to_markdown|default:null_text|safe }}</div>
{% endif %}
<form class="box is-hidden" method="POST" action="{% url 'settings-federated-server' server.id %}" id="edit_notes"> <form class="box is-hidden" method="POST" action="{% url 'settings-federated-server' server.id %}" id="edit_notes">
{% csrf_token %} {% csrf_token %}
<p> <p>
<label class="is-sr-only" for="id_notes">Notes:</label> <label class="is-sr-only" for="id_notes">Notes:</label>
<textarea name="notes" cols="None" rows="None" class="textarea" id="id_notes">{{ server.notes|default:"" }}</textarea> <textarea name="notes" cols="40" rows="5" class="textarea" id="id_notes">{{ server.notes|default:"" }}</textarea>
</p> </p>
<button type="submit" class="button is-primary">{% trans "Save" %}</button> <button type="submit" class="button is-primary">{% trans "Save" %}</button>
{% trans "Cancel" as button_text %} {% trans "Cancel" as button_text %}

View file

@ -59,7 +59,11 @@
<td>{{ server.get_status_display }}</td> <td>{{ server.get_status_display }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
{% if not servers %}
<tr><td colspan="5"><em>{% trans "No instances found" %}</em></td></tr>
{% endif %}
</table> </table>
{% include 'snippets/pagination.html' with page=servers path=request.path %} {% include 'snippets/pagination.html' with page=servers path=request.path %}
{% endblock %} {% endblock %}

View file

@ -1,6 +1,6 @@
{% extends 'snippets/filters_panel/filters_panel.html' %} {% extends 'snippets/filters_panel/filters_panel.html' %}
{% block filter_fields %} {% block filter_fields %}
{% include 'settings/status_filter.html' %} {% include 'settings/invites/status_filter.html' %}
{% endblock %} {% endblock %}

View file

@ -26,7 +26,7 @@
{% endif %} ({{ count }}) {% endif %} ({{ count }})
</h2> </h2>
{% include 'settings/invite_request_filters.html' %} {% include 'settings/invites/invite_request_filters.html' %}
<table class="table is-striped is-fullwidth"> <table class="table is-striped is-fullwidth">
{% url 'settings-invite-requests' as url %} {% url 'settings-invite-requests' as url %}
@ -47,7 +47,7 @@
<th>{% trans "Action" %}</th> <th>{% trans "Action" %}</th>
</tr> </tr>
{% if not requests %} {% if not requests %}
<tr><td colspan="4">{% trans "No requests" %}</td></tr> <tr><td colspan="5"><em>{% trans "No requests" %}</em></td></tr>
{% endif %} {% endif %}
{% for req in requests %} {% for req in requests %}
<tr> <tr>

View file

@ -0,0 +1,36 @@
{% extends 'components/inline_form.html' %}
{% load i18n %}
{% block header %}
{% trans "Add IP address" %}
{% endblock %}
{% block form %}
<form name="add-address" method="post" action="{% url 'settings-ip-blocks' %}">
<div class="block">
{% trans "Use IP address blocks with caution, and consider using blocks only temporarily, as IP addresses are often shared or change hands. If you block your own IP, you will not be able to access this page." %}
</div>
{% csrf_token %}
<div class="field">
<label class="label" for="id_address">
{% trans "IP Address:" %}
</label>
</div>
<div class="field">
<input type="text" name="address" maxlength="255" class="input" required="" id="id_address" placeholder="190.0.2.0/24">
</div>
{% for error in form.address.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<div class="field">
<div class="control">
<button type="submit" class="button is-primary">{% trans "Add" %}</button>
</div>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,50 @@
{% extends 'settings/layout.html' %}
{% load i18n %}
{% load humanize %}
{% block title %}{% trans "IP Address Blocklist" %}{% endblock %}
{% block header %}{% trans "IP Address Blocklist" %}{% endblock %}
{% block edit-button %}
{% trans "Add IP address" as button_text %}
{% include 'snippets/toggle/open_button.html' with controls_text="add_address" icon_with_text="plus" text=button_text focus="add_address_header" %}
{% endblock %}
{% block panel %}
{% include 'settings/ip_blocklist/ip_address_form.html' with controls_text="add_address" class="block" %}
<p class="notification block">
{% trans "Any traffic from this IP address will get a 404 response when trying to access any part of the application." %}
</p>
<table class="table is-striped is-fullwidth">
<tr>
<th>
{% trans "Address" %}
</th>
<th>
{% trans "Options" %}
</th>
</tr>
{% for address in addresses %}
<tr>
<td>{{ address.address }}</td>
<td>
<form name="remove-{{ address.id }}" action="{% url 'settings-ip-blocks-delete' address.id %}" method="post">
{% csrf_token %}
{% trans "Delete" as button_text %}
<button class="button" type="submit">
<span class="icon icon-x" title="{{ button_text }}" aria-hidden="true"></span>
<span class="is-hidden-mobile">{{ button_text }}</span>
</button>
</form>
</td>
</tr>
{% endfor %}
{% if not addresses.exists %}
<tr><td colspan="2"><em>{% trans "No IP addresses currently blocked" %}</em></td></tr>
{% endif %}
</table>
{% endblock %}

View file

@ -0,0 +1,8 @@
{% extends 'components/tooltip.html' %}
{% load i18n %}
{% block tooltip_content %}
{% trans "You can block IP ranges using CIDR syntax." %}
{% endblock %}

View file

@ -58,6 +58,10 @@
{% url 'settings-email-blocks' as url %} {% url 'settings-email-blocks' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Email Blocklist" %}</a> <a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Email Blocklist" %}</a>
</li> </li>
<li>
{% url 'settings-ip-blocks' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "IP Address Blocklist" %}</a>
</li>
</ul> </ul>
{% endif %} {% endif %}
{% if perms.bookwyrm.edit_instance_settings %} {% if perms.bookwyrm.edit_instance_settings %}
@ -70,14 +74,7 @@
<li> <li>
{% url 'settings-site' as url %} {% url 'settings-site' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Site Settings" %}</a> <a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Site Settings" %}</a>
{% if url in request.path %} {% block site-subtabs %}{% endblock %}
<ul class="emnu-list">
<li><a href="{{ url }}#instance-info">{% trans "Instance Info" %}</a></li>
<li><a href="{{ url }}#images">{% trans "Images" %}</a></li>
<li><a href="{{ url }}#footer">{% trans "Footer Content" %}</a></li>
<li><a href="{{ url }}#registration">{% trans "Registration" %}</a></li>
</ul>
{% endif %}
</li> </li>
</ul> </ul>
{% endif %} {% endif %}

View file

@ -3,20 +3,21 @@
{% load humanize %} {% load humanize %}
{% block title %}{% blocktrans with report_id=report.id username=report.user.username %}Report #{{ report_id }}: {{ username }}{% endblocktrans %}{% endblock %} {% block title %}{% blocktrans with report_id=report.id username=report.user.username %}Report #{{ report_id }}: {{ username }}{% endblocktrans %}{% endblock %}
{% block header %}{% blocktrans with report_id=report.id username=report.user.username %}Report #{{ report_id }}: {{ username }}{% endblocktrans %}{% endblock %}
{% block header %}
{% blocktrans with report_id=report.id username=report.user.username %}Report #{{ report_id }}: {{ username }}{% endblocktrans %}
<a href="{% url 'settings-reports' %}" class="has-text-weight-normal help">{% trans "Back to reports" %}</a>
{% endblock %}
{% block panel %} {% block panel %}
<div class="block">
<a href="{% url 'settings-reports' %}">{% trans "Back to reports" %}</a>
</div>
<div class="block"> <div class="block">
{% include 'moderation/report_preview.html' with report=report %} {% include 'settings/reports/report_preview.html' with report=report %}
</div> </div>
{% include 'user_admin/user_info.html' with user=report.user %} {% include 'settings/users/user_info.html' with user=report.user %}
{% include 'user_admin/user_moderation_actions.html' with user=report.user %} {% include 'settings/users/user_moderation_actions.html' with user=report.user %}
<div class="block"> <div class="block">
<h3 class="title is-4">{% trans "Moderator Comments" %}</h3> <h3 class="title is-4">{% trans "Moderator Comments" %}</h3>

View file

@ -30,7 +30,7 @@
</ul> </ul>
</div> </div>
{% include 'user_admin/user_admin_filters.html' %} {% include 'settings/users/user_admin_filters.html' %}
<div class="block"> <div class="block">
{% if not reports %} {% if not reports %}
@ -39,7 +39,7 @@
{% for report in reports %} {% for report in reports %}
<div class="block"> <div class="block">
{% include 'moderation/report_preview.html' with report=report %} {% include 'settings/reports/report_preview.html' with report=report %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>

View file

@ -5,36 +5,46 @@
{% block header %}{% trans "Site Settings" %}{% endblock %} {% block header %}{% trans "Site Settings" %}{% endblock %}
{% block panel %} {% block site-subtabs %}
<ul class="menu-list">
<li><a href="#instance-info">{% trans "Instance Info" %}</a></li>
<li><a href="#images">{% trans "Images" %}</a></li>
<li><a href="#footer">{% trans "Footer Content" %}</a></li>
<li><a href="#registration">{% trans "Registration" %}</a></li>
</ul>
{% endblock %}
{% block panel %}
<form action="{% url 'settings-site' %}" method="POST" class="content" enctype="multipart/form-data"> <form action="{% url 'settings-site' %}" method="POST" class="content" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<section class="block" id="instance_info"> <section class="block" id="instance_info">
<h2 class="title is-4">{% trans "Instance Info" %}</h2> <h2 class="title is-4">{% trans "Instance Info" %}</h2>
<div class="field"> <div class="box">
<label class="label" for="id_name">{% trans "Instance Name:" %}</label> <div class="field">
{{ site_form.name }} <label class="label" for="id_name">{% trans "Instance Name:" %}</label>
</div> {{ site_form.name }}
<div class="field"> </div>
<label class="label" for="id_instance_tagline">{% trans "Tagline:" %}</label> <div class="field">
{{ site_form.instance_tagline }} <label class="label" for="id_instance_tagline">{% trans "Tagline:" %}</label>
</div> {{ site_form.instance_tagline }}
<div class="field"> </div>
<label class="label" for="id_instance_description">{% trans "Instance description:" %}</label> <div class="field">
{{ site_form.instance_description }} <label class="label" for="id_instance_description">{% trans "Instance description:" %}</label>
</div> {{ site_form.instance_description }}
<div class="field"> </div>
<label class="label mb-0" for="id_short_description">{% trans "Short description:" %}</label> <div class="field">
<p class="help">{% trans "Used when the instance is previewed on joinbookwyrm.com. Does not support html or markdown." %}</p> <label class="label mb-0" for="id_short_description">{% trans "Short description:" %}</label>
{{ site_form.instance_short_description }} <p class="help">{% trans "Used when the instance is previewed on joinbookwyrm.com. Does not support html or markdown." %}</p>
</div> {{ site_form.instance_short_description }}
<div class="field"> </div>
<label class="label" for="id_code_of_conduct">{% trans "Code of conduct:" %}</label> <div class="field">
{{ site_form.code_of_conduct }} <label class="label" for="id_code_of_conduct">{% trans "Code of conduct:" %}</label>
</div> {{ site_form.code_of_conduct }}
<div class="field"> </div>
<label class="label" for="id_privacy_policy">{% trans "Privacy Policy:" %}</label> <div class="field">
{{ site_form.privacy_policy }} <label class="label" for="id_privacy_policy">{% trans "Privacy Policy:" %}</label>
{{ site_form.privacy_policy }}
</div>
</div> </div>
</section> </section>
@ -42,16 +52,16 @@
<section class="block" id="images"> <section class="block" id="images">
<h2 class="title is-4">{% trans "Images" %}</h2> <h2 class="title is-4">{% trans "Images" %}</h2>
<div class="columns"> <div class="box is-flex">
<div class="column"> <div>
<label class="label" for="id_logo">{% trans "Logo:" %}</label> <label class="label" for="id_logo">{% trans "Logo:" %}</label>
{{ site_form.logo }} {{ site_form.logo }}
</div> </div>
<div class="column"> <div>
<label class="label" for="id_logo_small">{% trans "Logo small:" %}</label> <label class="label" for="id_logo_small">{% trans "Logo small:" %}</label>
{{ site_form.logo_small }} {{ site_form.logo_small }}
</div> </div>
<div class="column"> <div>
<label class="label" for="id_favicon">{% trans "Favicon:" %}</label> <label class="label" for="id_favicon">{% trans "Favicon:" %}</label>
{{ site_form.favicon }} {{ site_form.favicon }}
</div> </div>
@ -62,21 +72,23 @@
<section class="block" id="footer"> <section class="block" id="footer">
<h2 class="title is-4">{% trans "Footer Content" %}</h2> <h2 class="title is-4">{% trans "Footer Content" %}</h2>
<div class="field"> <div class="box">
<label class="label" for="id_support_link">{% trans "Support link:" %}</label> <div class="field">
<input type="text" name="support_link" maxlength="255" class="input" id="id_support_link" placeholder="https://www.patreon.com/bookwyrm"{% if site.support_link %} value="{{ site.support_link }}"{% endif %}> <label class="label" for="id_support_link">{% trans "Support link:" %}</label>
</div> <input type="text" name="support_link" maxlength="255" class="input" id="id_support_link" placeholder="https://www.patreon.com/bookwyrm"{% if site.support_link %} value="{{ site.support_link }}"{% endif %}>
<div class="field"> </div>
<label class="label" for="id_support_title">{% trans "Support title:" %}</label> <div class="field">
<input type="text" name="support_title" maxlength="100" class="input" id="id_support_title" placeholder="Patreon"{% if site.support_title %} value="{{ site.support_title }}"{% endif %}> <label class="label" for="id_support_title">{% trans "Support title:" %}</label>
</div> <input type="text" name="support_title" maxlength="100" class="input" id="id_support_title" placeholder="Patreon"{% if site.support_title %} value="{{ site.support_title }}"{% endif %}>
<div class="field"> </div>
<label class="label" for="id_admin_email">{% trans "Admin email:" %}</label> <div class="field">
{{ site_form.admin_email }} <label class="label" for="id_admin_email">{% trans "Admin email:" %}</label>
</div> {{ site_form.admin_email }}
<div class="field"> </div>
<label class="label" for="id_footer_item">{% trans "Additional info:" %}</label> <div class="field">
{{ site_form.footer_item }} <label class="label" for="id_footer_item">{% trans "Additional info:" %}</label>
{{ site_form.footer_item }}
</div>
</div> </div>
</section> </section>
@ -84,28 +96,37 @@
<section class="block" id="registration"> <section class="block" id="registration">
<h2 class="title is-4">{% trans "Registration" %}</h2> <h2 class="title is-4">{% trans "Registration" %}</h2>
<div class="field"> <div class="box">
<label class="label" for="id_allow_registration"> <div class="field">
{{ site_form.allow_registration }} <label class="label" for="id_allow_registration">
{% trans "Allow registration" %} {{ site_form.allow_registration }}
</label> {% trans "Allow registration" %}
</div> </label>
<div class="field"> </div>
<label class="label" for="id_allow_invite_requests"> <div class="field">
{{ site_form.allow_invite_requests }} <label class="label" for="id_allow_invite_requests">
{% trans "Allow invite requests" %} {{ site_form.allow_invite_requests }}
</label> {% trans "Allow invite requests" %}
</div> </label>
<div class="field"> </div>
<label class="label mb-0" for="id_allow_invite_requests"> <div class="field">
{{ site_form.require_confirm_email }} <label class="label mb-0" for="id_require_confirm_email">
{% trans "Require users to confirm email address" %} {{ site_form.require_confirm_email }}
</label> {% trans "Require users to confirm email address" %}
<p class="help">{% trans "(Recommended if registration is open)" %}</p> </label>
</div> <p class="help">{% trans "(Recommended if registration is open)" %}</p>
<div class="field"> </div>
<label class="label" for="id_registration_closed_text">{% trans "Registration closed text:" %}</label> <div class="field">
{{ site_form.registration_closed_text }} <label class="label" for="id_registration_closed_text">{% trans "Registration closed text:" %}</label>
{{ site_form.registration_closed_text }}
</div>
<div class="field">
<label class="label" for="id_invite_request_text">{% trans "Invite request text:" %}</label>
{{ site_form.invite_request_text }}
{% for error in site_form.invite_request_text.errors %}
<p class="help is-danger">{{ error|escape }}</p>
{% endfor %}
</div>
</div> </div>
</section> </section>

Some files were not shown because too many files have changed in this diff Show more