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.
|
# and AWS_S3_CUSTOM_DOMAIN (if used) are added by default.
|
||||||
# Value should be a comma-separated list of host names.
|
# Value should be a comma-separated list of host names.
|
||||||
CSP_ADDITIONAL_HOSTS=
|
CSP_ADDITIONAL_HOSTS=
|
||||||
# The last number here means "megabytes"
|
|
||||||
# Increase if users are having trouble uploading BookWyrm export files.
|
# Time before being logged out (in seconds)
|
||||||
DATA_UPLOAD_MAX_MEMORY_SIZE = (1024**2 * 100)
|
# 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:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
|
|
2
.github/workflows/curlylint.yaml
vendored
2
.github/workflows/curlylint.yaml
vendored
|
@ -10,7 +10,7 @@ jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install curlylint
|
- name: Install curlylint
|
||||||
run: pip 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:
|
steps:
|
||||||
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it.
|
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it.
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install modules
|
- name: Install modules
|
||||||
run: npm install stylelint stylelint-config-recommended stylelint-config-standard stylelint-order eslint
|
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:
|
steps:
|
||||||
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it.
|
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it.
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install modules
|
- name: Install modules
|
||||||
run: npm install prettier@2.5.1
|
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
|
# BookWyrm
|
||||||
.env
|
.env
|
||||||
/images/
|
/images/
|
||||||
|
/static/
|
||||||
bookwyrm/static/css/bookwyrm.css
|
bookwyrm/static/css/bookwyrm.css
|
||||||
bookwyrm/static/css/themes/
|
bookwyrm/static/css/themes/
|
||||||
!bookwyrm/static/css/themes/bookwyrm-*.scss
|
!bookwyrm/static/css/themes/bookwyrm-*.scss
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
FROM python:3.9
|
FROM python:3.11
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED 1
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name
|
||||||
TBookWyrmModel = TypeVar("TBookWyrmModel", bound=base_model.BookWyrmModel)
|
TBookWyrmModel = TypeVar("TBookWyrmModel", bound=base_model.BookWyrmModel)
|
||||||
|
|
||||||
|
|
||||||
|
@ -423,6 +424,7 @@ def get_activitypub_data(url):
|
||||||
"Date": now,
|
"Date": now,
|
||||||
"Signature": make_signature("get", sender, url, now),
|
"Signature": make_signature("get", sender, url, now),
|
||||||
},
|
},
|
||||||
|
timeout=15,
|
||||||
)
|
)
|
||||||
except requests.RequestException:
|
except requests.RequestException:
|
||||||
raise ConnectorException()
|
raise ConnectorException()
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
""" actor serializer """
|
""" actor serializer """
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
from .base_activity import ActivityObject
|
from .base_activity import ActivityObject
|
||||||
|
@ -35,7 +35,7 @@ class Person(ActivityObject):
|
||||||
endpoints: Dict = None
|
endpoints: Dict = None
|
||||||
name: str = None
|
name: str = None
|
||||||
summary: str = None
|
summary: str = None
|
||||||
icon: Image = field(default_factory=lambda: {})
|
icon: Image = None
|
||||||
bookwyrmUser: bool = False
|
bookwyrmUser: bool = False
|
||||||
manuallyApprovesFollowers: str = False
|
manuallyApprovesFollowers: str = False
|
||||||
discoverable: str = False
|
discoverable: str = False
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
"""Do further startup configuration and initialization"""
|
"""Do further startup configuration and initialization"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import urllib
|
import urllib
|
||||||
import logging
|
import logging
|
||||||
|
@ -14,16 +15,16 @@ def download_file(url, destination):
|
||||||
"""Downloads a file to the given path"""
|
"""Downloads a file to the given path"""
|
||||||
try:
|
try:
|
||||||
# Ensure our destination directory exists
|
# 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 urllib.request.urlopen(url) as stream:
|
||||||
with open(destination, "b+w") as outfile:
|
with open(destination, "b+w") as outfile:
|
||||||
outfile.write(stream.read())
|
outfile.write(stream.read())
|
||||||
except (urllib.error.HTTPError, urllib.error.URLError):
|
except (urllib.error.HTTPError, urllib.error.URLError) as err:
|
||||||
logger.info("Failed to download file %s", url)
|
logger.error("Failed to download file %s: %s", url, err)
|
||||||
except OSError:
|
except OSError as err:
|
||||||
logger.info("Couldn't open font file %s for writing", destination)
|
logger.error("Couldn't open font file %s for writing: %s", destination, err)
|
||||||
except: # pylint: disable=bare-except
|
except Exception as err: # pylint:disable=broad-except
|
||||||
logger.info("Unknown error in file download")
|
logger.error("Unknown error in file download: %s", err)
|
||||||
|
|
||||||
|
|
||||||
class BookwyrmConfig(AppConfig):
|
class BookwyrmConfig(AppConfig):
|
||||||
|
|
|
@ -3,7 +3,9 @@ from __future__ import annotations
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Optional, TypedDict, Any, Callable, Union, Iterator
|
from typing import Optional, TypedDict, Any, Callable, Union, Iterator
|
||||||
from urllib.parse import quote_plus
|
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 logging
|
||||||
import re
|
import re
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
|
@ -15,6 +15,7 @@ class AuthorForm(CustomForm):
|
||||||
"aliases",
|
"aliases",
|
||||||
"bio",
|
"bio",
|
||||||
"wikipedia_link",
|
"wikipedia_link",
|
||||||
|
"wikidata",
|
||||||
"website",
|
"website",
|
||||||
"born",
|
"born",
|
||||||
"died",
|
"died",
|
||||||
|
@ -32,6 +33,7 @@ class AuthorForm(CustomForm):
|
||||||
"wikipedia_link": forms.TextInput(
|
"wikipedia_link": forms.TextInput(
|
||||||
attrs={"aria-describedby": "desc_wikipedia_link"}
|
attrs={"aria-describedby": "desc_wikipedia_link"}
|
||||||
),
|
),
|
||||||
|
"wikidata": forms.TextInput(attrs={"aria-describedby": "desc_wikidata"}),
|
||||||
"website": forms.TextInput(attrs={"aria-describedby": "desc_website"}),
|
"website": forms.TextInput(attrs={"aria-describedby": "desc_website"}),
|
||||||
"born": forms.SelectDateWidget(attrs={"aria-describedby": "desc_born"}),
|
"born": forms.SelectDateWidget(attrs={"aria-describedby": "desc_born"}),
|
||||||
"died": forms.SelectDateWidget(attrs={"aria-describedby": "desc_died"}),
|
"died": forms.SelectDateWidget(attrs={"aria-describedby": "desc_died"}),
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
""" using django model forms """
|
""" using django model forms """
|
||||||
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
@ -37,10 +38,9 @@ class FileLinkForm(CustomForm):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
not self.instance
|
models.FileLink.objects.filter(url=url, book=book, filetype=filetype)
|
||||||
and models.FileLink.objects.filter(
|
.exclude(pk=self.instance)
|
||||||
url=url, book=book, filetype=filetype
|
.exists()
|
||||||
).exists()
|
|
||||||
):
|
):
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
self.add_error(
|
self.add_error(
|
||||||
|
|
|
@ -26,7 +26,7 @@ class IsbnHyphenator:
|
||||||
|
|
||||||
def update_range_message(self) -> None:
|
def update_range_message(self) -> None:
|
||||||
"""Download the range message xml file and save it locally"""
|
"""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:
|
with open(self.__range_file_path, "w", encoding="utf-8") as file:
|
||||||
file.write(response.text)
|
file.write(response.text)
|
||||||
self.__element_tree = None
|
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
|
# find anyone who's tagged in a status, for example
|
||||||
mentions = self.recipients if hasattr(self, "recipients") else []
|
mentions = self.recipients if hasattr(self, "recipients") else []
|
||||||
|
|
||||||
# we always send activities to explicitly mentioned users' inboxes
|
# we always send activities to explicitly mentioned users (using shared inboxes
|
||||||
recipients = [u.inbox for u in mentions or [] if not u.local]
|
# 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
|
# unless it's a dm, all the followers should receive the activity
|
||||||
if privacy != "direct":
|
if privacy != "direct":
|
||||||
|
@ -173,18 +174,18 @@ class ActivitypubMixin:
|
||||||
if user:
|
if user:
|
||||||
queryset = queryset.filter(following=user)
|
queryset = queryset.filter(following=user)
|
||||||
|
|
||||||
# ideally, we will send to shared inboxes for efficiency
|
# as above, we prefer shared inboxes if available
|
||||||
shared_inboxes = (
|
recipients.update(
|
||||||
queryset.filter(shared_inbox__isnull=False)
|
queryset.filter(shared_inbox__isnull=False).values_list(
|
||||||
.values_list("shared_inbox", flat=True)
|
"shared_inbox", flat=True
|
||||||
.distinct()
|
)
|
||||||
)
|
)
|
||||||
# but not everyone has a shared inbox
|
recipients.update(
|
||||||
inboxes = queryset.filter(shared_inbox__isnull=True).values_list(
|
queryset.filter(shared_inbox__isnull=True).values_list(
|
||||||
"inbox", flat=True
|
"inbox", flat=True
|
||||||
|
)
|
||||||
)
|
)
|
||||||
recipients += list(shared_inboxes) + list(inboxes)
|
return list(recipients)
|
||||||
return list(set(recipients))
|
|
||||||
|
|
||||||
def to_activity_dataclass(self):
|
def to_activity_dataclass(self):
|
||||||
"""convert from a model to an activity"""
|
"""convert from a model to an activity"""
|
||||||
|
|
|
@ -2,11 +2,13 @@
|
||||||
import re
|
import re
|
||||||
from typing import Tuple, Any
|
from typing import Tuple, Any
|
||||||
|
|
||||||
from django.contrib.postgres.indexes import GinIndex
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.contrib.postgres.indexes import GinIndex
|
||||||
|
import pgtrigger
|
||||||
|
|
||||||
from bookwyrm import activitypub
|
from bookwyrm import activitypub
|
||||||
from bookwyrm.settings import DOMAIN
|
from bookwyrm.settings import DOMAIN
|
||||||
|
from bookwyrm.utils.db import format_trigger
|
||||||
|
|
||||||
from .book import BookDataModel
|
from .book import BookDataModel
|
||||||
from . import fields
|
from . import fields
|
||||||
|
@ -67,9 +69,46 @@ class Author(BookDataModel):
|
||||||
"""editions and works both use "book" instead of model_name"""
|
"""editions and works both use "book" instead of model_name"""
|
||||||
return f"https://{DOMAIN}/author/{self.id}"
|
return f"https://{DOMAIN}/author/{self.id}"
|
||||||
|
|
||||||
activity_serializer = activitypub.Author
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
"""sets up postgres GIN index field"""
|
"""sets up indexes and triggers"""
|
||||||
|
|
||||||
|
# pylint: disable=line-too-long
|
||||||
|
|
||||||
indexes = (GinIndex(fields=["search_vector"]),)
|
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 import FieldTracker
|
||||||
from model_utils.managers import InheritanceManager
|
from model_utils.managers import InheritanceManager
|
||||||
from imagekit.models import ImageSpecField
|
from imagekit.models import ImageSpecField
|
||||||
|
import pgtrigger
|
||||||
|
|
||||||
from bookwyrm import activitypub
|
from bookwyrm import activitypub
|
||||||
from bookwyrm.isbn.isbn import hyphenator_singleton as hyphenator
|
from bookwyrm.isbn.isbn import hyphenator_singleton as hyphenator
|
||||||
|
@ -24,6 +25,7 @@ from bookwyrm.settings import (
|
||||||
ENABLE_PREVIEW_IMAGES,
|
ENABLE_PREVIEW_IMAGES,
|
||||||
ENABLE_THUMBNAIL_GENERATION,
|
ENABLE_THUMBNAIL_GENERATION,
|
||||||
)
|
)
|
||||||
|
from bookwyrm.utils.db import format_trigger
|
||||||
|
|
||||||
from .activitypub_mixin import OrderedCollectionPageMixin, ObjectMixin
|
from .activitypub_mixin import OrderedCollectionPageMixin, ObjectMixin
|
||||||
from .base_model import BookWyrmModel
|
from .base_model import BookWyrmModel
|
||||||
|
@ -232,9 +234,49 @@ class Book(BookDataModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
"""sets up postgres GIN index field"""
|
"""set up indexes and triggers"""
|
||||||
|
|
||||||
|
# pylint: disable=line-too-long
|
||||||
|
|
||||||
indexes = (GinIndex(fields=["search_vector"]),)
|
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):
|
class Work(OrderedCollectionPageMixin, Book):
|
||||||
|
|
|
@ -80,10 +80,7 @@ def json_export(
|
||||||
exported_user = user.to_activity()
|
exported_user = user.to_activity()
|
||||||
# I don't love this but it prevents a JSON encoding error
|
# I don't love this but it prevents a JSON encoding error
|
||||||
# when there is no user image
|
# when there is no user image
|
||||||
if isinstance(
|
if exported_user.get("icon") in (None, dataclasses.MISSING):
|
||||||
exported_user["icon"],
|
|
||||||
dataclasses._MISSING_TYPE, # pylint: disable=protected-access
|
|
||||||
):
|
|
||||||
exported_user["icon"] = {}
|
exported_user["icon"] = {}
|
||||||
else:
|
else:
|
||||||
# change the URL to be relative to the JSON file
|
# change the URL to be relative to the JSON file
|
||||||
|
|
|
@ -482,7 +482,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
|
||||||
if not url:
|
if not url:
|
||||||
return None
|
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):
|
def field_from_activity(self, value, allow_external_connections=True):
|
||||||
image_slug = value
|
image_slug = value
|
||||||
|
|
|
@ -10,8 +10,11 @@ from django.dispatch import receiver
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from model_utils import FieldTracker
|
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.preview_images import generate_site_preview_image_task
|
||||||
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, STATIC_FULL_URL
|
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 .base_model import BookWyrmModel, new_access_code
|
||||||
from .user import User
|
from .user import User
|
||||||
from .fields import get_absolute_url
|
from .fields import get_absolute_url
|
||||||
|
@ -45,7 +48,7 @@ class SiteSettings(SiteModel):
|
||||||
default_theme = models.ForeignKey(
|
default_theme = models.ForeignKey(
|
||||||
"Theme", null=True, blank=True, on_delete=models.SET_NULL
|
"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
|
# admin setup options
|
||||||
install_mode = models.BooleanField(default=False)
|
install_mode = models.BooleanField(default=False)
|
||||||
|
@ -245,3 +248,14 @@ def preview_image(instance, *args, **kwargs):
|
||||||
|
|
||||||
if len(changed_fields) > 0:
|
if len(changed_fields) > 0:
|
||||||
generate_site_preview_image_task.delay()
|
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.dispatch import receiver
|
||||||
from django.template.loader import get_template
|
from django.template.loader import get_template
|
||||||
from django.utils import timezone
|
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 import FieldTracker
|
||||||
from model_utils.managers import InheritanceManager
|
from model_utils.managers import InheritanceManager
|
||||||
|
|
||||||
|
@ -107,14 +109,14 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
@property
|
@property
|
||||||
def recipients(self):
|
def recipients(self):
|
||||||
"""tagged users who definitely need to get this status in broadcast"""
|
"""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 (
|
if (
|
||||||
hasattr(self, "reply_parent")
|
hasattr(self, "reply_parent")
|
||||||
and self.reply_parent
|
and self.reply_parent
|
||||||
and not self.reply_parent.user.local
|
and not self.reply_parent.user.local
|
||||||
):
|
):
|
||||||
mentions.append(self.reply_parent.user)
|
mentions.add(self.reply_parent.user)
|
||||||
return list(set(mentions))
|
return list(mentions)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def ignore_activity(
|
def ignore_activity(
|
||||||
|
@ -178,6 +180,24 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
"""you can't boost dms"""
|
"""you can't boost dms"""
|
||||||
return self.privacy in ["unlisted", "public"]
|
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):
|
def to_replies(self, **kwargs):
|
||||||
"""helper function for loading AP serialized replies to a status"""
|
"""helper function for loading AP serialized replies to a status"""
|
||||||
return self.to_ordered_collection(
|
return self.to_ordered_collection(
|
||||||
|
@ -301,6 +321,10 @@ class BookStatus(Status):
|
||||||
|
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def page_image(self):
|
||||||
|
return self.book.preview_image or self.book.cover or super().page_image
|
||||||
|
|
||||||
|
|
||||||
class Comment(BookStatus):
|
class Comment(BookStatus):
|
||||||
"""like a review but without a rating and transient"""
|
"""like a review but without a rating and transient"""
|
||||||
|
@ -332,17 +356,26 @@ class Comment(BookStatus):
|
||||||
|
|
||||||
activity_serializer = activitypub.Comment
|
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):
|
class Quotation(BookStatus):
|
||||||
"""like a review but without a rating and transient"""
|
"""like a review but without a rating and transient"""
|
||||||
|
|
||||||
quote = fields.HtmlField()
|
quote = fields.HtmlField()
|
||||||
raw_quote = models.TextField(blank=True, null=True)
|
raw_quote = models.TextField(blank=True, null=True)
|
||||||
position = models.IntegerField(
|
position = models.TextField(
|
||||||
validators=[MinValueValidator(0)], null=True, blank=True
|
null=True,
|
||||||
|
blank=True,
|
||||||
)
|
)
|
||||||
endposition = models.IntegerField(
|
endposition = models.TextField(
|
||||||
validators=[MinValueValidator(0)], null=True, blank=True
|
null=True,
|
||||||
|
blank=True,
|
||||||
)
|
)
|
||||||
position_mode = models.CharField(
|
position_mode = models.CharField(
|
||||||
max_length=3,
|
max_length=3,
|
||||||
|
@ -374,6 +407,13 @@ class Quotation(BookStatus):
|
||||||
|
|
||||||
activity_serializer = activitypub.Quotation
|
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):
|
class Review(BookStatus):
|
||||||
"""a book review"""
|
"""a book review"""
|
||||||
|
@ -403,6 +443,13 @@ class Review(BookStatus):
|
||||||
"""indicate the book in question for mastodon (or w/e) users"""
|
"""indicate the book in question for mastodon (or w/e) users"""
|
||||||
return self.content
|
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
|
activity_serializer = activitypub.Review
|
||||||
pure_type = "Article"
|
pure_type = "Article"
|
||||||
|
|
||||||
|
@ -426,6 +473,18 @@ class ReviewRating(Review):
|
||||||
template = get_template("snippets/generated_status/rating.html")
|
template = get_template("snippets/generated_status/rating.html")
|
||||||
return template.render({"book": self.book, "rating": self.rating}).strip()
|
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
|
activity_serializer = activitypub.Rating
|
||||||
pure_type = "Note"
|
pure_type = "Note"
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
""" Generate social media preview images for twitter/mastodon/etc """
|
""" Generate social media preview images for twitter/mastodon/etc """
|
||||||
|
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import textwrap
|
import textwrap
|
||||||
|
@ -42,8 +43,8 @@ def get_imagefont(name, size):
|
||||||
return ImageFont.truetype(path, size)
|
return ImageFont.truetype(path, size)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
logger.error("Font %s not found in config", name)
|
logger.error("Font %s not found in config", name)
|
||||||
except OSError:
|
except OSError as err:
|
||||||
logger.error("Could not load font %s from file", name)
|
logger.error("Could not load font %s from file: %s", name, err)
|
||||||
|
|
||||||
return ImageFont.load_default()
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
@ -59,7 +60,7 @@ def get_font(weight, size=28):
|
||||||
font.set_variation_by_name("Bold")
|
font.set_variation_by_name("Bold")
|
||||||
if weight == "regular":
|
if weight == "regular":
|
||||||
font.set_variation_by_name("Regular")
|
font.set_variation_by_name("Regular")
|
||||||
except AttributeError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return font
|
return font
|
||||||
|
|
|
@ -30,6 +30,9 @@ RELEASE_API = env(
|
||||||
|
|
||||||
PAGE_LENGTH = env.int("PAGE_LENGTH", 15)
|
PAGE_LENGTH = env.int("PAGE_LENGTH", 15)
|
||||||
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
|
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"
|
JS_CACHE = "8a89cad7"
|
||||||
|
|
||||||
|
@ -105,6 +108,7 @@ INSTALLED_APPS = [
|
||||||
"celery",
|
"celery",
|
||||||
"django_celery_beat",
|
"django_celery_beat",
|
||||||
"imagekit",
|
"imagekit",
|
||||||
|
"pgtrigger",
|
||||||
"storages",
|
"storages",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -318,6 +322,7 @@ LANGUAGES = [
|
||||||
("eu-es", _("Euskara (Basque)")),
|
("eu-es", _("Euskara (Basque)")),
|
||||||
("gl-es", _("Galego (Galician)")),
|
("gl-es", _("Galego (Galician)")),
|
||||||
("it-it", _("Italiano (Italian)")),
|
("it-it", _("Italiano (Italian)")),
|
||||||
|
("ko-kr", _("한국어 (Korean)")),
|
||||||
("fi-fi", _("Suomi (Finnish)")),
|
("fi-fi", _("Suomi (Finnish)")),
|
||||||
("fr-fr", _("Français (French)")),
|
("fr-fr", _("Français (French)")),
|
||||||
("lt-lt", _("Lietuvių (Lithuanian)")),
|
("lt-lt", _("Lietuvių (Lithuanian)")),
|
||||||
|
@ -347,8 +352,7 @@ USE_L10N = True
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
agent = requests.utils.default_user_agent()
|
USER_AGENT = f"BookWyrm (BookWyrm/{VERSION}; +https://{DOMAIN}/)"
|
||||||
USER_AGENT = f"{agent} (BookWyrm/{VERSION}; +https://{DOMAIN}/)"
|
|
||||||
|
|
||||||
# Imagekit generated thumbnails
|
# Imagekit generated thumbnails
|
||||||
ENABLE_THUMBNAIL_GENERATION = env.bool("ENABLE_THUMBNAIL_GENERATION", False)
|
ENABLE_THUMBNAIL_GENERATION = env.bool("ENABLE_THUMBNAIL_GENERATION", False)
|
||||||
|
@ -443,4 +447,6 @@ if HTTP_X_FORWARDED_PROTO:
|
||||||
# user with the same username - in which case you should change it!
|
# user with the same username - in which case you should change it!
|
||||||
INSTANCE_ACTOR_USERNAME = "bookwyrm.instance.actor"
|
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: {
|
f: {
|
||||||
|
b: {
|
||||||
|
2: "FB2",
|
||||||
|
3: "FB3",
|
||||||
|
},
|
||||||
l: {
|
l: {
|
||||||
a: {
|
a: {
|
||||||
c: "FLAC",
|
c: "FLAC",
|
||||||
|
|
|
@ -31,10 +31,10 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns">
|
<div class="columns is-multiline">
|
||||||
{% if superlatives.top_rated %}
|
{% if superlatives.top_rated %}
|
||||||
{% with book=superlatives.top_rated.default_edition rating=superlatives.top_rated.rating %}
|
{% 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 notification is-clipped">
|
||||||
<div class="media-left">
|
<div class="media-left">
|
||||||
<a href="{{ book.local_path }}">
|
<a href="{{ book.local_path }}">
|
||||||
|
@ -53,7 +53,7 @@
|
||||||
|
|
||||||
{% if superlatives.wanted %}
|
{% if superlatives.wanted %}
|
||||||
{% with book=superlatives.wanted.default_edition %}
|
{% 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 notification is-clipped">
|
||||||
<div class="media-left">
|
<div class="media-left">
|
||||||
<a href="{{ book.local_path }}">
|
<a href="{{ book.local_path }}">
|
||||||
|
@ -72,7 +72,7 @@
|
||||||
|
|
||||||
{% if superlatives.controversial %}
|
{% if superlatives.controversial %}
|
||||||
{% with book=superlatives.controversial.default_edition %}
|
{% 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 notification is-clipped">
|
||||||
<div class="media-left">
|
<div class="media-left">
|
||||||
<a href="{{ book.local_path }}">
|
<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_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" %}
|
{% 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>
|
<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 title %}{{ book|book_title }}{% endblock %}
|
||||||
|
|
||||||
{% block opengraph %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
@ -44,18 +45,22 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if book.series %}
|
{% if book.series %}
|
||||||
<meta itemprop="position" content="{{ book.series_number }}">
|
{% spaceless %}
|
||||||
<span itemprop="isPartOf" itemscope itemtype="https://schema.org/BookSeries">
|
<span itemprop="isPartOf" itemscope itemtype="https://schema.org/BookSeries">
|
||||||
{% if book.authors.exists %}
|
{% if book.authors.exists %}
|
||||||
<a href="{% url 'book-series-by' book.authors.first.id %}?series_name={{ book.series | urlencode }}"
|
<a href="{% url 'book-series-by' book.authors.first.id %}?series_name={{ book.series | urlencode }}"
|
||||||
itemprop="url">
|
itemprop="url">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span itemprop="name">{{ book.series }}</span>
|
<span itemprop="name">{{ book.series }}</span>
|
||||||
{% if book.series_number %} #{{ book.series_number }}{% endif %}
|
|
||||||
{% if book.authors.exists %}
|
{% if book.authors.exists %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
|
{% if book.series_number %}
|
||||||
|
<span>, #</span>
|
||||||
|
<span itemprop="position">{{ book.series_number }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endspaceless %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -6,8 +6,8 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 class="title">{% trans "Confirm your email address" %}</h1>
|
<h1 class="title">{% trans "Confirm your email address" %}</h1>
|
||||||
|
|
||||||
<div class="columns">
|
<div class="columns is-multiline">
|
||||||
<div class="column">
|
<div class="column is-full is-half-desktop">
|
||||||
<div class="block content">
|
<div class="block content">
|
||||||
<section class="block">
|
<section class="block">
|
||||||
<p>{% trans "A confirmation code has been sent to the email address you used to register your account." %}</p>
|
<p>{% trans "A confirmation code has been sent to the email address you used to register your account." %}</p>
|
||||||
|
|
|
@ -41,7 +41,7 @@
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% 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 }}">
|
<section class="block is-hidden" data-hide="hide_annual_summary_{{ annual_summary_year }}">
|
||||||
{% include 'feed/summary_card.html' with year=annual_summary_year %}
|
{% include 'feed/summary_card.html' with year=annual_summary_year %}
|
||||||
<hr>
|
<hr>
|
||||||
|
|
|
@ -2,13 +2,11 @@
|
||||||
{% load feed_page_tags %}
|
{% load feed_page_tags %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{{ title }}{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
{% block opengraph %}
|
{% block opengraph %}
|
||||||
{% firstof status.book status.mention_books.first as book %}
|
{% include 'snippets/opengraph.html' with image=page_image %}
|
||||||
{% if book %}
|
|
||||||
{% include 'snippets/opengraph.html' with image=preview %}
|
|
||||||
{% else %}
|
|
||||||
{% include 'snippets/opengraph.html' %}
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -6,8 +6,8 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<h1 class="title">{% trans "Create an Account" %}</h1>
|
<h1 class="title">{% trans "Create an Account" %}</h1>
|
||||||
<div class="columns">
|
<div class="columns is-multiline">
|
||||||
<div class="column">
|
<div class="column is-full is-half-desktop">
|
||||||
<div class="block">
|
<div class="block">
|
||||||
{% if valid %}
|
{% if valid %}
|
||||||
<div>
|
<div>
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 class="title">{% trans "Log in" %}</h1>
|
<h1 class="title">{% trans "Log in" %}</h1>
|
||||||
<div class="columns is-multiline">
|
<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 %}
|
{% if login_form.non_field_errors %}
|
||||||
<p class="notification is-danger">{{ login_form.non_field_errors }}</p>
|
<p class="notification is-danger">{{ login_form.non_field_errors }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -20,13 +20,15 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_localname_confirm">{% trans "Username:" %}</label>
|
<label class="label" for="id_localname_confirm">{% trans "Username:" %}</label>
|
||||||
<div class="control">
|
<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>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_password_confirm">{% trans "Password:" %}</label>
|
<label class="label" for="id_password_confirm">{% trans "Password:" %}</label>
|
||||||
<div class="control">
|
<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>
|
</div>
|
||||||
|
|
||||||
{% include 'snippets/form_errors.html' with errors_list=login_form.password.errors id="desc_password" %}
|
{% include 'snippets/form_errors.html' with errors_list=login_form.password.errors id="desc_password" %}
|
||||||
|
@ -58,10 +60,10 @@
|
||||||
{% include 'snippets/about.html' %}
|
{% include 'snippets/about.html' %}
|
||||||
|
|
||||||
<p class="block">
|
<p class="block">
|
||||||
<a href="{% url 'about' %}">{% trans "More about this site" %}</a>
|
<a href="{% url 'about' %}">{% trans "More about this site" %}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
|
@ -4,8 +4,8 @@
|
||||||
{% block title %}{% trans "Reset Password" %}{% endblock %}
|
{% block title %}{% trans "Reset Password" %}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="columns">
|
<div class="columns is-multiline">
|
||||||
<div class="column">
|
<div class="column is-full is-half-desktop">
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<h1 class="title">{% trans "Reset Password" %}</h1>
|
<h1 class="title">{% trans "Reset Password" %}</h1>
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 class="title">{% trans "Reactivate Account" %}</h1>
|
<h1 class="title">{% trans "Reactivate Account" %}</h1>
|
||||||
<div class="columns is-multiline">
|
<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 %}
|
{% if login_form.non_field_errors %}
|
||||||
<p class="notification is-danger">{{ login_form.non_field_errors }}</p>
|
<p class="notification is-danger">{{ login_form.non_field_errors }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -16,13 +16,15 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_localname_confirm">{% trans "Username:" %}</label>
|
<label class="label" for="id_localname_confirm">{% trans "Username:" %}</label>
|
||||||
<div class="control">
|
<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>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_password_confirm">{% trans "Password:" %}</label>
|
<label class="label" for="id_password_confirm">{% trans "Password:" %}</label>
|
||||||
<div class="control">
|
<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>
|
</div>
|
||||||
|
|
||||||
{% include 'snippets/form_errors.html' with errors_list=login_form.password.errors id="desc_password" %}
|
{% include 'snippets/form_errors.html' with errors_list=login_form.password.errors id="desc_password" %}
|
||||||
|
@ -51,10 +53,10 @@
|
||||||
{% include 'snippets/about.html' %}
|
{% include 'snippets/about.html' %}
|
||||||
|
|
||||||
<p class="block">
|
<p class="block">
|
||||||
<a href="{% url 'about' %}">{% trans "More about this site" %}</a>
|
<a href="{% url 'about' %}">{% trans "More about this site" %}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
|
@ -36,7 +36,7 @@
|
||||||
<div class="field has-addons">
|
<div class="field has-addons">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
{% if request.user.is_authenticated %}
|
{% 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 %}
|
{% else %}
|
||||||
{% trans "Search for a book" as search_placeholder %}
|
{% trans "Search for a book" as search_placeholder %}
|
||||||
{% endif %}
|
{% 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>
|
<p> {% trans "You can create an export file here. This will allow you to migrate your data to another BookWyrm account." %}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="block mx-5 columns">
|
<div class="block mx-5 columns">
|
||||||
{% blocktrans trimmed %}
|
|
||||||
<div class="column is-half">
|
<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>
|
<ul>
|
||||||
<li>User profile</li>
|
<li>{% trans "User profile" %}</li>
|
||||||
<li>Most user settings</li>
|
<li>{% trans "Most user settings" %}</li>
|
||||||
<li>Reading goals</li>
|
<li>{% trans "Reading goals" %}</li>
|
||||||
<li>Shelves</li>
|
<li>{% trans "Shelves" %}</li>
|
||||||
<li>Reading history</li>
|
<li>{% trans "Reading history" %}</li>
|
||||||
<li>Book reviews</li>
|
<li>{% trans "Book reviews" %}</li>
|
||||||
<li>Statuses</li>
|
<li>{% trans "Statuses" %}</li>
|
||||||
<li>Your own lists and saved lists</li>
|
<li>{% trans "Your own lists and saved lists" %}</li>
|
||||||
<li>Which users you follow and block</li>
|
<li>{% trans "Which users you follow and block" %}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-half">
|
<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>
|
<ul>
|
||||||
<li>Direct messages</li>
|
<li>{% trans "Direct messages" %}</li>
|
||||||
<li>Replies to your statuses</li>
|
<li>{% trans "Replies to your statuses" %}</li>
|
||||||
<li>Groups</li>
|
<li>{% trans "Groups" %}</li>
|
||||||
<li>Favorites</li>
|
<li>{% trans "Favorites" %}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endblocktrans %}
|
|
||||||
</div>
|
</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="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">
|
<p class="notification is-warning">
|
||||||
|
@ -49,6 +47,13 @@
|
||||||
{% if not site.user_exports_enabled %}
|
{% if not site.user_exports_enabled %}
|
||||||
<p class="notification is-danger">
|
<p class="notification is-danger">
|
||||||
{% trans "New user exports are currently disabled." %}
|
{% 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>
|
</p>
|
||||||
{% elif next_available %}
|
{% elif next_available %}
|
||||||
<p class="notification is-warning">
|
<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">
|
<p class="block">
|
||||||
{% if request.user.is_authenticated %}
|
{% if request.user.is_authenticated %}
|
||||||
{% if not remote %}
|
{% 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" %}
|
{% trans "Load results from other catalogues" %}
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
|
@ -20,6 +20,7 @@
|
||||||
<div class="select" aria-label="{% trans 'Search type' %}">
|
<div class="select" aria-label="{% trans 'Search type' %}">
|
||||||
<select name="type">
|
<select name="type">
|
||||||
<option value="book" {% if type == "book" %}selected{% endif %}>{% trans "Books" %}</option>
|
<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 %}
|
{% if request.user.is_authenticated %}
|
||||||
<option value="user" {% if type == "user" %}selected{% endif %}>{% trans "Users" %}</option>
|
<option value="user" {% if type == "user" %}selected{% endif %}>{% trans "Users" %}</option>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -40,15 +41,18 @@
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
<ul>
|
<ul>
|
||||||
<li{% if type == "book" %} class="is-active"{% endif %}>
|
<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>
|
</li>
|
||||||
{% if request.user.is_authenticated %}
|
{% if request.user.is_authenticated %}
|
||||||
<li{% if type == "user" %} class="is-active"{% endif %}>
|
<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>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li{% if type == "list" %} class="is-active"{% 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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
@ -45,6 +45,10 @@
|
||||||
{% include 'settings/dashboard/warnings/update_version.html' with warning_level="warning" fullwidth=True %}
|
{% include 'settings/dashboard/warnings/update_version.html' with warning_level="warning" fullwidth=True %}
|
||||||
{% endif %}
|
{% 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 %}
|
{% if missing_privacy or missing_conduct %}
|
||||||
<div class="column is-12 columns m-0 p-0">
|
<div class="column is-12 columns m-0 p-0">
|
||||||
{% if missing_privacy %}
|
{% 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 %}
|
{% url 'settings-celery' as url %}
|
||||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Celery status" %}</a>
|
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Celery status" %}</a>
|
||||||
</li>
|
</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>
|
<li>
|
||||||
{% url 'settings-email-config' as url %}
|
{% 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>
|
<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 %}
|
{% block filter %}
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<label class="label" for="filter_query">{% trans 'Filter by keyword' %}</label>
|
<label class="label" for="my-books-filter">{% 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" />
|
<input id="my-books-filter" class="input" type="text" name="filter" placeholder="{% trans 'Enter text here' %}" value="{{ shelves_filter_query|default:'' }}" spellcheck="false" />
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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 %}
|
{% if book.authors.exists %}
|
||||||
{% blocktrans trimmed with path=book.local_path title=book|book_title %}
|
{% 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 %}
|
{% endblocktrans %} {% include 'snippets/authors.html' with book=book limit=3 %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ book.local_path }}">{{ book|book_title }}</a>
|
<a href="{{ book.local_path }}">{{ book|book_title }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% include 'snippets/book_series.html' with book=book %}
|
||||||
|
|
||||||
{% endcache %}
|
{% endcache %}
|
||||||
{% endspaceless %}
|
{% endspaceless %}
|
||||||
|
|
|
@ -56,8 +56,7 @@ uuid: a unique identifier used to make html "id" attributes unique and clarify j
|
||||||
<input
|
<input
|
||||||
aria-label="{% if draft.position_mode == 'PG' %}Page{% else %}Percent{% endif %}"
|
aria-label="{% if draft.position_mode == 'PG' %}Page{% else %}Percent{% endif %}"
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="text"
|
||||||
min="0"
|
|
||||||
name="position"
|
name="position"
|
||||||
size="3"
|
size="3"
|
||||||
value="{% firstof draft.position '' %}"
|
value="{% firstof draft.position '' %}"
|
||||||
|
@ -72,8 +71,7 @@ uuid: a unique identifier used to make html "id" attributes unique and clarify j
|
||||||
<input
|
<input
|
||||||
aria-label="{% if draft.position_mode == 'PG' %}Page{% else %}Percent{% endif %}"
|
aria-label="{% if draft.position_mode == 'PG' %}Page{% else %}Percent{% endif %}"
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="text"
|
||||||
min="0"
|
|
||||||
name="endposition"
|
name="endposition"
|
||||||
size="3"
|
size="3"
|
||||||
value="{% firstof draft.endposition '' %}"
|
value="{% firstof draft.endposition '' %}"
|
||||||
|
|
|
@ -1,24 +1,25 @@
|
||||||
{% load static %}
|
{% 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">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
{% if image %}
|
<meta name="twitter:image" content="{{ media_full_url }}{{ page_image }}">
|
||||||
<meta name="twitter:image" content="{{ media_full_url }}{{ image }}">
|
<meta name="og:image" content="{{ media_full_url }}{{ page_image }}">
|
||||||
<meta name="og:image" content="{{ media_full_url }}{{ image }}">
|
{% elif site.logo %}
|
||||||
{% else %}
|
<meta name="twitter:card" content="summary">
|
||||||
<meta name="twitter:image" content="{{ media_full_url }}{{ site.preview_image }}">
|
<meta name="twitter:image" content="{{ media_full_url }}{{ site.logo }}">
|
||||||
<meta name="og:image" content="{{ media_full_url }}{{ site.preview_image }}">
|
<meta name="twitter:image:alt" content="{{ site.name }} Logo">
|
||||||
{% endif %}
|
<meta name="og:image" content="{{ media_full_url }}{{ site.logo }}">
|
||||||
{% else %}
|
{% else %}
|
||||||
<meta name="twitter:card" content="summary">
|
<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="twitter:image" content="{% static "images/logo.png" %}">
|
||||||
<meta name="og:image" content="{% if site.logo %}{{ media_full_url }}{{ site.logo }}{% else %}{% static "images/logo.png" %}{% endif %}">
|
<meta name="twitter:image:alt" content="BookWyrm Logo">
|
||||||
|
<meta name="og:image" content="{% static "images/logo.png" %}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<meta name="twitter:image:alt" content="BookWyrm Logo">
|
|
||||||
|
|
||||||
<meta name="twitter:title" content="{% if title %}{{ title }} - {% endif %}{{ site.name }}">
|
<meta name="twitter:title" content="{% if title %}{{ title }} - {% endif %}{{ site.name }}">
|
||||||
<meta name="og:title" content="{% if title %}{{ title }} - {% endif %}{{ site.name }}">
|
<meta name="og:title" content="{% if title %}{{ title }} - {% endif %}{{ site.name }}">
|
||||||
|
|
||||||
<meta name="twitter:description" content="{% if description %}{{ description }}{% else %}{{ site.instance_tagline }}{% endif %}">
|
{% firstof description site.instance_tagline as description %}
|
||||||
<meta name="og:description" content="{% if description %}{{ description }}{% else %}{{ site.instance_tagline }}{% endif %}">
|
<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 %}
|
{% endblocktrans %}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% include 'snippets/book_series.html' with book=book %}
|
||||||
|
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
|
@ -17,4 +17,7 @@ quoted <a href="{{ book_path }}">{{ book }}</a>
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% include 'snippets/book_series.html' with book=book %}
|
||||||
|
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
|
@ -19,4 +19,7 @@ finished reading <a href="{{ book_path }}">{{ book }}</a>
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% include 'snippets/book_series.html' with book=book %}
|
||||||
|
|
||||||
{% endspaceless %}
|
{% endspaceless %}
|
||||||
|
|
|
@ -19,4 +19,7 @@ started reading <a href="{{ book_path }}">{{ book }}</a>
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% include 'snippets/book_series.html' with book=book %}
|
||||||
|
|
||||||
{% endspaceless %}
|
{% endspaceless %}
|
||||||
|
|
|
@ -17,4 +17,7 @@ reviewed <a href="{{ book_path }}">{{ book }}</a>
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% include 'snippets/book_series.html' with book=book %}
|
||||||
|
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
|
@ -19,5 +19,8 @@ stopped reading <a href="{{ book_path }}">{{ book }}</a>
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% include 'snippets/book_series.html' with book=book %}
|
||||||
|
|
||||||
{% endspaceless %}
|
{% endspaceless %}
|
||||||
|
|
||||||
|
|
|
@ -19,4 +19,7 @@ wants to read <a href="{{ book_path }}">{{ book }}</a>
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% include 'snippets/book_series.html' with book=book %}
|
||||||
|
|
||||||
{% endspaceless %}
|
{% endspaceless %}
|
||||||
|
|
|
@ -126,7 +126,7 @@ def id_to_username(user_id):
|
||||||
value = f"{name}@{domain}"
|
value = f"{name}@{domain}"
|
||||||
|
|
||||||
return value
|
return value
|
||||||
return "a new user account"
|
return _("a new user account")
|
||||||
|
|
||||||
|
|
||||||
@register.filter(name="get_file_size")
|
@register.filter(name="get_file_size")
|
||||||
|
|
|
@ -136,6 +136,7 @@
|
||||||
],
|
],
|
||||||
"bio": "<p>American political scientist and anthropologist</p>",
|
"bio": "<p>American political scientist and anthropologist</p>",
|
||||||
"wikipediaLink": "https://en.wikipedia.org/wiki/James_C._Scott",
|
"wikipediaLink": "https://en.wikipedia.org/wiki/James_C._Scott",
|
||||||
|
"wikidata": "Q3025403",
|
||||||
"website": "",
|
"website": "",
|
||||||
"@context": "https://www.w3.org/ns/activitystreams"
|
"@context": "https://www.w3.org/ns/activitystreams"
|
||||||
}
|
}
|
||||||
|
@ -320,6 +321,7 @@
|
||||||
"aliases": [],
|
"aliases": [],
|
||||||
"bio": "",
|
"bio": "",
|
||||||
"wikipediaLink": "",
|
"wikipediaLink": "",
|
||||||
|
"wikidata": "",
|
||||||
"website": "",
|
"website": "",
|
||||||
"@context": "https://www.w3.org/ns/activitystreams"
|
"@context": "https://www.w3.org/ns/activitystreams"
|
||||||
}
|
}
|
||||||
|
@ -396,4 +398,4 @@
|
||||||
"https://your.domain.here/user/rat"
|
"https://your.domain.here/user/rat"
|
||||||
],
|
],
|
||||||
"blocks": ["https://your.domain.here/user/badger"]
|
"blocks": ["https://your.domain.here/user/badger"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -227,14 +227,18 @@ class ActivitypubMixins(TestCase):
|
||||||
shared_inbox="http://example.com/inbox",
|
shared_inbox="http://example.com/inbox",
|
||||||
outbox="https://example.com/users/nutria/outbox",
|
outbox="https://example.com/users/nutria/outbox",
|
||||||
)
|
)
|
||||||
MockSelf = namedtuple("Self", ("privacy", "user"))
|
MockSelf = namedtuple("Self", ("privacy", "user", "recipients"))
|
||||||
mock_self = MockSelf("public", self.local_user)
|
|
||||||
self.local_user.followers.add(self.remote_user)
|
self.local_user.followers.add(self.remote_user)
|
||||||
self.local_user.followers.add(another_remote_user)
|
self.local_user.followers.add(another_remote_user)
|
||||||
|
|
||||||
|
mock_self = MockSelf("public", self.local_user, [])
|
||||||
recipients = ActivitypubMixin.get_recipients(mock_self)
|
recipients = ActivitypubMixin.get_recipients(mock_self)
|
||||||
self.assertEqual(len(recipients), 1)
|
self.assertCountEqual(recipients, ["http://example.com/inbox"])
|
||||||
self.assertEqual(recipients[0], "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, *_):
|
def test_get_recipients_software(self, *_):
|
||||||
"""should differentiate between bookwyrm and other remote users"""
|
"""should differentiate between bookwyrm and other remote users"""
|
||||||
|
|
|
@ -438,7 +438,7 @@ class ModelFields(TestCase):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertEqual(output.name, "")
|
self.assertEqual(output.name, "")
|
||||||
self.assertEqual(output.type, "Document")
|
self.assertEqual(output.type, "Image")
|
||||||
|
|
||||||
@responses.activate
|
@responses.activate
|
||||||
def test_image_field_from_activity(self, *_):
|
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 """
|
""" test searching for books """
|
||||||
import datetime
|
import datetime
|
||||||
|
from django.db import connection
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
@ -13,6 +14,13 @@ class BookSearch(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(self): # pylint: disable=bad-classmethod-argument
|
def setUpTestData(self): # pylint: disable=bad-classmethod-argument
|
||||||
"""we need basic test data and mocks"""
|
"""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.work = models.Work.objects.create(title="Example Work")
|
||||||
|
|
||||||
self.first_edition = models.Edition.objects.create(
|
self.first_edition = models.Edition.objects.create(
|
||||||
|
@ -22,6 +30,8 @@ class BookSearch(TestCase):
|
||||||
physical_format="Paperback",
|
physical_format="Paperback",
|
||||||
published_date=datetime.datetime(2019, 4, 9, 0, 0, tzinfo=timezone.utc),
|
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(
|
self.second_edition = models.Edition.objects.create(
|
||||||
title="Another Edition",
|
title="Another Edition",
|
||||||
parent_work=self.work,
|
parent_work=self.work,
|
||||||
|
@ -29,19 +39,34 @@ class BookSearch(TestCase):
|
||||||
openlibrary_key="hello",
|
openlibrary_key="hello",
|
||||||
pages=150,
|
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(
|
self.third_edition = models.Edition.objects.create(
|
||||||
title="Another Edition with annoying ISBN",
|
title="Another Edition with annoying ISBN",
|
||||||
parent_work=self.work,
|
parent_work=self.work,
|
||||||
isbn_10="022222222X",
|
isbn_10="022222222X",
|
||||||
)
|
)
|
||||||
|
self.third_edition.authors.add(self.first_author)
|
||||||
|
self.third_edition.authors.add(self.second_author)
|
||||||
|
|
||||||
def test_search(self):
|
def test_search(self):
|
||||||
"""search for a book in the db"""
|
"""search for a book in the db"""
|
||||||
# title/author
|
# title
|
||||||
results = book_search.search("Example")
|
results = book_search.search("Example")
|
||||||
self.assertEqual(len(results), 1)
|
self.assertEqual(len(results), 1)
|
||||||
self.assertEqual(results[0], self.first_edition)
|
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
|
# isbn
|
||||||
results = book_search.search("0000000000")
|
results = book_search.search("0000000000")
|
||||||
self.assertEqual(len(results), 1)
|
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
|
# there's really not much to test here, it's just a dataclass
|
||||||
self.assertEqual(result.confidence, 1)
|
self.assertEqual(result.confidence, 1)
|
||||||
self.assertEqual(result.title, "Title")
|
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,
|
"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(
|
errors = "\n".join(
|
||||||
e
|
e for e in errors.split("\n") if not any(exclude in e for exclude in excluded)
|
||||||
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
|
|
||||||
)
|
)
|
||||||
if errors:
|
if errors:
|
||||||
raise Exception(errors)
|
raise Exception(errors)
|
||||||
|
|
|
@ -272,8 +272,8 @@ class BookViews(TestCase):
|
||||||
book=self.book,
|
book=self.book,
|
||||||
content="hi",
|
content="hi",
|
||||||
quote="wow",
|
quote="wow",
|
||||||
position=12,
|
position="12",
|
||||||
endposition=13,
|
endposition="13",
|
||||||
)
|
)
|
||||||
|
|
||||||
request = self.factory.get("")
|
request = self.factory.get("")
|
||||||
|
@ -286,7 +286,9 @@ class BookViews(TestCase):
|
||||||
validate_html(result.render())
|
validate_html(result.render())
|
||||||
print(result.render())
|
print(result.render())
|
||||||
self.assertEqual(result.status_code, 200)
|
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():
|
def _setup_cover_url():
|
||||||
|
|
|
@ -133,3 +133,73 @@ class BookViews(TestCase):
|
||||||
|
|
||||||
self.assertEqual(models.ShelfBook.objects.get().book, edition2)
|
self.assertEqual(models.ShelfBook.objects.get().book, edition2)
|
||||||
self.assertEqual(models.ReadThrough.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)
|
view(request, request.user.username, shelf.identifier)
|
||||||
|
|
||||||
self.assertEqual(shelf.name, "To Read")
|
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(
|
re_path(
|
||||||
r"^settings/celery/ping/?$", views.celery_ping, name="settings-celery-ping"
|
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(
|
re_path(
|
||||||
r"^settings/email-config/?$",
|
r"^settings/email-config/?$",
|
||||||
views.EmailConfig.as_view(),
|
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 AutoMod, automod_delete, run_automod
|
||||||
from .admin.automod import schedule_automod_task, unschedule_automod_task
|
from .admin.automod import schedule_automod_task, unschedule_automod_task
|
||||||
from .admin.celery_status import CeleryStatus, celery_ping
|
from .admin.celery_status import CeleryStatus, celery_ping
|
||||||
|
from .admin.schedule import ScheduledTasks
|
||||||
from .admin.dashboard import Dashboard
|
from .admin.dashboard import Dashboard
|
||||||
from .admin.federation import Federation, FederatedServer
|
from .admin.federation import Federation, FederatedServer
|
||||||
from .admin.federation import AddFederatedServer, ImportServerBlocklist
|
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.utils.decorators import method_decorator
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.decorators.http import require_POST
|
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
|
from bookwyrm import forms, models
|
||||||
|
|
||||||
|
@ -54,7 +54,7 @@ def schedule_automod_task(request):
|
||||||
return TemplateResponse(request, "settings/automod/rules.html", data)
|
return TemplateResponse(request, "settings/automod/rules.html", data)
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
schedule = form.save(request)
|
schedule, _ = IntervalSchedule.objects.get_or_create(**form.cleaned_data)
|
||||||
PeriodicTask.objects.get_or_create(
|
PeriodicTask.objects.get_or_create(
|
||||||
interval=schedule,
|
interval=schedule,
|
||||||
name="automod-task",
|
name="automod-task",
|
||||||
|
|
|
@ -6,16 +6,18 @@ from dateutil.parser import parse
|
||||||
from packaging import version
|
from packaging import version
|
||||||
|
|
||||||
from django.contrib.auth.decorators import login_required, permission_required
|
from django.contrib.auth.decorators import login_required, permission_required
|
||||||
|
from django.db import transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.shortcuts import redirect
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views import View
|
from django.views import View
|
||||||
|
from django_celery_beat.models import PeriodicTask, IntervalSchedule
|
||||||
|
|
||||||
from csp.decorators import csp_update
|
from csp.decorators import csp_update
|
||||||
|
|
||||||
from bookwyrm import models, settings
|
from bookwyrm import forms, models, settings
|
||||||
from bookwyrm.connectors.abstract_connector import get_data
|
|
||||||
from bookwyrm.utils import regex
|
from bookwyrm.utils import regex
|
||||||
|
|
||||||
|
|
||||||
|
@ -59,21 +61,36 @@ class Dashboard(View):
|
||||||
== site._meta.get_field("privacy_policy").get_default()
|
== 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:
|
if not PeriodicTask.objects.filter(name="check-for-updates").exists():
|
||||||
release = get_data(settings.RELEASE_API, timeout=3)
|
data["schedule_form"] = forms.IntervalScheduleForm(
|
||||||
available_version = release.get("tag_name", None)
|
{"every": 1, "period": "days"}
|
||||||
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
|
|
||||||
|
|
||||||
return TemplateResponse(request, "settings/dashboard/dashboard.html", data)
|
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):
|
def get_charts_and_stats(request):
|
||||||
"""Defines the dashboard charts"""
|
"""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
|
import operator
|
||||||
|
|
||||||
from django.contrib.auth.decorators import login_required
|
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.core.paginator import Paginator
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
@ -93,6 +94,7 @@ def switch_edition(request):
|
||||||
user=shelfbook.user,
|
user=shelfbook.user,
|
||||||
shelf=shelfbook.shelf,
|
shelf=shelfbook.shelf,
|
||||||
book=new_edition,
|
book=new_edition,
|
||||||
|
shelved_date=shelfbook.shelved_date,
|
||||||
)
|
)
|
||||||
shelfbook.delete()
|
shelfbook.delete()
|
||||||
|
|
||||||
|
@ -103,4 +105,20 @@ def switch_edition(request):
|
||||||
readthrough.book = new_edition
|
readthrough.book = new_edition
|
||||||
readthrough.save()
|
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}")
|
return redirect(f"/book/{new_edition.id}")
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
""" non-interactive pages """
|
""" non-interactive pages """
|
||||||
|
from datetime import date
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
@ -52,6 +53,19 @@ class Feed(View):
|
||||||
|
|
||||||
suggestions = suggested_users.get_suggestions(request.user)
|
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 = {
|
data = {
|
||||||
**feed_page_data(request.user),
|
**feed_page_data(request.user),
|
||||||
**{
|
**{
|
||||||
|
@ -66,6 +80,7 @@ class Feed(View):
|
||||||
"path": f"/{tab['key']}",
|
"path": f"/{tab['key']}",
|
||||||
"annual_summary_year": get_annual_summary_year(),
|
"annual_summary_year": get_annual_summary_year(),
|
||||||
"has_tour": True,
|
"has_tour": True,
|
||||||
|
"has_summary_read_throughs": len(readthroughs),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return TemplateResponse(request, "feed/feed.html", data)
|
return TemplateResponse(request, "feed/feed.html", data)
|
||||||
|
@ -185,19 +200,15 @@ class Status(View):
|
||||||
params=[status.id, visible_thread, visible_thread],
|
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 = {
|
data = {
|
||||||
**feed_page_data(request.user),
|
**feed_page_data(request.user),
|
||||||
**{
|
**{
|
||||||
"status": status,
|
"status": status,
|
||||||
"children": children,
|
"children": children,
|
||||||
"ancestors": ancestors,
|
"ancestors": ancestors,
|
||||||
"preview": preview,
|
"title": status.page_title,
|
||||||
|
"description": status.page_description,
|
||||||
|
"page_image": status.page_image,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return TemplateResponse(request, "feed/status.html", data)
|
return TemplateResponse(request, "feed/status.html", data)
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
""" search views"""
|
""" search views"""
|
||||||
|
|
||||||
import re
|
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.core.paginator import Paginator
|
||||||
|
from django.db.models import F
|
||||||
from django.db.models.functions import Greatest
|
from django.db.models.functions import Greatest
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
|
@ -39,6 +41,7 @@ class Search(View):
|
||||||
|
|
||||||
endpoints = {
|
endpoints = {
|
||||||
"book": book_search,
|
"book": book_search,
|
||||||
|
"author": author_search,
|
||||||
"user": user_search,
|
"user": user_search,
|
||||||
"list": list_search,
|
"list": list_search,
|
||||||
}
|
}
|
||||||
|
@ -90,6 +93,33 @@ def book_search(request):
|
||||||
return TemplateResponse(request, "search/book.html", data)
|
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):
|
def user_search(request):
|
||||||
"""user search: search for a user"""
|
"""user search: search for a user"""
|
||||||
viewer = request.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/fi_FI
|
||||||
git checkout l10n_main locale/fr_FR
|
git checkout l10n_main locale/fr_FR
|
||||||
git checkout l10n_main locale/gl_ES
|
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/it_IT
|
||||||
git checkout l10n_main locale/lt_LT
|
git checkout l10n_main locale/lt_LT
|
||||||
git checkout l10n_main locale/nl_NL
|
git checkout l10n_main locale/nl_NL
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
FROM python:3.9
|
FROM python:3.11-bookworm
|
||||||
WORKDIR /app/dev-tools
|
WORKDIR /app/dev-tools
|
||||||
|
|
||||||
ENV PATH="/app/dev-tools/node_modules/.bin:$PATH"
|
ENV PATH="/app/dev-tools/node_modules/.bin:$PATH"
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
black==22.12.0
|
black==22.*
|
||||||
|
|
|
@ -89,7 +89,6 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- main
|
- 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
|
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
|
logging: *default-logging
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
@ -120,6 +119,7 @@ services:
|
||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
- static_volume:/app/static
|
||||||
networks:
|
networks:
|
||||||
- main
|
- main
|
||||||
depends_on:
|
depends_on:
|
||||||
|
@ -132,6 +132,8 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- /app/dev-tools/
|
- /app/dev-tools/
|
||||||
- .:/app
|
- .:/app
|
||||||
|
profiles:
|
||||||
|
- tools
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
backups:
|
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;
|
proxy_pass http://web;
|
||||||
}
|
}
|
||||||
|
|
||||||
# directly serve images and static files from the
|
# directly serve static files from the
|
||||||
# bookwyrm filesystem using sendfile.
|
# bookwyrm filesystem using sendfile.
|
||||||
# make the logs quieter by not reporting these requests
|
# make the logs quieter by not reporting these requests
|
||||||
location ~ \.(bmp|ico|jpg|jpeg|png|tif|tiff|webp|css|js)$ {
|
location ~ ^/static/ {
|
||||||
root /app;
|
root /app;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
add_header X-Cache-Status STATIC;
|
add_header X-Cache-Status STATIC;
|
||||||
access_log off;
|
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/ {
|
location ~ ^/images/ {
|
||||||
return 403;
|
return 403;
|
||||||
}
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue