Merge branch 'main' into production

This commit is contained in:
Mouse Reeve 2024-03-25 16:11:26 -07:00
commit 38654004f9
107 changed files with 8866 additions and 539 deletions

View file

@ -137,6 +137,10 @@ TWO_FACTOR_LOGIN_MAX_SECONDS=60
# and AWS_S3_CUSTOM_DOMAIN (if used) are added by default.
# Value should be a comma-separated list of host names.
CSP_ADDITIONAL_HOSTS=
# The last number here means "megabytes"
# Increase if users are having trouble uploading BookWyrm export files.
DATA_UPLOAD_MAX_MEMORY_SIZE = (1024**2 * 100)
# Time before being logged out (in seconds)
# SESSION_COOKIE_AGE=2592000 # current default: 30 days
# Maximum allowed memory for file uploads (increase if users are having trouble
# uploading BookWyrm export files).
# DATA_UPLOAD_MAX_MEMORY_MiB=100

View file

@ -1,17 +0,0 @@
name: Python Formatting (run ./bw-dev black to fix)
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
- uses: psf/black@22.12.0
with:
version: 22.12.0

View file

@ -36,7 +36,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL

View file

@ -10,7 +10,7 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Install curlylint
run: pip install curlylint

View file

@ -1,70 +0,0 @@
name: Run Python Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-20.04
services:
postgres:
image: postgres:13
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: hunter2
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: 3.9
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Check migrations up-to-date
run: |
python ./manage.py makemigrations --check
env:
SECRET_KEY: beepbeep
DOMAIN: your.domain.here
EMAIL_HOST: ""
EMAIL_HOST_USER: ""
EMAIL_HOST_PASSWORD: ""
- name: Run Tests
env:
SECRET_KEY: beepbeep
DEBUG: false
USE_HTTPS: true
DOMAIN: your.domain.here
BOOKWYRM_DATABASE_BACKEND: postgres
MEDIA_ROOT: images/
POSTGRES_PASSWORD: hunter2
POSTGRES_USER: postgres
POSTGRES_DB: github_actions
POSTGRES_HOST: 127.0.0.1
CELERY_BROKER: ""
REDIS_BROKER_PORT: 6379
REDIS_BROKER_PASSWORD: beep
USE_DUMMY_CACHE: true
FLOWER_PORT: 8888
EMAIL_HOST: "smtp.mailgun.org"
EMAIL_PORT: 587
EMAIL_HOST_USER: ""
EMAIL_HOST_PASSWORD: ""
EMAIL_USE_TLS: true
ENABLE_PREVIEW_IMAGES: false
ENABLE_THUMBNAIL_GENERATION: true
HTTP_X_FORWARDED_PROTO: false
run: |
pytest -n 3

View file

@ -19,7 +19,7 @@ jobs:
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it.
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Install modules
run: npm install stylelint stylelint-config-recommended stylelint-config-standard stylelint-order eslint

View file

@ -1,50 +0,0 @@
name: Mypy
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python 3.9
uses: actions/setup-python@v4
with:
python-version: 3.9
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Analysing the code with mypy
env:
SECRET_KEY: beepbeep
DEBUG: false
USE_HTTPS: true
DOMAIN: your.domain.here
BOOKWYRM_DATABASE_BACKEND: postgres
MEDIA_ROOT: images/
POSTGRES_PASSWORD: hunter2
POSTGRES_USER: postgres
POSTGRES_DB: github_actions
POSTGRES_HOST: 127.0.0.1
CELERY_BROKER: ""
REDIS_BROKER_PORT: 6379
REDIS_BROKER_PASSWORD: beep
USE_DUMMY_CACHE: true
FLOWER_PORT: 8888
EMAIL_HOST: "smtp.mailgun.org"
EMAIL_PORT: 587
EMAIL_HOST_USER: ""
EMAIL_HOST_PASSWORD: ""
EMAIL_USE_TLS: true
ENABLE_PREVIEW_IMAGES: false
ENABLE_THUMBNAIL_GENERATION: true
HTTP_X_FORWARDED_PROTO: false
run: |
mypy bookwyrm celerywyrm

View file

@ -14,7 +14,7 @@ jobs:
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it.
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Install modules
run: npm install prettier@2.5.1

View file

@ -1,27 +0,0 @@
name: Pylint
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python 3.9
uses: actions/setup-python@v4
with:
python-version: 3.9
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Analysing the code with pylint
run: |
pylint bookwyrm/

99
.github/workflows/python.yml vendored Normal file
View file

@ -0,0 +1,99 @@
name: Python
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
# overrides for .env.example
env:
POSTGRES_HOST: 127.0.0.1
PGPORT: 5432
POSTGRES_USER: postgres
POSTGRES_PASSWORD: hunter2
POSTGRES_DB: github_actions
SECRET_KEY: beepbeep
EMAIL_HOST_USER: ""
EMAIL_HOST_PASSWORD: ""
jobs:
pytest:
name: Tests (pytest)
runs-on: ubuntu-latest
services:
postgres:
image: postgres:13
env: # does not inherit from jobs.build.env
POSTGRES_USER: postgres
POSTGRES_PASSWORD: hunter2
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: 3.11
cache: pip
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest-github-actions-annotate-failures
- name: Set up .env
run: cp .env.example .env
- name: Check migrations up-to-date
run: python ./manage.py makemigrations --check
- name: Run Tests
run: pytest -n 3
pylint:
name: Linting (pylint)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: 3.11
cache: pip
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Analyse code with pylint
run: pylint bookwyrm/
mypy:
name: Typing (mypy)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: 3.11
cache: pip
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Set up .env
run: cp .env.example .env
- name: Analyse code with mypy
run: mypy bookwyrm celerywyrm
black:
name: Formatting (black; run ./bw-dev black to fix)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: psf/black@stable
with:
version: "22.*"

1
.gitignore vendored
View file

@ -16,6 +16,7 @@
# BookWyrm
.env
/images/
/static/
bookwyrm/static/css/bookwyrm.css
bookwyrm/static/css/themes/
!bookwyrm/static/css/themes/bookwyrm-*.scss

View file

@ -1,4 +1,4 @@
FROM python:3.9
FROM python:3.11
ENV PYTHONUNBUFFERED 1

View file

@ -1 +1 @@
0.7.1
0.7.2

View file

@ -20,6 +20,7 @@ from bookwyrm.tasks import app, MISC
logger = logging.getLogger(__name__)
# pylint: disable=invalid-name
TBookWyrmModel = TypeVar("TBookWyrmModel", bound=base_model.BookWyrmModel)
@ -423,6 +424,7 @@ def get_activitypub_data(url):
"Date": now,
"Signature": make_signature("get", sender, url, now),
},
timeout=15,
)
except requests.RequestException:
raise ConnectorException()

View file

@ -1,5 +1,5 @@
""" actor serializer """
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import Dict
from .base_activity import ActivityObject
@ -35,7 +35,7 @@ class Person(ActivityObject):
endpoints: Dict = None
name: str = None
summary: str = None
icon: Image = field(default_factory=lambda: {})
icon: Image = None
bookwyrmUser: bool = False
manuallyApprovesFollowers: str = False
discoverable: str = False

View file

@ -1,4 +1,5 @@
"""Do further startup configuration and initialization"""
import os
import urllib
import logging
@ -14,16 +15,16 @@ def download_file(url, destination):
"""Downloads a file to the given path"""
try:
# Ensure our destination directory exists
os.makedirs(os.path.dirname(destination))
os.makedirs(os.path.dirname(destination), exist_ok=True)
with urllib.request.urlopen(url) as stream:
with open(destination, "b+w") as outfile:
outfile.write(stream.read())
except (urllib.error.HTTPError, urllib.error.URLError):
logger.info("Failed to download file %s", url)
except OSError:
logger.info("Couldn't open font file %s for writing", destination)
except: # pylint: disable=bare-except
logger.info("Unknown error in file download")
except (urllib.error.HTTPError, urllib.error.URLError) as err:
logger.error("Failed to download file %s: %s", url, err)
except OSError as err:
logger.error("Couldn't open font file %s for writing: %s", destination, err)
except Exception as err: # pylint:disable=broad-except
logger.error("Unknown error in file download: %s", err)
class BookwyrmConfig(AppConfig):

View file

@ -3,7 +3,9 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Optional, TypedDict, Any, Callable, Union, Iterator
from urllib.parse import quote_plus
import imghdr
# pylint: disable-next=deprecated-module
import imghdr # Deprecated in 3.11 for removal in 3.13; no good alternative yet
import logging
import re
import asyncio

View file

@ -15,6 +15,7 @@ class AuthorForm(CustomForm):
"aliases",
"bio",
"wikipedia_link",
"wikidata",
"website",
"born",
"died",
@ -32,6 +33,7 @@ class AuthorForm(CustomForm):
"wikipedia_link": forms.TextInput(
attrs={"aria-describedby": "desc_wikipedia_link"}
),
"wikidata": forms.TextInput(attrs={"aria-describedby": "desc_wikidata"}),
"website": forms.TextInput(attrs={"aria-describedby": "desc_website"}),
"born": forms.SelectDateWidget(attrs={"aria-describedby": "desc_born"}),
"died": forms.SelectDateWidget(attrs={"aria-describedby": "desc_died"}),

View file

@ -1,4 +1,5 @@
""" using django model forms """
from urllib.parse import urlparse
from django.utils.translation import gettext_lazy as _
@ -37,10 +38,9 @@ class FileLinkForm(CustomForm):
),
)
if (
not self.instance
and models.FileLink.objects.filter(
url=url, book=book, filetype=filetype
).exists()
models.FileLink.objects.filter(url=url, book=book, filetype=filetype)
.exclude(pk=self.instance)
.exists()
):
# pylint: disable=line-too-long
self.add_error(

View file

@ -26,7 +26,7 @@ class IsbnHyphenator:
def update_range_message(self) -> None:
"""Download the range message xml file and save it locally"""
response = requests.get(self.__range_message_url)
response = requests.get(self.__range_message_url, timeout=15)
with open(self.__range_file_path, "w", encoding="utf-8") as file:
file.write(response.text)
self.__element_tree = None

View file

@ -1,54 +0,0 @@
""" Get your admin code to allow install """
from django.core.management.base import BaseCommand
from bookwyrm import models
from bookwyrm.settings import VERSION
# pylint: disable=no-self-use
class Command(BaseCommand):
"""command-line options"""
help = "What version is this?"
def add_arguments(self, parser):
"""specify which function to run"""
parser.add_argument(
"--current",
action="store_true",
help="Version stored in database",
)
parser.add_argument(
"--target",
action="store_true",
help="Version stored in settings",
)
parser.add_argument(
"--update",
action="store_true",
help="Update database version",
)
# pylint: disable=unused-argument
def handle(self, *args, **options):
"""execute init"""
site = models.SiteSettings.objects.get()
current = site.version or "0.0.1"
target = VERSION
if options.get("current"):
print(current)
return
if options.get("target"):
print(target)
return
if options.get("update"):
site.version = target
site.save()
return
if current != target:
print(f"{current}/{target}")
else:
print(current)

View file

@ -0,0 +1,16 @@
# Generated by Django 3.2.20 on 2023-11-24 17:11
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0188_theme_loads"),
]
operations = [
migrations.RemoveIndex(
model_name="author",
name="bookwyrm_au_search__b050a8_gin",
),
]

View file

