mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-10-31 22:19:00 +00:00
Merge branch 'main' into production
This commit is contained in:
commit
38654004f9
107 changed files with 8866 additions and 539 deletions
10
.env.example
10
.env.example
|
@ -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
|
||||
|
|
17
.github/workflows/black.yml
vendored
17
.github/workflows/black.yml
vendored
|
@ -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
|
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
|
@ -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
|
||||
|
|
2
.github/workflows/curlylint.yaml
vendored
2
.github/workflows/curlylint.yaml
vendored
|
@ -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
|
||||
|
|
70
.github/workflows/django-tests.yml
vendored
70
.github/workflows/django-tests.yml
vendored
|
@ -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
|
2
.github/workflows/lint-frontend.yaml
vendored
2
.github/workflows/lint-frontend.yaml
vendored
|
@ -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
|
||||
|
|
50
.github/workflows/mypy.yml
vendored
50
.github/workflows/mypy.yml
vendored
|
@ -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
|
2
.github/workflows/prettier.yaml
vendored
2
.github/workflows/prettier.yaml
vendored
|
@ -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
|
||||
|
|
27
.github/workflows/pylint.yml
vendored
27
.github/workflows/pylint.yml
vendored
|
@ -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
99
.github/workflows/python.yml
vendored
Normal 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
1
.gitignore
vendored
|
@ -16,6 +16,7 @@
|
|||
# BookWyrm
|
||||
.env
|
||||
/images/
|
||||
/static/
|
||||
bookwyrm/static/css/bookwyrm.css
|
||||
bookwyrm/static/css/themes/
|
||||
!bookwyrm/static/css/themes/bookwyrm-*.scss
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
FROM python:3.9
|
||||
FROM python:3.11
|
||||
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
|
|
2
VERSION
2
VERSION
|
@ -1 +1 @@
|
|||
0.7.1
|
||||
0.7.2
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"}),
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
16
bookwyrm/migrations/0190_book_search_updates.py
Normal file
16
bookwyrm/migrations/0190_book_search_updates.py
Normal 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",
|
||||
),
|
||||
]
|
|
@ -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,
|
||||
),
|
||||
]
|
23
bookwyrm/migrations/0192_make_page_positions_text.py
Normal file
23
bookwyrm/migrations/0192_make_page_positions_text.py
Normal 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),
|
||||
),
|
||||
]
|
|
@ -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",
|
||||
),
|
||||
]
|
13
bookwyrm/migrations/0193_merge_20240203_1539.py
Normal file
13
bookwyrm/migrations/0193_merge_20240203_1539.py
Normal 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 = []
|
13
bookwyrm/migrations/0194_merge_20240203_1619.py
Normal file
13
bookwyrm/migrations/0194_merge_20240203_1619.py
Normal 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 = []
|
46
bookwyrm/migrations/0195_alter_user_preferred_language.py
Normal file
46
bookwyrm/migrations/0195_alter_user_preferred_language.py
Normal 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,
|
||||
),
|
||||
),
|
||||
]
|
13
bookwyrm/migrations/0196_merge_pr3134_into_main.py
Normal file
13
bookwyrm/migrations/0196_merge_pr3134_into_main.py
Normal 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 = []
|
41
bookwyrm/migrations/0197_author_search_vector.py
Normal file
41
bookwyrm/migrations/0197_author_search_vector.py
Normal 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;",
|
||||
),
|
||||
]
|
|
@ -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;",
|
||||
),
|
||||
]
|
|
@ -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"""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"])
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -111,6 +111,10 @@ const tries = {
|
|||
},
|
||||
},
|
||||
f: {
|
||||
b: {
|
||||
2: "FB2",
|
||||
3: "FB3",
|
||||
},
|
||||
l: {
|
||||
a: {
|
||||
c: "FLAC",
|
||||
|
|
|
@ -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 }}">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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">
|
||||
|
|
17
bookwyrm/templates/search/author.html
Normal file
17
bookwyrm/templates/search/author.html
Normal 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 %}
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
@ -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>
|
||||
|
|
127
bookwyrm/templates/settings/schedules.html
Normal file
127
bookwyrm/templates/settings/schedules.html
Normal 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 %}
|
|
@ -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 %}
|
||||
|
|
3
bookwyrm/templates/snippets/book_series.html
Normal file
3
bookwyrm/templates/snippets/book_series.html
Normal file
|
@ -0,0 +1,3 @@
|
|||
{% if book.series %}
|
||||
({{book.series}}{%if book.series_number %}, #{{book.series_number}}{% endif %})
|
||||
{% endif %}
|
|
@ -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 %} {% 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 %}
|
||||
|
|
|
@ -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 '' %}"
|
||||
|
|
|
@ -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 }}">
|
||||
|
|
|
@ -17,4 +17,7 @@ commented on <a href="{{ book_path }}">{{ book }}</a>
|
|||
{% endblocktrans %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% include 'snippets/book_series.html' with book=book %}
|
||||
|
||||
{% endwith %}
|
||||
|
|
|
@ -17,4 +17,7 @@ quoted <a href="{{ book_path }}">{{ book }}</a>
|
|||
{% endblocktrans %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% include 'snippets/book_series.html' with book=book %}
|
||||
|
||||
{% endwith %}
|
||||
|
|
|
@ -19,4 +19,7 @@ finished reading <a href="{{ book_path }}">{{ book }}</a>
|
|||
{% endblocktrans %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% include 'snippets/book_series.html' with book=book %}
|
||||
|
||||
{% endspaceless %}
|
||||
|
|
|
@ -19,4 +19,7 @@ started reading <a href="{{ book_path }}">{{ book }}</a>
|
|||
{% endblocktrans %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% include 'snippets/book_series.html' with book=book %}
|
||||
|
||||
{% endspaceless %}
|
||||
|
|
|
@ -17,4 +17,7 @@ reviewed <a href="{{ book_path }}">{{ book }}</a>
|
|||
{% endblocktrans %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% include 'snippets/book_series.html' with book=book %}
|
||||
|
||||
{% endwith %}
|
||||
|
|
|
@ -19,5 +19,8 @@ stopped reading <a href="{{ book_path }}">{{ book }}</a>
|
|||
{% endblocktrans %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% include 'snippets/book_series.html' with book=book %}
|
||||
|
||||
{% endspaceless %}
|
||||
|
||||
|
|
|
@ -19,4 +19,7 @@ wants to read <a href="{{ book_path }}">{{ book }}</a>
|
|||
{% endblocktrans %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% include 'snippets/book_series.html' with book=book %}
|
||||
|
||||
{% endspaceless %}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -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, *_):
|
||||
|
|
87
bookwyrm/tests/test_author_search.py
Normal file
87
bookwyrm/tests/test_author_search.py
Normal 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
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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")
|
|
@ -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: `&` or `&`). 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)
|
||||
|
|
|
@ -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():
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
23
bookwyrm/utils/db.py
Normal 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",
|
||||
),
|
||||
)
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"""
|
||||
|
|
31
bookwyrm/views/admin/schedule.py
Normal file
31
bookwyrm/views/admin/schedule.py
Normal 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")
|
|
@ -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}")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
1
bw-dev
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -1 +1 @@
|
|||
black==22.12.0
|
||||
black==22.*
|
||||
|
|
|
@ -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
0
images/.gitkeep
Normal file
BIN
locale/ko_KR/LC_MESSAGES/django.mo
Normal file
BIN
locale/ko_KR/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
7177
locale/ko_KR/LC_MESSAGES/django.po
Normal file
7177
locale/ko_KR/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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
Loading…
Reference in a new issue