@ -0,0 +1,76 @@
# Generated by Django 3.2.20 on 2023-11-25 00:47
from importlib import import_module
import re
from django.db import migrations
import pgtrigger.compiler
import pgtrigger.migrations
trigger_migration = import_module("bookwyrm.migrations.0077_auto_20210623_2155")
# it's _very_ convenient for development that this migration be reversible
search_vector_trigger = trigger_migration.Migration.operations[4]
author_search_vector_trigger = trigger_migration.Migration.operations[5]
assert re.search(r"\bCREATE TRIGGER search_vector_trigger\b", search_vector_trigger.sql)
assert re.search(
r"\bCREATE TRIGGER author_search_vector_trigger\b",
author_search_vector_trigger.sql,
)
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0190_book_search_updates"),
]
operations = [
pgtrigger.migrations.AddTrigger(
model_name="book",
trigger=pgtrigger.compiler.Trigger(
name="update_search_vector_on_book_edit",
sql=pgtrigger.compiler.UpsertTriggerSql(
func="new.search_vector := setweight(coalesce(nullif(to_tsvector('english', new.title), ''), to_tsvector('simple', new.title)), 'A') || setweight(to_tsvector('english', coalesce(new.subtitle, '')), 'B') || (SELECT setweight(to_tsvector('simple', coalesce(array_to_string(array_agg(bookwyrm_author.name), ' '), '')), 'C') FROM bookwyrm_author LEFT JOIN bookwyrm_book_authors ON bookwyrm_author.id = bookwyrm_book_authors.author_id WHERE bookwyrm_book_authors.book_id = new.id ) || setweight(to_tsvector('english', coalesce(new.series, '')), 'D');RETURN NEW;",
hash="77d6399497c0a89b0bf09d296e33c396da63705c",
operation='INSERT OR UPDATE OF "title", "subtitle", "series", "search_vector"',
pgid="pgtrigger_update_search_vector_on_book_edit_bec58",
table="bookwyrm_book",
when="BEFORE",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="author",
trigger=pgtrigger.compiler.Trigger(
name="reset_search_vector_on_author_edit",
sql=pgtrigger.compiler.UpsertTriggerSql(
func="WITH updated_books AS (SELECT book_id FROM bookwyrm_book_authors WHERE author_id = new.id ) UPDATE bookwyrm_book SET search_vector = '' FROM updated_books WHERE id = updated_books.book_id;RETURN NEW;",
hash="e7bbf08711ff3724c58f4d92fb7a082ffb3d7826",
operation='UPDATE OF "name"',
pgid="pgtrigger_reset_search_vector_on_author_edit_a447c",
table="bookwyrm_author",
when="AFTER",
),
),
),
migrations.RunSQL(
sql="""DROP TRIGGER IF EXISTS search_vector_trigger ON bookwyrm_book;
DROP FUNCTION IF EXISTS book_trigger;
""",
reverse_sql=search_vector_trigger.sql,
),
migrations.RunSQL(
sql="""DROP TRIGGER IF EXISTS author_search_vector_trigger ON bookwyrm_author;
DROP FUNCTION IF EXISTS author_trigger;
""",
reverse_sql=author_search_vector_trigger.sql,
),
migrations.RunSQL(
# Recalculate book search vector for any missed author name changes
# due to bug in JOIN in the old trigger.
sql="UPDATE bookwyrm_book SET search_vector = NULL;",
reverse_sql=migrations.RunSQL.noop,
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 3.2.23 on 2024-01-04 23:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0191_merge_20240102_0326"),
]
operations = [
migrations.AlterField(
model_name="quotation",
name="endposition",
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name="quotation",
name="position",
field=models.TextField(blank=True, null=True),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.23 on 2024-01-02 19:36
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0191_merge_20240102_0326"),
]
operations = [
migrations.RenameField(
model_name="sitesettings",
old_name="version",
new_name="available_version",
),
]

View file

@ -0,0 +1,13 @@
# Generated by Django 3.2.23 on 2024-02-03 15:39
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0192_make_page_positions_text"),
("bookwyrm", "0192_sitesettings_user_exports_enabled"),
]
operations = []

View file

@ -0,0 +1,13 @@
# Generated by Django 3.2.23 on 2024-02-03 16:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0192_rename_version_sitesettings_available_version"),
("bookwyrm", "0193_merge_20240203_1539"),
]
operations = []

View file

@ -0,0 +1,46 @@
# Generated by Django 3.2.23 on 2024-02-21 00:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0194_merge_20240203_1619"),
]
operations = [
migrations.AlterField(
model_name="user",
name="preferred_language",
field=models.CharField(
blank=True,
choices=[
("en-us", "English"),
("ca-es", "Català (Catalan)"),
("de-de", "Deutsch (German)"),
("eo-uy", "Esperanto (Esperanto)"),
("es-es", "Español (Spanish)"),
("eu-es", "Euskara (Basque)"),
("gl-es", "Galego (Galician)"),
("it-it", "Italiano (Italian)"),
("ko-kr", "한국어 (Korean)"),
("fi-fi", "Suomi (Finnish)"),
("fr-fr", "Français (French)"),
("lt-lt", "Lietuvių (Lithuanian)"),
("nl-nl", "Nederlands (Dutch)"),
("no-no", "Norsk (Norwegian)"),
("pl-pl", "Polski (Polish)"),
("pt-br", "Português do Brasil (Brazilian Portuguese)"),
("pt-pt", "Português Europeu (European Portuguese)"),
("ro-ro", "Română (Romanian)"),
("sv-se", "Svenska (Swedish)"),
("uk-ua", "Українська (Ukrainian)"),
("zh-hans", "简体中文 (Simplified Chinese)"),
("zh-hant", "繁體中文 (Traditional Chinese)"),
],
max_length=255,
null=True,
),
),
]

View file

@ -0,0 +1,13 @@
# Generated by Django 3.2.23 on 2024-03-18 00:48
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0191_migrate_search_vec_triggers_to_pgtriggers"),
("bookwyrm", "0195_alter_user_preferred_language"),
]
operations = []

View file

@ -0,0 +1,41 @@
# Generated by Django 3.2.25 on 2024-03-20 15:15
import django.contrib.postgres.indexes
from django.db import migrations
import pgtrigger.compiler
import pgtrigger.migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0196_merge_pr3134_into_main"),
]
operations = [
migrations.AddIndex(
model_name="author",
index=django.contrib.postgres.indexes.GinIndex(
fields=["search_vector"], name="bookwyrm_au_search__b050a8_gin"
),
),
pgtrigger.migrations.AddTrigger(
model_name="author",
trigger=pgtrigger.compiler.Trigger(
name="update_search_vector_on_author_edit",
sql=pgtrigger.compiler.UpsertTriggerSql(
func="new.search_vector := setweight(to_tsvector('simple', new.name), 'A') || setweight(to_tsvector('simple', coalesce(array_to_string(new.aliases, ' '), '')), 'B');RETURN NEW;",
hash="b97919016236d74d0ade51a0769a173ea269da64",
operation='INSERT OR UPDATE OF "name", "aliases", "search_vector"',
pgid="pgtrigger_update_search_vector_on_author_edit_c61cb",
table="bookwyrm_author",
when="BEFORE",
),
),
),
migrations.RunSQL(
# Calculate search vector for all Authors.
sql="UPDATE bookwyrm_author SET search_vector = NULL;",
reverse_sql="UPDATE bookwyrm_author SET search_vector = NULL;",
),
]

View file

@ -0,0 +1,57 @@
# Generated by Django 3.2.25 on 2024-03-20 15:52
from django.db import migrations
import pgtrigger.compiler
import pgtrigger.migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0197_author_search_vector"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="author",
name="reset_search_vector_on_author_edit",
),
pgtrigger.migrations.RemoveTrigger(
model_name="book",
name="update_search_vector_on_book_edit",
),
pgtrigger.migrations.AddTrigger(
model_name="author",
trigger=pgtrigger.compiler.Trigger(
name="reset_book_search_vector_on_author_edit",
sql=pgtrigger.compiler.UpsertTriggerSql(
func="WITH updated_books AS (SELECT book_id FROM bookwyrm_book_authors WHERE author_id = new.id ) UPDATE bookwyrm_book SET search_vector = '' FROM updated_books WHERE id = updated_books.book_id;RETURN NEW;",
hash="68422c0f29879c5802b82159dde45297eff53e73",
operation='UPDATE OF "name", "aliases"',
pgid="pgtrigger_reset_book_search_vector_on_author_edit_a50c7",
table="bookwyrm_author",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="book",
trigger=pgtrigger.compiler.Trigger(
name="update_search_vector_on_book_edit",
sql=pgtrigger.compiler.UpsertTriggerSql(
func="WITH author_names AS (SELECT array_to_string(bookwyrm_author.name || bookwyrm_author.aliases, ' ') AS name_and_aliases FROM bookwyrm_author LEFT JOIN bookwyrm_book_authors ON bookwyrm_author.id = bookwyrm_book_authors.author_id WHERE bookwyrm_book_authors.book_id = new.id ) SELECT setweight(coalesce(nullif(to_tsvector('english', new.title), ''), to_tsvector('simple', new.title)), 'A') || setweight(to_tsvector('english', coalesce(new.subtitle, '')), 'B') || (SELECT setweight(to_tsvector('simple', coalesce(array_to_string(array_agg(name_and_aliases), ' '), '')), 'C') FROM author_names) || setweight(to_tsvector('english', coalesce(new.series, '')), 'D') INTO new.search_vector;RETURN NEW;",
hash="9324f5ca76a6f5e63931881d62d11da11f595b2c",
operation='INSERT OR UPDATE OF "title", "subtitle", "series", "search_vector"',
pgid="pgtrigger_update_search_vector_on_book_edit_bec58",
table="bookwyrm_book",
when="BEFORE",
),
),
),
migrations.RunSQL(
# Recalculate search vector for all Books because it now includes
# Author aliases.
sql="UPDATE bookwyrm_book SET search_vector = NULL;",
reverse_sql="UPDATE bookwyrm_book SET search_vector = NULL;",
),
]

View file

@ -152,8 +152,9 @@ class ActivitypubMixin:
# find anyone who's tagged in a status, for example
mentions = self.recipients if hasattr(self, "recipients") else []
# we always send activities to explicitly mentioned users' inboxes
recipients = [u.inbox for u in mentions or [] if not u.local]
# we always send activities to explicitly mentioned users (using shared inboxes
# where available to avoid duplicate submissions to a given instance)
recipients = {u.shared_inbox or u.inbox for u in mentions if not u.local}
# unless it's a dm, all the followers should receive the activity
if privacy != "direct":
@ -173,18 +174,18 @@ class ActivitypubMixin:
if user:
queryset = queryset.filter(following=user)
# ideally, we will send to shared inboxes for efficiency
shared_inboxes = (
queryset.filter(shared_inbox__isnull=False)
.values_list("shared_inbox", flat=True)
.distinct()
# as above, we prefer shared inboxes if available
recipients.update(
queryset.filter(shared_inbox__isnull=False).values_list(
"shared_inbox", flat=True
)
)
# but not everyone has a shared inbox
inboxes = queryset.filter(shared_inbox__isnull=True).values_list(
"inbox", flat=True
recipients.update(
queryset.filter(shared_inbox__isnull=True).values_list(
"inbox", flat=True
)
)
recipients += list(shared_inboxes) + list(inboxes)
return list(set(recipients))
return list(recipients)
def to_activity_dataclass(self):
"""convert from a model to an activity"""

View file

@ -2,11 +2,13 @@
import re
from typing import Tuple, Any
from django.contrib.postgres.indexes import GinIndex
from django.db import models
from django.contrib.postgres.indexes import GinIndex
import pgtrigger
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from bookwyrm.utils.db import format_trigger
from .book import BookDataModel
from . import fields
@ -67,9 +69,46 @@ class Author(BookDataModel):
"""editions and works both use "book" instead of model_name"""
return f"https://{DOMAIN}/author/{self.id}"
activity_serializer = activitypub.Author
class Meta:
"""sets up postgres GIN index field"""
"""sets up indexes and triggers"""
# pylint: disable=line-too-long
indexes = (GinIndex(fields=["search_vector"]),)
triggers = [
pgtrigger.Trigger(
name="update_search_vector_on_author_edit",
when=pgtrigger.Before,
operation=pgtrigger.Insert
| pgtrigger.UpdateOf("name", "aliases", "search_vector"),
func=format_trigger(
"""new.search_vector :=
-- author name, with priority A
setweight(to_tsvector('simple', new.name), 'A') ||
-- author aliases, with priority B
setweight(to_tsvector('simple', coalesce(array_to_string(new.aliases, ' '), '')), 'B');
RETURN new;
"""
),
),
pgtrigger.Trigger(
name="reset_book_search_vector_on_author_edit",
when=pgtrigger.After,
operation=pgtrigger.UpdateOf("name", "aliases"),
func=format_trigger(
"""WITH updated_books AS (
SELECT book_id
FROM bookwyrm_book_authors
WHERE author_id = new.id
)
UPDATE bookwyrm_book
SET search_vector = ''
FROM updated_books
WHERE id = updated_books.book_id;
RETURN new;
"""
),
),
]
activity_serializer = activitypub.Author

View file

@ -13,6 +13,7 @@ from django.utils.translation import gettext_lazy as _
from model_utils import FieldTracker
from model_utils.managers import InheritanceManager
from imagekit.models import ImageSpecField
import pgtrigger
from bookwyrm import activitypub
from bookwyrm.isbn.isbn import hyphenator_singleton as hyphenator
@ -24,6 +25,7 @@ from bookwyrm.settings import (
ENABLE_PREVIEW_IMAGES,
ENABLE_THUMBNAIL_GENERATION,
)
from bookwyrm.utils.db import format_trigger
from .activitypub_mixin import OrderedCollectionPageMixin, ObjectMixin
from .base_model import BookWyrmModel
@ -232,9 +234,49 @@ class Book(BookDataModel):
)
class Meta:
"""sets up postgres GIN index field"""
"""set up indexes and triggers"""
# pylint: disable=line-too-long
indexes = (GinIndex(fields=["search_vector"]),)
triggers = [
pgtrigger.Trigger(
name="update_search_vector_on_book_edit",
when=pgtrigger.Before,
operation=pgtrigger.Insert
| pgtrigger.UpdateOf("title", "subtitle", "series", "search_vector"),
func=format_trigger(
"""
WITH author_names AS (
SELECT array_to_string(bookwyrm_author.name || bookwyrm_author.aliases, ' ') AS name_and_aliases
FROM bookwyrm_author
LEFT JOIN bookwyrm_book_authors
ON bookwyrm_author.id = bookwyrm_book_authors.author_id
WHERE bookwyrm_book_authors.book_id = new.id
)
SELECT
-- title, with priority A (parse in English, default to simple if empty)
setweight(COALESCE(nullif(
to_tsvector('english', new.title), ''),
to_tsvector('simple', new.title)), 'A') ||
-- subtitle, with priority B (always in English?)
setweight(to_tsvector('english', COALESCE(new.subtitle, '')), 'B') ||
-- list of authors names and aliases (with priority C)
(SELECT setweight(to_tsvector('simple', COALESCE(array_to_string(ARRAY_AGG(name_and_aliases), ' '), '')), 'C')
FROM author_names
) ||
--- last: series name, with lowest priority
setweight(to_tsvector('english', COALESCE(new.series, '')), 'D')
INTO new.search_vector;
RETURN new;
"""
),
)
]
class Work(OrderedCollectionPageMixin, Book):

View file

@ -80,10 +80,7 @@ def json_export(
exported_user = user.to_activity()
# I don't love this but it prevents a JSON encoding error
# when there is no user image
if isinstance(
exported_user["icon"],
dataclasses._MISSING_TYPE, # pylint: disable=protected-access
):
if exported_user.get("icon") in (None, dataclasses.MISSING):
exported_user["icon"] = {}
else:
# change the URL to be relative to the JSON file

View file

@ -482,7 +482,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
if not url:
return None
return activitypub.Document(url=url, name=alt)
return activitypub.Image(url=url, name=alt)
def field_from_activity(self, value, allow_external_connections=True):
image_slug = value

View file

@ -10,8 +10,11 @@ from django.dispatch import receiver
from django.utils import timezone
from model_utils import FieldTracker
from bookwyrm.connectors.abstract_connector import get_data
from bookwyrm.preview_images import generate_site_preview_image_task
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, STATIC_FULL_URL
from bookwyrm.settings import RELEASE_API
from bookwyrm.tasks import app, MISC
from .base_model import BookWyrmModel, new_access_code
from .user import User
from .fields import get_absolute_url
@ -45,7 +48,7 @@ class SiteSettings(SiteModel):
default_theme = models.ForeignKey(
"Theme", null=True, blank=True, on_delete=models.SET_NULL
)
version = models.CharField(null=True, blank=True, max_length=10)
available_version = models.CharField(null=True, blank=True, max_length=10)
# admin setup options
install_mode = models.BooleanField(default=False)
@ -245,3 +248,14 @@ def preview_image(instance, *args, **kwargs):
if len(changed_fields) > 0:
generate_site_preview_image_task.delay()
@app.task(queue=MISC)
def check_for_updates_task():
"""See if git remote knows about a new version"""
site = SiteSettings.objects.get()
release = get_data(RELEASE_API, timeout=3)
available_version = release.get("tag_name", None)
if available_version:
site.available_version = available_version
site.save(update_fields=["available_version"])

View file

@ -12,6 +12,8 @@ from django.db.models import Q
from django.dispatch import receiver
from django.template.loader import get_template
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext_lazy
from model_utils import FieldTracker
from model_utils.managers import InheritanceManager
@ -107,14 +109,14 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
@property
def recipients(self):
"""tagged users who definitely need to get this status in broadcast"""
mentions = [u for u in self.mention_users.all() if not u.local]
mentions = {u for u in self.mention_users.all() if not u.local}
if (
hasattr(self, "reply_parent")
and self.reply_parent
and not self.reply_parent.user.local
):
mentions.append(self.reply_parent.user)
return list(set(mentions))
mentions.add(self.reply_parent.user)
return list(mentions)
@classmethod
def ignore_activity(
@ -178,6 +180,24 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
"""you can't boost dms"""
return self.privacy in ["unlisted", "public"]
@property
def page_title(self):
"""title of the page when only this status is shown"""
return _("%(display_name)s's status") % {"display_name": self.user.display_name}
@property
def page_description(self):
"""description of the page in meta tags when only this status is shown"""
return None
@property
def page_image(self):
"""image to use as preview in meta tags when only this status is shown"""
if self.mention_books.exists():
book = self.mention_books.first()
return book.preview_image or book.cover
return self.user.preview_image
def to_replies(self, **kwargs):
"""helper function for loading AP serialized replies to a status"""
return self.to_ordered_collection(
@ -301,6 +321,10 @@ class BookStatus(Status):
abstract = True
@property
def page_image(self):
return self.book.preview_image or self.book.cover or super().page_image
class Comment(BookStatus):
"""like a review but without a rating and transient"""
@ -332,17 +356,26 @@ class Comment(BookStatus):
activity_serializer = activitypub.Comment
@property
def page_title(self):
return _("%(display_name)s's comment on %(book_title)s") % {
"display_name": self.user.display_name,
"book_title": self.book.title,
}
class Quotation(BookStatus):
"""like a review but without a rating and transient"""
quote = fields.HtmlField()
raw_quote = models.TextField(blank=True, null=True)
position = models.IntegerField(
validators=[MinValueValidator(0)], null=True, blank=True
position = models.TextField(
null=True,
blank=True,
)
endposition = models.IntegerField(
validators=[MinValueValidator(0)], null=True, blank=True
endposition = models.TextField(
null=True,
blank=True,
)
position_mode = models.CharField(
max_length=3,
@ -374,6 +407,13 @@ class Quotation(BookStatus):
activity_serializer = activitypub.Quotation
@property
def page_title(self):
return _("%(display_name)s's quote from %(book_title)s") % {
"display_name": self.user.display_name,
"book_title": self.book.title,
}
class Review(BookStatus):
"""a book review"""
@ -403,6 +443,13 @@ class Review(BookStatus):
"""indicate the book in question for mastodon (or w/e) users"""
return self.content
@property
def page_title(self):
return _("%(display_name)s's review of %(book_title)s") % {
"display_name": self.user.display_name,
"book_title": self.book.title,
}
activity_serializer = activitypub.Review
pure_type = "Article"
@ -426,6 +473,18 @@ class ReviewRating(Review):
template = get_template("snippets/generated_status/rating.html")
return template.render({"book": self.book, "rating": self.rating}).strip()
@property
def page_description(self):
return ngettext_lazy(
"%(display_name)s rated %(book_title)s: %(display_rating).1f star",
"%(display_name)s rated %(book_title)s: %(display_rating).1f stars",
"display_rating",
) % {
"display_name": self.user.display_name,
"book_title": self.book.title,
"display_rating": self.rating,
}
activity_serializer = activitypub.Rating
pure_type = "Note"

View file

@ -1,4 +1,5 @@
""" Generate social media preview images for twitter/mastodon/etc """
import math
import os
import textwrap
@ -42,8 +43,8 @@ def get_imagefont(name, size):
return ImageFont.truetype(path, size)
except KeyError:
logger.error("Font %s not found in config", name)
except OSError:
logger.error("Could not load font %s from file", name)
except OSError as err:
logger.error("Could not load font %s from file: %s", name, err)
return ImageFont.load_default()
@ -59,7 +60,7 @@ def get_font(weight, size=28):
font.set_variation_by_name("Bold")
if weight == "regular":
font.set_variation_by_name("Regular")
except AttributeError:
except OSError:
pass
return font

View file

@ -30,6 +30,9 @@ RELEASE_API = env(
PAGE_LENGTH = env.int("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
# TODO: extend maximum age to 1 year once termination of active sessions
# is implemented (see bookwyrm-social#2278, bookwyrm-social#3082).
SESSION_COOKIE_AGE = env.int("SESSION_COOKIE_AGE", 3600 * 24 * 30) # 1 month
JS_CACHE = "8a89cad7"
@ -105,6 +108,7 @@ INSTALLED_APPS = [
"celery",
"django_celery_beat",
"imagekit",
"pgtrigger",
"storages",
]
@ -318,6 +322,7 @@ LANGUAGES = [
("eu-es", _("Euskara (Basque)")),
("gl-es", _("Galego (Galician)")),
("it-it", _("Italiano (Italian)")),
("ko-kr", _("한국어 (Korean)")),
("fi-fi", _("Suomi (Finnish)")),
("fr-fr", _("Français (French)")),
("lt-lt", _("Lietuvių (Lithuanian)")),
@ -347,8 +352,7 @@ USE_L10N = True
USE_TZ = True
agent = requests.utils.default_user_agent()
USER_AGENT = f"{agent} (BookWyrm/{VERSION}; +https://{DOMAIN}/)"
USER_AGENT = f"BookWyrm (BookWyrm/{VERSION}; +https://{DOMAIN}/)"
# Imagekit generated thumbnails
ENABLE_THUMBNAIL_GENERATION = env.bool("ENABLE_THUMBNAIL_GENERATION", False)
@ -443,4 +447,6 @@ if HTTP_X_FORWARDED_PROTO:
# user with the same username - in which case you should change it!
INSTANCE_ACTOR_USERNAME = "bookwyrm.instance.actor"
DATA_UPLOAD_MAX_MEMORY_SIZE = env.int("DATA_UPLOAD_MAX_MEMORY_SIZE", (1024**2 * 100))
# We only allow specifying DATA_UPLOAD_MAX_MEMORY_SIZE in MiB from .env
# (note the difference in variable names).
DATA_UPLOAD_MAX_MEMORY_SIZE = env.int("DATA_UPLOAD_MAX_MEMORY_MiB", 100) << 20

View file

@ -111,6 +111,10 @@ const tries = {
},
},
f: {
b: {
2: "FB2",
3: "FB3",
},
l: {
a: {
c: "FLAC",

View file

@ -31,10 +31,10 @@
</p>
</div>
<div class="columns">
<div class="columns is-multiline">
{% if superlatives.top_rated %}
{% with book=superlatives.top_rated.default_edition rating=superlatives.top_rated.rating %}
<div class="column is-one-third is-flex">
<div class="column is-half-tablet is-one-third-desktop is-flex-grow-1 is-flex">
<div class="media notification is-clipped">
<div class="media-left">
<a href="{{ book.local_path }}">
@ -53,7 +53,7 @@
{% if superlatives.wanted %}
{% with book=superlatives.wanted.default_edition %}
<div class="column is-one-third is-flex">
<div class="column is-half-tablet is-one-third-desktop is-flex-grow-1 is-flex">
<div class="media notification is-clipped">
<div class="media-left">
<a href="{{ book.local_path }}">
@ -72,7 +72,7 @@
{% if superlatives.controversial %}
{% with book=superlatives.controversial.default_edition %}
<div class="column is-one-third is-flex">
<div class="column is-half-tablet is-one-third-desktop is-flex-grow-1 is-flex">
<div class="media notification is-clipped">
<div class="media-left">
<a href="{{ book.local_path }}">

View file

@ -55,6 +55,8 @@
<p class="field"><label class="label" for="id_wikipedia_link">{% trans "Wikipedia link:" %}</label> {{ form.wikipedia_link }}</p>
<p class="field"><label class="label" for="id_wikidata">{% trans "Wikidata:" %}</label> {{ form.wikidata }}</p>
{% include 'snippets/form_errors.html' with errors_list=form.wikipedia_link.errors id="desc_wikipedia_link" %}
<p class="field"><label class="label" for="id_website">{% trans "Website:" %}</label> {{ form.website }}</p>

View file

@ -9,7 +9,8 @@
{% block title %}{{ book|book_title }}{% endblock %}
{% block opengraph %}
{% include 'snippets/opengraph.html' with title=book.title description=book|book_description image=book.preview_image %}
{% firstof book.preview_image book.cover as book_image %}
{% include 'snippets/opengraph.html' with title=book.title description=book|book_description image=book_image %}
{% endblock %}
{% block content %}
@ -44,18 +45,22 @@
{% endif %}
{% if book.series %}
<meta itemprop="position" content="{{ book.series_number }}">
{% spaceless %}
<span itemprop="isPartOf" itemscope itemtype="https://schema.org/BookSeries">
{% if book.authors.exists %}
<a href="{% url 'book-series-by' book.authors.first.id %}?series_name={{ book.series | urlencode }}"
itemprop="url">
{% endif %}
<span itemprop="name">{{ book.series }}</span>
{% if book.series_number %} #{{ book.series_number }}{% endif %}
{% if book.authors.exists %}
</a>
{% endif %}
</span>
{% if book.series_number %}
<span>, #</span>
<span itemprop="position">{{ book.series_number }}</span>
{% endif %}
{% endspaceless %}
{% endif %}
</p>
{% endif %}

View file

@ -6,8 +6,8 @@
{% block content %}
<h1 class="title">{% trans "Confirm your email address" %}</h1>
<div class="columns">
<div class="column">
<div class="columns is-multiline">
<div class="column is-full is-half-desktop">
<div class="block content">
<section class="block">
<p>{% trans "A confirmation code has been sent to the email address you used to register your account." %}</p>

View file

@ -41,7 +41,7 @@
</section>
{% endif %}
{% if annual_summary_year and tab.key == 'home' %}
{% if annual_summary_year and tab.key == 'home' and has_summary_read_throughs %}
<section class="block is-hidden" data-hide="hide_annual_summary_{{ annual_summary_year }}">
{% include 'feed/summary_card.html' with year=annual_summary_year %}
<hr>

View file

@ -2,13 +2,11 @@
{% load feed_page_tags %}
{% load i18n %}
{% block title %}{{ title }}{% endblock %}
{% block opengraph %}
{% firstof status.book status.mention_books.first as book %}
{% if book %}
{% include 'snippets/opengraph.html' with image=preview %}
{% else %}
{% include 'snippets/opengraph.html' %}
{% endif %}
{% include 'snippets/opengraph.html' with image=page_image %}
{% endblock %}

View file

@ -6,8 +6,8 @@
{% block content %}
<h1 class="title">{% trans "Create an Account" %}</h1>
<div class="columns">
<div class="column">
<div class="columns is-multiline">
<div class="column is-full is-half-desktop">
<div class="block">
{% if valid %}
<div>

View file

@ -6,7 +6,7 @@
{% block content %}
<h1 class="title">{% trans "Log in" %}</h1>
<div class="columns is-multiline">
<div class="column is-half">
<div class="column {% if site.allow_registration %}is-half{% else %}is-full is-half-desktop{% endif %}">
{% if login_form.non_field_errors %}
<p class="notification is-danger">{{ login_form.non_field_errors }}</p>
{% endif %}
@ -20,13 +20,15 @@
<div class="field">
<label class="label" for="id_localname_confirm">{% trans "Username:" %}</label>
<div class="control">
<input type="text" name="localname" maxlength="255" class="input" required="" id="id_localname_confirm" value="{{ login_form.localname.value|default:'' }}">
<input type="text" name="localname" maxlength="255" class="input" required=""
id="id_localname_confirm" value="{{ login_form.localname.value|default:'' }}">
</div>
</div>
<div class="field">
<label class="label" for="id_password_confirm">{% trans "Password:" %}</label>
<div class="control">
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password_confirm" aria-describedby="desc_password">
<input type="password" name="password" maxlength="128" class="input" required=""
id="id_password_confirm" aria-describedby="desc_password">
</div>
{% include 'snippets/form_errors.html' with errors_list=login_form.password.errors id="desc_password" %}
@ -58,7 +60,7 @@
{% include 'snippets/about.html' %}
<p class="block">
<a href="{% url 'about' %}">{% trans "More about this site" %}</a>
<a href="{% url 'about' %}">{% trans "More about this site" %}</a>
</p>
</div>
</div>

View file

@ -4,8 +4,8 @@
{% block title %}{% trans "Reset Password" %}{% endblock %}
{% block content %}
<div class="columns">
<div class="column">
<div class="columns is-multiline">
<div class="column is-full is-half-desktop">
<div class="block">
<h1 class="title">{% trans "Reset Password" %}</h1>

View file

@ -6,7 +6,7 @@
{% block content %}
<h1 class="title">{% trans "Reactivate Account" %}</h1>
<div class="columns is-multiline">
<div class="column is-half">
<div class="column {% if site.allow_registration %}is-half{% else %}is-full is-half-desktop{% endif %}">
{% if login_form.non_field_errors %}
<p class="notification is-danger">{{ login_form.non_field_errors }}</p>
{% endif %}
@ -16,13 +16,15 @@
<div class="field">
<label class="label" for="id_localname_confirm">{% trans "Username:" %}</label>
<div class="control">
<input type="text" name="localname" maxlength="255" class="input" required="" id="id_localname_confirm" value="{{ login_form.localname.value|default:'' }}">
<input type="text" name="localname" maxlength="255" class="input" required=""
id="id_localname_confirm" value="{{ login_form.localname.value|default:'' }}">
</div>
</div>
<div class="field">
<label class="label" for="id_password_confirm">{% trans "Password:" %}</label>
<div class="control">
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password_confirm" aria-describedby="desc_password">
<input type="password" name="password" maxlength="128" class="input" required=""
id="id_password_confirm" aria-describedby="desc_password">
</div>
{% include 'snippets/form_errors.html' with errors_list=login_form.password.errors id="desc_password" %}
@ -51,7 +53,7 @@
{% include 'snippets/about.html' %}
<p class="block">
<a href="{% url 'about' %}">{% trans "More about this site" %}</a>
<a href="{% url 'about' %}">{% trans "More about this site" %}</a>
</p>
</div>
</div>

View file

@ -36,7 +36,7 @@
<div class="field has-addons">
<div class="control">
{% if request.user.is_authenticated %}
{% trans "Search for a book, user, or list" as search_placeholder %}
{% trans "Search for a book, author, user, or list" as search_placeholder %}
{% else %}
{% trans "Search for a book" as search_placeholder %}
{% endif %}

View file

@ -14,31 +14,29 @@
<p> {% trans "You can create an export file here. This will allow you to migrate your data to another BookWyrm account." %}</p>
</div>
<div class="block mx-5 columns">
{% blocktrans trimmed %}
<div class="column is-half">
<h2 class="is-size-5">Your file will include:</h2>
<h2 class="is-size-5">{% trans "Your file will include:" %}</h2>
<ul>
<li>User profile</li>
<li>Most user settings</li>
<li>Reading goals</li>
<li>Shelves</li>
<li>Reading history</li>
<li>Book reviews</li>
<li>Statuses</li>
<li>Your own lists and saved lists</li>
<li>Which users you follow and block</li>
<li>{% trans "User profile" %}</li>
<li>{% trans "Most user settings" %}</li>
<li>{% trans "Reading goals" %}</li>
<li>{% trans "Shelves" %}</li>
<li>{% trans "Reading history" %}</li>
<li>{% trans "Book reviews" %}</li>
<li>{% trans "Statuses" %}</li>
<li>{% trans "Your own lists and saved lists" %}</li>
<li>{% trans "Which users you follow and block" %}</li>
</ul>
</div>
<div class="column is-half">
<h2 class="is-size-5">Your file will not include:</h2>
<h2 class="is-size-5">{% trans "Your file will not include:" %}</h2>
<ul>
<li>Direct messages</li>
<li>Replies to your statuses</li>
<li>Groups</li>
<li>Favorites</li>
<li>{% trans "Direct messages" %}</li>
<li>{% trans "Replies to your statuses" %}</li>
<li>{% trans "Groups" %}</li>
<li>{% trans "Favorites" %}</li>
</ul>
</div>
{% endblocktrans %}
</div>
<p class="block">{% trans "In your new BookWyrm account can choose what to import: you will not have to import everything that is exported." %}</p>
<p class="notification is-warning">
@ -49,6 +47,13 @@
{% if not site.user_exports_enabled %}
<p class="notification is-danger">
{% trans "New user exports are currently disabled." %}
{% if perms.bookwyrm.edit_instance_settings %}
<br/>
{% url 'settings-imports' as url %}
{% blocktrans trimmed %}
User exports settings can be changed from <a href="{{ url }}">the Imports page</a> in the Admin dashboard.
{% endblocktrans %}
{% endif%}
</p>
{% elif next_available %}
<p class="notification is-warning">

View file

@ -0,0 +1,17 @@
{% extends 'search/layout.html' %}
{% block panel %}
{% if results %}
<ul class="block">
{% for author in results %}
<li class="">
<a href="{{ author.local_path }}" class="author" itemprop="author" itemscope itemtype="https://schema.org/Thing">
<span itemprop="name">{{ author.name }}</span>
</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

View file

@ -109,7 +109,7 @@
<p class="block">
{% if request.user.is_authenticated %}
{% if not remote %}
<a href="{{ request.path }}?q={{ query }}&type=book&remote=true" id="tour-load-from-other-catalogues">
<a href="{{ request.path }}?q={{ query|urlencode }}&type=book&remote=true" id="tour-load-from-other-catalogues">
{% trans "Load results from other catalogues" %}
</a>
{% else %}

View file

@ -20,6 +20,7 @@
<div class="select" aria-label="{% trans 'Search type' %}">
<select name="type">
<option value="book" {% if type == "book" %}selected{% endif %}>{% trans "Books" %}</option>
<option value="author" {% if type == "author" %}selected{% endif %}>{% trans "Authors" %}</option>
{% if request.user.is_authenticated %}
<option value="user" {% if type == "user" %}selected{% endif %}>{% trans "Users" %}</option>
{% endif %}
@ -40,15 +41,18 @@
<nav class="tabs">
<ul>
<li{% if type == "book" %} class="is-active"{% endif %}>
<a href="{% url 'search' %}?q={{ query }}&type=book">{% trans "Books" %}</a>
<a href="{% url 'search' %}?q={{ query|urlencode }}&type=book">{% trans "Books" %}</a>
</li>
<li{% if type == "author" %} class="is-active"{% endif %}>
<a href="{% url 'search' %}?q={{ query|urlencode }}&type=author">{% trans "Authors" %}</a>
</li>
{% if request.user.is_authenticated %}
<li{% if type == "user" %} class="is-active"{% endif %}>
<a href="{% url 'search' %}?q={{ query }}&type=user">{% trans "Users" %}</a>
<a href="{% url 'search' %}?q={{ query|urlencode }}&type=user">{% trans "Users" %}</a>
</li>
{% endif %}
<li{% if type == "list" %} class="is-active"{% endif %}>
<a href="{% url 'search' %}?q={{ query }}&type=list">{% trans "Lists" %}</a>
<a href="{% url 'search' %}?q={{ query|urlencode }}&type=list">{% trans "Lists" %}</a>
</li>
</ul>
</nav>

View file

@ -45,6 +45,10 @@
{% include 'settings/dashboard/warnings/update_version.html' with warning_level="warning" fullwidth=True %}
{% endif %}
{% if schedule_form %}
{% include 'settings/dashboard/warnings/check_for_updates.html' with warning_level="success" fullwidth=True %}
{% endif %}
{% if missing_privacy or missing_conduct %}
<div class="column is-12 columns m-0 p-0">
{% if missing_privacy %}

View file

@ -0,0 +1,24 @@
{% extends 'settings/dashboard/warnings/layout.html' %}
{% load i18n %}
{% block warning_link %}#{% endblock %}
{% block warning_text %}
<form name="check-version" method="POST" action="{% url 'settings-dashboard' %}" class="is-flex is-align-items-center">
{% csrf_token %}
<p class="pr-2">
{% blocktrans trimmed with current=current_version available=available_version %}
Would you like to automatically check for new BookWyrm releases? (recommended)
{% endblocktrans %}
</p>
{{ schedule_form.every.as_hidden }}
{{ schedule_form.period.as_hidden }}
<button class="button is-small" type="submit">{% trans "Schedule checks" %}</button>
</form>
{% endblock %}

View file

@ -85,6 +85,10 @@
{% url 'settings-celery' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Celery status" %}</a>
</li>
<li>
{% url 'settings-schedules' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Scheduled tasks" %}</a>
</li>
<li>
{% url 'settings-email-config' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Email Configuration" %}</a>

View file

@ -0,0 +1,127 @@
{% extends 'settings/layout.html' %}
{% load i18n %}
{% load humanize %}
{% load utilities %}
{% block title %}
{% trans "Scheduled tasks" %}
{% endblock %}
{% block header %}
{% trans "Scheduled tasks" %}
{% endblock %}
{% block panel %}
<div class="block content">
<h3>{% trans "Tasks" %}</h3>
<div class="table-container">
<table class="table is-striped is-fullwidth">
<tr>
<th>
{% trans "Name" %}
</th>
<th>
{% trans "Celery task" %}
</th>
<th>
{% trans "Date changed" %}
</th>
<th>
{% trans "Last run at" %}
</th>
<th>
{% trans "Schedule" %}
</th>
<th>
{% trans "Schedule ID" %}
</th>
<th>
{% trans "Enabled" %}
</th>
</tr>
{% for task in tasks %}
<tr>
<td>
{{ task.name }}
</td>
<td class="overflow-wrap-anywhere">
{{ task.task }}
</td>
<td>
{{ task.date_changed }}
</td>
<td>
{{ task.last_run_at }}
</td>
<td>
{% firstof task.interval task.crontab "None" %}
</td>
<td>
{{ task.interval.id }}
</td>
<td>
<span class="tag">
{% if task.enabled %}
<span class="icon icon-check" aria-hidden="true"></span>
{% endif %}
{{ task.enabled|yesno }}
</span>
{% if task.name != "celery.backend_cleanup" %}
<form name="unschedule-{{ task.id }}" method="POST" action="{% url 'settings-schedules' task.id %}">
{% csrf_token %}
<button type="submit" class="button is-danger is-small">{% trans "Un-schedule" %}</button>
</form>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="2">
{% trans "No scheduled tasks" %}
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
<div class="block content">
<h3>{% trans "Schedules" %}</h3>
<div class="table-container">
<table class="table is-striped is-fullwidth">
<tr>
<th>
{% trans "ID" %}
</th>
<th>
{% trans "Schedule" %}
</th>
<th>
{% trans "Tasks" %}
</th>
</tr>
{% for schedule in schedules %}
<tr>
<td>
{{ schedule.id }}
</td>
<td class="overflow-wrap-anywhere">
{{ schedule }}
</td>
<td>
{{ schedule.periodictask_set.count }}
</td>
</tr>
{% empty %}
<tr>
<td colspan="2">
{% trans "No schedules found" %}
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
{% endblock %}

View file

@ -3,7 +3,7 @@
{% block filter %}
<div class="control">
<label class="label" for="filter_query">{% trans 'Filter by keyword' %}</label>
<input aria-label="Filter by keyword" id="my-books-filter" class="input" type="text" name="filter" placeholder="{% trans 'Enter text here' %}" value="{{ shelves_filter_query|default:'' }}" spellcheck="false" />
<label class="label" for="my-books-filter">{% trans 'Filter by keyword' %}</label>
<input id="my-books-filter" class="input" type="text" name="filter" placeholder="{% trans 'Enter text here' %}" value="{{ shelves_filter_query|default:'' }}" spellcheck="false" />
</div>
{% endblock %}

View file

@ -0,0 +1,3 @@
{% if book.series %}
({{book.series}}{%if book.series_number %}, #{{book.series_number}}{% endif %})
{% endif %}

View file

@ -9,12 +9,15 @@
{% if book.authors.exists %}
{% blocktrans trimmed with path=book.local_path title=book|book_title %}
<a href="{{ path }}">{{ title }}</a> by
<a href="{{ path }}">{{ title }}</a>
by
{% endblocktrans %}&nbsp;{% include 'snippets/authors.html' with book=book limit=3 %}
{% else %}
<a href="{{ book.local_path }}">{{ book|book_title }}</a>
{% endif %}
{% include 'snippets/book_series.html' with book=book %}
{% endcache %}
{% endspaceless %}

View file

@ -56,8 +56,7 @@ uuid: a unique identifier used to make html "id" attributes unique and clarify j
<input
aria-label="{% if draft.position_mode == 'PG' %}Page{% else %}Percent{% endif %}"
class="input"
type="number"
min="0"
type="text"
name="position"
size="3"
value="{% firstof draft.position '' %}"
@ -72,8 +71,7 @@ uuid: a unique identifier used to make html "id" attributes unique and clarify j
<input
aria-label="{% if draft.position_mode == 'PG' %}Page{% else %}Percent{% endif %}"
class="input"
type="number"
min="0"
type="text"
name="endposition"
size="3"
value="{% firstof draft.endposition '' %}"

View file

@ -1,24 +1,25 @@
{% load static %}
{% if preview_images_enabled is True %}
{% firstof image site.preview_image as page_image %}
{% if page_image %}
<meta name="twitter:card" content="summary_large_image">
{% if image %}
<meta name="twitter:image" content="{{ media_full_url }}{{ image }}">
<meta name="og:image" content="{{ media_full_url }}{{ image }}">
{% else %}
<meta name="twitter:image" content="{{ media_full_url }}{{ site.preview_image }}">
<meta name="og:image" content="{{ media_full_url }}{{ site.preview_image }}">
{% endif %}
<meta name="twitter:image" content="{{ media_full_url }}{{ page_image }}">
<meta name="og:image" content="{{ media_full_url }}{{ page_image }}">
{% elif site.logo %}
<meta name="twitter:card" content="summary">
<meta name="twitter:image" content="{{ media_full_url }}{{ site.logo }}">
<meta name="twitter:image:alt" content="{{ site.name }} Logo">
<meta name="og:image" content="{{ media_full_url }}{{ site.logo }}">
{% else %}
<meta name="twitter:card" content="summary">
<meta name="twitter:image" content="{% if site.logo %}{{ media_full_url }}{{ site.logo }}{% else %}{% static "images/logo.png" %}{% endif %}">
<meta name="og:image" content="{% if site.logo %}{{ media_full_url }}{{ site.logo }}{% else %}{% static "images/logo.png" %}{% endif %}">
<meta name="twitter:image" content="{% static "images/logo.png" %}">
<meta name="twitter:image:alt" content="BookWyrm Logo">
<meta name="og:image" content="{% static "images/logo.png" %}">
{% endif %}
<meta name="twitter:image:alt" content="BookWyrm Logo">
<meta name="twitter:title" content="{% if title %}{{ title }} - {% endif %}{{ site.name }}">
<meta name="og:title" content="{% if title %}{{ title }} - {% endif %}{{ site.name }}">
<meta name="twitter:description" content="{% if description %}{{ description }}{% else %}{{ site.instance_tagline }}{% endif %}">
<meta name="og:description" content="{% if description %}{{ description }}{% else %}{{ site.instance_tagline }}{% endif %}">
{% firstof description site.instance_tagline as description %}
<meta name="twitter:description" content="{{ description }}">
<meta name="og:description" content="{{ description }}">

View file

@ -17,4 +17,7 @@ commented on <a href="{{ book_path }}">{{ book }}</a>
{% endblocktrans %}
{% endif %}
{% include 'snippets/book_series.html' with book=book %}
{% endwith %}

View file

@ -17,4 +17,7 @@ quoted <a href="{{ book_path }}">{{ book }}</a>
{% endblocktrans %}
{% endif %}
{% include 'snippets/book_series.html' with book=book %}
{% endwith %}

View file

@ -19,4 +19,7 @@ finished reading <a href="{{ book_path }}">{{ book }}</a>
{% endblocktrans %}
{% endif %}
{% include 'snippets/book_series.html' with book=book %}
{% endspaceless %}

View file

@ -19,4 +19,7 @@ started reading <a href="{{ book_path }}">{{ book }}</a>
{% endblocktrans %}
{% endif %}
{% include 'snippets/book_series.html' with book=book %}
{% endspaceless %}

View file

@ -17,4 +17,7 @@ reviewed <a href="{{ book_path }}">{{ book }}</a>
{% endblocktrans %}
{% endif %}
{% include 'snippets/book_series.html' with book=book %}
{% endwith %}

View file

@ -19,5 +19,8 @@ stopped reading <a href="{{ book_path }}">{{ book }}</a>
{% endblocktrans %}
{% endif %}
{% include 'snippets/book_series.html' with book=book %}
{% endspaceless %}

View file

@ -19,4 +19,7 @@ wants to read <a href="{{ book_path }}">{{ book }}</a>
{% endblocktrans %}
{% endif %}
{% include 'snippets/book_series.html' with book=book %}
{% endspaceless %}

View file

@ -126,7 +126,7 @@ def id_to_username(user_id):
value = f"{name}@{domain}"
return value
return "a new user account"
return _("a new user account")
@register.filter(name="get_file_size")

View file

@ -136,6 +136,7 @@
],
"bio": "<p>American political scientist and anthropologist</p>",
"wikipediaLink": "https://en.wikipedia.org/wiki/James_C._Scott",
"wikidata": "Q3025403",
"website": "",
"@context": "https://www.w3.org/ns/activitystreams"
}
@ -320,6 +321,7 @@
"aliases": [],
"bio": "",
"wikipediaLink": "",
"wikidata": "",
"website": "",
"@context": "https://www.w3.org/ns/activitystreams"
}

View file

@ -227,14 +227,18 @@ class ActivitypubMixins(TestCase):
shared_inbox="http://example.com/inbox",
outbox="https://example.com/users/nutria/outbox",
)
MockSelf = namedtuple("Self", ("privacy", "user"))
mock_self = MockSelf("public", self.local_user)
MockSelf = namedtuple("Self", ("privacy", "user", "recipients"))
self.local_user.followers.add(self.remote_user)
self.local_user.followers.add(another_remote_user)
mock_self = MockSelf("public", self.local_user, [])
recipients = ActivitypubMixin.get_recipients(mock_self)
self.assertEqual(len(recipients), 1)
self.assertEqual(recipients[0], "http://example.com/inbox")
self.assertCountEqual(recipients, ["http://example.com/inbox"])
# should also work with recipient that is a follower
mock_self.recipients.append(another_remote_user)
recipients = ActivitypubMixin.get_recipients(mock_self)
self.assertCountEqual(recipients, ["http://example.com/inbox"])
def test_get_recipients_software(self, *_):
"""should differentiate between bookwyrm and other remote users"""

View file

@ -438,7 +438,7 @@ class ModelFields(TestCase):
)
)
self.assertEqual(output.name, "")
self.assertEqual(output.type, "Document")
self.assertEqual(output.type, "Image")
@responses.activate
def test_image_field_from_activity(self, *_):

View file

@ -0,0 +1,87 @@
""" test searching for authors """
from django.test import TestCase
from django.contrib.postgres.search import SearchRank, SearchQuery
from django.db.models import F
from bookwyrm import models
class AuthorSearch(TestCase):
"""look for some authors"""
@classmethod
def setUpTestData(cls):
"""we need basic test data and mocks"""
cls.bob = models.Author.objects.create(
name="Bob", aliases=["Robertus", "Alice"]
)
cls.alice = models.Author.objects.create(name="Alice")
def test_search(self):
"""search for an author in the db"""
results = self._search("Bob")
self.assertEqual(len(results), 1)
self.assertEqual(results[0], self.bob)
def test_alias_priority(self):
"""aliases should be included, with lower priority than name"""
results = self._search("Alice")
self.assertEqual(len(results), 2)
self.assertEqual(results[0], self.alice)
def _search_first(self, query):
"""wrapper around search_title_author"""
return self._search(query, return_first=True)
@staticmethod
def _search(query, *, return_first=False):
"""author search"""
search_query = SearchQuery(query, config="simple")
min_confidence = 0
results = (
models.Author.objects.filter(search_vector=search_query)
.annotate(rank=SearchRank(F("search_vector"), search_query))
.filter(rank__gt=min_confidence)
.order_by("-rank")
)
if return_first:
return results.first()
return results
class SearchVectorTest(TestCase):
"""check search_vector is computed correctly"""
def test_search_vector_simple(self):
"""simplest search vector"""
author = self._create_author("Mary")
self.assertEqual(author.search_vector, "'mary':1A")
def test_search_vector_aliases(self):
"""author aliases should be included with lower priority"""
author = self._create_author("Mary", aliases=["Maria", "Example"])
self.assertEqual(author.search_vector, "'example':3B 'maria':2B 'mary':1A")
def test_search_vector_parse_author(self):
"""author name and alias is not stem'd or affected by stop words"""
author = self._create_author("Writes", aliases=["Reads"])
self.assertEqual(author.search_vector, "'reads':2B 'writes':1A")
def test_search_vector_on_update(self):
"""make sure that search_vector is being set correctly on edit"""
author = self._create_author("Mary")
self.assertEqual(author.search_vector, "'mary':1A")
author.name = "Example"
author.save(broadcast=False)
author.refresh_from_db()
self.assertEqual(author.search_vector, "'example':1A")
@staticmethod
def _create_author(name, /, *, aliases=None):
"""quickly create an author"""
author = models.Author.objects.create(name=name, aliases=aliases or [])
author.refresh_from_db()
return author

View file

@ -1,5 +1,6 @@
""" test searching for books """
import datetime
from django.db import connection
from django.test import TestCase
from django.utils import timezone
@ -13,6 +14,13 @@ class BookSearch(TestCase):
@classmethod
def setUpTestData(self): # pylint: disable=bad-classmethod-argument
"""we need basic test data and mocks"""
self.first_author = models.Author.objects.create(
name="Author One", aliases=["The First"]
)
self.second_author = models.Author.objects.create(
name="Author Two", aliases=["The Second"]
)
self.work = models.Work.objects.create(title="Example Work")
self.first_edition = models.Edition.objects.create(
@ -22,6 +30,8 @@ class BookSearch(TestCase):
physical_format="Paperback",
published_date=datetime.datetime(2019, 4, 9, 0, 0, tzinfo=timezone.utc),
)
self.first_edition.authors.add(self.first_author)
self.second_edition = models.Edition.objects.create(
title="Another Edition",
parent_work=self.work,
@ -29,19 +39,34 @@ class BookSearch(TestCase):
openlibrary_key="hello",
pages=150,
)
self.second_edition.authors.add(self.first_author)
self.second_edition.authors.add(self.second_author)
self.third_edition = models.Edition.objects.create(
title="Another Edition with annoying ISBN",
parent_work=self.work,
isbn_10="022222222X",
)
self.third_edition.authors.add(self.first_author)
self.third_edition.authors.add(self.second_author)
def test_search(self):
"""search for a book in the db"""
# title/author
# title
results = book_search.search("Example")
self.assertEqual(len(results), 1)
self.assertEqual(results[0], self.first_edition)
# author
results = book_search.search("One")
self.assertEqual(len(results), 1)
self.assertEqual(results[0], self.first_edition)
# author alias
results = book_search.search("First")
self.assertEqual(len(results), 1)
self.assertEqual(results[0], self.first_edition)
# isbn
results = book_search.search("0000000000")
self.assertEqual(len(results), 1)
@ -140,3 +165,265 @@ class BookSearch(TestCase):
# there's really not much to test here, it's just a dataclass
self.assertEqual(result.confidence, 1)
self.assertEqual(result.title, "Title")
class SearchVectorTest(TestCase):
"""check search_vector is computed correctly"""
def test_search_vector_simple(self):
"""simplest search vector"""
book = self._create_book("Book", "Mary")
self.assertEqual(book.search_vector, "'book':1A 'mary':2C") # A > C (priority)
def test_search_vector_all_parts(self):
"""search vector with subtitle and series"""
# for a book like this we call `to_tsvector("Book Long Mary Bunch")`, hence the
# indexes in the search vector. (priority "D" is the default, and never shown.)
book = self._create_book(
"Book",
"Mary",
subtitle="Long",
series="Bunch",
author_alias=["Maria", "Mary Ann"],
)
self.assertEqual(
book.search_vector,
"'ann':6C 'book':1A 'bunch':7 'long':2B 'maria':4C 'mary':3C,5C",
)
def test_search_vector_parse_book(self):
"""book parts are parsed in english"""
# FIXME: at some point this should stop being the default.
book = self._create_book(
"Edition", "Editor", series="Castle", subtitle="Writing"
)
self.assertEqual(
book.search_vector, "'castl':4 'edit':1A 'editor':3C 'write':2B"
)
def test_search_vector_parse_author(self):
"""author name is not stem'd or affected by stop words"""
book = self._create_book("Writing", "Writes", author_alias=["Reads"])
self.assertEqual(book.search_vector, "'reads':3C 'write':1A 'writes':2C")
book = self._create_book("She Is Writing", "She Writes")
self.assertEqual(book.search_vector, "'she':4C 'write':3A 'writes':5C")
def test_search_vector_parse_title_empty(self):
"""empty parse in English retried as simple title"""
book = self._create_book("Here We", "John")
self.assertEqual(book.search_vector, "'here':1A 'john':3C 'we':2A")
book = self._create_book("Hear We Come", "John")
self.assertEqual(book.search_vector, "'come':3A 'hear':1A 'john':4C")
book = self._create_book("there there", "the")
self.assertEqual(book.search_vector, "'the':3C 'there':1A,2A")
def test_search_vector_no_author(self):
"""book with no authors gets processed normally"""
book = self._create_book("Book", None, series="Bunch")
self.assertEqual(book.search_vector, "'book':1A 'bunch':2")
book = self._create_book("there there", None)
self.assertEqual(book.search_vector, "'there':1A,2A")
# n.b.: the following originally from test_posgres.py
def test_search_vector_on_update(self):
"""make sure that search_vector is being set correctly on edit"""
book = self._create_book("The Long Goodbye", None)
self.assertEqual(book.search_vector, "'goodby':3A 'long':2A")
book.title = "The Even Longer Goodbye"
book.save(broadcast=False)
book.refresh_from_db()
self.assertEqual(book.search_vector, "'even':2A 'goodby':4A 'longer':3A")
def test_search_vector_on_author_update(self):
"""update search when an author name changes"""
book = self._create_book("The Long Goodbye", "The Rays")
self.assertEqual(book.search_vector, "'goodby':3A 'long':2A 'rays':5C 'the':4C")
author = models.Author.objects.get(name="The Rays")
author.name = "Jeremy"
author.save(broadcast=False)
book.refresh_from_db()
self.assertEqual(book.search_vector, "'goodby':3A 'jeremy':4C 'long':2A")
author.aliases = ["Example"]
author.save(broadcast=False)
book.refresh_from_db()
self.assertEqual(
book.search_vector, "'example':5C 'goodby':3A 'jeremy':4C 'long':2A"
)
def test_search_vector_on_author_delete(self):
"""update search when an author is deleted"""
book = self._create_book("The Long Goodbye", "The Rays")
self.assertEqual(book.search_vector, "'goodby':3A 'long':2A 'rays':5C 'the':4C")
author = models.Author.objects.get(name="The Rays")
book.authors.remove(author)
book.refresh_from_db()
self.assertEqual(book.search_vector, "'goodby':3A 'long':2A")
def test_search_vector_fields(self):
"""language field irrelevant for search_vector"""
author = models.Author.objects.create(name="The Rays")
book = models.Edition.objects.create(
title="The Long Goodbye",
subtitle="wow cool",
series="series name",
languages=["irrelevant"],
)
book.authors.add(author)
book.refresh_from_db()
self.assertEqual(
book.search_vector,
# pylint: disable-next=line-too-long
"'cool':5B 'goodby':3A 'long':2A 'name':9 'rays':7C 'seri':8 'the':6C 'wow':4B",
)
@staticmethod
def _create_book(
title, author_name, /, *, subtitle="", series="", author_alias=None
):
"""quickly create a book"""
work = models.Work.objects.create(title="work")
edition = models.Edition.objects.create(
title=title,
series=series or None,
subtitle=subtitle or None,
isbn_10="0000000000",
parent_work=work,
)
if author_name is not None:
author = models.Author.objects.create(
name=author_name, aliases=author_alias or []
)
edition.authors.add(author)
edition.save(broadcast=False)
edition.refresh_from_db()
return edition
class SearchVectorUpdates(TestCase):
"""look for books as they change""" # functional tests of the above
def setUp(self):
"""we need basic test data and mocks"""
self.work = models.Work.objects.create(title="This Work")
self.author = models.Author.objects.create(name="Name", aliases=["Alias"])
self.edition = models.Edition.objects.create(
title="First Edition of Work",
subtitle="Some Extra Words Are Good",
series="A Fabulous Sequence of Items",
parent_work=self.work,
isbn_10="0000000000",
)
self.edition.authors.add(self.author)
self.edition.save(broadcast=False)
@classmethod
def setUpTestData(cls):
"""create conditions that trigger known old bugs"""
with connection.cursor() as cursor:
cursor.execute(
"""
ALTER SEQUENCE bookwyrm_author_id_seq RESTART WITH 20;
ALTER SEQUENCE bookwyrm_book_authors_id_seq RESTART WITH 300;
"""
)
def test_search_after_changed_metadata(self):
"""book found after updating metadata"""
self.assertEqual(self.edition, self._search_first("First")) # title
self.assertEqual(self.edition, self._search_first("Good")) # subtitle
self.assertEqual(self.edition, self._search_first("Sequence")) # series
self.edition.title = "Second Title of Work"
self.edition.subtitle = "Fewer Words Is Better"
self.edition.series = "A Wondrous Bunch"
self.edition.save(broadcast=False)
self.assertEqual(self.edition, self._search_first("Second")) # title new
self.assertEqual(self.edition, self._search_first("Fewer")) # subtitle new
self.assertEqual(self.edition, self._search_first("Wondrous")) # series new
self.assertFalse(self._search_first("First")) # title old
self.assertFalse(self._search_first("Good")) # subtitle old
self.assertFalse(self._search_first("Sequence")) # series old
def test_search_after_author_remove(self):
"""book not found via removed author"""
self.assertEqual(self.edition, self._search_first("Name"))
self.edition.authors.set([])
self.edition.save(broadcast=False)
self.assertFalse(self._search("Name"))
self.assertEqual(self.edition, self._search_first("Edition"))
def test_search_after_author_add(self):
"""book found by newly-added author"""
new_author = models.Author.objects.create(name="Mozilla")
self.assertFalse(self._search("Mozilla"))
self.edition.authors.add(new_author)
self.edition.save(broadcast=False)
self.assertEqual(self.edition, self._search_first("Mozilla"))
self.assertEqual(self.edition, self._search_first("Name"))
def test_search_after_author_add_remove_sql(self):
"""add/remove author through SQL to ensure execution of book_authors trigger"""
# Tests calling edition.save(), above, pass even if the trigger in
# bookwyrm_book_authors is removed (probably because they trigger the one
# in bookwyrm_book directly). Here we make sure to exercise the former.
new_author = models.Author.objects.create(name="Mozilla")
with connection.cursor() as cursor:
cursor.execute(
"DELETE FROM bookwyrm_book_authors WHERE book_id = %s",
[self.edition.id],
)
self.assertFalse(self._search("Name"))
self.assertFalse(self._search("Mozilla"))
with connection.cursor() as cursor:
cursor.execute(
"INSERT INTO bookwyrm_book_authors (book_id,author_id) VALUES (%s,%s)",
[self.edition.id, new_author.id],
)
self.assertFalse(self._search("Name"))
self.assertEqual(self.edition, self._search_first("Mozilla"))
def test_search_after_updated_author_name(self):
"""book found under new author name"""
self.assertEqual(self.edition, self._search_first("Name"))
self.assertEqual(self.edition, self._search_first("Alias"))
self.assertFalse(self._search("Identifier"))
self.assertFalse(self._search("Another"))
self.author.name = "Identifier"
self.author.aliases = ["Another"]
self.author.save(broadcast=False)
self.assertFalse(self._search("Name"))
self.assertFalse(self._search("Aliases"))
self.assertEqual(self.edition, self._search_first("Identifier"))
self.assertEqual(self.edition, self._search_first("Another"))
self.assertEqual(self.edition, self._search_first("Work"))
def _search_first(self, query):
"""wrapper around search_title_author"""
return self._search(query, return_first=True)
@staticmethod
def _search(query, *, return_first=False):
"""wrapper around search_title_author"""
return book_search.search_title_author(
query, min_confidence=0, return_first=return_first
)

View file

@ -1,77 +0,0 @@
""" django configuration of postgres """
from unittest.mock import patch
from django.test import TestCase
from bookwyrm import models
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
class PostgresTriggers(TestCase):
"""special migrations, fancy stuff ya know"""
def test_search_vector_on_create(self, _):
"""make sure that search_vector is being set correctly on create"""
book = models.Edition.objects.create(title="The Long Goodbye")
book.refresh_from_db()
self.assertEqual(book.search_vector, "'goodby':3A 'long':2A")
def test_search_vector_on_update(self, _):
"""make sure that search_vector is being set correctly on edit"""
book = models.Edition.objects.create(title="The Long Goodbye")
book.title = "The Even Longer Goodbye"
book.save(broadcast=False)
book.refresh_from_db()
self.assertEqual(book.search_vector, "'even':2A 'goodby':4A 'longer':3A")
def test_search_vector_fields(self, _):
"""use multiple fields to create search vector"""
author = models.Author.objects.create(name="The Rays")
book = models.Edition.objects.create(
title="The Long Goodbye",
subtitle="wow cool",
series="series name",
languages=["irrelevant"],
)
book.authors.add(author)
book.refresh_from_db()
# pylint: disable=line-too-long
self.assertEqual(
book.search_vector,
"'cool':5B 'goodby':3A 'long':2A 'name':9 'rays':7C 'seri':8 'the':6C 'wow':4B",
)
def test_search_vector_on_author_update(self, _):
"""update search when an author name changes"""
author = models.Author.objects.create(name="The Rays")
book = models.Edition.objects.create(
title="The Long Goodbye",
)
book.authors.add(author)
author.name = "Jeremy"
author.save(broadcast=False)
book.refresh_from_db()
self.assertEqual(book.search_vector, "'goodby':3A 'jeremy':4C 'long':2A")
def test_search_vector_on_author_delete(self, _):
"""update search when an author name changes"""
author = models.Author.objects.create(name="Jeremy")
book = models.Edition.objects.create(
title="The Long Goodbye",
)
book.authors.add(author)
book.refresh_from_db()
self.assertEqual(book.search_vector, "'goodby':3A 'jeremy':4C 'long':2A")
book.authors.remove(author)
book.refresh_from_db()
self.assertEqual(book.search_vector, "'goodby':3A 'long':2A")
def test_search_vector_stop_word_fallback(self, _):
"""use a fallback when removing stop words leads to an empty vector"""
book = models.Edition.objects.create(
title="there there",
)
book.refresh_from_db()
self.assertEqual(book.search_vector, "'there':1A,2A")

View file

@ -13,16 +13,26 @@ def validate_html(html):
"warn-proprietary-attributes": False,
},
)
# idk how else to filter out these unescape amp errs
# Tidy's parser is strict when validating unescaped/encoded ampersands found within
# the html document that are notpart of a character or entity reference
# (eg: `&amp;` or `&#38`). Despite the fact the HTML5 spec no longer recommends
# escaping ampersands in URLs, Tidy will still complain if they are used as query
# param keys. Unfortunately, there is no way currently to configure tidy to ignore
# this so we must explictly redlist related strings that will appear in Tidy's
# errors output.
#
# See further discussion: https://github.com/htacg/tidy-html5/issues/1017
excluded = [
"&book",
"&type",
"&resolved",
"id and name attribute",
"illegal characters found in URI",
"escaping malformed URI reference",
"&filter",
]
errors = "\n".join(
e
for e in errors.split("\n")
if "&book" not in e
and "&type" not in e
and "&resolved" not in e
and "id and name attribute" not in e
and "illegal characters found in URI" not in e
and "escaping malformed URI reference" not in e
e for e in errors.split("\n") if not any(exclude in e for exclude in excluded)
)
if errors:
raise Exception(errors)

View file

@ -272,8 +272,8 @@ class BookViews(TestCase):
book=self.book,
content="hi",
quote="wow",
position=12,
endposition=13,
position="12",
endposition="13",
)
request = self.factory.get("")
@ -286,7 +286,9 @@ class BookViews(TestCase):
validate_html(result.render())
print(result.render())
self.assertEqual(result.status_code, 200)
self.assertEqual(result.context_data["statuses"].object_list[0].endposition, 13)
self.assertEqual(
result.context_data["statuses"].object_list[0].endposition, "13"
)
def _setup_cover_url():

View file

@ -133,3 +133,73 @@ class BookViews(TestCase):
self.assertEqual(models.ShelfBook.objects.get().book, edition2)
self.assertEqual(models.ReadThrough.objects.get().book, edition2)
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
def test_move_ratings_on_switch_edition(self, *_):
"""updates user's rating on a book to new edition"""
work = models.Work.objects.create(title="test work")
edition1 = models.Edition.objects.create(title="first ed", parent_work=work)
edition2 = models.Edition.objects.create(title="second ed", parent_work=work)
models.ReviewRating.objects.create(
book=edition1,
user=self.local_user,
rating=3,
)
self.assertIsInstance(
models.ReviewRating.objects.get(user=self.local_user, book=edition1),
models.ReviewRating,
)
with self.assertRaises(models.ReviewRating.DoesNotExist):
models.ReviewRating.objects.get(user=self.local_user, book=edition2)
request = self.factory.post("", {"edition": edition2.id})
request.user = self.local_user
views.switch_edition(request)
self.assertIsInstance(
models.ReviewRating.objects.get(user=self.local_user, book=edition2),
models.ReviewRating,
)
with self.assertRaises(models.ReviewRating.DoesNotExist):
models.ReviewRating.objects.get(user=self.local_user, book=edition1)
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
def test_move_reviews_on_switch_edition(self, *_):
"""updates user's review on a book to new edition"""
work = models.Work.objects.create(title="test work")
edition1 = models.Edition.objects.create(title="first ed", parent_work=work)
edition2 = models.Edition.objects.create(title="second ed", parent_work=work)
models.Review.objects.create(
book=edition1,
user=self.local_user,
name="blah",
rating=3,
content="not bad",
)
self.assertIsInstance(
models.Review.objects.get(user=self.local_user, book=edition1),
models.Review,
)
with self.assertRaises(models.Review.DoesNotExist):
models.Review.objects.get(user=self.local_user, book=edition2)
request = self.factory.post("", {"edition": edition2.id})
request.user = self.local_user
views.switch_edition(request)
self.assertIsInstance(
models.Review.objects.get(user=self.local_user, book=edition2),
models.Review,
)
with self.assertRaises(models.Review.DoesNotExist):
models.Review.objects.get(user=self.local_user, book=edition1)

View file

@ -219,3 +219,48 @@ class ShelfViews(TestCase):
view(request, request.user.username, shelf.identifier)
self.assertEqual(shelf.name, "To Read")
def test_filter_shelf_found(self, *_):
"""display books that match a filter keyword"""
models.ShelfBook.objects.create(
book=self.book,
shelf=self.shelf,
user=self.local_user,
)
shelf_book = models.ShelfBook.objects.create(
book=self.book,
shelf=self.local_user.shelf_set.first(),
user=self.local_user,
)
view = views.Shelf.as_view()
request = self.factory.get("", {"filter": shelf_book.book.title})
request.user = self.local_user
with patch("bookwyrm.views.shelf.shelf.is_api_request") as is_api:
is_api.return_value = False
result = view(request, self.local_user.username)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
self.assertEqual(len(result.context_data["books"].object_list), 1)
self.assertEqual(
result.context_data["books"].object_list[0].title,
shelf_book.book.title,
)
def test_filter_shelf_none(self, *_):
"""display a message when no books match a filter keyword"""
models.ShelfBook.objects.create(
book=self.book,
shelf=self.shelf,
user=self.local_user,
)
view = views.Shelf.as_view()
request = self.factory.get("", {"filter": "NOPE"})
request.user = self.local_user
with patch("bookwyrm.views.shelf.shelf.is_api_request") as is_api:
is_api.return_value = False
result = view(request, self.local_user.username)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
self.assertEqual(len(result.context_data["books"].object_list), 0)

View file

@ -369,6 +369,11 @@ urlpatterns = [
re_path(
r"^settings/celery/ping/?$", views.celery_ping, name="settings-celery-ping"
),
re_path(
r"^settings/schedules/(?P<task_id>\d+)?$",
views.ScheduledTasks.as_view(),
name="settings-schedules",
),
re_path(
r"^settings/email-config/?$",
views.EmailConfig.as_view(),

23
bookwyrm/utils/db.py Normal file
View file

@ -0,0 +1,23 @@
""" Database utilities """
from typing import cast
import sqlparse # type: ignore
def format_trigger(sql: str) -> str:
"""format SQL trigger before storing
we remove whitespace and use consistent casing so as to avoid migrations
due to formatting changes.
"""
return cast(
str,
sqlparse.format(
sql,
strip_comments=True,
strip_whitespace=True,
use_space_around_operators=True,
keyword_case="upper",
identifier_case="lower",
),
)

View file

@ -5,6 +5,7 @@ from .admin.announcements import EditAnnouncement, delete_announcement
from .admin.automod import AutoMod, automod_delete, run_automod
from .admin.automod import schedule_automod_task, unschedule_automod_task
from .admin.celery_status import CeleryStatus, celery_ping
from .admin.schedule import ScheduledTasks
from .admin.dashboard import Dashboard
from .admin.federation import Federation, FederatedServer
from .admin.federation import AddFederatedServer, ImportServerBlocklist

View file

@ -6,7 +6,7 @@ from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.http import require_POST
from django_celery_beat.models import PeriodicTask
from django_celery_beat.models import PeriodicTask, IntervalSchedule
from bookwyrm import forms, models
@ -54,7 +54,7 @@ def schedule_automod_task(request):
return TemplateResponse(request, "settings/automod/rules.html", data)
with transaction.atomic():
schedule = form.save(request)
schedule, _ = IntervalSchedule.objects.get_or_create(**form.cleaned_data)
PeriodicTask.objects.get_or_create(
interval=schedule,
name="automod-task",

View file

@ -6,16 +6,18 @@ from dateutil.parser import parse
from packaging import version
from django.contrib.auth.decorators import login_required, permission_required
from django.db import transaction
from django.db.models import Q
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from django_celery_beat.models import PeriodicTask, IntervalSchedule
from csp.decorators import csp_update
from bookwyrm import models, settings
from bookwyrm.connectors.abstract_connector import get_data
from bookwyrm import forms, models, settings
from bookwyrm.utils import regex
@ -59,21 +61,36 @@ class Dashboard(View):
== site._meta.get_field("privacy_policy").get_default()
)
# check version
if site.available_version and version.parse(
site.available_version
) > version.parse(settings.VERSION):
data["current_version"] = settings.VERSION
data["available_version"] = site.available_version
try:
release = get_data(settings.RELEASE_API, timeout=3)
available_version = release.get("tag_name", None)
if available_version and version.parse(available_version) > version.parse(
settings.VERSION
):
data["current_version"] = settings.VERSION
data["available_version"] = available_version
except: # pylint: disable= bare-except
pass
if not PeriodicTask.objects.filter(name="check-for-updates").exists():
data["schedule_form"] = forms.IntervalScheduleForm(
{"every": 1, "period": "days"}
)
return TemplateResponse(request, "settings/dashboard/dashboard.html", data)
def post(self, request):
"""Create a schedule task to check for updates"""
schedule_form = forms.IntervalScheduleForm(request.POST)
if not schedule_form.is_valid():
raise schedule_form.ValidationError(schedule_form.errors)
with transaction.atomic():
schedule, _ = IntervalSchedule.objects.get_or_create(
**schedule_form.cleaned_data
)
PeriodicTask.objects.get_or_create(
interval=schedule,
name="check-for-updates",
task="bookwyrm.models.site.check_for_updates_task",
)
return redirect("settings-dashboard")
def get_charts_and_stats(request):
"""Defines the dashboard charts"""

View file

@ -0,0 +1,31 @@
""" Scheduled celery tasks """
from django.contrib.auth.decorators import login_required, permission_required
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views import View
from django_celery_beat.models import PeriodicTask, IntervalSchedule
@method_decorator(login_required, name="dispatch")
@method_decorator(
permission_required("bookwyrm.edit_instance_settings", raise_exception=True),
name="dispatch",
)
# pylint: disable=no-self-use
class ScheduledTasks(View):
"""Manage automated flagging"""
def get(self, request):
"""view schedules"""
data = {}
data["tasks"] = PeriodicTask.objects.all()
data["schedules"] = IntervalSchedule.objects.all()
return TemplateResponse(request, "settings/schedules.html", data)
# pylint: disable=unused-argument
def post(self, request, task_id):
"""un-schedule a task"""
task = PeriodicTask.objects.get(id=task_id)
task.delete()
return redirect("settings-schedules")

View file

@ -3,6 +3,7 @@ from functools import reduce
import operator
from django.contrib.auth.decorators import login_required
from django.core.cache import cache as django_cache
from django.core.paginator import Paginator
from django.db import transaction
from django.db.models import Q
@ -93,6 +94,7 @@ def switch_edition(request):
user=shelfbook.user,
shelf=shelfbook.shelf,
book=new_edition,
shelved_date=shelfbook.shelved_date,
)
shelfbook.delete()
@ -103,4 +105,20 @@ def switch_edition(request):
readthrough.book = new_edition
readthrough.save()
django_cache.delete_many(
[
f"active_shelf-{request.user.id}-{book_id}"
for book_id in new_edition.parent_work.editions.values_list("id", flat=True)
]
)
reviews = models.Review.objects.filter(
book__parent_work=new_edition.parent_work, user=request.user
)
for review in reviews.all():
# because ratings are a subclass of reviews,
# this will pick up both ratings and reviews
review.book = new_edition
review.save()
return redirect(f"/book/{new_edition.id}")

View file

@ -1,4 +1,5 @@
""" non-interactive pages """
from datetime import date
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Q
@ -52,6 +53,19 @@ class Feed(View):
suggestions = suggested_users.get_suggestions(request.user)
cutoff = (
date(get_annual_summary_year(), 12, 31)
if get_annual_summary_year()
else None
)
readthroughs = (
models.ReadThrough.objects.filter(
user=request.user, finish_date__lte=cutoff
)
if get_annual_summary_year()
else []
)
data = {
**feed_page_data(request.user),
**{
@ -66,6 +80,7 @@ class Feed(View):
"path": f"/{tab['key']}",
"annual_summary_year": get_annual_summary_year(),
"has_tour": True,
"has_summary_read_throughs": len(readthroughs),
},
}
return TemplateResponse(request, "feed/feed.html", data)
@ -185,19 +200,15 @@ class Status(View):
params=[status.id, visible_thread, visible_thread],
)
preview = None
if hasattr(status, "book"):
preview = status.book.preview_image
elif status.mention_books.exists():
preview = status.mention_books.first().preview_image
data = {
**feed_page_data(request.user),
**{
"status": status,
"children": children,
"ancestors": ancestors,
"preview": preview,
"title": status.page_title,
"description": status.page_description,
"page_image": status.page_image,
},
}
return TemplateResponse(request, "feed/status.html", data)

View file

@ -1,8 +1,10 @@
""" search views"""
import re
from django.contrib.postgres.search import TrigramSimilarity
from django.contrib.postgres.search import TrigramSimilarity, SearchRank, SearchQuery
from django.core.paginator import Paginator
from django.db.models import F
from django.db.models.functions import Greatest
from django.http import JsonResponse
from django.template.response import TemplateResponse
@ -39,6 +41,7 @@ class Search(View):
endpoints = {
"book": book_search,
"author": author_search,
"user": user_search,
"list": list_search,
}
@ -90,6 +93,33 @@ def book_search(request):
return TemplateResponse(request, "search/book.html", data)
def author_search(request):
"""search for an author"""
query = request.GET.get("q").strip()
search_query = SearchQuery(query, config="simple")
min_confidence = 0
results = (
models.Author.objects.filter(search_vector=search_query)
.annotate(rank=SearchRank(F("search_vector"), search_query))
.filter(rank__gt=min_confidence)
.order_by("-rank")
)
paginated = Paginator(results, PAGE_LENGTH)
page = paginated.get_page(request.GET.get("page"))
data = {
"type": "author",
"query": query,
"results": page,
"page_range": paginated.get_elided_page_range(
page.number, on_each_side=2, on_ends=1
),
}
return TemplateResponse(request, "search/author.html", data)
def user_search(request):
"""user search: search for a user"""
viewer = request.user

1
bw-dev
View file

@ -156,6 +156,7 @@ case "$CMD" in
git checkout l10n_main locale/fi_FI
git checkout l10n_main locale/fr_FR
git checkout l10n_main locale/gl_ES
git checkout l10n_main locale/ko_KR
git checkout l10n_main locale/it_IT
git checkout l10n_main locale/lt_LT
git checkout l10n_main locale/nl_NL

View file

@ -1,4 +1,4 @@
FROM python:3.9
FROM python:3.11-bookworm
WORKDIR /app/dev-tools
ENV PATH="/app/dev-tools/node_modules/.bin:$PATH"

View file

@ -1 +1 @@
black==22.12.0
black==22.*

View file

@ -89,7 +89,6 @@ services:
networks:
- main
command: celery -A celerywyrm worker -l info -Q high_priority,medium_priority,low_priority,streams,images,suggested_users,email,connectors,lists,inbox,imports,import_triggered,broadcast,misc
logging: *default-logging
volumes:
- .:/app
@ -120,6 +119,7 @@ services:
env_file: .env
volumes:
- .:/app
- static_volume:/app/static
networks:
- main
depends_on:
@ -132,6 +132,8 @@ services:
volumes:
- /app/dev-tools/
- .:/app
profiles:
- tools
volumes:
pgdata:
backups:

0
images/.gitkeep Normal file
View file

Binary file not shown.

File diff suppressed because it is too large Load diff

View file

@ -61,17 +61,25 @@ server {
proxy_pass http://web;
}
# directly serve images and static files from the
# directly serve static files from the
# bookwyrm filesystem using sendfile.
# make the logs quieter by not reporting these requests
location ~ \.(bmp|ico|jpg|jpeg|png|tif|tiff|webp|css|js)$ {
location ~ ^/static/ {
root /app;
try_files $uri =404;
add_header X-Cache-Status STATIC;
access_log off;
}
# block access to any non-image files from images or static
# same with image files not in static folder
location ~ \.(bmp|ico|jpg|jpeg|png|svg|tif|tiff|webp)$ {
root /app;
try_files $uri =404;
add_header X-Cache-Status STATIC;
access_log off;
}
# block access to any non-image files from images
location ~ ^/images/ {
return 403;
}

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