mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-12-01 05:51:16 +00:00
Merge branch 'main' into debug-toolbar
This commit is contained in:
commit
57529d7e50
211 changed files with 13941 additions and 5135 deletions
|
@ -21,8 +21,8 @@ MEDIA_ROOT=images/
|
||||||
# Database configuration
|
# Database configuration
|
||||||
PGPORT=5432
|
PGPORT=5432
|
||||||
POSTGRES_PASSWORD=securedbypassword123
|
POSTGRES_PASSWORD=securedbypassword123
|
||||||
POSTGRES_USER=fedireads
|
POSTGRES_USER=bookwyrm
|
||||||
POSTGRES_DB=fedireads
|
POSTGRES_DB=bookwyrm
|
||||||
POSTGRES_HOST=db
|
POSTGRES_HOST=db
|
||||||
|
|
||||||
# Redis activity stream manager
|
# Redis activity stream manager
|
||||||
|
@ -56,7 +56,7 @@ EMAIL_SENDER_NAME=admin
|
||||||
EMAIL_SENDER_DOMAIN=
|
EMAIL_SENDER_DOMAIN=
|
||||||
|
|
||||||
# Query timeouts
|
# Query timeouts
|
||||||
SEARCH_TIMEOUT=15
|
SEARCH_TIMEOUT=5
|
||||||
QUERY_TIMEOUT=5
|
QUERY_TIMEOUT=5
|
||||||
|
|
||||||
# Thumbnails Generation
|
# Thumbnails Generation
|
||||||
|
@ -79,7 +79,7 @@ AWS_SECRET_ACCESS_KEY=
|
||||||
|
|
||||||
|
|
||||||
# Preview image generation can be computing and storage intensive
|
# Preview image generation can be computing and storage intensive
|
||||||
# ENABLE_PREVIEW_IMAGES=True
|
ENABLE_PREVIEW_IMAGES=False
|
||||||
|
|
||||||
# Specify RGB tuple or RGB hex strings,
|
# Specify RGB tuple or RGB hex strings,
|
||||||
# or use_dominant_color_light / use_dominant_color_dark
|
# or use_dominant_color_light / use_dominant_color_dark
|
||||||
|
|
8
.github/workflows/lint-frontend.yaml
vendored
8
.github/workflows/lint-frontend.yaml
vendored
|
@ -25,10 +25,10 @@ jobs:
|
||||||
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
|
||||||
|
|
||||||
# See .stylelintignore for files that are not linted.
|
# See .stylelintignore for files that are not linted.
|
||||||
- name: Run stylelint
|
# - name: Run stylelint
|
||||||
run: >
|
# run: >
|
||||||
npx stylelint bookwyrm/static/css/*.scss bookwyrm/static/css/bookwyrm/**/*.scss \
|
# npx stylelint bookwyrm/static/css/*.scss bookwyrm/static/css/bookwyrm/**/*.scss \
|
||||||
--config dev-tools/.stylelintrc.js
|
# --config dev-tools/.stylelintrc.js
|
||||||
|
|
||||||
# See .eslintignore for files that are not linted.
|
# See .eslintignore for files that are not linted.
|
||||||
- name: Run ESLint
|
- name: Run ESLint
|
||||||
|
|
|
@ -7,7 +7,7 @@ from django.apps import apps
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
|
|
||||||
from bookwyrm.connectors import ConnectorException, get_data
|
from bookwyrm.connectors import ConnectorException, get_data
|
||||||
from bookwyrm.tasks import app
|
from bookwyrm.tasks import app, MEDIUM
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -202,7 +202,7 @@ class ActivityObject:
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="medium_priority")
|
@app.task(queue=MEDIUM)
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def set_related_field(
|
def set_related_field(
|
||||||
model_name, origin_model_name, related_field_name, related_remote_id, data
|
model_name, origin_model_name, related_field_name, related_remote_id, data
|
||||||
|
|
|
@ -117,6 +117,17 @@ class ActivityStream(RedisStore):
|
||||||
Q(id=status.user.id) # if the user is the post's author
|
Q(id=status.user.id) # if the user is the post's author
|
||||||
| Q(id__in=status.mention_users.all()) # if the user is mentioned
|
| Q(id__in=status.mention_users.all()) # if the user is mentioned
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# don't show replies to statuses the user can't see
|
||||||
|
elif status.reply_parent and status.reply_parent.privacy == "followers":
|
||||||
|
audience = audience.filter(
|
||||||
|
Q(id=status.user.id) # if the user is the post's author
|
||||||
|
| Q(id=status.reply_parent.user.id) # if the user is the OG author
|
||||||
|
| (
|
||||||
|
Q(following=status.user) & Q(following=status.reply_parent.user)
|
||||||
|
) # if the user is following both authors
|
||||||
|
).distinct()
|
||||||
|
|
||||||
# only visible to the poster's followers and tagged users
|
# only visible to the poster's followers and tagged users
|
||||||
elif status.privacy == "followers":
|
elif status.privacy == "followers":
|
||||||
audience = audience.filter(
|
audience = audience.filter(
|
||||||
|
@ -287,6 +298,12 @@ def add_status_on_create(sender, instance, created, *args, **kwargs):
|
||||||
remove_status_task.delay(instance.id)
|
remove_status_task.delay(instance.id)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# To avoid creating a zillion unnecessary tasks caused by re-saving the model,
|
||||||
|
# check if it's actually ready to send before we go. We're trusting this was
|
||||||
|
# set correctly by the inbox or view
|
||||||
|
if not instance.ready:
|
||||||
|
return
|
||||||
|
|
||||||
# when creating new things, gotta wait on the transaction
|
# when creating new things, gotta wait on the transaction
|
||||||
transaction.on_commit(
|
transaction.on_commit(
|
||||||
lambda: add_status_on_create_command(sender, instance, created)
|
lambda: add_status_on_create_command(sender, instance, created)
|
||||||
|
|
|
@ -7,6 +7,7 @@ from django.contrib.postgres.search import SearchRank, SearchQuery
|
||||||
from django.db.models import OuterRef, Subquery, F, Q
|
from django.db.models import OuterRef, Subquery, F, Q
|
||||||
|
|
||||||
from bookwyrm import models
|
from bookwyrm import models
|
||||||
|
from bookwyrm import connectors
|
||||||
from bookwyrm.settings import MEDIA_FULL_URL
|
from bookwyrm.settings import MEDIA_FULL_URL
|
||||||
|
|
||||||
|
|
||||||
|
@ -30,7 +31,9 @@ def isbn_search(query):
|
||||||
"""search your local database"""
|
"""search your local database"""
|
||||||
if not query:
|
if not query:
|
||||||
return []
|
return []
|
||||||
|
# Up-case the ISBN string to ensure any 'X' check-digit is correct
|
||||||
|
# If the ISBN has only 9 characters, prepend missing zero
|
||||||
|
query = query.strip().upper().rjust(10, "0")
|
||||||
filters = [{f: query} for f in ["isbn_10", "isbn_13"]]
|
filters = [{f: query} for f in ["isbn_10", "isbn_13"]]
|
||||||
results = models.Edition.objects.filter(
|
results = models.Edition.objects.filter(
|
||||||
reduce(operator.or_, (Q(**f) for f in filters))
|
reduce(operator.or_, (Q(**f) for f in filters))
|
||||||
|
@ -72,6 +75,10 @@ def format_search_result(search_result):
|
||||||
|
|
||||||
def search_identifiers(query, *filters, return_first=False):
|
def search_identifiers(query, *filters, return_first=False):
|
||||||
"""tries remote_id, isbn; defined as dedupe fields on the model"""
|
"""tries remote_id, isbn; defined as dedupe fields on the model"""
|
||||||
|
if connectors.maybe_isbn(query):
|
||||||
|
# Oh did you think the 'S' in ISBN stood for 'standard'?
|
||||||
|
normalized_isbn = query.strip().upper().rjust(10, "0")
|
||||||
|
query = normalized_isbn
|
||||||
# pylint: disable=W0212
|
# pylint: disable=W0212
|
||||||
or_filters = [
|
or_filters = [
|
||||||
{f.name: query}
|
{f.name: query}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
""" bring connectors into the namespace """
|
""" bring connectors into the namespace """
|
||||||
from .settings import CONNECTORS
|
from .settings import CONNECTORS
|
||||||
from .abstract_connector import ConnectorException
|
from .abstract_connector import ConnectorException
|
||||||
from .abstract_connector import get_data, get_image
|
from .abstract_connector import get_data, get_image, maybe_isbn
|
||||||
|
|
||||||
from .connector_manager import search, first_search_result
|
from .connector_manager import search, first_search_result
|
||||||
|
|
|
@ -42,8 +42,10 @@ class AbstractMinimalConnector(ABC):
|
||||||
"""format the query url"""
|
"""format the query url"""
|
||||||
# Check if the query resembles an ISBN
|
# Check if the query resembles an ISBN
|
||||||
if maybe_isbn(query) and self.isbn_search_url and self.isbn_search_url != "":
|
if maybe_isbn(query) and self.isbn_search_url and self.isbn_search_url != "":
|
||||||
return f"{self.isbn_search_url}{query}"
|
# Up-case the ISBN string to ensure any 'X' check-digit is correct
|
||||||
|
# If the ISBN has only 9 characters, prepend missing zero
|
||||||
|
normalized_query = query.strip().upper().rjust(10, "0")
|
||||||
|
return f"{self.isbn_search_url}{normalized_query}"
|
||||||
# NOTE: previously, we tried searching isbn and if that produces no results,
|
# NOTE: previously, we tried searching isbn and if that produces no results,
|
||||||
# searched as free text. This, instead, only searches isbn if it's isbn-y
|
# searched as free text. This, instead, only searches isbn if it's isbn-y
|
||||||
return f"{self.search_url}{query}"
|
return f"{self.search_url}{query}"
|
||||||
|
@ -325,4 +327,11 @@ def unique_physical_format(format_text):
|
||||||
def maybe_isbn(query):
|
def maybe_isbn(query):
|
||||||
"""check if a query looks like an isbn"""
|
"""check if a query looks like an isbn"""
|
||||||
isbn = re.sub(r"[\W_]", "", query) # removes filler characters
|
isbn = re.sub(r"[\W_]", "", query) # removes filler characters
|
||||||
return len(isbn) in [10, 13] # ISBN10 or ISBN13
|
# ISBNs must be numeric except an ISBN10 checkdigit can be 'X'
|
||||||
|
if not isbn.upper().rstrip("X").isnumeric():
|
||||||
|
return False
|
||||||
|
return len(isbn) in [
|
||||||
|
9,
|
||||||
|
10,
|
||||||
|
13,
|
||||||
|
] # ISBN10 or ISBN13, or maybe ISBN10 missing a leading zero
|
||||||
|
|
|
@ -13,7 +13,7 @@ from requests import HTTPError
|
||||||
|
|
||||||
from bookwyrm import book_search, models
|
from bookwyrm import book_search, models
|
||||||
from bookwyrm.settings import SEARCH_TIMEOUT, USER_AGENT
|
from bookwyrm.settings import SEARCH_TIMEOUT, USER_AGENT
|
||||||
from bookwyrm.tasks import app
|
from bookwyrm.tasks import app, LOW
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -143,7 +143,7 @@ def get_or_create_connector(remote_id):
|
||||||
return load_connector(connector_info)
|
return load_connector(connector_info)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def load_more_data(connector_id, book_id):
|
def load_more_data(connector_id, book_id):
|
||||||
"""background the work of getting all 10,000 editions of LoTR"""
|
"""background the work of getting all 10,000 editions of LoTR"""
|
||||||
connector_info = models.Connector.objects.get(id=connector_id)
|
connector_info = models.Connector.objects.get(id=connector_id)
|
||||||
|
@ -152,7 +152,7 @@ def load_more_data(connector_id, book_id):
|
||||||
connector.expand_book_data(book)
|
connector.expand_book_data(book)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def create_edition_task(connector_id, work_id, data):
|
def create_edition_task(connector_id, work_id, data):
|
||||||
"""separate task for each of the 10,000 editions of LoTR"""
|
"""separate task for each of the 10,000 editions of LoTR"""
|
||||||
connector_info = models.Connector.objects.get(id=connector_id)
|
connector_info = models.Connector.objects.get(id=connector_id)
|
||||||
|
|
|
@ -3,7 +3,7 @@ from django.core.mail import EmailMultiAlternatives
|
||||||
from django.template.loader import get_template
|
from django.template.loader import get_template
|
||||||
|
|
||||||
from bookwyrm import models, settings
|
from bookwyrm import models, settings
|
||||||
from bookwyrm.tasks import app
|
from bookwyrm.tasks import app, HIGH
|
||||||
from bookwyrm.settings import DOMAIN
|
from bookwyrm.settings import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@ def email_confirmation_email(user):
|
||||||
data = email_data()
|
data = email_data()
|
||||||
data["confirmation_code"] = user.confirmation_code
|
data["confirmation_code"] = user.confirmation_code
|
||||||
data["confirmation_link"] = user.confirmation_link
|
data["confirmation_link"] = user.confirmation_link
|
||||||
send_email.delay(user.email, *format_email("confirm", data))
|
send_email(user.email, *format_email("confirm", data))
|
||||||
|
|
||||||
|
|
||||||
def invite_email(invite_request):
|
def invite_email(invite_request):
|
||||||
|
@ -48,6 +48,7 @@ def moderation_report_email(report):
|
||||||
if report.user:
|
if report.user:
|
||||||
data["reportee"] = report.user.localname or report.user.username
|
data["reportee"] = report.user.localname or report.user.username
|
||||||
data["report_link"] = report.remote_id
|
data["report_link"] = report.remote_id
|
||||||
|
data["link_domain"] = report.links.exists()
|
||||||
|
|
||||||
for admin in models.User.objects.filter(
|
for admin in models.User.objects.filter(
|
||||||
groups__name__in=["admin", "moderator"]
|
groups__name__in=["admin", "moderator"]
|
||||||
|
@ -68,7 +69,7 @@ def format_email(email_name, data):
|
||||||
return (subject, html_content, text_content)
|
return (subject, html_content, text_content)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="high_priority")
|
@app.task(queue=HIGH)
|
||||||
def send_email(recipient, subject, html_content, text_content):
|
def send_email(recipient, subject, html_content, text_content):
|
||||||
"""use a task to send the email"""
|
"""use a task to send the email"""
|
||||||
email = EmailMultiAlternatives(
|
email = EmailMultiAlternatives(
|
||||||
|
|
|
@ -2,13 +2,14 @@
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.forms import widgets
|
from django.forms import widgets
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_celery_beat.models import IntervalSchedule
|
from django_celery_beat.models import IntervalSchedule
|
||||||
|
|
||||||
from bookwyrm import models
|
from bookwyrm import models
|
||||||
from .custom_form import CustomForm
|
from .custom_form import CustomForm, StyledForm
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=missing-class-docstring
|
# pylint: disable=missing-class-docstring
|
||||||
|
@ -130,7 +131,7 @@ class AutoModRuleForm(CustomForm):
|
||||||
fields = ["string_match", "flag_users", "flag_statuses", "created_by"]
|
fields = ["string_match", "flag_users", "flag_statuses", "created_by"]
|
||||||
|
|
||||||
|
|
||||||
class IntervalScheduleForm(CustomForm):
|
class IntervalScheduleForm(StyledForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = IntervalSchedule
|
model = IntervalSchedule
|
||||||
fields = ["every", "period"]
|
fields = ["every", "period"]
|
||||||
|
@ -139,3 +140,10 @@ class IntervalScheduleForm(CustomForm):
|
||||||
"every": forms.NumberInput(attrs={"aria-describedby": "desc_every"}),
|
"every": forms.NumberInput(attrs={"aria-describedby": "desc_every"}),
|
||||||
"period": forms.Select(attrs={"aria-describedby": "desc_period"}),
|
"period": forms.Select(attrs={"aria-describedby": "desc_period"}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# pylint: disable=arguments-differ
|
||||||
|
def save(self, request, *args, **kwargs):
|
||||||
|
"""This is an outside model so the perms check works differently"""
|
||||||
|
if not request.user.has_perm("bookwyrm.moderate_user"):
|
||||||
|
raise PermissionDenied()
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
|
|
@ -4,7 +4,7 @@ from django.forms import ModelForm
|
||||||
from django.forms.widgets import Textarea
|
from django.forms.widgets import Textarea
|
||||||
|
|
||||||
|
|
||||||
class CustomForm(ModelForm):
|
class StyledForm(ModelForm):
|
||||||
"""add css classes to the forms"""
|
"""add css classes to the forms"""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
@ -16,7 +16,7 @@ class CustomForm(ModelForm):
|
||||||
css_classes["checkbox"] = "checkbox"
|
css_classes["checkbox"] = "checkbox"
|
||||||
css_classes["textarea"] = "textarea"
|
css_classes["textarea"] = "textarea"
|
||||||
# pylint: disable=super-with-arguments
|
# pylint: disable=super-with-arguments
|
||||||
super(CustomForm, self).__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
for visible in self.visible_fields():
|
for visible in self.visible_fields():
|
||||||
if hasattr(visible.field.widget, "input_type"):
|
if hasattr(visible.field.widget, "input_type"):
|
||||||
input_type = visible.field.widget.input_type
|
input_type = visible.field.widget.input_type
|
||||||
|
@ -24,3 +24,13 @@ class CustomForm(ModelForm):
|
||||||
input_type = "textarea"
|
input_type = "textarea"
|
||||||
visible.field.widget.attrs["rows"] = 5
|
visible.field.widget.attrs["rows"] = 5
|
||||||
visible.field.widget.attrs["class"] = css_classes[input_type]
|
visible.field.widget.attrs["class"] = css_classes[input_type]
|
||||||
|
|
||||||
|
|
||||||
|
class CustomForm(StyledForm):
|
||||||
|
"""Check permissions on save"""
|
||||||
|
|
||||||
|
# pylint: disable=arguments-differ
|
||||||
|
def save(self, request, *args, **kwargs):
|
||||||
|
"""Save and check perms"""
|
||||||
|
self.instance.raise_not_editable(request.user)
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
|
|
@ -8,7 +8,6 @@ from bookwyrm import models
|
||||||
from bookwyrm.models.fields import ClearableFileInputWithWarning
|
from bookwyrm.models.fields import ClearableFileInputWithWarning
|
||||||
from .custom_form import CustomForm
|
from .custom_form import CustomForm
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=missing-class-docstring
|
# pylint: disable=missing-class-docstring
|
||||||
class EditUserForm(CustomForm):
|
class EditUserForm(CustomForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -99,3 +98,21 @@ class ChangePasswordForm(CustomForm):
|
||||||
validate_password(new_password)
|
validate_password(new_password)
|
||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
self.add_error("password", err)
|
self.add_error("password", err)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmPasswordForm(CustomForm):
|
||||||
|
password = forms.CharField(widget=forms.PasswordInput)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.User
|
||||||
|
fields = ["password"]
|
||||||
|
widgets = {
|
||||||
|
"password": forms.PasswordInput(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""Make sure password is correct"""
|
||||||
|
password = self.data.get("password")
|
||||||
|
|
||||||
|
if not self.instance.check_password(password):
|
||||||
|
self.add_error("password", _("Incorrect Password"))
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
""" using django model forms """
|
""" using django model forms """
|
||||||
|
import datetime
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.forms import widgets
|
from django.forms import widgets
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
@ -7,7 +8,6 @@ from bookwyrm import models
|
||||||
from bookwyrm.models.user import FeedFilterChoices
|
from bookwyrm.models.user import FeedFilterChoices
|
||||||
from .custom_form import CustomForm
|
from .custom_form import CustomForm
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=missing-class-docstring
|
# pylint: disable=missing-class-docstring
|
||||||
class FeedStatusTypesForm(CustomForm):
|
class FeedStatusTypesForm(CustomForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -58,6 +58,21 @@ class ReadThroughForm(CustomForm):
|
||||||
self.add_error(
|
self.add_error(
|
||||||
"stopped_date", _("Reading stopped date cannot be before start date.")
|
"stopped_date", _("Reading stopped date cannot be before start date.")
|
||||||
)
|
)
|
||||||
|
current_time = datetime.datetime.now()
|
||||||
|
if (
|
||||||
|
stopped_date is not None
|
||||||
|
and current_time.timestamp() < stopped_date.timestamp()
|
||||||
|
):
|
||||||
|
self.add_error(
|
||||||
|
"stopped_date", _("Reading stopped date cannot be in the future.")
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
finish_date is not None
|
||||||
|
and current_time.timestamp() < finish_date.timestamp()
|
||||||
|
):
|
||||||
|
self.add_error(
|
||||||
|
"finish_date", _("Reading finished date cannot be in the future.")
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.ReadThrough
|
model = models.ReadThrough
|
||||||
|
|
|
@ -4,7 +4,10 @@ from django.contrib.auth.password_validation import validate_password
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
import pyotp
|
||||||
|
|
||||||
from bookwyrm import models
|
from bookwyrm import models
|
||||||
|
from bookwyrm.settings import DOMAIN
|
||||||
from .custom_form import CustomForm
|
from .custom_form import CustomForm
|
||||||
|
|
||||||
|
|
||||||
|
@ -18,6 +21,21 @@ class LoginForm(CustomForm):
|
||||||
"password": forms.PasswordInput(),
|
"password": forms.PasswordInput(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def infer_username(self):
|
||||||
|
"""Users may enter their localname, username, or email"""
|
||||||
|
localname = self.data.get("localname")
|
||||||
|
if "@" in localname: # looks like an email address to me
|
||||||
|
try:
|
||||||
|
return models.User.objects.get(email=localname).username
|
||||||
|
except models.User.DoesNotExist: # maybe it's a full username?
|
||||||
|
return localname
|
||||||
|
return f"{localname}@{DOMAIN}"
|
||||||
|
|
||||||
|
def add_invalid_password_error(self):
|
||||||
|
"""We don't want to be too specific about this"""
|
||||||
|
# pylint: disable=attribute-defined-outside-init
|
||||||
|
self.non_field_errors = _("Username or password are incorrect")
|
||||||
|
|
||||||
|
|
||||||
class RegisterForm(CustomForm):
|
class RegisterForm(CustomForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -74,3 +92,40 @@ class PasswordResetForm(CustomForm):
|
||||||
validate_password(new_password)
|
validate_password(new_password)
|
||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
self.add_error("password", err)
|
self.add_error("password", err)
|
||||||
|
|
||||||
|
|
||||||
|
class Confirm2FAForm(CustomForm):
|
||||||
|
otp = forms.CharField(
|
||||||
|
max_length=6, min_length=6, widget=forms.TextInput(attrs={"autofocus": True})
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.User
|
||||||
|
fields = ["otp_secret", "hotp_count"]
|
||||||
|
|
||||||
|
def clean_otp(self):
|
||||||
|
"""Check otp matches"""
|
||||||
|
otp = self.data.get("otp")
|
||||||
|
totp = pyotp.TOTP(self.instance.otp_secret)
|
||||||
|
|
||||||
|
if not totp.verify(otp):
|
||||||
|
|
||||||
|
if self.instance.hotp_secret:
|
||||||
|
# maybe it's a backup code?
|
||||||
|
hotp = pyotp.HOTP(self.instance.hotp_secret)
|
||||||
|
hotp_count = (
|
||||||
|
self.instance.hotp_count
|
||||||
|
if self.instance.hotp_count is not None
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
if not hotp.verify(otp, hotp_count):
|
||||||
|
self.add_error("otp", _("Incorrect code"))
|
||||||
|
|
||||||
|
# increment the user hotp_count
|
||||||
|
else:
|
||||||
|
self.instance.hotp_count = hotp_count + 1
|
||||||
|
self.instance.save(broadcast=False, update_fields=["hotp_count"])
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.add_error("otp", _("Incorrect code"))
|
||||||
|
|
|
@ -1,15 +1,7 @@
|
||||||
""" handle reading a csv from an external service, defaults are from Goodreads """
|
""" handle reading a csv from an external service, defaults are from Goodreads """
|
||||||
import csv
|
import csv
|
||||||
import logging
|
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
from bookwyrm import models
|
|
||||||
from bookwyrm.models import ImportJob, ImportItem
|
from bookwyrm.models import ImportJob, ImportItem
|
||||||
from bookwyrm.tasks import app, LOW
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class Importer:
|
class Importer:
|
||||||
|
@ -118,127 +110,3 @@ class Importer:
|
||||||
# this will re-normalize the raw data
|
# this will re-normalize the raw data
|
||||||
self.create_item(job, item.index, item.data)
|
self.create_item(job, item.index, item.data)
|
||||||
return job
|
return job
|
||||||
|
|
||||||
def start_import(self, job): # pylint: disable=no-self-use
|
|
||||||
"""initalizes a csv import job"""
|
|
||||||
result = start_import_task.delay(job.id)
|
|
||||||
job.task_id = result.id
|
|
||||||
job.save()
|
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
|
||||||
def start_import_task(job_id):
|
|
||||||
"""trigger the child tasks for each row"""
|
|
||||||
job = ImportJob.objects.get(id=job_id)
|
|
||||||
# these are sub-tasks so that one big task doesn't use up all the memory in celery
|
|
||||||
for item in job.items.values_list("id", flat=True).all():
|
|
||||||
import_item_task.delay(item)
|
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
|
||||||
def import_item_task(item_id):
|
|
||||||
"""resolve a row into a book"""
|
|
||||||
item = models.ImportItem.objects.get(id=item_id)
|
|
||||||
try:
|
|
||||||
item.resolve()
|
|
||||||
except Exception as err: # pylint: disable=broad-except
|
|
||||||
item.fail_reason = _("Error loading book")
|
|
||||||
item.save()
|
|
||||||
item.update_job()
|
|
||||||
raise err
|
|
||||||
|
|
||||||
if item.book:
|
|
||||||
# shelves book and handles reviews
|
|
||||||
handle_imported_book(item)
|
|
||||||
else:
|
|
||||||
item.fail_reason = _("Could not find a match for book")
|
|
||||||
|
|
||||||
item.save()
|
|
||||||
item.update_job()
|
|
||||||
|
|
||||||
|
|
||||||
def handle_imported_book(item):
|
|
||||||
"""process a csv and then post about it"""
|
|
||||||
job = item.job
|
|
||||||
user = job.user
|
|
||||||
if isinstance(item.book, models.Work):
|
|
||||||
item.book = item.book.default_edition
|
|
||||||
if not item.book:
|
|
||||||
item.fail_reason = _("Error loading book")
|
|
||||||
item.save()
|
|
||||||
return
|
|
||||||
if not isinstance(item.book, models.Edition):
|
|
||||||
item.book = item.book.edition
|
|
||||||
|
|
||||||
existing_shelf = models.ShelfBook.objects.filter(book=item.book, user=user).exists()
|
|
||||||
|
|
||||||
# shelve the book if it hasn't been shelved already
|
|
||||||
if item.shelf and not existing_shelf:
|
|
||||||
desired_shelf = models.Shelf.objects.get(identifier=item.shelf, user=user)
|
|
||||||
shelved_date = item.date_added or timezone.now()
|
|
||||||
models.ShelfBook(
|
|
||||||
book=item.book, shelf=desired_shelf, user=user, shelved_date=shelved_date
|
|
||||||
).save(priority=LOW)
|
|
||||||
|
|
||||||
for read in item.reads:
|
|
||||||
# check for an existing readthrough with the same dates
|
|
||||||
if models.ReadThrough.objects.filter(
|
|
||||||
user=user,
|
|
||||||
book=item.book,
|
|
||||||
start_date=read.start_date,
|
|
||||||
finish_date=read.finish_date,
|
|
||||||
).exists():
|
|
||||||
continue
|
|
||||||
read.book = item.book
|
|
||||||
read.user = user
|
|
||||||
read.save()
|
|
||||||
|
|
||||||
if job.include_reviews and (item.rating or item.review) and not item.linked_review:
|
|
||||||
# we don't know the publication date of the review,
|
|
||||||
# but "now" is a bad guess
|
|
||||||
published_date_guess = item.date_read or item.date_added
|
|
||||||
if item.review:
|
|
||||||
# pylint: disable=consider-using-f-string
|
|
||||||
review_title = "Review of {!r} on {!r}".format(
|
|
||||||
item.book.title,
|
|
||||||
job.source,
|
|
||||||
)
|
|
||||||
review = models.Review.objects.filter(
|
|
||||||
user=user,
|
|
||||||
book=item.book,
|
|
||||||
name=review_title,
|
|
||||||
rating=item.rating,
|
|
||||||
published_date=published_date_guess,
|
|
||||||
).first()
|
|
||||||
if not review:
|
|
||||||
review = models.Review(
|
|
||||||
user=user,
|
|
||||||
book=item.book,
|
|
||||||
name=review_title,
|
|
||||||
content=item.review,
|
|
||||||
rating=item.rating,
|
|
||||||
published_date=published_date_guess,
|
|
||||||
privacy=job.privacy,
|
|
||||||
)
|
|
||||||
review.save(software="bookwyrm", priority=LOW)
|
|
||||||
else:
|
|
||||||
# just a rating
|
|
||||||
review = models.ReviewRating.objects.filter(
|
|
||||||
user=user,
|
|
||||||
book=item.book,
|
|
||||||
published_date=published_date_guess,
|
|
||||||
rating=item.rating,
|
|
||||||
).first()
|
|
||||||
if not review:
|
|
||||||
review = models.ReviewRating(
|
|
||||||
user=user,
|
|
||||||
book=item.book,
|
|
||||||
rating=item.rating,
|
|
||||||
published_date=published_date_guess,
|
|
||||||
privacy=job.privacy,
|
|
||||||
)
|
|
||||||
review.save(software="bookwyrm", priority=LOW)
|
|
||||||
|
|
||||||
# only broadcast this review to other bookwyrm instances
|
|
||||||
item.linked_review = review
|
|
||||||
item.save()
|
|
||||||
|
|
22
bookwyrm/management/commands/remove_2fa.py
Normal file
22
bookwyrm/management/commands/remove_2fa.py
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
"""deactivate two factor auth"""
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from bookwyrm import models
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""command-line options"""
|
||||||
|
|
||||||
|
help = "Remove Two Factor Authorisation from user"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("username")
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
name = options["username"]
|
||||||
|
user = models.User.objects.get(localname=name)
|
||||||
|
user.two_factor_auth = False
|
||||||
|
user.save(broadcast=False, update_fields=["two_factor_auth"])
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS("Two Factor Authorisation was removed from user")
|
||||||
|
)
|
31
bookwyrm/management/commands/revoke_preview_image_tasks.py
Normal file
31
bookwyrm/management/commands/revoke_preview_image_tasks.py
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
""" Actually let's not generate those preview images """
|
||||||
|
import json
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from bookwyrm.tasks import app
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""Find and revoke image tasks"""
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
"""reveoke nonessential low priority tasks"""
|
||||||
|
types = [
|
||||||
|
"bookwyrm.preview_images.generate_edition_preview_image_task",
|
||||||
|
"bookwyrm.preview_images.generate_user_preview_image_task",
|
||||||
|
]
|
||||||
|
self.stdout.write(" | Finding tasks of types:")
|
||||||
|
self.stdout.write("\n".join(types))
|
||||||
|
with app.pool.acquire(block=True) as conn:
|
||||||
|
tasks = conn.default_channel.client.lrange("low_priority", 0, -1)
|
||||||
|
self.stdout.write(f" | Found {len(tasks)} task(s) in low priority queue")
|
||||||
|
|
||||||
|
revoke_ids = []
|
||||||
|
for task in tasks:
|
||||||
|
task_json = json.loads(task)
|
||||||
|
task_type = task_json.get("headers", {}).get("task")
|
||||||
|
if task_type in types:
|
||||||
|
revoke_ids.append(task_json.get("headers", {}).get("id"))
|
||||||
|
self.stdout.write(".", ending="")
|
||||||
|
self.stdout.write(f"\n | Revoking {len(revoke_ids)} task(s)")
|
||||||
|
app.control.revoke(revoke_ids)
|
647
bookwyrm/migrations/0157_auto_20220909_2338.py
Normal file
647
bookwyrm/migrations/0157_auto_20220909_2338.py
Normal file
|
@ -0,0 +1,647 @@
|
||||||
|
# Generated by Django 3.2.15 on 2022-09-09 23:38
|
||||||
|
|
||||||
|
import bookwyrm.models.fields
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0156_alter_user_preferred_language"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="review",
|
||||||
|
name="rating",
|
||||||
|
field=bookwyrm.models.fields.DecimalField(
|
||||||
|
blank=True,
|
||||||
|
decimal_places=2,
|
||||||
|
default=None,
|
||||||
|
max_digits=3,
|
||||||
|
null=True,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(0.5),
|
||||||
|
django.core.validators.MaxValueValidator(5),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="preferred_timezone",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("Africa/Abidjan", "Africa/Abidjan"),
|
||||||
|
("Africa/Accra", "Africa/Accra"),
|
||||||
|
("Africa/Addis_Ababa", "Africa/Addis_Ababa"),
|
||||||
|
("Africa/Algiers", "Africa/Algiers"),
|
||||||
|
("Africa/Asmara", "Africa/Asmara"),
|
||||||
|
("Africa/Asmera", "Africa/Asmera"),
|
||||||
|
("Africa/Bamako", "Africa/Bamako"),
|
||||||
|
("Africa/Bangui", "Africa/Bangui"),
|
||||||
|
("Africa/Banjul", "Africa/Banjul"),
|
||||||
|
("Africa/Bissau", "Africa/Bissau"),
|
||||||
|
("Africa/Blantyre", "Africa/Blantyre"),
|
||||||
|
("Africa/Brazzaville", "Africa/Brazzaville"),
|
||||||
|
("Africa/Bujumbura", "Africa/Bujumbura"),
|
||||||
|
("Africa/Cairo", "Africa/Cairo"),
|
||||||
|
("Africa/Casablanca", "Africa/Casablanca"),
|
||||||
|
("Africa/Ceuta", "Africa/Ceuta"),
|
||||||
|
("Africa/Conakry", "Africa/Conakry"),
|
||||||
|
("Africa/Dakar", "Africa/Dakar"),
|
||||||
|
("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"),
|
||||||
|
("Africa/Djibouti", "Africa/Djibouti"),
|
||||||
|
("Africa/Douala", "Africa/Douala"),
|
||||||
|
("Africa/El_Aaiun", "Africa/El_Aaiun"),
|
||||||
|
("Africa/Freetown", "Africa/Freetown"),
|
||||||
|
("Africa/Gaborone", "Africa/Gaborone"),
|
||||||
|
("Africa/Harare", "Africa/Harare"),
|
||||||
|
("Africa/Johannesburg", "Africa/Johannesburg"),
|
||||||
|
("Africa/Juba", "Africa/Juba"),
|
||||||
|
("Africa/Kampala", "Africa/Kampala"),
|
||||||
|
("Africa/Khartoum", "Africa/Khartoum"),
|
||||||
|
("Africa/Kigali", "Africa/Kigali"),
|
||||||
|
("Africa/Kinshasa", "Africa/Kinshasa"),
|
||||||
|
("Africa/Lagos", "Africa/Lagos"),
|
||||||
|
("Africa/Libreville", "Africa/Libreville"),
|
||||||
|
("Africa/Lome", "Africa/Lome"),
|
||||||
|
("Africa/Luanda", "Africa/Luanda"),
|
||||||
|
("Africa/Lubumbashi", "Africa/Lubumbashi"),
|
||||||
|
("Africa/Lusaka", "Africa/Lusaka"),
|
||||||
|
("Africa/Malabo", "Africa/Malabo"),
|
||||||
|
("Africa/Maputo", "Africa/Maputo"),
|
||||||
|
("Africa/Maseru", "Africa/Maseru"),
|
||||||
|
("Africa/Mbabane", "Africa/Mbabane"),
|
||||||
|
("Africa/Mogadishu", "Africa/Mogadishu"),
|
||||||
|
("Africa/Monrovia", "Africa/Monrovia"),
|
||||||
|
("Africa/Nairobi", "Africa/Nairobi"),
|
||||||
|
("Africa/Ndjamena", "Africa/Ndjamena"),
|
||||||
|
("Africa/Niamey", "Africa/Niamey"),
|
||||||
|
("Africa/Nouakchott", "Africa/Nouakchott"),
|
||||||
|
("Africa/Ouagadougou", "Africa/Ouagadougou"),
|
||||||
|
("Africa/Porto-Novo", "Africa/Porto-Novo"),
|
||||||
|
("Africa/Sao_Tome", "Africa/Sao_Tome"),
|
||||||
|
("Africa/Timbuktu", "Africa/Timbuktu"),
|
||||||
|
("Africa/Tripoli", "Africa/Tripoli"),
|
||||||
|
("Africa/Tunis", "Africa/Tunis"),
|
||||||
|
("Africa/Windhoek", "Africa/Windhoek"),
|
||||||
|
("America/Adak", "America/Adak"),
|
||||||
|
("America/Anchorage", "America/Anchorage"),
|
||||||
|
("America/Anguilla", "America/Anguilla"),
|
||||||
|
("America/Antigua", "America/Antigua"),
|
||||||
|
("America/Araguaina", "America/Araguaina"),
|
||||||
|
(
|
||||||
|
"America/Argentina/Buenos_Aires",
|
||||||
|
"America/Argentina/Buenos_Aires",
|
||||||
|
),
|
||||||
|
("America/Argentina/Catamarca", "America/Argentina/Catamarca"),
|
||||||
|
(
|
||||||
|
"America/Argentina/ComodRivadavia",
|
||||||
|
"America/Argentina/ComodRivadavia",
|
||||||
|
),
|
||||||
|
("America/Argentina/Cordoba", "America/Argentina/Cordoba"),
|
||||||
|
("America/Argentina/Jujuy", "America/Argentina/Jujuy"),
|
||||||
|
("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"),
|
||||||
|
("America/Argentina/Mendoza", "America/Argentina/Mendoza"),
|
||||||
|
(
|
||||||
|
"America/Argentina/Rio_Gallegos",
|
||||||
|
"America/Argentina/Rio_Gallegos",
|
||||||
|
),
|
||||||
|
("America/Argentina/Salta", "America/Argentina/Salta"),
|
||||||
|
("America/Argentina/San_Juan", "America/Argentina/San_Juan"),
|
||||||
|
("America/Argentina/San_Luis", "America/Argentina/San_Luis"),
|
||||||
|
("America/Argentina/Tucuman", "America/Argentina/Tucuman"),
|
||||||
|
("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"),
|
||||||
|
("America/Aruba", "America/Aruba"),
|
||||||
|
("America/Asuncion", "America/Asuncion"),
|
||||||
|
("America/Atikokan", "America/Atikokan"),
|
||||||
|
("America/Atka", "America/Atka"),
|
||||||
|
("America/Bahia", "America/Bahia"),
|
||||||
|
("America/Bahia_Banderas", "America/Bahia_Banderas"),
|
||||||
|
("America/Barbados", "America/Barbados"),
|
||||||
|
("America/Belem", "America/Belem"),
|
||||||
|
("America/Belize", "America/Belize"),
|
||||||
|
("America/Blanc-Sablon", "America/Blanc-Sablon"),
|
||||||
|
("America/Boa_Vista", "America/Boa_Vista"),
|
||||||
|
("America/Bogota", "America/Bogota"),
|
||||||
|
("America/Boise", "America/Boise"),
|
||||||
|
("America/Buenos_Aires", "America/Buenos_Aires"),
|
||||||
|
("America/Cambridge_Bay", "America/Cambridge_Bay"),
|
||||||
|
("America/Campo_Grande", "America/Campo_Grande"),
|
||||||
|
("America/Cancun", "America/Cancun"),
|
||||||
|
("America/Caracas", "America/Caracas"),
|
||||||
|
("America/Catamarca", "America/Catamarca"),
|
||||||
|
("America/Cayenne", "America/Cayenne"),
|
||||||
|
("America/Cayman", "America/Cayman"),
|
||||||
|
("America/Chicago", "America/Chicago"),
|
||||||
|
("America/Chihuahua", "America/Chihuahua"),
|
||||||
|
("America/Coral_Harbour", "America/Coral_Harbour"),
|
||||||
|
("America/Cordoba", "America/Cordoba"),
|
||||||
|
("America/Costa_Rica", "America/Costa_Rica"),
|
||||||
|
("America/Creston", "America/Creston"),
|
||||||
|
("America/Cuiaba", "America/Cuiaba"),
|
||||||
|
("America/Curacao", "America/Curacao"),
|
||||||
|
("America/Danmarkshavn", "America/Danmarkshavn"),
|
||||||
|
("America/Dawson", "America/Dawson"),
|
||||||
|
("America/Dawson_Creek", "America/Dawson_Creek"),
|
||||||
|
("America/Denver", "America/Denver"),
|
||||||
|
("America/Detroit", "America/Detroit"),
|
||||||
|
("America/Dominica", "America/Dominica"),
|
||||||
|
("America/Edmonton", "America/Edmonton"),
|
||||||
|
("America/Eirunepe", "America/Eirunepe"),
|
||||||
|
("America/El_Salvador", "America/El_Salvador"),
|
||||||
|
("America/Ensenada", "America/Ensenada"),
|
||||||
|
("America/Fort_Nelson", "America/Fort_Nelson"),
|
||||||
|
("America/Fort_Wayne", "America/Fort_Wayne"),
|
||||||
|
("America/Fortaleza", "America/Fortaleza"),
|
||||||
|
("America/Glace_Bay", "America/Glace_Bay"),
|
||||||
|
("America/Godthab", "America/Godthab"),
|
||||||
|
("America/Goose_Bay", "America/Goose_Bay"),
|
||||||
|
("America/Grand_Turk", "America/Grand_Turk"),
|
||||||
|
("America/Grenada", "America/Grenada"),
|
||||||
|
("America/Guadeloupe", "America/Guadeloupe"),
|
||||||
|
("America/Guatemala", "America/Guatemala"),
|
||||||
|
("America/Guayaquil", "America/Guayaquil"),
|
||||||
|
("America/Guyana", "America/Guyana"),
|
||||||
|
("America/Halifax", "America/Halifax"),
|
||||||
|
("America/Havana", "America/Havana"),
|
||||||
|
("America/Hermosillo", "America/Hermosillo"),
|
||||||
|
("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"),
|
||||||
|
("America/Indiana/Knox", "America/Indiana/Knox"),
|
||||||
|
("America/Indiana/Marengo", "America/Indiana/Marengo"),
|
||||||
|
("America/Indiana/Petersburg", "America/Indiana/Petersburg"),
|
||||||
|
("America/Indiana/Tell_City", "America/Indiana/Tell_City"),
|
||||||
|
("America/Indiana/Vevay", "America/Indiana/Vevay"),
|
||||||
|
("America/Indiana/Vincennes", "America/Indiana/Vincennes"),
|
||||||
|
("America/Indiana/Winamac", "America/Indiana/Winamac"),
|
||||||
|
("America/Indianapolis", "America/Indianapolis"),
|
||||||
|
("America/Inuvik", "America/Inuvik"),
|
||||||
|
("America/Iqaluit", "America/Iqaluit"),
|
||||||
|
("America/Jamaica", "America/Jamaica"),
|
||||||
|
("America/Jujuy", "America/Jujuy"),
|
||||||
|
("America/Juneau", "America/Juneau"),
|
||||||
|
("America/Kentucky/Louisville", "America/Kentucky/Louisville"),
|
||||||
|
("America/Kentucky/Monticello", "America/Kentucky/Monticello"),
|
||||||
|
("America/Knox_IN", "America/Knox_IN"),
|
||||||
|
("America/Kralendijk", "America/Kralendijk"),
|
||||||
|
("America/La_Paz", "America/La_Paz"),
|
||||||
|
("America/Lima", "America/Lima"),
|
||||||
|
("America/Los_Angeles", "America/Los_Angeles"),
|
||||||
|
("America/Louisville", "America/Louisville"),
|
||||||
|
("America/Lower_Princes", "America/Lower_Princes"),
|
||||||
|
("America/Maceio", "America/Maceio"),
|
||||||
|
("America/Managua", "America/Managua"),
|
||||||
|
("America/Manaus", "America/Manaus"),
|
||||||
|
("America/Marigot", "America/Marigot"),
|
||||||
|
("America/Martinique", "America/Martinique"),
|
||||||
|
("America/Matamoros", "America/Matamoros"),
|
||||||
|
("America/Mazatlan", "America/Mazatlan"),
|
||||||
|
("America/Mendoza", "America/Mendoza"),
|
||||||
|
("America/Menominee", "America/Menominee"),
|
||||||
|
("America/Merida", "America/Merida"),
|
||||||
|
("America/Metlakatla", "America/Metlakatla"),
|
||||||
|
("America/Mexico_City", "America/Mexico_City"),
|
||||||
|
("America/Miquelon", "America/Miquelon"),
|
||||||
|
("America/Moncton", "America/Moncton"),
|
||||||
|
("America/Monterrey", "America/Monterrey"),
|
||||||
|
("America/Montevideo", "America/Montevideo"),
|
||||||
|
("America/Montreal", "America/Montreal"),
|
||||||
|
("America/Montserrat", "America/Montserrat"),
|
||||||
|
("America/Nassau", "America/Nassau"),
|
||||||
|
("America/New_York", "America/New_York"),
|
||||||
|
("America/Nipigon", "America/Nipigon"),
|
||||||
|
("America/Nome", "America/Nome"),
|
||||||
|
("America/Noronha", "America/Noronha"),
|
||||||
|
("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"),
|
||||||
|
("America/North_Dakota/Center", "America/North_Dakota/Center"),
|
||||||
|
(
|
||||||
|
"America/North_Dakota/New_Salem",
|
||||||
|
"America/North_Dakota/New_Salem",
|
||||||
|
),
|
||||||
|
("America/Nuuk", "America/Nuuk"),
|
||||||
|
("America/Ojinaga", "America/Ojinaga"),
|
||||||
|
("America/Panama", "America/Panama"),
|
||||||
|
("America/Pangnirtung", "America/Pangnirtung"),
|
||||||
|
("America/Paramaribo", "America/Paramaribo"),
|
||||||
|
("America/Phoenix", "America/Phoenix"),
|
||||||
|
("America/Port-au-Prince", "America/Port-au-Prince"),
|
||||||
|
("America/Port_of_Spain", "America/Port_of_Spain"),
|
||||||
|
("America/Porto_Acre", "America/Porto_Acre"),
|
||||||
|
("America/Porto_Velho", "America/Porto_Velho"),
|
||||||
|
("America/Puerto_Rico", "America/Puerto_Rico"),
|
||||||
|
("America/Punta_Arenas", "America/Punta_Arenas"),
|
||||||
|
("America/Rainy_River", "America/Rainy_River"),
|
||||||
|
("America/Rankin_Inlet", "America/Rankin_Inlet"),
|
||||||
|
("America/Recife", "America/Recife"),
|
||||||
|
("America/Regina", "America/Regina"),
|
||||||
|
("America/Resolute", "America/Resolute"),
|
||||||
|
("America/Rio_Branco", "America/Rio_Branco"),
|
||||||
|
("America/Rosario", "America/Rosario"),
|
||||||
|
("America/Santa_Isabel", "America/Santa_Isabel"),
|
||||||
|
("America/Santarem", "America/Santarem"),
|
||||||
|
("America/Santiago", "America/Santiago"),
|
||||||
|
("America/Santo_Domingo", "America/Santo_Domingo"),
|
||||||
|
("America/Sao_Paulo", "America/Sao_Paulo"),
|
||||||
|
("America/Scoresbysund", "America/Scoresbysund"),
|
||||||
|
("America/Shiprock", "America/Shiprock"),
|
||||||
|
("America/Sitka", "America/Sitka"),
|
||||||
|
("America/St_Barthelemy", "America/St_Barthelemy"),
|
||||||
|
("America/St_Johns", "America/St_Johns"),
|
||||||
|
("America/St_Kitts", "America/St_Kitts"),
|
||||||
|
("America/St_Lucia", "America/St_Lucia"),
|
||||||
|
("America/St_Thomas", "America/St_Thomas"),
|
||||||
|
("America/St_Vincent", "America/St_Vincent"),
|
||||||
|
("America/Swift_Current", "America/Swift_Current"),
|
||||||
|
("America/Tegucigalpa", "America/Tegucigalpa"),
|
||||||
|
("America/Thule", "America/Thule"),
|
||||||
|
("America/Thunder_Bay", "America/Thunder_Bay"),
|
||||||
|
("America/Tijuana", "America/Tijuana"),
|
||||||
|
("America/Toronto", "America/Toronto"),
|
||||||
|
("America/Tortola", "America/Tortola"),
|
||||||
|
("America/Vancouver", "America/Vancouver"),
|
||||||
|
("America/Virgin", "America/Virgin"),
|
||||||
|
("America/Whitehorse", "America/Whitehorse"),
|
||||||
|
("America/Winnipeg", "America/Winnipeg"),
|
||||||
|
("America/Yakutat", "America/Yakutat"),
|
||||||
|
("America/Yellowknife", "America/Yellowknife"),
|
||||||
|
("Antarctica/Casey", "Antarctica/Casey"),
|
||||||
|
("Antarctica/Davis", "Antarctica/Davis"),
|
||||||
|
("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"),
|
||||||
|
("Antarctica/Macquarie", "Antarctica/Macquarie"),
|
||||||
|
("Antarctica/Mawson", "Antarctica/Mawson"),
|
||||||
|
("Antarctica/McMurdo", "Antarctica/McMurdo"),
|
||||||
|
("Antarctica/Palmer", "Antarctica/Palmer"),
|
||||||
|
("Antarctica/Rothera", "Antarctica/Rothera"),
|
||||||
|
("Antarctica/South_Pole", "Antarctica/South_Pole"),
|
||||||
|
("Antarctica/Syowa", "Antarctica/Syowa"),
|
||||||
|
("Antarctica/Troll", "Antarctica/Troll"),
|
||||||
|
("Antarctica/Vostok", "Antarctica/Vostok"),
|
||||||
|
("Arctic/Longyearbyen", "Arctic/Longyearbyen"),
|
||||||
|
("Asia/Aden", "Asia/Aden"),
|
||||||
|
("Asia/Almaty", "Asia/Almaty"),
|
||||||
|
("Asia/Amman", "Asia/Amman"),
|
||||||
|
("Asia/Anadyr", "Asia/Anadyr"),
|
||||||
|
("Asia/Aqtau", "Asia/Aqtau"),
|
||||||
|
("Asia/Aqtobe", "Asia/Aqtobe"),
|
||||||
|
("Asia/Ashgabat", "Asia/Ashgabat"),
|
||||||
|
("Asia/Ashkhabad", "Asia/Ashkhabad"),
|
||||||
|
("Asia/Atyrau", "Asia/Atyrau"),
|
||||||
|
("Asia/Baghdad", "Asia/Baghdad"),
|
||||||
|
("Asia/Bahrain", "Asia/Bahrain"),
|
||||||
|
("Asia/Baku", "Asia/Baku"),
|
||||||
|
("Asia/Bangkok", "Asia/Bangkok"),
|
||||||
|
("Asia/Barnaul", "Asia/Barnaul"),
|
||||||
|
("Asia/Beirut", "Asia/Beirut"),
|
||||||
|
("Asia/Bishkek", "Asia/Bishkek"),
|
||||||
|
("Asia/Brunei", "Asia/Brunei"),
|
||||||
|
("Asia/Calcutta", "Asia/Calcutta"),
|
||||||
|
("Asia/Chita", "Asia/Chita"),
|
||||||
|
("Asia/Choibalsan", "Asia/Choibalsan"),
|
||||||
|
("Asia/Chongqing", "Asia/Chongqing"),
|
||||||
|
("Asia/Chungking", "Asia/Chungking"),
|
||||||
|
("Asia/Colombo", "Asia/Colombo"),
|
||||||
|
("Asia/Dacca", "Asia/Dacca"),
|
||||||
|
("Asia/Damascus", "Asia/Damascus"),
|
||||||
|
("Asia/Dhaka", "Asia/Dhaka"),
|
||||||
|
("Asia/Dili", "Asia/Dili"),
|
||||||
|
("Asia/Dubai", "Asia/Dubai"),
|
||||||
|
("Asia/Dushanbe", "Asia/Dushanbe"),
|
||||||
|
("Asia/Famagusta", "Asia/Famagusta"),
|
||||||
|
("Asia/Gaza", "Asia/Gaza"),
|
||||||
|
("Asia/Harbin", "Asia/Harbin"),
|
||||||
|
("Asia/Hebron", "Asia/Hebron"),
|
||||||
|
("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"),
|
||||||
|
("Asia/Hong_Kong", "Asia/Hong_Kong"),
|
||||||
|
("Asia/Hovd", "Asia/Hovd"),
|
||||||
|
("Asia/Irkutsk", "Asia/Irkutsk"),
|
||||||
|
("Asia/Istanbul", "Asia/Istanbul"),
|
||||||
|
("Asia/Jakarta", "Asia/Jakarta"),
|
||||||
|
("Asia/Jayapura", "Asia/Jayapura"),
|
||||||
|
("Asia/Jerusalem", "Asia/Jerusalem"),
|
||||||
|
("Asia/Kabul", "Asia/Kabul"),
|
||||||
|
("Asia/Kamchatka", "Asia/Kamchatka"),
|
||||||
|
("Asia/Karachi", "Asia/Karachi"),
|
||||||
|
("Asia/Kashgar", "Asia/Kashgar"),
|
||||||
|
("Asia/Kathmandu", "Asia/Kathmandu"),
|
||||||
|
("Asia/Katmandu", "Asia/Katmandu"),
|
||||||
|
("Asia/Khandyga", "Asia/Khandyga"),
|
||||||
|
("Asia/Kolkata", "Asia/Kolkata"),
|
||||||
|
("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"),
|
||||||
|
("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"),
|
||||||
|
("Asia/Kuching", "Asia/Kuching"),
|
||||||
|
("Asia/Kuwait", "Asia/Kuwait"),
|
||||||
|
("Asia/Macao", "Asia/Macao"),
|
||||||
|
("Asia/Macau", "Asia/Macau"),
|
||||||
|
("Asia/Magadan", "Asia/Magadan"),
|
||||||
|
("Asia/Makassar", "Asia/Makassar"),
|
||||||
|
("Asia/Manila", "Asia/Manila"),
|
||||||
|
("Asia/Muscat", "Asia/Muscat"),
|
||||||
|
("Asia/Nicosia", "Asia/Nicosia"),
|
||||||
|
("Asia/Novokuznetsk", "Asia/Novokuznetsk"),
|
||||||
|
("Asia/Novosibirsk", "Asia/Novosibirsk"),
|
||||||
|
("Asia/Omsk", "Asia/Omsk"),
|
||||||
|
("Asia/Oral", "Asia/Oral"),
|
||||||
|
("Asia/Phnom_Penh", "Asia/Phnom_Penh"),
|
||||||
|
("Asia/Pontianak", "Asia/Pontianak"),
|
||||||
|
("Asia/Pyongyang", "Asia/Pyongyang"),
|
||||||
|
("Asia/Qatar", "Asia/Qatar"),
|
||||||
|
("Asia/Qostanay", "Asia/Qostanay"),
|
||||||
|
("Asia/Qyzylorda", "Asia/Qyzylorda"),
|
||||||
|
("Asia/Rangoon", "Asia/Rangoon"),
|
||||||
|
("Asia/Riyadh", "Asia/Riyadh"),
|
||||||
|
("Asia/Saigon", "Asia/Saigon"),
|
||||||
|
("Asia/Sakhalin", "Asia/Sakhalin"),
|
||||||
|
("Asia/Samarkand", "Asia/Samarkand"),
|
||||||
|
("Asia/Seoul", "Asia/Seoul"),
|
||||||
|
("Asia/Shanghai", "Asia/Shanghai"),
|
||||||
|
("Asia/Singapore", "Asia/Singapore"),
|
||||||
|
("Asia/Srednekolymsk", "Asia/Srednekolymsk"),
|
||||||
|
("Asia/Taipei", "Asia/Taipei"),
|
||||||
|
("Asia/Tashkent", "Asia/Tashkent"),
|
||||||
|
("Asia/Tbilisi", "Asia/Tbilisi"),
|
||||||
|
("Asia/Tehran", "Asia/Tehran"),
|
||||||
|
("Asia/Tel_Aviv", "Asia/Tel_Aviv"),
|
||||||
|
("Asia/Thimbu", "Asia/Thimbu"),
|
||||||
|
("Asia/Thimphu", "Asia/Thimphu"),
|
||||||
|
("Asia/Tokyo", "Asia/Tokyo"),
|
||||||
|
("Asia/Tomsk", "Asia/Tomsk"),
|
||||||
|
("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"),
|
||||||
|
("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"),
|
||||||
|
("Asia/Ulan_Bator", "Asia/Ulan_Bator"),
|
||||||
|
("Asia/Urumqi", "Asia/Urumqi"),
|
||||||
|
("Asia/Ust-Nera", "Asia/Ust-Nera"),
|
||||||
|
("Asia/Vientiane", "Asia/Vientiane"),
|
||||||
|
("Asia/Vladivostok", "Asia/Vladivostok"),
|
||||||
|
("Asia/Yakutsk", "Asia/Yakutsk"),
|
||||||
|
("Asia/Yangon", "Asia/Yangon"),
|
||||||
|
("Asia/Yekaterinburg", "Asia/Yekaterinburg"),
|
||||||
|
("Asia/Yerevan", "Asia/Yerevan"),
|
||||||
|
("Atlantic/Azores", "Atlantic/Azores"),
|
||||||
|
("Atlantic/Bermuda", "Atlantic/Bermuda"),
|
||||||
|
("Atlantic/Canary", "Atlantic/Canary"),
|
||||||
|
("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"),
|
||||||
|
("Atlantic/Faeroe", "Atlantic/Faeroe"),
|
||||||
|
("Atlantic/Faroe", "Atlantic/Faroe"),
|
||||||
|
("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"),
|
||||||
|
("Atlantic/Madeira", "Atlantic/Madeira"),
|
||||||
|
("Atlantic/Reykjavik", "Atlantic/Reykjavik"),
|
||||||
|
("Atlantic/South_Georgia", "Atlantic/South_Georgia"),
|
||||||
|
("Atlantic/St_Helena", "Atlantic/St_Helena"),
|
||||||
|
("Atlantic/Stanley", "Atlantic/Stanley"),
|
||||||
|
("Australia/ACT", "Australia/ACT"),
|
||||||
|
("Australia/Adelaide", "Australia/Adelaide"),
|
||||||
|
("Australia/Brisbane", "Australia/Brisbane"),
|
||||||
|
("Australia/Broken_Hill", "Australia/Broken_Hill"),
|
||||||
|
("Australia/Canberra", "Australia/Canberra"),
|
||||||
|
("Australia/Currie", "Australia/Currie"),
|
||||||
|
("Australia/Darwin", "Australia/Darwin"),
|
||||||
|
("Australia/Eucla", "Australia/Eucla"),
|
||||||
|
("Australia/Hobart", "Australia/Hobart"),
|
||||||
|
("Australia/LHI", "Australia/LHI"),
|
||||||
|
("Australia/Lindeman", "Australia/Lindeman"),
|
||||||
|
("Australia/Lord_Howe", "Australia/Lord_Howe"),
|
||||||
|
("Australia/Melbourne", "Australia/Melbourne"),
|
||||||
|
("Australia/NSW", "Australia/NSW"),
|
||||||
|
("Australia/North", "Australia/North"),
|
||||||
|
("Australia/Perth", "Australia/Perth"),
|
||||||
|
("Australia/Queensland", "Australia/Queensland"),
|
||||||
|
("Australia/South", "Australia/South"),
|
||||||
|
("Australia/Sydney", "Australia/Sydney"),
|
||||||
|
("Australia/Tasmania", "Australia/Tasmania"),
|
||||||
|
("Australia/Victoria", "Australia/Victoria"),
|
||||||
|
("Australia/West", "Australia/West"),
|
||||||
|
("Australia/Yancowinna", "Australia/Yancowinna"),
|
||||||
|
("Brazil/Acre", "Brazil/Acre"),
|
||||||
|
("Brazil/DeNoronha", "Brazil/DeNoronha"),
|
||||||
|
("Brazil/East", "Brazil/East"),
|
||||||
|
("Brazil/West", "Brazil/West"),
|
||||||
|
("CET", "CET"),
|
||||||
|
("CST6CDT", "CST6CDT"),
|
||||||
|
("Canada/Atlantic", "Canada/Atlantic"),
|
||||||
|
("Canada/Central", "Canada/Central"),
|
||||||
|
("Canada/Eastern", "Canada/Eastern"),
|
||||||
|
("Canada/Mountain", "Canada/Mountain"),
|
||||||
|
("Canada/Newfoundland", "Canada/Newfoundland"),
|
||||||
|
("Canada/Pacific", "Canada/Pacific"),
|
||||||
|
("Canada/Saskatchewan", "Canada/Saskatchewan"),
|
||||||
|
("Canada/Yukon", "Canada/Yukon"),
|
||||||
|
("Chile/Continental", "Chile/Continental"),
|
||||||
|
("Chile/EasterIsland", "Chile/EasterIsland"),
|
||||||
|
("Cuba", "Cuba"),
|
||||||
|
("EET", "EET"),
|
||||||
|
("EST", "EST"),
|
||||||
|
("EST5EDT", "EST5EDT"),
|
||||||
|
("Egypt", "Egypt"),
|
||||||
|
("Eire", "Eire"),
|
||||||
|
("Etc/GMT", "Etc/GMT"),
|
||||||
|
("Etc/GMT+0", "Etc/GMT+0"),
|
||||||
|
("Etc/GMT+1", "Etc/GMT+1"),
|
||||||
|
("Etc/GMT+10", "Etc/GMT+10"),
|
||||||
|
("Etc/GMT+11", "Etc/GMT+11"),
|
||||||
|
("Etc/GMT+12", "Etc/GMT+12"),
|
||||||
|
("Etc/GMT+2", "Etc/GMT+2"),
|
||||||
|
("Etc/GMT+3", "Etc/GMT+3"),
|
||||||
|
("Etc/GMT+4", "Etc/GMT+4"),
|
||||||
|
("Etc/GMT+5", "Etc/GMT+5"),
|
||||||
|
("Etc/GMT+6", "Etc/GMT+6"),
|
||||||
|
("Etc/GMT+7", "Etc/GMT+7"),
|
||||||
|
("Etc/GMT+8", "Etc/GMT+8"),
|
||||||
|
("Etc/GMT+9", "Etc/GMT+9"),
|
||||||
|
("Etc/GMT-0", "Etc/GMT-0"),
|
||||||
|
("Etc/GMT-1", "Etc/GMT-1"),
|
||||||
|
("Etc/GMT-10", "Etc/GMT-10"),
|
||||||
|
("Etc/GMT-11", "Etc/GMT-11"),
|
||||||
|
("Etc/GMT-12", "Etc/GMT-12"),
|
||||||
|
("Etc/GMT-13", "Etc/GMT-13"),
|
||||||
|
("Etc/GMT-14", "Etc/GMT-14"),
|
||||||
|
("Etc/GMT-2", "Etc/GMT-2"),
|
||||||
|
("Etc/GMT-3", "Etc/GMT-3"),
|
||||||
|
("Etc/GMT-4", "Etc/GMT-4"),
|
||||||
|
("Etc/GMT-5", "Etc/GMT-5"),
|
||||||
|
("Etc/GMT-6", "Etc/GMT-6"),
|
||||||
|
("Etc/GMT-7", "Etc/GMT-7"),
|
||||||
|
("Etc/GMT-8", "Etc/GMT-8"),
|
||||||
|
("Etc/GMT-9", "Etc/GMT-9"),
|
||||||
|
("Etc/GMT0", "Etc/GMT0"),
|
||||||
|
("Etc/Greenwich", "Etc/Greenwich"),
|
||||||
|
("Etc/UCT", "Etc/UCT"),
|
||||||
|
("Etc/UTC", "Etc/UTC"),
|
||||||
|
("Etc/Universal", "Etc/Universal"),
|
||||||
|
("Etc/Zulu", "Etc/Zulu"),
|
||||||
|
("Europe/Amsterdam", "Europe/Amsterdam"),
|
||||||
|
("Europe/Andorra", "Europe/Andorra"),
|
||||||
|
("Europe/Astrakhan", "Europe/Astrakhan"),
|
||||||
|
("Europe/Athens", "Europe/Athens"),
|
||||||
|
("Europe/Belfast", "Europe/Belfast"),
|
||||||
|
("Europe/Belgrade", "Europe/Belgrade"),
|
||||||
|
("Europe/Berlin", "Europe/Berlin"),
|
||||||
|
("Europe/Bratislava", "Europe/Bratislava"),
|
||||||
|
("Europe/Brussels", "Europe/Brussels"),
|
||||||
|
("Europe/Bucharest", "Europe/Bucharest"),
|
||||||
|
("Europe/Budapest", "Europe/Budapest"),
|
||||||
|
("Europe/Busingen", "Europe/Busingen"),
|
||||||
|
("Europe/Chisinau", "Europe/Chisinau"),
|
||||||
|
("Europe/Copenhagen", "Europe/Copenhagen"),
|
||||||
|
("Europe/Dublin", "Europe/Dublin"),
|
||||||
|
("Europe/Gibraltar", "Europe/Gibraltar"),
|
||||||
|
("Europe/Guernsey", "Europe/Guernsey"),
|
||||||
|
("Europe/Helsinki", "Europe/Helsinki"),
|
||||||
|
("Europe/Isle_of_Man", "Europe/Isle_of_Man"),
|
||||||
|
("Europe/Istanbul", "Europe/Istanbul"),
|
||||||
|
("Europe/Jersey", "Europe/Jersey"),
|
||||||
|
("Europe/Kaliningrad", "Europe/Kaliningrad"),
|
||||||
|
("Europe/Kiev", "Europe/Kiev"),
|
||||||
|
("Europe/Kirov", "Europe/Kirov"),
|
||||||
|
("Europe/Kyiv", "Europe/Kyiv"),
|
||||||
|
("Europe/Lisbon", "Europe/Lisbon"),
|
||||||
|
("Europe/Ljubljana", "Europe/Ljubljana"),
|
||||||
|
("Europe/London", "Europe/London"),
|
||||||
|
("Europe/Luxembourg", "Europe/Luxembourg"),
|
||||||
|
("Europe/Madrid", "Europe/Madrid"),
|
||||||
|
("Europe/Malta", "Europe/Malta"),
|
||||||
|
("Europe/Mariehamn", "Europe/Mariehamn"),
|
||||||
|
("Europe/Minsk", "Europe/Minsk"),
|
||||||
|
("Europe/Monaco", "Europe/Monaco"),
|
||||||
|
("Europe/Moscow", "Europe/Moscow"),
|
||||||
|
("Europe/Nicosia", "Europe/Nicosia"),
|
||||||
|
("Europe/Oslo", "Europe/Oslo"),
|
||||||
|
("Europe/Paris", "Europe/Paris"),
|
||||||
|
("Europe/Podgorica", "Europe/Podgorica"),
|
||||||
|
("Europe/Prague", "Europe/Prague"),
|
||||||
|
("Europe/Riga", "Europe/Riga"),
|
||||||
|
("Europe/Rome", "Europe/Rome"),
|
||||||
|
("Europe/Samara", "Europe/Samara"),
|
||||||
|
("Europe/San_Marino", "Europe/San_Marino"),
|
||||||
|
("Europe/Sarajevo", "Europe/Sarajevo"),
|
||||||
|
("Europe/Saratov", "Europe/Saratov"),
|
||||||
|
("Europe/Simferopol", "Europe/Simferopol"),
|
||||||
|
("Europe/Skopje", "Europe/Skopje"),
|
||||||
|
("Europe/Sofia", "Europe/Sofia"),
|
||||||
|
("Europe/Stockholm", "Europe/Stockholm"),
|
||||||
|
("Europe/Tallinn", "Europe/Tallinn"),
|
||||||
|
("Europe/Tirane", "Europe/Tirane"),
|
||||||
|
("Europe/Tiraspol", "Europe/Tiraspol"),
|
||||||
|
("Europe/Ulyanovsk", "Europe/Ulyanovsk"),
|
||||||
|
("Europe/Uzhgorod", "Europe/Uzhgorod"),
|
||||||
|
("Europe/Vaduz", "Europe/Vaduz"),
|
||||||
|
("Europe/Vatican", "Europe/Vatican"),
|
||||||
|
("Europe/Vienna", "Europe/Vienna"),
|
||||||
|
("Europe/Vilnius", "Europe/Vilnius"),
|
||||||
|
("Europe/Volgograd", "Europe/Volgograd"),
|
||||||
|
("Europe/Warsaw", "Europe/Warsaw"),
|
||||||
|
("Europe/Zagreb", "Europe/Zagreb"),
|
||||||
|
("Europe/Zaporozhye", "Europe/Zaporozhye"),
|
||||||
|
("Europe/Zurich", "Europe/Zurich"),
|
||||||
|
("GB", "GB"),
|
||||||
|
("GB-Eire", "GB-Eire"),
|
||||||
|
("GMT", "GMT"),
|
||||||
|
("GMT+0", "GMT+0"),
|
||||||
|
("GMT-0", "GMT-0"),
|
||||||
|
("GMT0", "GMT0"),
|
||||||
|
("Greenwich", "Greenwich"),
|
||||||
|
("HST", "HST"),
|
||||||
|
("Hongkong", "Hongkong"),
|
||||||
|
("Iceland", "Iceland"),
|
||||||
|
("Indian/Antananarivo", "Indian/Antananarivo"),
|
||||||
|
("Indian/Chagos", "Indian/Chagos"),
|
||||||
|
("Indian/Christmas", "Indian/Christmas"),
|
||||||
|
("Indian/Cocos", "Indian/Cocos"),
|
||||||
|
("Indian/Comoro", "Indian/Comoro"),
|
||||||
|
("Indian/Kerguelen", "Indian/Kerguelen"),
|
||||||
|
("Indian/Mahe", "Indian/Mahe"),
|
||||||
|
("Indian/Maldives", "Indian/Maldives"),
|
||||||
|
("Indian/Mauritius", "Indian/Mauritius"),
|
||||||
|
("Indian/Mayotte", "Indian/Mayotte"),
|
||||||
|
("Indian/Reunion", "Indian/Reunion"),
|
||||||
|
("Iran", "Iran"),
|
||||||
|
("Israel", "Israel"),
|
||||||
|
("Jamaica", "Jamaica"),
|
||||||
|
("Japan", "Japan"),
|
||||||
|
("Kwajalein", "Kwajalein"),
|
||||||
|
("Libya", "Libya"),
|
||||||
|
("MET", "MET"),
|
||||||
|
("MST", "MST"),
|
||||||
|
("MST7MDT", "MST7MDT"),
|
||||||
|
("Mexico/BajaNorte", "Mexico/BajaNorte"),
|
||||||
|
("Mexico/BajaSur", "Mexico/BajaSur"),
|
||||||
|
("Mexico/General", "Mexico/General"),
|
||||||
|
("NZ", "NZ"),
|
||||||
|
("NZ-CHAT", "NZ-CHAT"),
|
||||||
|
("Navajo", "Navajo"),
|
||||||
|
("PRC", "PRC"),
|
||||||
|
("PST8PDT", "PST8PDT"),
|
||||||
|
("Pacific/Apia", "Pacific/Apia"),
|
||||||
|
("Pacific/Auckland", "Pacific/Auckland"),
|
||||||
|
("Pacific/Bougainville", "Pacific/Bougainville"),
|
||||||
|
("Pacific/Chatham", "Pacific/Chatham"),
|
||||||
|
("Pacific/Chuuk", "Pacific/Chuuk"),
|
||||||
|
("Pacific/Easter", "Pacific/Easter"),
|
||||||
|
("Pacific/Efate", "Pacific/Efate"),
|
||||||
|
("Pacific/Enderbury", "Pacific/Enderbury"),
|
||||||
|
("Pacific/Fakaofo", "Pacific/Fakaofo"),
|
||||||
|
("Pacific/Fiji", "Pacific/Fiji"),
|
||||||
|
("Pacific/Funafuti", "Pacific/Funafuti"),
|
||||||
|
("Pacific/Galapagos", "Pacific/Galapagos"),
|
||||||
|
("Pacific/Gambier", "Pacific/Gambier"),
|
||||||
|
("Pacific/Guadalcanal", "Pacific/Guadalcanal"),
|
||||||
|
("Pacific/Guam", "Pacific/Guam"),
|
||||||
|
("Pacific/Honolulu", "Pacific/Honolulu"),
|
||||||
|
("Pacific/Johnston", "Pacific/Johnston"),
|
||||||
|
("Pacific/Kanton", "Pacific/Kanton"),
|
||||||
|
("Pacific/Kiritimati", "Pacific/Kiritimati"),
|
||||||
|
("Pacific/Kosrae", "Pacific/Kosrae"),
|
||||||
|
("Pacific/Kwajalein", "Pacific/Kwajalein"),
|
||||||
|
("Pacific/Majuro", "Pacific/Majuro"),
|
||||||
|
("Pacific/Marquesas", "Pacific/Marquesas"),
|
||||||
|
("Pacific/Midway", "Pacific/Midway"),
|
||||||
|
("Pacific/Nauru", "Pacific/Nauru"),
|
||||||
|
("Pacific/Niue", "Pacific/Niue"),
|
||||||
|
("Pacific/Norfolk", "Pacific/Norfolk"),
|
||||||
|
("Pacific/Noumea", "Pacific/Noumea"),
|
||||||
|
("Pacific/Pago_Pago", "Pacific/Pago_Pago"),
|
||||||
|
("Pacific/Palau", "Pacific/Palau"),
|
||||||
|
("Pacific/Pitcairn", "Pacific/Pitcairn"),
|
||||||
|
("Pacific/Pohnpei", "Pacific/Pohnpei"),
|
||||||
|
("Pacific/Ponape", "Pacific/Ponape"),
|
||||||
|
("Pacific/Port_Moresby", "Pacific/Port_Moresby"),
|
||||||
|
("Pacific/Rarotonga", "Pacific/Rarotonga"),
|
||||||
|
("Pacific/Saipan", "Pacific/Saipan"),
|
||||||
|
("Pacific/Samoa", "Pacific/Samoa"),
|
||||||
|
("Pacific/Tahiti", "Pacific/Tahiti"),
|
||||||
|
("Pacific/Tarawa", "Pacific/Tarawa"),
|
||||||
|
("Pacific/Tongatapu", "Pacific/Tongatapu"),
|
||||||
|
("Pacific/Truk", "Pacific/Truk"),
|
||||||
|
("Pacific/Wake", "Pacific/Wake"),
|
||||||
|
("Pacific/Wallis", "Pacific/Wallis"),
|
||||||
|
("Pacific/Yap", "Pacific/Yap"),
|
||||||
|
("Poland", "Poland"),
|
||||||
|
("Portugal", "Portugal"),
|
||||||
|
("ROC", "ROC"),
|
||||||
|
("ROK", "ROK"),
|
||||||
|
("Singapore", "Singapore"),
|
||||||
|
("Turkey", "Turkey"),
|
||||||
|
("UCT", "UCT"),
|
||||||
|
("US/Alaska", "US/Alaska"),
|
||||||
|
("US/Aleutian", "US/Aleutian"),
|
||||||
|
("US/Arizona", "US/Arizona"),
|
||||||
|
("US/Central", "US/Central"),
|
||||||
|
("US/East-Indiana", "US/East-Indiana"),
|
||||||
|
("US/Eastern", "US/Eastern"),
|
||||||
|
("US/Hawaii", "US/Hawaii"),
|
||||||
|
("US/Indiana-Starke", "US/Indiana-Starke"),
|
||||||
|
("US/Michigan", "US/Michigan"),
|
||||||
|
("US/Mountain", "US/Mountain"),
|
||||||
|
("US/Pacific", "US/Pacific"),
|
||||||
|
("US/Samoa", "US/Samoa"),
|
||||||
|
("UTC", "UTC"),
|
||||||
|
("Universal", "Universal"),
|
||||||
|
("W-SU", "W-SU"),
|
||||||
|
("WET", "WET"),
|
||||||
|
("Zulu", "Zulu"),
|
||||||
|
],
|
||||||
|
default="UTC",
|
||||||
|
max_length=255,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
65
bookwyrm/migrations/0158_auto_20220919_1634.py
Normal file
65
bookwyrm/migrations/0158_auto_20220919_1634.py
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
# Generated by Django 3.2.15 on 2022-09-19 16:34
|
||||||
|
|
||||||
|
import bookwyrm.models.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.utils.timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0157_auto_20220909_2338"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="automod",
|
||||||
|
name="created_date",
|
||||||
|
field=models.DateTimeField(
|
||||||
|
auto_now_add=True, default=django.utils.timezone.now
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="automod",
|
||||||
|
name="remote_id",
|
||||||
|
field=bookwyrm.models.fields.RemoteIdField(
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
validators=[bookwyrm.models.fields.validate_remote_id],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="automod",
|
||||||
|
name="updated_date",
|
||||||
|
field=models.DateTimeField(auto_now=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="emailblocklist",
|
||||||
|
name="remote_id",
|
||||||
|
field=bookwyrm.models.fields.RemoteIdField(
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
validators=[bookwyrm.models.fields.validate_remote_id],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="emailblocklist",
|
||||||
|
name="updated_date",
|
||||||
|
field=models.DateTimeField(auto_now=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="ipblocklist",
|
||||||
|
name="remote_id",
|
||||||
|
field=bookwyrm.models.fields.RemoteIdField(
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
validators=[bookwyrm.models.fields.validate_remote_id],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="ipblocklist",
|
||||||
|
name="updated_date",
|
||||||
|
field=models.DateTimeField(auto_now=True),
|
||||||
|
),
|
||||||
|
]
|
33
bookwyrm/migrations/0159_auto_20220924_0634.py
Normal file
33
bookwyrm/migrations/0159_auto_20220924_0634.py
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
# Generated by Django 3.2.15 on 2022-09-24 06:34
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0158_auto_20220919_1634"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="hotp_count",
|
||||||
|
field=models.IntegerField(blank=True, default=0, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="hotp_secret",
|
||||||
|
field=models.CharField(blank=True, default=None, max_length=32, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="otp_secret",
|
||||||
|
field=models.CharField(blank=True, default=None, max_length=32, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="two_factor_auth",
|
||||||
|
field=models.BooleanField(blank=True, default=None, null=True),
|
||||||
|
),
|
||||||
|
]
|
52
bookwyrm/migrations/0160_auto_20221101_2251.py
Normal file
52
bookwyrm/migrations/0160_auto_20221101_2251.py
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
# Generated by Django 3.2.15 on 2022-11-01 22:51
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0159_auto_20220924_0634"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="allow_reactivation",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="connector",
|
||||||
|
name="deactivation_reason",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("pending", "Pending"),
|
||||||
|
("self_deletion", "Self deletion"),
|
||||||
|
("self_deactivation", "Self deactivation"),
|
||||||
|
("moderator_suspension", "Moderator suspension"),
|
||||||
|
("moderator_deletion", "Moderator deletion"),
|
||||||
|
("domain_block", "Domain block"),
|
||||||
|
],
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="deactivation_reason",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("pending", "Pending"),
|
||||||
|
("self_deletion", "Self deletion"),
|
||||||
|
("self_deactivation", "Self deactivation"),
|
||||||
|
("moderator_suspension", "Moderator suspension"),
|
||||||
|
("moderator_deletion", "Moderator deletion"),
|
||||||
|
("domain_block", "Domain block"),
|
||||||
|
],
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
32
bookwyrm/migrations/0160_auto_20221105_2030.py
Normal file
32
bookwyrm/migrations/0160_auto_20221105_2030.py
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
# Generated by Django 3.2.15 on 2022-11-05 20:30
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0159_auto_20220924_0634"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="importitem",
|
||||||
|
name="task_id",
|
||||||
|
field=models.CharField(blank=True, max_length=200, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="importjob",
|
||||||
|
name="status",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("pending", "Pending"),
|
||||||
|
("active", "Active"),
|
||||||
|
("complete", "Complete"),
|
||||||
|
("stopped", "Stopped"),
|
||||||
|
],
|
||||||
|
max_length=50,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
28
bookwyrm/migrations/0161_alter_importjob_status.py
Normal file
28
bookwyrm/migrations/0161_alter_importjob_status.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
# Generated by Django 3.2.15 on 2022-11-05 20:40
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0160_auto_20221105_2030"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="importjob",
|
||||||
|
name="status",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("pending", "Pending"),
|
||||||
|
("active", "Active"),
|
||||||
|
("complete", "Complete"),
|
||||||
|
("stopped", "Stopped"),
|
||||||
|
],
|
||||||
|
default="pending",
|
||||||
|
max_length=50,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
18
bookwyrm/migrations/0162_importjob_task_id.py
Normal file
18
bookwyrm/migrations/0162_importjob_task_id.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 3.2.15 on 2022-11-05 22:28
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0161_alter_importjob_status"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="importjob",
|
||||||
|
name="task_id",
|
||||||
|
field=models.CharField(blank=True, max_length=200, null=True),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Generated by Django 3.2.15 on 2022-11-10 20:34
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0160_auto_20221101_2251"),
|
||||||
|
("bookwyrm", "0162_importjob_task_id"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = []
|
18
bookwyrm/migrations/0164_status_ready.py
Normal file
18
bookwyrm/migrations/0164_status_ready.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 3.2.16 on 2022-11-15 21:40
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0163_merge_0160_auto_20221101_2251_0162_importjob_task_id"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="status",
|
||||||
|
name="ready",
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
]
|
18
bookwyrm/migrations/0165_alter_inviterequest_answer.py
Normal file
18
bookwyrm/migrations/0165_alter_inviterequest_answer.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 3.2.16 on 2022-11-15 22:43
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0164_status_ready"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="inviterequest",
|
||||||
|
name="answer",
|
||||||
|
field=models.TextField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,14 +1,15 @@
|
||||||
""" activitypub model functionality """
|
""" activitypub model functionality """
|
||||||
|
import asyncio
|
||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
import json
|
import json
|
||||||
import operator
|
import operator
|
||||||
import logging
|
import logging
|
||||||
|
from typing import List
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
import requests
|
|
||||||
from requests.exceptions import RequestException
|
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
from Crypto.PublicKey import RSA
|
from Crypto.PublicKey import RSA
|
||||||
from Crypto.Signature import pkcs1_15
|
from Crypto.Signature import pkcs1_15
|
||||||
from Crypto.Hash import SHA256
|
from Crypto.Hash import SHA256
|
||||||
|
@ -136,7 +137,7 @@ class ActivitypubMixin:
|
||||||
queue=queue,
|
queue=queue,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_recipients(self, software=None):
|
def get_recipients(self, software=None) -> List[str]:
|
||||||
"""figure out which inbox urls to post to"""
|
"""figure out which inbox urls to post to"""
|
||||||
# first we have to figure out who should receive this activity
|
# first we have to figure out who should receive this activity
|
||||||
privacy = self.privacy if hasattr(self, "privacy") else "public"
|
privacy = self.privacy if hasattr(self, "privacy") else "public"
|
||||||
|
@ -506,19 +507,31 @@ def unfurl_related_field(related_field, sort_field=None):
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue=MEDIUM)
|
@app.task(queue=MEDIUM)
|
||||||
def broadcast_task(sender_id, activity, recipients):
|
def broadcast_task(sender_id: int, activity: str, recipients: List[str]):
|
||||||
"""the celery task for broadcast"""
|
"""the celery task for broadcast"""
|
||||||
user_model = apps.get_model("bookwyrm.User", require_ready=True)
|
user_model = apps.get_model("bookwyrm.User", require_ready=True)
|
||||||
sender = user_model.objects.get(id=sender_id)
|
sender = user_model.objects.select_related("key_pair").get(id=sender_id)
|
||||||
|
asyncio.run(async_broadcast(recipients, sender, activity))
|
||||||
|
|
||||||
|
|
||||||
|
async def async_broadcast(recipients: List[str], sender, data: str):
|
||||||
|
"""Send all the broadcasts simultaneously"""
|
||||||
|
timeout = aiohttp.ClientTimeout(total=10)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
tasks = []
|
||||||
for recipient in recipients:
|
for recipient in recipients:
|
||||||
try:
|
tasks.append(
|
||||||
sign_and_send(sender, activity, recipient)
|
asyncio.ensure_future(sign_and_send(session, sender, data, recipient))
|
||||||
except RequestException:
|
)
|
||||||
pass
|
|
||||||
|
results = await asyncio.gather(*tasks)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
def sign_and_send(sender, data, destination):
|
async def sign_and_send(
|
||||||
"""crpyto whatever and http junk"""
|
session: aiohttp.ClientSession, sender, data: str, destination: str
|
||||||
|
):
|
||||||
|
"""Sign the messages and send them in an asynchronous bundle"""
|
||||||
now = http_date()
|
now = http_date()
|
||||||
|
|
||||||
if not sender.key_pair.private_key:
|
if not sender.key_pair.private_key:
|
||||||
|
@ -527,20 +540,25 @@ def sign_and_send(sender, data, destination):
|
||||||
|
|
||||||
digest = make_digest(data)
|
digest = make_digest(data)
|
||||||
|
|
||||||
response = requests.post(
|
headers = {
|
||||||
destination,
|
|
||||||
data=data,
|
|
||||||
headers={
|
|
||||||
"Date": now,
|
"Date": now,
|
||||||
"Digest": digest,
|
"Digest": digest,
|
||||||
"Signature": make_signature(sender, destination, now, digest),
|
"Signature": make_signature(sender, destination, now, digest),
|
||||||
"Content-Type": "application/activity+json; charset=utf-8",
|
"Content-Type": "application/activity+json; charset=utf-8",
|
||||||
"User-Agent": USER_AGENT,
|
"User-Agent": USER_AGENT,
|
||||||
},
|
}
|
||||||
)
|
|
||||||
|
try:
|
||||||
|
async with session.post(destination, data=data, headers=headers) as response:
|
||||||
if not response.ok:
|
if not response.ok:
|
||||||
response.raise_for_status()
|
logger.exception(
|
||||||
|
"Failed to send broadcast to %s: %s", destination, response.reason
|
||||||
|
)
|
||||||
return response
|
return response
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.info("Connection timed out for url: %s", destination)
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
logger.exception(err)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
|
|
|
@ -3,18 +3,33 @@ from functools import reduce
|
||||||
import operator
|
import operator
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from bookwyrm.tasks import app
|
from bookwyrm.tasks import app, LOW
|
||||||
|
from .base_model import BookWyrmModel
|
||||||
from .user import User
|
from .user import User
|
||||||
|
|
||||||
|
|
||||||
class EmailBlocklist(models.Model):
|
class AdminModel(BookWyrmModel):
|
||||||
|
"""Overrides the permissions methods"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
"""this is just here to provide default fields for other models"""
|
||||||
|
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
def raise_not_editable(self, viewer):
|
||||||
|
if viewer.has_perm("bookwyrm.moderate_user"):
|
||||||
|
return
|
||||||
|
raise PermissionDenied()
|
||||||
|
|
||||||
|
|
||||||
|
class EmailBlocklist(AdminModel):
|
||||||
"""blocked email addresses"""
|
"""blocked email addresses"""
|
||||||
|
|
||||||
created_date = models.DateTimeField(auto_now_add=True)
|
|
||||||
domain = models.CharField(max_length=255, unique=True)
|
domain = models.CharField(max_length=255, unique=True)
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
@ -29,10 +44,9 @@ class EmailBlocklist(models.Model):
|
||||||
return User.objects.filter(email__endswith=f"@{self.domain}")
|
return User.objects.filter(email__endswith=f"@{self.domain}")
|
||||||
|
|
||||||
|
|
||||||
class IPBlocklist(models.Model):
|
class IPBlocklist(AdminModel):
|
||||||
"""blocked ip addresses"""
|
"""blocked ip addresses"""
|
||||||
|
|
||||||
created_date = models.DateTimeField(auto_now_add=True)
|
|
||||||
address = models.CharField(max_length=255, unique=True)
|
address = models.CharField(max_length=255, unique=True)
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
@ -42,7 +56,7 @@ class IPBlocklist(models.Model):
|
||||||
ordering = ("-created_date",)
|
ordering = ("-created_date",)
|
||||||
|
|
||||||
|
|
||||||
class AutoMod(models.Model):
|
class AutoMod(AdminModel):
|
||||||
"""rules to automatically flag suspicious activity"""
|
"""rules to automatically flag suspicious activity"""
|
||||||
|
|
||||||
string_match = models.CharField(max_length=200, unique=True)
|
string_match = models.CharField(max_length=200, unique=True)
|
||||||
|
@ -51,7 +65,7 @@ class AutoMod(models.Model):
|
||||||
created_by = models.ForeignKey("User", on_delete=models.PROTECT)
|
created_by = models.ForeignKey("User", on_delete=models.PROTECT)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def automod_task():
|
def automod_task():
|
||||||
"""Create reports"""
|
"""Create reports"""
|
||||||
if not AutoMod.objects.exists():
|
if not AutoMod.objects.exists():
|
||||||
|
@ -61,17 +75,14 @@ def automod_task():
|
||||||
if not reports:
|
if not reports:
|
||||||
return
|
return
|
||||||
|
|
||||||
admins = User.objects.filter(
|
admins = User.admins()
|
||||||
models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
|
|
||||||
| models.Q(is_superuser=True)
|
|
||||||
).all()
|
|
||||||
notification_model = apps.get_model("bookwyrm", "Notification", require_ready=True)
|
notification_model = apps.get_model("bookwyrm", "Notification", require_ready=True)
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
for admin in admins:
|
for admin in admins:
|
||||||
notification, _ = notification_model.objects.get_or_create(
|
notification, _ = notification_model.objects.get_or_create(
|
||||||
user=admin, notification_type=notification_model.REPORT, read=False
|
user=admin, notification_type=notification_model.REPORT, read=False
|
||||||
)
|
)
|
||||||
notification.related_repors.add(reports)
|
notification.related_reports.set(reports)
|
||||||
|
|
||||||
|
|
||||||
def automod_users(reporter):
|
def automod_users(reporter):
|
||||||
|
|
|
@ -42,6 +42,11 @@ class Author(BookDataModel):
|
||||||
for book in self.book_set.values_list("id", flat=True)
|
for book in self.book_set.values_list("id", flat=True)
|
||||||
]
|
]
|
||||||
cache.delete_many(cache_keys)
|
cache.delete_many(cache_keys)
|
||||||
|
|
||||||
|
# normalize isni format
|
||||||
|
if self.isni:
|
||||||
|
self.isni = re.sub(r"\s", "", self.isni)
|
||||||
|
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
@ -17,6 +17,7 @@ from .fields import RemoteIdField
|
||||||
DeactivationReason = [
|
DeactivationReason = [
|
||||||
("pending", _("Pending")),
|
("pending", _("Pending")),
|
||||||
("self_deletion", _("Self deletion")),
|
("self_deletion", _("Self deletion")),
|
||||||
|
("self_deactivation", _("Self deactivation")),
|
||||||
("moderator_suspension", _("Moderator suspension")),
|
("moderator_suspension", _("Moderator suspension")),
|
||||||
("moderator_deletion", _("Moderator deletion")),
|
("moderator_deletion", _("Moderator deletion")),
|
||||||
("domain_block", _("Domain block")),
|
("domain_block", _("Domain block")),
|
||||||
|
|
|
@ -241,6 +241,10 @@ class Work(OrderedCollectionPageMixin, Book):
|
||||||
"""in case the default edition is not set"""
|
"""in case the default edition is not set"""
|
||||||
return self.editions.order_by("-edition_rank").first()
|
return self.editions.order_by("-edition_rank").first()
|
||||||
|
|
||||||
|
def author_edition(self, author):
|
||||||
|
"""in case the default edition doesn't have the required author"""
|
||||||
|
return self.editions.filter(authors=author).order_by("-edition_rank").first()
|
||||||
|
|
||||||
def to_edition_list(self, **kwargs):
|
def to_edition_list(self, **kwargs):
|
||||||
"""an ordered collection of editions"""
|
"""an ordered collection of editions"""
|
||||||
return self.to_ordered_collection(
|
return self.to_ordered_collection(
|
||||||
|
|
|
@ -1,12 +1,25 @@
|
||||||
""" track progress of goodreads imports """
|
""" track progress of goodreads imports """
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
import dateutil.parser
|
import dateutil.parser
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from bookwyrm.connectors import connector_manager
|
from bookwyrm.connectors import connector_manager
|
||||||
from bookwyrm.models import ReadThrough, User, Book, Edition
|
from bookwyrm.models import (
|
||||||
|
User,
|
||||||
|
Book,
|
||||||
|
Edition,
|
||||||
|
Work,
|
||||||
|
ShelfBook,
|
||||||
|
Shelf,
|
||||||
|
ReadThrough,
|
||||||
|
Review,
|
||||||
|
ReviewRating,
|
||||||
|
)
|
||||||
|
from bookwyrm.tasks import app, LOW
|
||||||
from .fields import PrivacyLevels
|
from .fields import PrivacyLevels
|
||||||
|
|
||||||
|
|
||||||
|
@ -30,6 +43,14 @@ def construct_search_term(title, author):
|
||||||
return " ".join([title, author])
|
return " ".join([title, author])
|
||||||
|
|
||||||
|
|
||||||
|
ImportStatuses = [
|
||||||
|
("pending", _("Pending")),
|
||||||
|
("active", _("Active")),
|
||||||
|
("complete", _("Complete")),
|
||||||
|
("stopped", _("Stopped")),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class ImportJob(models.Model):
|
class ImportJob(models.Model):
|
||||||
"""entry for a specific request for book data import"""
|
"""entry for a specific request for book data import"""
|
||||||
|
|
||||||
|
@ -38,16 +59,78 @@ class ImportJob(models.Model):
|
||||||
updated_date = models.DateTimeField(default=timezone.now)
|
updated_date = models.DateTimeField(default=timezone.now)
|
||||||
include_reviews = models.BooleanField(default=True)
|
include_reviews = models.BooleanField(default=True)
|
||||||
mappings = models.JSONField()
|
mappings = models.JSONField()
|
||||||
complete = models.BooleanField(default=False)
|
|
||||||
source = models.CharField(max_length=100)
|
source = models.CharField(max_length=100)
|
||||||
privacy = models.CharField(max_length=255, default="public", choices=PrivacyLevels)
|
privacy = models.CharField(max_length=255, default="public", choices=PrivacyLevels)
|
||||||
retry = models.BooleanField(default=False)
|
retry = models.BooleanField(default=False)
|
||||||
|
task_id = models.CharField(max_length=200, null=True, blank=True)
|
||||||
|
|
||||||
|
complete = models.BooleanField(default=False)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=50, choices=ImportStatuses, default="pending", null=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def start_job(self):
|
||||||
|
"""Report that the job has started"""
|
||||||
|
task = start_import_task.delay(self.id)
|
||||||
|
self.task_id = task.id
|
||||||
|
|
||||||
|
self.status = "active"
|
||||||
|
self.save(update_fields=["status", "task_id"])
|
||||||
|
|
||||||
|
def complete_job(self):
|
||||||
|
"""Report that the job has completed"""
|
||||||
|
self.status = "complete"
|
||||||
|
self.complete = True
|
||||||
|
self.pending_items.update(fail_reason=_("Import stopped"))
|
||||||
|
self.save(update_fields=["status", "complete"])
|
||||||
|
|
||||||
|
def stop_job(self):
|
||||||
|
"""Stop the job"""
|
||||||
|
self.status = "stopped"
|
||||||
|
self.complete = True
|
||||||
|
self.save(update_fields=["status", "complete"])
|
||||||
|
self.pending_items.update(fail_reason=_("Import stopped"))
|
||||||
|
|
||||||
|
# stop starting
|
||||||
|
app.control.revoke(self.task_id, terminate=True)
|
||||||
|
tasks = self.pending_items.filter(task_id__isnull=False).values_list(
|
||||||
|
"task_id", flat=True
|
||||||
|
)
|
||||||
|
app.control.revoke(list(tasks))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pending_items(self):
|
def pending_items(self):
|
||||||
"""items that haven't been processed yet"""
|
"""items that haven't been processed yet"""
|
||||||
return self.items.filter(fail_reason__isnull=True, book__isnull=True)
|
return self.items.filter(fail_reason__isnull=True, book__isnull=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def item_count(self):
|
||||||
|
"""How many books do you want to import???"""
|
||||||
|
return self.items.count()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def percent_complete(self):
|
||||||
|
"""How far along?"""
|
||||||
|
item_count = self.item_count
|
||||||
|
if not item_count:
|
||||||
|
return 0
|
||||||
|
return math.floor((item_count - self.pending_item_count) / item_count * 100)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pending_item_count(self):
|
||||||
|
"""And how many pending items??"""
|
||||||
|
return self.pending_items.count()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def successful_item_count(self):
|
||||||
|
"""How many found a book?"""
|
||||||
|
return self.items.filter(book__isnull=False).count()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def failed_item_count(self):
|
||||||
|
"""How many found a book?"""
|
||||||
|
return self.items.filter(fail_reason__isnull=False).count()
|
||||||
|
|
||||||
|
|
||||||
class ImportItem(models.Model):
|
class ImportItem(models.Model):
|
||||||
"""a single line of a csv being imported"""
|
"""a single line of a csv being imported"""
|
||||||
|
@ -68,15 +151,18 @@ class ImportItem(models.Model):
|
||||||
linked_review = models.ForeignKey(
|
linked_review = models.ForeignKey(
|
||||||
"Review", on_delete=models.SET_NULL, null=True, blank=True
|
"Review", on_delete=models.SET_NULL, null=True, blank=True
|
||||||
)
|
)
|
||||||
|
task_id = models.CharField(max_length=200, null=True, blank=True)
|
||||||
|
|
||||||
def update_job(self):
|
def update_job(self):
|
||||||
"""let the job know when the items get work done"""
|
"""let the job know when the items get work done"""
|
||||||
job = self.job
|
job = self.job
|
||||||
|
if job.complete:
|
||||||
|
return
|
||||||
|
|
||||||
job.updated_date = timezone.now()
|
job.updated_date = timezone.now()
|
||||||
job.save()
|
job.save()
|
||||||
if not job.pending_items.exists() and not job.complete:
|
if not job.pending_items.exists() and not job.complete:
|
||||||
job.complete = True
|
job.complete_job()
|
||||||
job.save(update_fields=["complete"])
|
|
||||||
|
|
||||||
def resolve(self):
|
def resolve(self):
|
||||||
"""try various ways to lookup a book"""
|
"""try various ways to lookup a book"""
|
||||||
|
@ -240,3 +326,136 @@ class ImportItem(models.Model):
|
||||||
return "{} by {}".format(
|
return "{} by {}".format(
|
||||||
self.normalized_data.get("title"), self.normalized_data.get("authors")
|
self.normalized_data.get("title"), self.normalized_data.get("authors")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.task(queue=LOW)
|
||||||
|
def start_import_task(job_id):
|
||||||
|
"""trigger the child tasks for each row"""
|
||||||
|
job = ImportJob.objects.get(id=job_id)
|
||||||
|
# don't start the job if it was stopped from the UI
|
||||||
|
if job.complete:
|
||||||
|
return
|
||||||
|
|
||||||
|
# these are sub-tasks so that one big task doesn't use up all the memory in celery
|
||||||
|
for item in job.items.all():
|
||||||
|
task = import_item_task.delay(item.id)
|
||||||
|
item.task_id = task.id
|
||||||
|
item.save()
|
||||||
|
job.status = "active"
|
||||||
|
job.save()
|
||||||
|
|
||||||
|
|
||||||
|
@app.task(queue=LOW)
|
||||||
|
def import_item_task(item_id):
|
||||||
|
"""resolve a row into a book"""
|
||||||
|
item = ImportItem.objects.get(id=item_id)
|
||||||
|
# make sure the job has not been stopped
|
||||||
|
if item.job.complete:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
item.resolve()
|
||||||
|
except Exception as err: # pylint: disable=broad-except
|
||||||
|
item.fail_reason = _("Error loading book")
|
||||||
|
item.save()
|
||||||
|
item.update_job()
|
||||||
|
raise err
|
||||||
|
|
||||||
|
if item.book:
|
||||||
|
# shelves book and handles reviews
|
||||||
|
handle_imported_book(item)
|
||||||
|
else:
|
||||||
|
item.fail_reason = _("Could not find a match for book")
|
||||||
|
|
||||||
|
item.save()
|
||||||
|
item.update_job()
|
||||||
|
|
||||||
|
|
||||||
|
def handle_imported_book(item):
|
||||||
|
"""process a csv and then post about it"""
|
||||||
|
job = item.job
|
||||||
|
if job.complete:
|
||||||
|
return
|
||||||
|
|
||||||
|
user = job.user
|
||||||
|
if isinstance(item.book, Work):
|
||||||
|
item.book = item.book.default_edition
|
||||||
|
if not item.book:
|
||||||
|
item.fail_reason = _("Error loading book")
|
||||||
|
item.save()
|
||||||
|
return
|
||||||
|
if not isinstance(item.book, Edition):
|
||||||
|
item.book = item.book.edition
|
||||||
|
|
||||||
|
existing_shelf = ShelfBook.objects.filter(book=item.book, user=user).exists()
|
||||||
|
|
||||||
|
# shelve the book if it hasn't been shelved already
|
||||||
|
if item.shelf and not existing_shelf:
|
||||||
|
desired_shelf = Shelf.objects.get(identifier=item.shelf, user=user)
|
||||||
|
shelved_date = item.date_added or timezone.now()
|
||||||
|
ShelfBook(
|
||||||
|
book=item.book, shelf=desired_shelf, user=user, shelved_date=shelved_date
|
||||||
|
).save(priority=LOW)
|
||||||
|
|
||||||
|
for read in item.reads:
|
||||||
|
# check for an existing readthrough with the same dates
|
||||||
|
if ReadThrough.objects.filter(
|
||||||
|
user=user,
|
||||||
|
book=item.book,
|
||||||
|
start_date=read.start_date,
|
||||||
|
finish_date=read.finish_date,
|
||||||
|
).exists():
|
||||||
|
continue
|
||||||
|
read.book = item.book
|
||||||
|
read.user = user
|
||||||
|
read.save()
|
||||||
|
|
||||||
|
if job.include_reviews and (item.rating or item.review) and not item.linked_review:
|
||||||
|
# we don't know the publication date of the review,
|
||||||
|
# but "now" is a bad guess
|
||||||
|
published_date_guess = item.date_read or item.date_added
|
||||||
|
if item.review:
|
||||||
|
# pylint: disable=consider-using-f-string
|
||||||
|
review_title = "Review of {!r} on {!r}".format(
|
||||||
|
item.book.title,
|
||||||
|
job.source,
|
||||||
|
)
|
||||||
|
review = Review.objects.filter(
|
||||||
|
user=user,
|
||||||
|
book=item.book,
|
||||||
|
name=review_title,
|
||||||
|
rating=item.rating,
|
||||||
|
published_date=published_date_guess,
|
||||||
|
).first()
|
||||||
|
if not review:
|
||||||
|
review = Review(
|
||||||
|
user=user,
|
||||||
|
book=item.book,
|
||||||
|
name=review_title,
|
||||||
|
content=item.review,
|
||||||
|
rating=item.rating,
|
||||||
|
published_date=published_date_guess,
|
||||||
|
privacy=job.privacy,
|
||||||
|
)
|
||||||
|
review.save(software="bookwyrm", priority=LOW)
|
||||||
|
else:
|
||||||
|
# just a rating
|
||||||
|
review = ReviewRating.objects.filter(
|
||||||
|
user=user,
|
||||||
|
book=item.book,
|
||||||
|
published_date=published_date_guess,
|
||||||
|
rating=item.rating,
|
||||||
|
).first()
|
||||||
|
if not review:
|
||||||
|
review = ReviewRating(
|
||||||
|
user=user,
|
||||||
|
book=item.book,
|
||||||
|
rating=item.rating,
|
||||||
|
published_date=published_date_guess,
|
||||||
|
privacy=job.privacy,
|
||||||
|
)
|
||||||
|
review.save(software="bookwyrm", priority=LOW)
|
||||||
|
|
||||||
|
# only broadcast this review to other bookwyrm instances
|
||||||
|
item.linked_review = review
|
||||||
|
item.save()
|
||||||
|
|
|
@ -214,7 +214,7 @@ def notify_user_on_import_complete(
|
||||||
update_fields = update_fields or []
|
update_fields = update_fields or []
|
||||||
if not instance.complete or "complete" not in update_fields:
|
if not instance.complete or "complete" not in update_fields:
|
||||||
return
|
return
|
||||||
Notification.objects.create(
|
Notification.objects.get_or_create(
|
||||||
user=instance.user,
|
user=instance.user,
|
||||||
notification_type=Notification.IMPORT,
|
notification_type=Notification.IMPORT,
|
||||||
related_import=instance,
|
related_import=instance,
|
||||||
|
@ -231,10 +231,7 @@ def notify_admins_on_report(sender, instance, created, *args, **kwargs):
|
||||||
return
|
return
|
||||||
|
|
||||||
# moderators and superusers should be notified
|
# moderators and superusers should be notified
|
||||||
admins = User.objects.filter(
|
admins = User.admins()
|
||||||
models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
|
|
||||||
| models.Q(is_superuser=True)
|
|
||||||
).all()
|
|
||||||
for admin in admins:
|
for admin in admins:
|
||||||
notification, _ = Notification.objects.get_or_create(
|
notification, _ = Notification.objects.get_or_create(
|
||||||
user=admin,
|
user=admin,
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
""" flagged for moderation """
|
""" flagged for moderation """
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from bookwyrm.settings import DOMAIN
|
from bookwyrm.settings import DOMAIN
|
||||||
from .base_model import BookWyrmModel
|
from .base_model import BookWyrmModel
|
||||||
|
|
||||||
|
@ -21,6 +23,12 @@ class Report(BookWyrmModel):
|
||||||
links = models.ManyToManyField("Link", blank=True)
|
links = models.ManyToManyField("Link", blank=True)
|
||||||
resolved = models.BooleanField(default=False)
|
resolved = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
def raise_not_editable(self, viewer):
|
||||||
|
"""instead of user being the owner field, it's reporter"""
|
||||||
|
if self.reporter == viewer or viewer.has_perm("bookwyrm.moderate_user"):
|
||||||
|
return
|
||||||
|
raise PermissionDenied()
|
||||||
|
|
||||||
def get_remote_id(self):
|
def get_remote_id(self):
|
||||||
return f"https://{DOMAIN}/settings/reports/{self.id}"
|
return f"https://{DOMAIN}/settings/reports/{self.id}"
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ import datetime
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db import models, IntegrityError
|
from django.db import models, IntegrityError
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
@ -15,7 +16,23 @@ from .user import User
|
||||||
from .fields import get_absolute_url
|
from .fields import get_absolute_url
|
||||||
|
|
||||||
|
|
||||||
class SiteSettings(models.Model):
|
class SiteModel(models.Model):
|
||||||
|
"""we just need edit perms"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
"""this is just here to provide default fields for other models"""
|
||||||
|
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
# pylint: disable=no-self-use
|
||||||
|
def raise_not_editable(self, viewer):
|
||||||
|
"""Check if the user has the right permissions"""
|
||||||
|
if viewer.has_perm("bookwyrm.edit_instance_settings"):
|
||||||
|
return
|
||||||
|
raise PermissionDenied()
|
||||||
|
|
||||||
|
|
||||||
|
class SiteSettings(SiteModel):
|
||||||
"""customized settings for this instance"""
|
"""customized settings for this instance"""
|
||||||
|
|
||||||
name = models.CharField(default="BookWyrm", max_length=100)
|
name = models.CharField(default="BookWyrm", max_length=100)
|
||||||
|
@ -115,7 +132,7 @@ class SiteSettings(models.Model):
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class Theme(models.Model):
|
class Theme(SiteModel):
|
||||||
"""Theme files"""
|
"""Theme files"""
|
||||||
|
|
||||||
created_date = models.DateTimeField(auto_now_add=True)
|
created_date = models.DateTimeField(auto_now_add=True)
|
||||||
|
@ -138,6 +155,13 @@ class SiteInvite(models.Model):
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
invitees = models.ManyToManyField(User, related_name="invitees")
|
invitees = models.ManyToManyField(User, related_name="invitees")
|
||||||
|
|
||||||
|
# pylint: disable=no-self-use
|
||||||
|
def raise_not_editable(self, viewer):
|
||||||
|
"""Admins only"""
|
||||||
|
if viewer.has_perm("bookwyrm.create_invites"):
|
||||||
|
return
|
||||||
|
raise PermissionDenied()
|
||||||
|
|
||||||
def valid(self):
|
def valid(self):
|
||||||
"""make sure it hasn't expired or been used"""
|
"""make sure it hasn't expired or been used"""
|
||||||
return (self.expiry is None or self.expiry > timezone.now()) and (
|
return (self.expiry is None or self.expiry > timezone.now()) and (
|
||||||
|
@ -157,10 +181,16 @@ class InviteRequest(BookWyrmModel):
|
||||||
invite = models.ForeignKey(
|
invite = models.ForeignKey(
|
||||||
SiteInvite, on_delete=models.SET_NULL, null=True, blank=True
|
SiteInvite, on_delete=models.SET_NULL, null=True, blank=True
|
||||||
)
|
)
|
||||||
answer = models.TextField(max_length=50, unique=False, null=True, blank=True)
|
answer = models.TextField(max_length=255, unique=False, null=True, blank=True)
|
||||||
invite_sent = models.BooleanField(default=False)
|
invite_sent = models.BooleanField(default=False)
|
||||||
ignored = models.BooleanField(default=False)
|
ignored = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
def raise_not_editable(self, viewer):
|
||||||
|
"""Only check perms on edit, not create"""
|
||||||
|
if not self.id or viewer.has_perm("bookwyrm.create_invites"):
|
||||||
|
return
|
||||||
|
raise PermissionDenied()
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""don't create a request for a registered email"""
|
"""don't create a request for a registered email"""
|
||||||
if not self.id and User.objects.filter(email=self.email).exists():
|
if not self.id and User.objects.filter(email=self.email).exists():
|
||||||
|
|
|
@ -63,6 +63,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
activitypub_field="inReplyTo",
|
activitypub_field="inReplyTo",
|
||||||
)
|
)
|
||||||
thread_id = models.IntegerField(blank=True, null=True)
|
thread_id = models.IntegerField(blank=True, null=True)
|
||||||
|
# statuses get saved a few times, this indicates if they're set
|
||||||
|
ready = models.BooleanField(default=True)
|
||||||
|
|
||||||
objects = InheritanceManager()
|
objects = InheritanceManager()
|
||||||
|
|
||||||
activity_serializer = activitypub.Note
|
activity_serializer = activitypub.Note
|
||||||
|
@ -83,7 +86,6 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
|
|
||||||
if not self.reply_parent:
|
if not self.reply_parent:
|
||||||
self.thread_id = self.id
|
self.thread_id = self.id
|
||||||
|
|
||||||
super().save(broadcast=False, update_fields=["thread_id"])
|
super().save(broadcast=False, update_fields=["thread_id"])
|
||||||
|
|
||||||
def delete(self, *args, **kwargs): # pylint: disable=unused-argument
|
def delete(self, *args, **kwargs): # pylint: disable=unused-argument
|
||||||
|
@ -363,7 +365,7 @@ class Review(BookStatus):
|
||||||
default=None,
|
default=None,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
validators=[MinValueValidator(1), MaxValueValidator(5)],
|
validators=[MinValueValidator(0.5), MaxValueValidator(5)],
|
||||||
decimal_places=2,
|
decimal_places=2,
|
||||||
max_digits=3,
|
max_digits=3,
|
||||||
)
|
)
|
||||||
|
@ -399,7 +401,7 @@ class ReviewRating(Review):
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self.rating:
|
if not self.rating:
|
||||||
raise ValueError("ReviewRating object must include a numerical rating")
|
raise ValueError("ReviewRating object must include a numerical rating")
|
||||||
return super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pure_content(self):
|
def pure_content(self):
|
||||||
|
|
|
@ -5,6 +5,7 @@ from urllib.parse import urlparse
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.contrib.auth.models import AbstractUser, Group
|
from django.contrib.auth.models import AbstractUser, Group
|
||||||
from django.contrib.postgres.fields import ArrayField, CICharField
|
from django.contrib.postgres.fields import ArrayField, CICharField
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
@ -19,7 +20,7 @@ from bookwyrm.models.status import Status
|
||||||
from bookwyrm.preview_images import generate_user_preview_image_task
|
from bookwyrm.preview_images import generate_user_preview_image_task
|
||||||
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, USE_HTTPS, LANGUAGES
|
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, USE_HTTPS, LANGUAGES
|
||||||
from bookwyrm.signatures import create_key_pair
|
from bookwyrm.signatures import create_key_pair
|
||||||
from bookwyrm.tasks import app
|
from bookwyrm.tasks import app, LOW
|
||||||
from bookwyrm.utils import regex
|
from bookwyrm.utils import regex
|
||||||
from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin
|
from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin
|
||||||
from .base_model import BookWyrmModel, DeactivationReason, new_access_code
|
from .base_model import BookWyrmModel, DeactivationReason, new_access_code
|
||||||
|
@ -46,6 +47,7 @@ def site_link():
|
||||||
return f"{protocol}://{DOMAIN}"
|
return f"{protocol}://{DOMAIN}"
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=too-many-public-methods
|
||||||
class User(OrderedCollectionPageMixin, AbstractUser):
|
class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
"""a user who wants to read books"""
|
"""a user who wants to read books"""
|
||||||
|
|
||||||
|
@ -168,12 +170,19 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
max_length=255, choices=DeactivationReason, null=True, blank=True
|
max_length=255, choices=DeactivationReason, null=True, blank=True
|
||||||
)
|
)
|
||||||
deactivation_date = models.DateTimeField(null=True, blank=True)
|
deactivation_date = models.DateTimeField(null=True, blank=True)
|
||||||
|
allow_reactivation = models.BooleanField(default=False)
|
||||||
confirmation_code = models.CharField(max_length=32, default=new_access_code)
|
confirmation_code = models.CharField(max_length=32, default=new_access_code)
|
||||||
|
|
||||||
name_field = "username"
|
name_field = "username"
|
||||||
property_fields = [("following_link", "following")]
|
property_fields = [("following_link", "following")]
|
||||||
field_tracker = FieldTracker(fields=["name", "avatar"])
|
field_tracker = FieldTracker(fields=["name", "avatar"])
|
||||||
|
|
||||||
|
# two factor authentication
|
||||||
|
two_factor_auth = models.BooleanField(default=None, blank=True, null=True)
|
||||||
|
otp_secret = models.CharField(max_length=32, default=None, blank=True, null=True)
|
||||||
|
hotp_secret = models.CharField(max_length=32, default=None, blank=True, null=True)
|
||||||
|
hotp_count = models.IntegerField(default=0, blank=True, null=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def active_follower_requests(self):
|
def active_follower_requests(self):
|
||||||
"""Follow requests from active users"""
|
"""Follow requests from active users"""
|
||||||
|
@ -231,6 +240,14 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
queryset = queryset.exclude(blocks=viewer)
|
queryset = queryset.exclude(blocks=viewer)
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def admins(cls):
|
||||||
|
"""Get a queryset of the admins for this instance"""
|
||||||
|
return cls.objects.filter(
|
||||||
|
models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
|
||||||
|
| models.Q(is_superuser=True)
|
||||||
|
)
|
||||||
|
|
||||||
def update_active_date(self):
|
def update_active_date(self):
|
||||||
"""this user is here! they are doing things!"""
|
"""this user is here! they are doing things!"""
|
||||||
self.last_active_date = timezone.now()
|
self.last_active_date = timezone.now()
|
||||||
|
@ -352,12 +369,28 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
self.create_shelves()
|
self.create_shelves()
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
"""deactivate rather than delete a user"""
|
"""We don't actually delete the database entry"""
|
||||||
# pylint: disable=attribute-defined-outside-init
|
# pylint: disable=attribute-defined-outside-init
|
||||||
self.is_active = False
|
self.is_active = False
|
||||||
# skip the logic in this class's save()
|
# skip the logic in this class's save()
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def deactivate(self):
|
||||||
|
"""Disable the user but allow them to reactivate"""
|
||||||
|
# pylint: disable=attribute-defined-outside-init
|
||||||
|
self.is_active = False
|
||||||
|
self.deactivation_reason = "self_deactivation"
|
||||||
|
self.allow_reactivation = True
|
||||||
|
super().save(broadcast=False)
|
||||||
|
|
||||||
|
def reactivate(self):
|
||||||
|
"""Now you want to come back, huh?"""
|
||||||
|
# pylint: disable=attribute-defined-outside-init
|
||||||
|
self.is_active = True
|
||||||
|
self.deactivation_reason = None
|
||||||
|
self.allow_reactivation = False
|
||||||
|
super().save(broadcast=False)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def local_path(self):
|
def local_path(self):
|
||||||
"""this model doesn't inherit bookwyrm model, so here we are"""
|
"""this model doesn't inherit bookwyrm model, so here we are"""
|
||||||
|
@ -393,6 +426,12 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
editable=False,
|
editable=False,
|
||||||
).save(broadcast=False)
|
).save(broadcast=False)
|
||||||
|
|
||||||
|
def raise_not_editable(self, viewer):
|
||||||
|
"""Who can edit the user object?"""
|
||||||
|
if self == viewer or viewer.has_perm("bookwyrm.moderate_user"):
|
||||||
|
return
|
||||||
|
raise PermissionDenied()
|
||||||
|
|
||||||
|
|
||||||
class KeyPair(ActivitypubMixin, BookWyrmModel):
|
class KeyPair(ActivitypubMixin, BookWyrmModel):
|
||||||
"""public and private keys for a user"""
|
"""public and private keys for a user"""
|
||||||
|
@ -419,7 +458,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def set_remote_server(user_id):
|
def set_remote_server(user_id):
|
||||||
"""figure out the user's remote server in the background"""
|
"""figure out the user's remote server in the background"""
|
||||||
user = User.objects.get(id=user_id)
|
user = User.objects.get(id=user_id)
|
||||||
|
@ -463,7 +502,7 @@ def get_or_create_remote_server(domain, refresh=False):
|
||||||
return server
|
return server
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def get_remote_reviews(outbox):
|
def get_remote_reviews(outbox):
|
||||||
"""ingest reviews by a new remote bookwyrm user"""
|
"""ingest reviews by a new remote bookwyrm user"""
|
||||||
outbox_page = outbox + "?page=true&type=Review"
|
outbox_page = outbox + "?page=true&type=Review"
|
||||||
|
|
|
@ -16,7 +16,7 @@ from django.core.files.storage import default_storage
|
||||||
from django.db.models import Avg
|
from django.db.models import Avg
|
||||||
|
|
||||||
from bookwyrm import models, settings
|
from bookwyrm import models, settings
|
||||||
from bookwyrm.tasks import app
|
from bookwyrm.tasks import app, LOW
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -401,7 +401,7 @@ def save_and_cleanup(image, instance=None):
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def generate_site_preview_image_task():
|
def generate_site_preview_image_task():
|
||||||
"""generate preview_image for the website"""
|
"""generate preview_image for the website"""
|
||||||
if not settings.ENABLE_PREVIEW_IMAGES:
|
if not settings.ENABLE_PREVIEW_IMAGES:
|
||||||
|
@ -426,7 +426,7 @@ def generate_site_preview_image_task():
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def generate_edition_preview_image_task(book_id):
|
def generate_edition_preview_image_task(book_id):
|
||||||
"""generate preview_image for a book"""
|
"""generate preview_image for a book"""
|
||||||
if not settings.ENABLE_PREVIEW_IMAGES:
|
if not settings.ENABLE_PREVIEW_IMAGES:
|
||||||
|
@ -451,7 +451,7 @@ def generate_edition_preview_image_task(book_id):
|
||||||
save_and_cleanup(image, instance=book)
|
save_and_cleanup(image, instance=book)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def generate_user_preview_image_task(user_id):
|
def generate_user_preview_image_task(user_id):
|
||||||
"""generate preview_image for a book"""
|
"""generate preview_image for a book"""
|
||||||
if not settings.ENABLE_PREVIEW_IMAGES:
|
if not settings.ENABLE_PREVIEW_IMAGES:
|
||||||
|
|
|
@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _
|
||||||
env = Env()
|
env = Env()
|
||||||
env.read_env()
|
env.read_env()
|
||||||
DOMAIN = env("DOMAIN")
|
DOMAIN = env("DOMAIN")
|
||||||
VERSION = "0.4.4"
|
VERSION = "0.4.6"
|
||||||
|
|
||||||
RELEASE_API = env(
|
RELEASE_API = env(
|
||||||
"RELEASE_API",
|
"RELEASE_API",
|
||||||
|
@ -21,7 +21,7 @@ RELEASE_API = env(
|
||||||
PAGE_LENGTH = env("PAGE_LENGTH", 15)
|
PAGE_LENGTH = env("PAGE_LENGTH", 15)
|
||||||
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
|
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
|
||||||
|
|
||||||
JS_CACHE = "e678183b"
|
JS_CACHE = "e678183c"
|
||||||
|
|
||||||
# email
|
# email
|
||||||
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
|
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
|
||||||
|
@ -149,6 +149,9 @@ LOGGING = {
|
||||||
"require_debug_true": {
|
"require_debug_true": {
|
||||||
"()": "django.utils.log.RequireDebugTrue",
|
"()": "django.utils.log.RequireDebugTrue",
|
||||||
},
|
},
|
||||||
|
"ignore_missing_variable": {
|
||||||
|
"()": "bookwyrm.utils.log.IgnoreVariableDoesNotExist",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"handlers": {
|
"handlers": {
|
||||||
# Overrides the default handler to make it log to console
|
# Overrides the default handler to make it log to console
|
||||||
|
@ -156,6 +159,7 @@ LOGGING = {
|
||||||
# console if DEBUG=False)
|
# console if DEBUG=False)
|
||||||
"console": {
|
"console": {
|
||||||
"level": LOG_LEVEL,
|
"level": LOG_LEVEL,
|
||||||
|
"filters": ["ignore_missing_variable"],
|
||||||
"class": "logging.StreamHandler",
|
"class": "logging.StreamHandler",
|
||||||
},
|
},
|
||||||
# This is copied as-is from the default logger, and is
|
# This is copied as-is from the default logger, and is
|
||||||
|
@ -361,6 +365,9 @@ OTEL_EXPORTER_OTLP_ENDPOINT = env("OTEL_EXPORTER_OTLP_ENDPOINT", None)
|
||||||
OTEL_EXPORTER_OTLP_HEADERS = env("OTEL_EXPORTER_OTLP_HEADERS", None)
|
OTEL_EXPORTER_OTLP_HEADERS = env("OTEL_EXPORTER_OTLP_HEADERS", None)
|
||||||
OTEL_SERVICE_NAME = env("OTEL_SERVICE_NAME", None)
|
OTEL_SERVICE_NAME = env("OTEL_SERVICE_NAME", None)
|
||||||
|
|
||||||
|
TWO_FACTOR_LOGIN_MAX_SECONDS = 60
|
||||||
|
|
||||||
|
|
||||||
def show_toolbar(_):
|
def show_toolbar(_):
|
||||||
"""workaround for docker"""
|
"""workaround for docker"""
|
||||||
return True
|
return True
|
||||||
|
|
|
@ -67,7 +67,7 @@ details.dropdown .dropdown-menu a:focus-visible {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 100;
|
z-index: 35;
|
||||||
}
|
}
|
||||||
|
|
||||||
details .dropdown-menu > * {
|
details .dropdown-menu > * {
|
||||||
|
|
|
@ -38,11 +38,12 @@ let BookWyrm = new (class {
|
||||||
.querySelectorAll("[data-modal-open]")
|
.querySelectorAll("[data-modal-open]")
|
||||||
.forEach((node) => node.addEventListener("click", this.handleModalButton.bind(this)));
|
.forEach((node) => node.addEventListener("click", this.handleModalButton.bind(this)));
|
||||||
|
|
||||||
document
|
document.querySelectorAll("details.dropdown").forEach((node) => {
|
||||||
.querySelectorAll("details.dropdown")
|
node.addEventListener("toggle", this.handleDetailsDropdown.bind(this));
|
||||||
.forEach((node) =>
|
node.querySelectorAll("[data-modal-open]").forEach((modal_node) =>
|
||||||
node.addEventListener("toggle", this.handleDetailsDropdown.bind(this))
|
modal_node.addEventListener("click", () => (node.open = false))
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
document
|
document
|
||||||
.querySelector("#barcode-scanner-modal")
|
.querySelector("#barcode-scanner-modal")
|
||||||
|
|
|
@ -7,7 +7,7 @@ from django.db.models import signals, Count, Q, Case, When, IntegerField
|
||||||
|
|
||||||
from bookwyrm import models
|
from bookwyrm import models
|
||||||
from bookwyrm.redis_store import RedisStore, r
|
from bookwyrm.redis_store import RedisStore, r
|
||||||
from bookwyrm.tasks import app
|
from bookwyrm.tasks import app, LOW, MEDIUM
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -237,41 +237,41 @@ def domain_level_update(sender, instance, created, update_fields=None, **kwargs)
|
||||||
# ------------------- TASKS
|
# ------------------- TASKS
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def rerank_suggestions_task(user_id):
|
def rerank_suggestions_task(user_id):
|
||||||
"""do the hard work in celery"""
|
"""do the hard work in celery"""
|
||||||
suggested_users.rerank_user_suggestions(user_id)
|
suggested_users.rerank_user_suggestions(user_id)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def rerank_user_task(user_id, update_only=False):
|
def rerank_user_task(user_id, update_only=False):
|
||||||
"""do the hard work in celery"""
|
"""do the hard work in celery"""
|
||||||
user = models.User.objects.get(id=user_id)
|
user = models.User.objects.get(id=user_id)
|
||||||
suggested_users.rerank_obj(user, update_only=update_only)
|
suggested_users.rerank_obj(user, update_only=update_only)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def remove_user_task(user_id):
|
def remove_user_task(user_id):
|
||||||
"""do the hard work in celery"""
|
"""do the hard work in celery"""
|
||||||
user = models.User.objects.get(id=user_id)
|
user = models.User.objects.get(id=user_id)
|
||||||
suggested_users.remove_object_from_related_stores(user)
|
suggested_users.remove_object_from_related_stores(user)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="medium_priority")
|
@app.task(queue=MEDIUM)
|
||||||
def remove_suggestion_task(user_id, suggested_user_id):
|
def remove_suggestion_task(user_id, suggested_user_id):
|
||||||
"""remove a specific user from a specific user's suggestions"""
|
"""remove a specific user from a specific user's suggestions"""
|
||||||
suggested_user = models.User.objects.get(id=suggested_user_id)
|
suggested_user = models.User.objects.get(id=suggested_user_id)
|
||||||
suggested_users.remove_suggestion(user_id, suggested_user)
|
suggested_users.remove_suggestion(user_id, suggested_user)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def bulk_remove_instance_task(instance_id):
|
def bulk_remove_instance_task(instance_id):
|
||||||
"""remove a bunch of users from recs"""
|
"""remove a bunch of users from recs"""
|
||||||
for user in models.User.objects.filter(federated_server__id=instance_id):
|
for user in models.User.objects.filter(federated_server__id=instance_id):
|
||||||
suggested_users.remove_object_from_related_stores(user)
|
suggested_users.remove_object_from_related_stores(user)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def bulk_add_instance_task(instance_id):
|
def bulk_add_instance_task(instance_id):
|
||||||
"""remove a bunch of users from recs"""
|
"""remove a bunch of users from recs"""
|
||||||
for user in models.User.objects.filter(federated_server__id=instance_id):
|
for user in models.User.objects.filter(federated_server__id=instance_id):
|
||||||
|
|
|
@ -23,7 +23,9 @@
|
||||||
<p class="subtitle notification has-background-primary-highlight">
|
<p class="subtitle notification has-background-primary-highlight">
|
||||||
{% blocktrans trimmed with site_name=site.name %}
|
{% blocktrans trimmed with site_name=site.name %}
|
||||||
{{ site_name }} is part of <em>BookWyrm</em>, a network of independent, self-directed communities for readers.
|
{{ site_name }} is part of <em>BookWyrm</em>, a network of independent, self-directed communities for readers.
|
||||||
While you can interact seamlessly with users anywhere in the <a href="https://joinbookwyrm.com/instances/" target="_blank">BookWyrm network</a>, this community is unique.
|
While you can interact seamlessly with users anywhere in the
|
||||||
|
<a href="https://joinbookwyrm.com/instances/" target="_blank" rel="nofollow noopener noreferrer">BookWyrm network</a>,
|
||||||
|
this community is unique.
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -88,7 +90,10 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
{% trans "Track your reading, talk about books, write reviews, and discover what to read next. Always ad-free, anti-corporate, and community-oriented, BookWyrm is human-scale software, designed to stay small and personal. If you have feature requests, bug reports, or grand dreams, <a href='https://joinbookwyrm.com/get-involved' target='_blank'>reach out</a> and make yourself heard." %}
|
{% blocktrans trimmed %}
|
||||||
|
Track your reading, talk about books, write reviews, and discover what to read next. Always ad-free, anti-corporate, and community-oriented, BookWyrm is human-scale software, designed to stay small and personal.
|
||||||
|
If you have feature requests, bug reports, or grand dreams, <a href="https://joinbookwyrm.com/get-involved" target="_blank" rel="nofollow noopener noreferrer">reach out</a> and make yourself heard.
|
||||||
|
{% endblocktrans %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
{% load markdown %}
|
{% load markdown %}
|
||||||
{% load humanize %}
|
{% load humanize %}
|
||||||
{% load utilities %}
|
{% load utilities %}
|
||||||
|
{% load book_display_tags %}
|
||||||
|
|
||||||
{% block title %}{{ author.name }}{% endblock %}
|
{% block title %}{{ author.name }}{% endblock %}
|
||||||
|
|
||||||
|
@ -66,7 +67,7 @@
|
||||||
<div class="box">
|
<div class="box">
|
||||||
{% if author.wikipedia_link %}
|
{% if author.wikipedia_link %}
|
||||||
<div>
|
<div>
|
||||||
<a itemprop="sameAs" href="{{ author.wikipedia_link }}" rel="noopener noreferrer" target="_blank">
|
<a itemprop="sameAs" href="{{ author.wikipedia_link }}" rel="nofollow noopener noreferrer" target="_blank">
|
||||||
{% trans "Wikipedia" %}
|
{% trans "Wikipedia" %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -74,7 +75,7 @@
|
||||||
|
|
||||||
{% if author.isni %}
|
{% if author.isni %}
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
<a itemprop="sameAs" href="{{ author.isni_link }}" rel="noopener noreferrer" target="_blank">
|
<a itemprop="sameAs" href="{{ author.isni_link }}" rel="nofollow noopener noreferrer" target="_blank">
|
||||||
{% trans "View ISNI record" %}
|
{% trans "View ISNI record" %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -83,7 +84,7 @@
|
||||||
{% trans "Load data" as button_text %}
|
{% trans "Load data" as button_text %}
|
||||||
{% if author.openlibrary_key %}
|
{% if author.openlibrary_key %}
|
||||||
<div class="mt-1 is-flex">
|
<div class="mt-1 is-flex">
|
||||||
<a class="mr-3" itemprop="sameAs" href="{{ author.openlibrary_link }}" target="_blank" rel="noopener noreferrer">
|
<a class="mr-3" itemprop="sameAs" href="{{ author.openlibrary_link }}" target="_blank" rel="nofollow noopener noreferrer">
|
||||||
{% trans "View on OpenLibrary" %}
|
{% trans "View on OpenLibrary" %}
|
||||||
</a>
|
</a>
|
||||||
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
|
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
|
||||||
|
@ -98,7 +99,7 @@
|
||||||
|
|
||||||
{% if author.inventaire_id %}
|
{% if author.inventaire_id %}
|
||||||
<div class="mt-1 is-flex">
|
<div class="mt-1 is-flex">
|
||||||
<a class="mr-3" itemprop="sameAs" href="{{ author.inventaire_link }}" target="_blank" rel="noopener noreferrer">
|
<a class="mr-3" itemprop="sameAs" href="{{ author.inventaire_link }}" target="_blank" rel="nofollow noopener noreferrer">
|
||||||
{% trans "View on Inventaire" %}
|
{% trans "View on Inventaire" %}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
@ -114,7 +115,7 @@
|
||||||
|
|
||||||
{% if author.librarything_key %}
|
{% if author.librarything_key %}
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
<a itemprop="sameAs" href="https://www.librarything.com/author/{{ author.librarything_key }}" target="_blank" rel="noopener noreferrer">
|
<a itemprop="sameAs" href="https://www.librarything.com/author/{{ author.librarything_key }}" target="_blank" rel="nofollow noopener noreferrer">
|
||||||
{% trans "View on LibraryThing" %}
|
{% trans "View on LibraryThing" %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -122,7 +123,7 @@
|
||||||
|
|
||||||
{% if author.goodreads_key %}
|
{% if author.goodreads_key %}
|
||||||
<div>
|
<div>
|
||||||
<a itemprop="sameAs" href="https://www.goodreads.com/author/show/{{ author.goodreads_key }}" target="_blank" rel="noopener noreferrer">
|
<a itemprop="sameAs" href="https://www.goodreads.com/author/show/{{ author.goodreads_key }}" target="_blank" rel="nofollow noopener noreferrer">
|
||||||
{% trans "View on Goodreads" %}
|
{% trans "View on Goodreads" %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -141,7 +142,7 @@
|
||||||
<h2 class="title is-4">{% blocktrans with name=author.name %}Books by {{ name }}{% endblocktrans %}</h2>
|
<h2 class="title is-4">{% blocktrans with name=author.name %}Books by {{ name }}{% endblocktrans %}</h2>
|
||||||
<div class="columns is-multiline is-mobile">
|
<div class="columns is-multiline is-mobile">
|
||||||
{% for book in books %}
|
{% for book in books %}
|
||||||
{% with book=book.default_edition %}
|
{% with book=book|author_edition:author %}
|
||||||
<div class="column is-one-fifth-tablet is-half-mobile is-flex is-flex-direction-column">
|
<div class="column is-one-fifth-tablet is-half-mobile is-flex is-flex-direction-column">
|
||||||
<div class="is-flex-grow-1">
|
<div class="is-flex-grow-1">
|
||||||
{% include 'landing/small-book.html' with book=book %}
|
{% include 'landing/small-book.html' with book=book %}
|
||||||
|
|
|
@ -131,7 +131,7 @@
|
||||||
{% trans "Load data" as button_text %}
|
{% trans "Load data" as button_text %}
|
||||||
{% if book.openlibrary_key %}
|
{% if book.openlibrary_key %}
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ book.openlibrary_link }}" target="_blank" rel="noopener noreferrer">
|
<a href="{{ book.openlibrary_link }}" target="_blank" rel="nofollow noopener noreferrer">
|
||||||
{% trans "View on OpenLibrary" %}
|
{% trans "View on OpenLibrary" %}
|
||||||
</a>
|
</a>
|
||||||
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
|
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
|
||||||
|
@ -145,7 +145,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if book.inventaire_id %}
|
{% if book.inventaire_id %}
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ book.inventaire_link }}" target="_blank" rel="noopener noreferrer">
|
<a href="{{ book.inventaire_link }}" target="_blank" rel="nofollow noopener noreferrer">
|
||||||
{% trans "View on Inventaire" %}
|
{% trans "View on Inventaire" %}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|
|
@ -78,9 +78,13 @@
|
||||||
<p class="help ml-5 mb-2">
|
<p class="help ml-5 mb-2">
|
||||||
{% with book_title=match.book_set.first.title alt_title=match.bio %}
|
{% with book_title=match.book_set.first.title alt_title=match.bio %}
|
||||||
{% if book_title %}
|
{% if book_title %}
|
||||||
<a href="{{ match.local_path }}" target="_blank">{% trans "Author of " %}<em>{{ book_title }}</em></a>
|
<a href="{{ match.local_path }}" target="_blank" rel="nofollow noopener noreferrer">{% blocktrans trimmed %}
|
||||||
|
Author of <em>{{ book_title }}</em>
|
||||||
|
{% endblocktrans %}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ match.id }}" target="_blank">{% if alt_title %}{% trans "Author of " %}<em>{{ alt_title }}</em>{% else %} {% trans "Find more information at isni.org" %}{% endif %}</a>
|
<a href="{{ match.id }}" target="_blank" rel="nofollow noopener noreferrer">{% if alt_title %}{% blocktrans trimmed %}
|
||||||
|
Author of <em>{{ alt_title }}</em>
|
||||||
|
{% endblocktrans %}{% else %}{% trans "Find more information at isni.org" %}{% endif %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -39,7 +39,7 @@
|
||||||
{% for link in links %}
|
{% for link in links %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="overflow-wrap-anywhere">
|
<td class="overflow-wrap-anywhere">
|
||||||
<a href="{{ link.url }}" target="_blank" rel="noopener noreferrer">{{ link.url }}</a>
|
<a href="{{ link.url }}" target="_blank" rel="nofollow noopener noreferrer">{{ link.url }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if link.added_by %}
|
{% if link.added_by %}
|
||||||
|
|
|
@ -28,7 +28,7 @@
|
||||||
{% for link in links.all %}
|
{% for link in links.all %}
|
||||||
{% join "verify" link.id as verify_modal %}
|
{% join "verify" link.id as verify_modal %}
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ link.url }}" rel="noopener noreferrer" target="_blank" title="{{ link.url }}" data-modal-open="{{ verify_modal }}">{{ link.name }}</a>
|
<a href="{{ link.url }}" rel="nofollow noopener noreferrer" target="_blank" title="{{ link.url }}" data-modal-open="{{ verify_modal }}">{{ link.name }}</a>
|
||||||
({{ link.filetype }})
|
({{ link.filetype }})
|
||||||
|
|
||||||
{% if link.availability != "free" %}
|
{% if link.availability != "free" %}
|
||||||
|
|
|
@ -23,7 +23,7 @@ Is that where you'd like to go?
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
|
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
|
||||||
<a href="{{ link.url }}" target="_blank" rel="noopener noreferrer" class="button is-primary">{% trans "Continue" %}</a>
|
<a href="{{ link.url }}" target="_blank" rel="nofollow noopener noreferrer" noreferrer" class="button is-primary">{% trans "Continue" %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p>
|
<p>
|
||||||
{% if report_link %}
|
{% if link_domain %}
|
||||||
|
|
||||||
{% blocktrans trimmed %}
|
{% blocktrans trimmed %}
|
||||||
@{{ reporter }} has flagged a link domain for moderation.
|
@{{ reporter }} has flagged a link domain for moderation.
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
{% if report_link %}
|
{% if link_domain %}
|
||||||
{% blocktrans trimmed %}
|
{% blocktrans trimmed %}
|
||||||
@{{ reporter }} has flagged a link domain for moderation.
|
@{{ reporter }} has flagged a link domain for moderation.
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
|
|
|
@ -119,7 +119,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: `{% trans "If you still can't find your book, you can add a record manually." %}`,
|
text: `{% trans "If you still can't find your book, you can add a record manually." %}`,
|
||||||
title: "{% trans 'Add a record manally' %}",
|
title: "{% trans 'Add a record manually' %}",
|
||||||
attachTo: {
|
attachTo: {
|
||||||
element: "#tour-manually-add-book",
|
element: "#tour-manually-add-book",
|
||||||
on: "right",
|
on: "right",
|
||||||
|
|
|
@ -7,12 +7,28 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<h1 class="title">{% trans "Import Books" %}</h1>
|
<h1 class="title">{% trans "Import Books" %}</h1>
|
||||||
|
|
||||||
|
{% if recent_avg_hours or recent_avg_minutes %}
|
||||||
|
<div class="notification">
|
||||||
|
<p>
|
||||||
|
{% if recent_avg_hours %}
|
||||||
|
{% blocktrans trimmed with hours=recent_avg_hours|floatformat:0|intcomma %}
|
||||||
|
On average, recent imports have taken {{ hours }} hours.
|
||||||
|
{% endblocktrans %}
|
||||||
|
{% else %}
|
||||||
|
{% blocktrans trimmed with minutes=recent_avg_minutes|floatformat:0|intcomma %}
|
||||||
|
On average, recent imports have taken {{ minutes }} minutes.
|
||||||
|
{% endblocktrans %}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<form class="box" name="import" action="/import" method="post" enctype="multipart/form-data">
|
<form class="box" name="import" action="/import" method="post" enctype="multipart/form-data">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column is-half">
|
<div class="column is-half">
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="source">
|
<label class="label" for="source">
|
||||||
{% trans "Data source:" %}
|
{% trans "Data source:" %}
|
||||||
|
@ -21,25 +37,29 @@
|
||||||
<div class="select">
|
<div class="select">
|
||||||
<select name="source" id="source" aria-describedby="desc_source">
|
<select name="source" id="source" aria-describedby="desc_source">
|
||||||
<option value="Goodreads" {% if current == 'Goodreads' %}selected{% endif %}>
|
<option value="Goodreads" {% if current == 'Goodreads' %}selected{% endif %}>
|
||||||
Goodreads (CSV)
|
{% trans "Goodreads (CSV)" %}
|
||||||
</option>
|
</option>
|
||||||
<option value="Storygraph" {% if current == 'Storygraph' %}selected{% endif %}>
|
<option value="Storygraph" {% if current == 'Storygraph' %}selected{% endif %}>
|
||||||
Storygraph (CSV)
|
{% trans "Storygraph (CSV)" %}
|
||||||
</option>
|
</option>
|
||||||
<option value="LibraryThing" {% if current == 'LibraryThing' %}selected{% endif %}>
|
<option value="LibraryThing" {% if current == 'LibraryThing' %}selected{% endif %}>
|
||||||
LibraryThing (TSV)
|
{% trans "LibraryThing (TSV)" %}
|
||||||
</option>
|
</option>
|
||||||
<option value="OpenLibrary" {% if current == 'OpenLibrary' %}selected{% endif %}>
|
<option value="OpenLibrary" {% if current == 'OpenLibrary' %}selected{% endif %}>
|
||||||
OpenLibrary (CSV)
|
{% trans "OpenLibrary (CSV)" %}
|
||||||
</option>
|
</option>
|
||||||
<option value="Calibre" {% if current == 'Calibre' %}selected{% endif %}>
|
<option value="Calibre" {% if current == 'Calibre' %}selected{% endif %}>
|
||||||
Calibre (CSV)
|
{% trans "Calibre (CSV)" %}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="help" id="desc_source">
|
<p class="help" id="desc_source">
|
||||||
{% trans 'You can download your Goodreads data from the <a href="https://www.goodreads.com/review/import" target="_blank" rel="noopener noreferrer">Import/Export page</a> of your Goodreads account.' %}
|
{% blocktrans trimmed %}
|
||||||
|
You can download your Goodreads data from the
|
||||||
|
<a href="https://www.goodreads.com/review/import" target="_blank" rel="nofollow noopener noreferrer">Import/Export page</a>
|
||||||
|
of your Goodreads account.
|
||||||
|
{% endblocktrans %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -69,13 +89,63 @@
|
||||||
|
|
||||||
<div class="content block">
|
<div class="content block">
|
||||||
<h2 class="title">{% trans "Recent Imports" %}</h2>
|
<h2 class="title">{% trans "Recent Imports" %}</h2>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table is-striped is-fullwidth">
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
{% trans "Date Created" %}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{% trans "Last Updated" %}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{% trans "Items" %}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{% trans "Status" %}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
{% if not jobs %}
|
{% if not jobs %}
|
||||||
<p><em>{% trans "No recent imports" %}</em></p>
|
<tr>
|
||||||
|
<td colspan="4">
|
||||||
|
<em>{% trans "No recent imports" %}</em>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<ul>
|
|
||||||
{% for job in jobs %}
|
{% for job in jobs %}
|
||||||
<li><a href="{% url 'import-status' job.id %}">{{ job.created_date | naturaltime }}</a></li>
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{% url 'import-status' job.id %}">{{ job.created_date }}</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ job.updated_date }}</td>
|
||||||
|
<td>{{ job.item_count|intcomma }}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
{% if job.status == "stopped" %}
|
||||||
|
class="tag is-danger"
|
||||||
|
{% elif job.status == "pending" %}
|
||||||
|
class="tag is-warning"
|
||||||
|
{% elif job.complete %}
|
||||||
|
class="tag"
|
||||||
|
{% else %}
|
||||||
|
class="tag is-success"
|
||||||
|
{% endif %}
|
||||||
|
>
|
||||||
|
{% if job.status %}
|
||||||
|
{{ job.status }}
|
||||||
|
{{ job.status_display }}
|
||||||
|
{% elif job.complete %}
|
||||||
|
{% trans "Complete" %}
|
||||||
|
{% else %}
|
||||||
|
{% trans "Active" %}
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include 'snippets/pagination.html' with page=jobs path=request.path %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -41,7 +41,7 @@
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if not job.complete %}
|
{% if not job.complete and show_progress %}
|
||||||
<div class="box is-processing">
|
<div class="box is-processing">
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<span class="icon icon-spinner is-pulled-left" aria-hidden="true"></span>
|
<span class="icon icon-spinner is-pulled-left" aria-hidden="true"></span>
|
||||||
|
@ -66,6 +66,13 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not job.complete %}
|
||||||
|
<form name="stop-import" action="{% url 'import-stop' job.id %}" method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="button is-danger" type="submit">{% trans "Stop import" %}</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if manual_review_count and not legacy %}
|
{% if manual_review_count and not legacy %}
|
||||||
<div class="notification">
|
<div class="notification">
|
||||||
{% blocktrans trimmed count counter=manual_review_count with display_counter=manual_review_count|intcomma %}
|
{% blocktrans trimmed count counter=manual_review_count with display_counter=manual_review_count|intcomma %}
|
||||||
|
@ -94,7 +101,7 @@
|
||||||
<div class="block">
|
<div class="block">
|
||||||
{% block actions %}{% endblock %}
|
{% block actions %}{% endblock %}
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="table is-striped">
|
<table class="table is-striped is-fullwidth">
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th>
|
||||||
{% trans "Row" %}
|
{% trans "Row" %}
|
||||||
|
@ -137,6 +144,13 @@
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
{% if not items %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6">
|
||||||
|
<em>{% trans "No items currently need review" %}</em>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<tr>
|
<tr>
|
||||||
{% block index_col %}
|
{% block index_col %}
|
||||||
|
@ -169,7 +183,7 @@
|
||||||
<p>{{ item.review|truncatechars:100 }}</p>
|
<p>{{ item.review|truncatechars:100 }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if item.linked_review %}
|
{% if item.linked_review %}
|
||||||
<a href="{{ item.linked_review.remote_id }}" target="_blank">{% trans "View imported review" %}</a>
|
<a href="{{ item.linked_review.remote_id }}" target="_blank" rel="nofollow noopener noreferrer">{% trans "View imported review" %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% block import_cols %}
|
{% block import_cols %}
|
||||||
|
|
|
@ -42,7 +42,7 @@
|
||||||
<div class="columns is-mobile">
|
<div class="columns is-mobile">
|
||||||
{% with guess=item.book_guess %}
|
{% with guess=item.book_guess %}
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
<a href="{{ item.book.local_path }}" target="_blank">
|
<a href="{{ item.book.local_path }}" target="_blank" rel="nofollow noopener noreferrer">
|
||||||
{% include 'snippets/book_cover.html' with book=guess cover_class='is-h-s' size='small' %}
|
{% include 'snippets/book_cover.html' with book=guess cover_class='is-h-s' size='small' %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
{% if show_confirmed_email %}
|
{% if show_confirmed_email %}
|
||||||
<p class="notification is-success">{% trans "Success! Email address confirmed." %}</p>
|
<p class="notification is-success">{% trans "Success! Email address confirmed." %}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form name="login-confirm" method="post" action="/login">
|
<form name="login-confirm" method="post" action="{% url 'login' %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% if show_confirmed_email %}<input type="hidden" name="first_login" value="true">{% endif %}
|
{% if show_confirmed_email %}<input type="hidden" name="first_login" value="true">{% endif %}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|
60
bookwyrm/templates/landing/reactivate.html
Normal file
60
bookwyrm/templates/landing/reactivate.html
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
{% extends 'layout.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Reactivate Account" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="title">{% trans "Reactivate Account" %}</h1>
|
||||||
|
<div class="columns is-multiline">
|
||||||
|
<div class="column is-half">
|
||||||
|
{% if login_form.non_field_errors %}
|
||||||
|
<p class="notification is-danger">{{ login_form.non_field_errors }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form name="login-confirm" method="post" action="{% url 'prefs-reactivate' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="id_localname_confirm">{% trans "Username:" %}</label>
|
||||||
|
<div class="control">
|
||||||
|
<input type="text" name="localname" maxlength="255" class="input" required="" id="id_localname_confirm" value="{{ login_form.localname.value|default:'' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="id_password_confirm">{% trans "Password:" %}</label>
|
||||||
|
<div class="control">
|
||||||
|
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password_confirm" aria-describedby="desc_password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include 'snippets/form_errors.html' with errors_list=login_form.password.errors id="desc_password" %}
|
||||||
|
</div>
|
||||||
|
<div class="field is-grouped">
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-primary" type="submit">{% trans "Reactivate account" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if site.allow_registration %}
|
||||||
|
<div class="column is-half">
|
||||||
|
<div class="box has-background-primary-light">
|
||||||
|
<h2 class="title">{% trans "Create an Account" %}</h2>
|
||||||
|
<form name="register" method="post" action="/register">
|
||||||
|
{% include 'snippets/register_form.html' %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="column">
|
||||||
|
<div class="box">
|
||||||
|
{% include 'snippets/about.html' %}
|
||||||
|
|
||||||
|
<p class="block">
|
||||||
|
<a href="{% url 'about' %}">{% trans "More about this site" %}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
|
@ -67,9 +67,27 @@
|
||||||
</form>
|
</form>
|
||||||
{% include "search/barcode_modal.html" with id="barcode-scanner-modal" %}
|
{% include "search/barcode_modal.html" with id="barcode-scanner-modal" %}
|
||||||
|
|
||||||
<button type="button" tabindex="0" class="navbar-burger pulldown-menu my-4" data-controls="main_nav" aria-expanded="false">
|
<button
|
||||||
<i class="icon icon-dots-three-vertical" aria-hidden="true"></i>
|
type="button"
|
||||||
<span class="is-sr-only">{% trans "Main navigation menu" %}</span>
|
tabindex="0"
|
||||||
|
class="navbar-burger pulldown-menu my-4 is-flex-touch is-align-items-center is-justify-content-center"
|
||||||
|
data-controls="main_nav"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="{% trans 'Main navigation menu' %}"
|
||||||
|
>
|
||||||
|
<i class="icon-dots-three-vertical" aria-hidden="true"></i>
|
||||||
|
|
||||||
|
{% with request.user.unread_notification_count as notification_count %}
|
||||||
|
<strong
|
||||||
|
class="{% if not notification_count %}is-hidden {% elif request.user.has_unread_mentions %}is-danger {% else %}is-primary {% endif %} tag is-small px-1"
|
||||||
|
data-poll-wrapper
|
||||||
|
>
|
||||||
|
<span class="is-sr-only">{% trans "Notifications" %}</span>
|
||||||
|
<strong data-poll="notifications" class="has-text-white">
|
||||||
|
{{ notification_count }}
|
||||||
|
</strong>
|
||||||
|
</strong>
|
||||||
|
{% endwith %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -150,7 +168,7 @@
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{% if request.user.is_authenticated and active_announcements.exists %}
|
{% if request.user.is_authenticated and active_announcements.exists %}
|
||||||
<div class="block is-flex-grow-1">
|
<div class="is-flex-grow-1">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{% for announcement in active_announcements %}
|
{% for announcement in active_announcements %}
|
||||||
{% include 'snippets/announcement.html' with announcement=announcement %}
|
{% include 'snippets/announcement.html' with announcement=announcement %}
|
||||||
|
@ -174,47 +192,7 @@
|
||||||
<p id="status-error-message" class="live-message notification is-danger p-3 pr-5 pl-5 is-hidden">{% trans "Error posting status" %}</p>
|
<p id="status-error-message" class="live-message notification is-danger p-3 pr-5 pl-5 is-hidden">{% trans "Error posting status" %}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="footer">
|
{% include 'snippets/footer.html' %}
|
||||||
<div class="container">
|
|
||||||
<div class="columns">
|
|
||||||
<div class="column is-one-fifth">
|
|
||||||
<p>
|
|
||||||
<a href="{% url 'about' %}">{% blocktrans with site_name=site.name %}About {{ site_name }}{% endblocktrans %}</a>
|
|
||||||
</p>
|
|
||||||
{% if site.admin_email %}
|
|
||||||
<p>
|
|
||||||
<a href="mailto:{{ site.admin_email }}">{% trans "Contact site admin" %}</a>
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
<p>
|
|
||||||
<a href="https://docs.joinbookwyrm.com/">{% trans "Documentation" %}</a>
|
|
||||||
</p>
|
|
||||||
{% if request.user.is_authenticated %}
|
|
||||||
<p id="tour-begin">
|
|
||||||
<a href="/guided-tour/True">{% trans "Guided Tour" %}</a>
|
|
||||||
<noscript>(requires JavaScript)</noscript>
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="column content is-two-fifth">
|
|
||||||
{% if site.support_link %}
|
|
||||||
<p>
|
|
||||||
<span class="icon icon-heart"></span>
|
|
||||||
{% blocktrans with site_name=site.name support_link=site.support_link support_title=site.support_title %}Support {{ site_name }} on <a href="{{ support_link }}" target="_blank">{{ support_title }}</a>{% endblocktrans %}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
<p>
|
|
||||||
{% blocktrans %}BookWyrm's source code is freely available. You can contribute or report issues on <a href="https://github.com/mouse-reeve/bookwyrm">GitHub</a>.{% endblocktrans %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% if site.footer_item %}
|
|
||||||
<div class="column">
|
|
||||||
<p>{{ site.footer_item|safe }}</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
|
@ -10,6 +10,7 @@
|
||||||
<h1 class="title">{% trans "Notifications" %}</h1>
|
<h1 class="title">{% trans "Notifications" %}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if notifications %}
|
||||||
<form name="clear" action="/notifications" method="POST" class="column is-narrow">
|
<form name="clear" action="/notifications" method="POST" class="column is-narrow">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% spaceless %}
|
{% spaceless %}
|
||||||
|
@ -19,6 +20,7 @@
|
||||||
</button>
|
</button>
|
||||||
{% endspaceless %}
|
{% endspaceless %}
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="block">
|
<div class="block">
|
||||||
|
|
|
@ -4,7 +4,14 @@
|
||||||
|
|
||||||
<div class="field mb-0">
|
<div class="field mb-0">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<a class="button is-small is-link" href="{% url 'remote-follow-page' %}?user={{ user.username }}" target="_blank" rel="noopener noreferrer" onclick="BookWyrm.displayPopUp(`{% url 'remote-follow-page' %}?user={{ user.username }}`, `remoteFollow`); return false;" aria-describedby="remote_follow_warning">
|
<a
|
||||||
|
class="button is-small is-link"
|
||||||
|
href="{% url 'remote-follow-page' %}?user={{ user.username }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="nofollow noopener noreferrer"
|
||||||
|
onclick="BookWyrm.displayPopUp(`{% url 'remote-follow-page' %}?user={{ user.username }}`, `remoteFollow`); return false;"
|
||||||
|
aria-describedby="remote_follow_warning"
|
||||||
|
>
|
||||||
{% blocktrans with username=user.localname %}Follow on Fediverse{% endblocktrans %}
|
{% blocktrans with username=user.localname %}Follow on Fediverse{% endblocktrans %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
{% load layout %}
|
{% load layout %}
|
||||||
|
{% load sass_tags %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% load utilities %}
|
{% load utilities %}
|
||||||
|
@ -9,9 +10,7 @@
|
||||||
<head>
|
<head>
|
||||||
<title>{% block title %}{% endblock %}</title>
|
<title>{% block title %}{% endblock %}</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="{% static 'css/vendor/bulma.min.css' %}">
|
<link href="{% sass_src site_theme %}" rel="stylesheet" type="text/css" />
|
||||||
<link rel="stylesheet" href="{% static 'css/vendor/icons.css' %}">
|
|
||||||
<link rel="stylesheet" href="{% static 'css/bookwyrm.css' %}">
|
|
||||||
<script>
|
<script>
|
||||||
function closeWindow() {
|
function closeWindow() {
|
||||||
window.close();
|
window.close();
|
||||||
|
|
78
bookwyrm/templates/preferences/2fa.html
Normal file
78
bookwyrm/templates/preferences/2fa.html
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
{% extends 'preferences/layout.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Two Factor Authentication" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}
|
||||||
|
{% trans "Two Factor Authentication" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="block">
|
||||||
|
{% if success %}
|
||||||
|
<div class="notification is-success is-light">
|
||||||
|
<span class="icon icon-check" aria-hidden="true"></span>
|
||||||
|
<span>
|
||||||
|
{% trans "Successfully updated 2FA settings" %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if backup_codes %}
|
||||||
|
<div class="block">
|
||||||
|
<h3>Backup codes</h3>
|
||||||
|
<div class="block">
|
||||||
|
<p>{% trans "Write down or copy and paste these codes somewhere safe." %}</p>
|
||||||
|
<p>{% trans "You must use them in order, and they will not be displayed again." %}</p>
|
||||||
|
</div>
|
||||||
|
<ul class="content" style="list-style: none;">
|
||||||
|
{% for code in backup_codes %}
|
||||||
|
<li>{{ code }}</li>
|
||||||
|
{% endfor%}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% elif request.user.two_factor_auth %}
|
||||||
|
<div class="block">
|
||||||
|
<p>{% trans "Two Factor Authentication is active on your account." %}</p>
|
||||||
|
<a class="button is-danger" href="{% url 'disable-2fa' %}">{% trans "Disable 2FA" %}</a>
|
||||||
|
</div>
|
||||||
|
<div class="block">
|
||||||
|
<p>{% trans "You can generate backup codes to use in case you do not have access to your authentication app. If you generate new codes, any backup codes previously generated will no longer work." %}</p>
|
||||||
|
<a class="button" href="{% url 'generate-2fa-backup-codes' %}">{% trans "Generate backup codes" %}</a>
|
||||||
|
</div>
|
||||||
|
{% elif password_confirmed %}
|
||||||
|
<form name="confirm-2fa" action="{% url 'conf-2fa' %}" method="post" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
<p>{% trans "Scan the QR code with your authentication app and then enter the code from your app below to confirm your app is set up." %}</p>
|
||||||
|
<div class="columns">
|
||||||
|
<section class="column is-narrow">
|
||||||
|
<figure class="m-4">{{ qrcode | safe }}</figure>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="id_otp">{% trans "Enter the code from your app:" %}</label>
|
||||||
|
{{ form.otp }}
|
||||||
|
{% include 'snippets/form_errors.html' with errors_list=form.otp.errors id="desc_otp" %}
|
||||||
|
</div>
|
||||||
|
<button class="button is-primary" type="submit">{% trans "Confirm" %}</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p>
|
||||||
|
{% trans "You can make your account more secure by using Two Factor Authentication (2FA). This will require you to enter a one-time code using a phone app like <em>Authy</em>, <em>Google Authenticator</em> or <em>Microsoft Authenticator</em> each time you log in." %}
|
||||||
|
</p>
|
||||||
|
<p> {% trans "Confirm your password to begin setting up 2FA." %}</p>
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-one-third">
|
||||||
|
<form name="confirm-password" action="{% url 'prefs-2fa' %}" method="post" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="id_password">{% trans "Password:" %}</label>
|
||||||
|
{{ form.password }}
|
||||||
|
{% include 'snippets/form_errors.html' with errors_list=form.password.errors id="desc_password" %}
|
||||||
|
</div>
|
||||||
|
<button class="button is-primary" type="submit">{% trans "Set up 2FA" %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -8,8 +8,23 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
|
<div class="block">
|
||||||
|
<h2 class="title is-4">{% trans "Deactivate account" %}</h2>
|
||||||
|
<div class="box">
|
||||||
|
<p class="notification is-link is-light">
|
||||||
|
{% trans "Your account will be hidden. You can log back in at any time to re-activate your account." %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form name="deactivate-user" action="{% url 'prefs-deactivate' %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="button is-link">{% trans "Deactivate Account" %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<h2 class="title is-4">{% trans "Permanently delete account" %}</h2>
|
<h2 class="title is-4">{% trans "Permanently delete account" %}</h2>
|
||||||
|
<div class="box">
|
||||||
<p class="notification is-danger is-light">
|
<p class="notification is-danger is-light">
|
||||||
{% trans "Deleting your account cannot be undone. The username will not be available to register in the future." %}
|
{% trans "Deleting your account cannot be undone. The username will not be available to register in the future." %}
|
||||||
</p>
|
</p>
|
||||||
|
@ -24,6 +39,6 @@
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="button is-danger">{% trans "Delete Account" %}</button>
|
<button type="submit" class="button is-danger">{% trans "Delete Account" %}</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
23
bookwyrm/templates/preferences/disable-2fa.html
Normal file
23
bookwyrm/templates/preferences/disable-2fa.html
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
{% extends 'preferences/layout.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Disable 2FA" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}
|
||||||
|
{% trans "Disable 2FA" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="block">
|
||||||
|
<h2 class="title is-4">{% trans "Disable Two Factor Authentication" %}</h2>
|
||||||
|
<p class="notification is-danger is-light">
|
||||||
|
{% trans "Disabling 2FA will allow anyone with your username and password to log in to your account." %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form name="disable-2fa" action="{% url 'disable-2fa' %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<a class="button" href="{% url 'prefs-2fa' %}">{% trans "Cancel" %}</a>
|
||||||
|
<button type="submit" class="button is-danger">{% trans "Turn off 2FA" %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -19,6 +19,10 @@
|
||||||
{% url 'prefs-password' as url %}
|
{% url 'prefs-password' as url %}
|
||||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Change Password" %}</a>
|
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Change Password" %}</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
{% url 'prefs-2fa' as url %}
|
||||||
|
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Two Factor Authentication" %}</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
{% url 'prefs-delete' as url %}
|
{% url 'prefs-delete' as url %}
|
||||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Delete Account" %}</a>
|
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Delete Account" %}</a>
|
||||||
|
|
|
@ -3,10 +3,9 @@
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
|
|
||||||
{% if results %}
|
{% if results or remote_results %}
|
||||||
{% with results|first as local_results %}
|
|
||||||
<ul class="block">
|
<ul class="block">
|
||||||
{% for result in local_results.results %}
|
{% for result in results %}
|
||||||
<li class="pd-4 mb-5 local-book-search-result" id="tour-local-book-search-result">
|
<li class="pd-4 mb-5 local-book-search-result" id="tour-local-book-search-result">
|
||||||
<div class="columns is-mobile is-gapless mb-0">
|
<div class="columns is-mobile is-gapless mb-0">
|
||||||
<div class="column is-cover">
|
<div class="column is-cover">
|
||||||
|
@ -29,25 +28,24 @@
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<div class="block">
|
<div class="block">
|
||||||
{% for result_set in results|slice:"1:" %}
|
{% for result_set in remote_results %}
|
||||||
{% if result_set.results %}
|
{% if result_set.results %}
|
||||||
<section class="mb-5">
|
<section class="mb-5">
|
||||||
{% if not result_set.connector.local %}
|
|
||||||
<details class="details-panel box" open>
|
<details class="details-panel box" open>
|
||||||
{% endif %}
|
|
||||||
{% if not result_set.connector.local %}
|
|
||||||
<summary class="is-flex is-align-items-center is-flex-wrap-wrap is-gap-2 remote-book-search-result" id="tour-remote-search-result">
|
<summary class="is-flex is-align-items-center is-flex-wrap-wrap is-gap-2 remote-book-search-result" id="tour-remote-search-result">
|
||||||
<span class="mb-0 title is-5">
|
<span class="mb-0 title is-5">
|
||||||
{% trans 'Results from' %}
|
{% trans 'Results from' %}
|
||||||
<a href="{{ result_set.connector.base_url }}" target="_blank">{{ result_set.connector.name|default:result_set.connector.identifier }}</a>
|
<a
|
||||||
|
href="{{ result_set.connector.base_url }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="nofollow noopener noreferrer"
|
||||||
|
>{{ result_set.connector.name|default:result_set.connector.identifier }}</a>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="details-close icon icon-x" aria-hidden="true"></span>
|
<span class="details-close icon icon-x" aria-hidden="true"></span>
|
||||||
</summary>
|
</summary>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="mt-5">
|
<div class="mt-5">
|
||||||
<div class="is-flex is-flex-direction-row-reverse">
|
<div class="is-flex is-flex-direction-row-reverse">
|
||||||
|
@ -63,7 +61,7 @@
|
||||||
<strong>
|
<strong>
|
||||||
<a
|
<a
|
||||||
href="{{ result.view_link|default:result.key }}"
|
href="{{ result.view_link|default:result.key }}"
|
||||||
rel="noopener noreferrer"
|
rel="nofollow noopener noreferrer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>{{ result.title }}</a>
|
>{{ result.title }}</a>
|
||||||
</strong>
|
</strong>
|
||||||
|
@ -88,17 +86,15 @@
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if not result_set.connector.local %}
|
|
||||||
</details>
|
</details>
|
||||||
{% endif %}
|
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block search_footer %}
|
||||||
<p class="block">
|
<p class="block">
|
||||||
{% if request.user.is_authenticated %}
|
{% if request.user.is_authenticated %}
|
||||||
{% if not remote %}
|
{% if not remote %}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
{% extends 'layout.html' %}
|
{% extends 'layout.html' %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load humanize %}
|
||||||
|
|
||||||
{% block title %}{% trans "Search" %}{% endblock %}
|
{% block title %}{% trans "Search" %}{% endblock %}
|
||||||
|
|
||||||
|
@ -53,17 +54,24 @@
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<section class="block" id="search-results-block">
|
<section class="block" id="search-results-block">
|
||||||
|
<p class="block">
|
||||||
{% if not results %}
|
{% if not results %}
|
||||||
<p>
|
|
||||||
<em>{% blocktrans %}No results found for "{{ query }}"{% endblocktrans %}</em>
|
<em>{% blocktrans %}No results found for "{{ query }}"{% endblocktrans %}</em>
|
||||||
</p>
|
{% else %}
|
||||||
|
<em>{% blocktrans trimmed count counter=results.paginator.count with result_count=results.paginator.count|intcomma %}
|
||||||
|
{{ result_count }} result found
|
||||||
|
{% plural %}
|
||||||
|
{{ result_count }} results found
|
||||||
|
{% endblocktrans %}</em>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</p>
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
<div>
|
<div class="block">
|
||||||
{% include 'snippets/pagination.html' with page=results path=request.path %}
|
{% include 'snippets/pagination.html' with page=results path=request.path %}
|
||||||
</div>
|
</div>
|
||||||
|
{% block search_footer %}{% endblock %}
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
109
bookwyrm/templates/settings/celery.html
Normal file
109
bookwyrm/templates/settings/celery.html
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
{% extends 'settings/layout.html' %}
|
||||||
|
{% load humanize %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load celery_tags %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Celery Status" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}{% trans "Celery Status" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
|
||||||
|
{% if queues %}
|
||||||
|
<section class="block content">
|
||||||
|
<h2>{% trans "Queues" %}</h2>
|
||||||
|
<div class="columns has-text-centered">
|
||||||
|
<div class="column is-4">
|
||||||
|
<div class="notification">
|
||||||
|
<p class="header">{% trans "Low priority" %}</p>
|
||||||
|
<p class="title is-5">{{ queues.low_priority|intcomma }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-4">
|
||||||
|
<div class="notification">
|
||||||
|
<p class="header">{% trans "Medium priority" %}</p>
|
||||||
|
<p class="title is-5">{{ queues.medium_priority|intcomma }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-4">
|
||||||
|
<div class="notification">
|
||||||
|
<p class="header">{% trans "High priority" %}</p>
|
||||||
|
<p class="title is-5">{{ queues.high_priority|intcomma }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% else %}
|
||||||
|
<div class="notification is-danger is-flex is-align-items-start">
|
||||||
|
<span class="icon icon-warning is-size-4 pr-3" aria-hidden="true"></span>
|
||||||
|
<span>
|
||||||
|
{% trans "Could not connect to Redis broker" %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if stats %}
|
||||||
|
<section class="block content">
|
||||||
|
<h2>{% trans "Active Tasks" %}</h2>
|
||||||
|
{% for worker in active_tasks.values %}
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table is-striped is-fullwidth">
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "ID" %}</th>
|
||||||
|
<th>{% trans "Task name" %}</th>
|
||||||
|
<th>{% trans "Run time" %}</th>
|
||||||
|
<th>{% trans "Priority" %}</th>
|
||||||
|
</tr>
|
||||||
|
{% if not worker %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4">
|
||||||
|
<em>{% trans "No active tasks" %}</em>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% for task in worker %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ task.id }}</td>
|
||||||
|
<td>{{ task.name|shortname }}</td>
|
||||||
|
<td>{{ task.time_start|runtime }}</td>
|
||||||
|
<td>{{ task.delivery_info.routing_key }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block content">
|
||||||
|
<h2>{% trans "Workers" %}</h2>
|
||||||
|
|
||||||
|
{% for worker_name, worker in stats.items %}
|
||||||
|
<div class="notification">
|
||||||
|
<h3>{{ worker_name }}</h3>
|
||||||
|
{% trans "Uptime:" %} {{ worker.uptime|uptime }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<div class="notification is-danger is-flex is-align-items-start">
|
||||||
|
<span class="icon icon-warning is-size-4 pr-3" aria-hidden="true"></span>
|
||||||
|
<span>
|
||||||
|
{% trans "Could not connect to Celery" %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if errors %}
|
||||||
|
<div class="block content">
|
||||||
|
<h2>{% trans "Errors" %}</h2>
|
||||||
|
{% for error in errors %}
|
||||||
|
<pre>{{ error }}</pre>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
|
@ -57,10 +57,6 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if current_version %}
|
|
||||||
{% include 'settings/dashboard/warnings/update_version.html' with warning_level="warning" fullwidth=True %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if reports %}
|
{% if reports %}
|
||||||
{% include 'settings/dashboard/warnings/reports.html' with warning_level="warning" %}
|
{% include 'settings/dashboard/warnings/reports.html' with warning_level="warning" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -59,7 +59,9 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_file">JSON data:</label>
|
<label class="label" for="id_file">JSON data:</label>
|
||||||
<aside class="help">
|
<aside class="help">
|
||||||
Expects a json file in the format provided by <a href="https://fediblock.org/" target="_blank" rel="noopener noreferrer">FediBlock</a>, with a list of entries that have <code>instance</code> and <code>url</code> fields. For example:
|
{% blocktrans trimmed %}
|
||||||
|
Expects a json file in the format provided by <a href="https://fediblock.org/" target="_blank" rel="nofollow noopener noreferrer">FediBlock</a>, with a list of entries that have <code>instance</code> and <code>url</code> fields. For example:
|
||||||
|
{% endblocktrans %}
|
||||||
<pre>
|
<pre>
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
{% extends 'components/modal.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block modal-title %}{% trans "Stop import?" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block modal-body %}
|
||||||
|
{% trans "This action cannot be un-done" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block modal-footer %}
|
||||||
|
<form name="complete-import-{{ import.id }}" action="{% url 'settings-imports-complete' import.id %}" method="POST" class="is-flex-grow-1">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="id" value="{{ list.id }}">
|
||||||
|
<div class="buttons is-right is-flex-grow-1">
|
||||||
|
<button type="button" class="button" data-modal-close>
|
||||||
|
{% trans "Cancel" %}
|
||||||
|
</button>
|
||||||
|
<button class="button is-danger" type="submit">
|
||||||
|
{% trans "Confirm" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
100
bookwyrm/templates/settings/imports/imports.html
Normal file
100
bookwyrm/templates/settings/imports/imports.html
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
{% extends 'settings/layout.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load utilities %}
|
||||||
|
{% load humanize %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Imports" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}
|
||||||
|
{% trans "Imports" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
|
||||||
|
<div class="block">
|
||||||
|
<div class="tabs">
|
||||||
|
<ul>
|
||||||
|
{% url 'settings-imports' as url %}
|
||||||
|
<li {% if request.path in url %}class="is-active" aria-current="page"{% endif %}>
|
||||||
|
<a href="{{ url }}">{% trans "Active" %}</a>
|
||||||
|
</li>
|
||||||
|
{% url 'settings-imports' status="complete" as url %}
|
||||||
|
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
|
||||||
|
<a href="{{ url }}">{% trans "Completed" %}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container block content">
|
||||||
|
<table class="table is-striped is-fullwidth">
|
||||||
|
<tr>
|
||||||
|
{% url 'settings-imports' status as url %}
|
||||||
|
<th>
|
||||||
|
{% trans "ID" %}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{% trans "User" as text %}
|
||||||
|
{% include 'snippets/table-sort-header.html' with field="user" sort=sort text=text %}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{% trans "Date Created" as text %}
|
||||||
|
{% include 'snippets/table-sort-header.html' with field="created_date" sort=sort text=text %}
|
||||||
|
</th>
|
||||||
|
{% if status != "active" %}
|
||||||
|
<th>
|
||||||
|
{% trans "Date Updated" %}
|
||||||
|
</th>
|
||||||
|
{% endif %}
|
||||||
|
<th>
|
||||||
|
{% trans "Items" %}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{% trans "Pending items" %}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{% trans "Successful items" %}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{% trans "Failed items" %}
|
||||||
|
</th>
|
||||||
|
{% if status == "active" %}
|
||||||
|
<th>{% trans "Actions" %}</th>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% for import in imports %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ import.id }}</td>
|
||||||
|
<td class="overflow-wrap-anywhere">
|
||||||
|
<a href="{% url 'settings-user' import.user.id %}">{{ import.user|username }}</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ import.created_date }}</td>
|
||||||
|
{% if status != "active" %}
|
||||||
|
<td>{{ import.updated_date }}</td>
|
||||||
|
{% endif %}
|
||||||
|
<td>{{ import.item_count|intcomma }}</td>
|
||||||
|
<td>{{ import.pending_item_count|intcomma }}</td>
|
||||||
|
<td>{{ import.successful_item_count|intcomma }}</td>
|
||||||
|
<td>{{ import.failed_item_count|intcomma }}</td>
|
||||||
|
{% if status == "active" %}
|
||||||
|
<td>
|
||||||
|
{% join "complete" import.id as modal_id %}
|
||||||
|
<button type="button" data-modal-open="{{ modal_id }}" class="button is-danger">{% trans "Stop import" %}</button>
|
||||||
|
{% include "settings/imports/complete_import_modal.html" with id=modal_id %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not imports %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6">
|
||||||
|
<em>{% trans "No matching imports found." %} </em>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include 'snippets/pagination.html' with page=imports path=request.path %}
|
||||||
|
{% endblock %}
|
||||||
|
|
|
@ -28,6 +28,7 @@
|
||||||
|
|
||||||
{% include 'settings/invites/invite_request_filters.html' %}
|
{% include 'settings/invites/invite_request_filters.html' %}
|
||||||
|
|
||||||
|
<div class="table-container content scroll-x">
|
||||||
<table class="table is-striped is-fullwidth">
|
<table class="table is-striped is-fullwidth">
|
||||||
{% url 'settings-invite-requests' as url %}
|
{% url 'settings-invite-requests' as url %}
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -108,6 +109,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{% include 'snippets/pagination.html' with page=requests path=request.path %}
|
{% include 'snippets/pagination.html' with page=requests path=request.path %}
|
||||||
|
|
||||||
{% if ignored %}
|
{% if ignored %}
|
||||||
|
|
|
@ -74,6 +74,21 @@
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if perms.edit_instance_settings %}
|
||||||
|
<h2 class="menu-label">{% trans "System" %}</h2>
|
||||||
|
<ul class="menu-list">
|
||||||
|
<li>
|
||||||
|
{% url 'settings-imports' as url %}
|
||||||
|
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Imports" %}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<ul class="menu-list">
|
||||||
|
<li>
|
||||||
|
{% url 'settings-celery' as url %}
|
||||||
|
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Celery status" %}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
{% if perms.bookwyrm.edit_instance_settings %}
|
{% if perms.bookwyrm.edit_instance_settings %}
|
||||||
<h2 class="menu-label">{% trans "Instance Settings" %}</h2>
|
<h2 class="menu-label">{% trans "Instance Settings" %}</h2>
|
||||||
<ul class="menu-list">
|
<ul class="menu-list">
|
||||||
|
|
|
@ -36,7 +36,7 @@
|
||||||
<header class="column">
|
<header class="column">
|
||||||
<h2 class="title is-5">
|
<h2 class="title is-5">
|
||||||
{{ domain.name }}
|
{{ domain.name }}
|
||||||
(<a href="http://{{ domain.domain }}" target="_blank" rel="noopener noreferrer">{{ domain.domain }}</a>)
|
(<a href="http://{{ domain.domain }}" target="_blank" rel="nofollow noopener noreferrer">{{ domain.domain }}</a>)
|
||||||
</h2>
|
</h2>
|
||||||
</header>
|
</header>
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
{% for link in links %}
|
{% for link in links %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="overflow-wrap-anywhere">
|
<td class="overflow-wrap-anywhere">
|
||||||
<a href="{{ link.url }}" target="_blank" rel="noopener noreferrer">{{ link.url }}</a>
|
<a href="{{ link.url }}" target="_blank" rel="nofollow noopener noreferrer">{{ link.url }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if link.added_by %}
|
{% if link.added_by %}
|
||||||
|
|
|
@ -54,7 +54,6 @@
|
||||||
method="POST"
|
method="POST"
|
||||||
action="{% url 'settings-themes' %}"
|
action="{% url 'settings-themes' %}"
|
||||||
class="box"
|
class="box"
|
||||||
enctype="multipart/form-data"
|
|
||||||
>
|
>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
|
@ -51,7 +51,7 @@
|
||||||
{% trans "Once the instance is set up, you can promote other users to moderator or admin roles from the admin panel." %}
|
{% trans "Once the instance is set up, you can promote other users to moderator or admin roles from the admin panel." %}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://docs.joinbookwyrm.com/moderation.html" target="_blank">
|
<a href="https://docs.joinbookwyrm.com/moderation.html" target="_blank" rel="nofollow noopener noreferrer">
|
||||||
{% trans "Learn more about moderation" %}
|
{% trans "Learn more about moderation" %}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -144,7 +144,7 @@
|
||||||
{% blocktrans trimmed %}
|
{% blocktrans trimmed %}
|
||||||
You can change your instance settings in the <code>.env</code> file on your server.
|
You can change your instance settings in the <code>.env</code> file on your server.
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
<a href="https://docs.joinbookwyrm.com/install-prod.html" target="_blank">
|
<a href="https://docs.joinbookwyrm.com/install-prod.html" target="_blank" rel="nofollow noopener noreferrer">
|
||||||
{% trans "View installation instructions" %}
|
{% trans "View installation instructions" %}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -9,13 +9,17 @@
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="navbar-brand is-flex-grow-1">
|
<div class="navbar-brand is-flex-grow-1">
|
||||||
<span class="navbar-item" href="/">
|
<span class="navbar-item" href="/">
|
||||||
<img class="image logo" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="{% blocktrans with site_name=site.name %}{{ site_name }} home page{% endblocktrans %}">
|
<img
|
||||||
|
class="image logo"
|
||||||
|
src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}"
|
||||||
|
alt="{% blocktrans with site_name=site.name %}{{ site_name }} home page{% endblocktrans %}"
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
<div class="navbar-item is-align-items-start pt-5 is-flex-grow-1">
|
<div class="navbar-item is-align-items-start pt-5 is-flex-grow-1">
|
||||||
{% trans "Installing BookWyrm" %}
|
{% trans "Installing BookWyrm" %}
|
||||||
</div>
|
</div>
|
||||||
<div class="navbar-item is-align-items-start pt-5">
|
<div class="navbar-item is-align-items-start pt-5">
|
||||||
<a href="https://joinbookwyrm.com/get-involved/#dev-chat" target="_blank">{% trans "Need help?" %}</a>
|
<a href="https://joinbookwyrm.com/get-involved/#dev-chat" target="_blank" rel="nofollow noopener noreferrer">{% trans "Need help?" %}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
57
bookwyrm/templates/snippets/footer.html
Normal file
57
bookwyrm/templates/snippets/footer.html
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-2">
|
||||||
|
<p>
|
||||||
|
<a href="{% url 'about' %}">{% blocktrans with site_name=site.name %}About {{ site_name }}{% endblocktrans %}</a>
|
||||||
|
</p>
|
||||||
|
{% if site.admin_email %}
|
||||||
|
<p>
|
||||||
|
<a href="mailto:{{ site.admin_email }}">{% trans "Contact site admin" %}</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<p>
|
||||||
|
<a href="https://docs.joinbookwyrm.com/">{% trans "Documentation" %}</a>
|
||||||
|
</p>
|
||||||
|
{% if request.user.is_authenticated %}
|
||||||
|
<p id="tour-begin">
|
||||||
|
<a href="/guided-tour/True">{% trans "Guided Tour" %}</a>
|
||||||
|
<noscript>(requires JavaScript)</noscript>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="column is-2">
|
||||||
|
<p>
|
||||||
|
<a href ="{% url 'privacy' %}">{% trans "Code of Conduct" %}</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href ="{% url 'privacy' %}">{% trans "Privacy Policy" %}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="column content">
|
||||||
|
{% if site.support_link %}
|
||||||
|
<p>
|
||||||
|
<span class="icon icon-heart"></span>
|
||||||
|
{% blocktrans trimmed with site_name=site.name support_link=site.support_link support_title=site.support_title %}
|
||||||
|
Support {{ site_name }} on
|
||||||
|
<a href="{{ support_link }}" target="_blank" rel="nofollow noopener noreferrer">{{ support_title }}</a>
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<p>
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
BookWyrm's source code is freely available. You can contribute or report issues on
|
||||||
|
<a href="https://github.com/bookwyrm-social/bookwyrm" target="_blank" rel="nofollow noopener noreferrer">GitHub</a>.
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% if site.footer_item %}
|
||||||
|
<div class="column is-4">
|
||||||
|
<p>{{ site.footer_item|safe }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
|
@ -37,7 +37,7 @@
|
||||||
type="radio"
|
type="radio"
|
||||||
name="rating"
|
name="rating"
|
||||||
value="{{ forloop.counter0 }}.5"
|
value="{{ forloop.counter0 }}.5"
|
||||||
{% if default_rating == forloop.counter %}checked{% endif %}
|
{% if default_rating > 0 and default_rating >= forloop.counter0 %}checked{% endif %}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
id="{{ type|slugify }}_book{{ book.id }}_star_{{ forloop.counter }}"
|
id="{{ type|slugify }}_book{{ book.id }}_star_{{ forloop.counter }}"
|
||||||
|
@ -45,7 +45,7 @@
|
||||||
type="radio"
|
type="radio"
|
||||||
name="rating"
|
name="rating"
|
||||||
value="{{ forloop.counter }}"
|
value="{{ forloop.counter }}"
|
||||||
{% if default_rating == forloop.counter %}checked{% endif %}
|
{% if default_rating >= forloop.counter %}checked{% endif %}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
|
|
|
@ -123,6 +123,7 @@
|
||||||
<a
|
<a
|
||||||
href="{% get_media_prefix %}{{ attachment.image }}"
|
href="{% get_media_prefix %}{{ attachment.image }}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
rel="nofollow noopener noreferrer"
|
||||||
aria-label="{% trans 'Open image in new window' %}"
|
aria-label="{% trans 'Open image in new window' %}"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
|
|
49
bookwyrm/templates/two_factor_auth/two_factor_login.html
Normal file
49
bookwyrm/templates/two_factor_auth/two_factor_login.html
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
{% load layout %}
|
||||||
|
{% load sass_tags %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{% get_lang %}">
|
||||||
|
<head>
|
||||||
|
<title>{% block title %}BookWyrm{% endblock %} - {{ site.name }}</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link href="{% sass_src site_theme %}" rel="stylesheet" type="text/css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar" aria-label="main navigation">
|
||||||
|
<div class="container">
|
||||||
|
<div class="navbar-brand">
|
||||||
|
<a class="navbar-item" href="/">
|
||||||
|
<img class="image logo" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="{% blocktrans with site_name=site.name %}{{ site_name }} home page{% endblocktrans %}">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div class="container">
|
||||||
|
<div class="columns is-multiline is-centered">
|
||||||
|
<div class="column">
|
||||||
|
<header class="block">
|
||||||
|
{% block header %}
|
||||||
|
<h1 class="title">
|
||||||
|
{% trans "2FA check" %}
|
||||||
|
</h1>
|
||||||
|
{% endblock %}
|
||||||
|
</header>
|
||||||
|
<div class="is-centered">
|
||||||
|
<form name="confirm-2fa" action="{% url 'login-with-2fa' %}" method="post" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="id_otp">{% trans "Enter the code from your authenticator app:" %}</label>
|
||||||
|
{{ form.otp }}
|
||||||
|
{% include 'snippets/form_errors.html' with errors_list=form.otp.errors id="desc_otp" %}
|
||||||
|
</div>
|
||||||
|
<button class="button is-primary" type="submit">{% trans "Confirm and Log In" %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% include 'snippets/footer.html' %}
|
||||||
|
</body>
|
||||||
|
</html>
|
45
bookwyrm/templates/two_factor_auth/two_factor_prompt.html
Normal file
45
bookwyrm/templates/two_factor_auth/two_factor_prompt.html
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
{% load layout %}
|
||||||
|
{% load sass_tags %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{% get_lang %}">
|
||||||
|
<head>
|
||||||
|
<title>{% block title %}BookWyrm{% endblock %} - {{ site.name }}</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link href="{% sass_src site_theme %}" rel="stylesheet" type="text/css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar" aria-label="main navigation">
|
||||||
|
<div class="container">
|
||||||
|
<div class="navbar-brand">
|
||||||
|
<a class="navbar-item" href="/">
|
||||||
|
<img class="image logo" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="{% blocktrans with site_name=site.name %}{{ site_name }} home page{% endblocktrans %}">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div class="container">
|
||||||
|
<div class="columns is-multiline is-centered hero is-halfheight is-vcentered">
|
||||||
|
<div class="column is-one-third m-4">
|
||||||
|
<header class="block">
|
||||||
|
{% block header %}
|
||||||
|
<h1 class="title">
|
||||||
|
{% trans "2FA is available" %}
|
||||||
|
</h1>
|
||||||
|
{% endblock %}
|
||||||
|
</header>
|
||||||
|
<div class="is-centered p-2">
|
||||||
|
<p class="block">{% trans "You can secure your account by setting up two factor authentication in your user preferences. This will require a one-time code from your phone in addition to your password each time you log in." %}</p>
|
||||||
|
<div class="block has-text-centered">
|
||||||
|
<a class="button" href="/">{% trans "No thanks" %}</a>
|
||||||
|
<a class="button is-primary" href="/preferences/2fa">{% trans "Set up 2FA" %}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% include 'snippets/footer.html' %}
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -65,7 +65,7 @@
|
||||||
<div class="columns is-mobile">
|
<div class="columns is-mobile">
|
||||||
<h2 class="title column">{% trans "User Activity" %}</h2>
|
<h2 class="title column">{% trans "User Activity" %}</h2>
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
<a target="_blank" href="{{ user.local_path }}/rss">
|
<a target="_blank" href="{{ user.local_path }}/rss" rel="nofollow noopener noreferrer">
|
||||||
<span class="icon icon-rss" aria-hidden="true"></span>
|
<span class="icon icon-rss" aria-hidden="true"></span>
|
||||||
<span class="is-hidden-mobile">{% trans "RSS feed" %}</span>
|
<span class="is-hidden-mobile">{% trans "RSS feed" %}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -8,10 +8,21 @@ register = template.Library()
|
||||||
@register.filter(name="book_description")
|
@register.filter(name="book_description")
|
||||||
def get_book_description(book):
|
def get_book_description(book):
|
||||||
"""use the work's text if the book doesn't have it"""
|
"""use the work's text if the book doesn't have it"""
|
||||||
return book.description or book.parent_work.description
|
if book.description:
|
||||||
|
return book.description
|
||||||
|
if book.parent_work:
|
||||||
|
# this shoud always be true
|
||||||
|
return book.parent_work.description
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag(takes_context=False)
|
@register.simple_tag(takes_context=False)
|
||||||
def get_book_file_links(book):
|
def get_book_file_links(book):
|
||||||
"""links for a book"""
|
"""links for a book"""
|
||||||
return book.file_links.filter(domain__status="approved")
|
return book.file_links.filter(domain__status="approved")
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter(name="author_edition")
|
||||||
|
def get_author_edition(book, author):
|
||||||
|
"""default edition for a book on the author page"""
|
||||||
|
return book.author_edition(author)
|
||||||
|
|
24
bookwyrm/templatetags/celery_tags.py
Normal file
24
bookwyrm/templatetags/celery_tags.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
""" template filters for really common utilities """
|
||||||
|
import datetime
|
||||||
|
from django import template
|
||||||
|
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter(name="uptime")
|
||||||
|
def uptime(seconds):
|
||||||
|
"""Seconds uptime to a readable format"""
|
||||||
|
return str(datetime.timedelta(seconds=seconds))
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter(name="runtime")
|
||||||
|
def runtime(timestamp):
|
||||||
|
"""How long has it been?"""
|
||||||
|
return datetime.datetime.now() - datetime.datetime.fromtimestamp(timestamp)
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter(name="shortname")
|
||||||
|
def shortname(name):
|
||||||
|
"""removes bookwyrm.celery..."""
|
||||||
|
return ".".join(name.split(".")[-2:])
|
|
@ -27,6 +27,7 @@ from bookwyrm import models
|
||||||
class BaseActivity(TestCase):
|
class BaseActivity(TestCase):
|
||||||
"""the super class for model-linked activitypub dataclasses"""
|
"""the super class for model-linked activitypub dataclasses"""
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""we're probably going to re-use this so why copy/paste"""
|
"""we're probably going to re-use this so why copy/paste"""
|
||||||
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
|
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
|
||||||
|
|
|
@ -7,6 +7,7 @@ from bookwyrm import activitystreams, models
|
||||||
class Activitystreams(TestCase):
|
class Activitystreams(TestCase):
|
||||||
"""using redis to build activity streams"""
|
"""using redis to build activity streams"""
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""use a test csv"""
|
"""use a test csv"""
|
||||||
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
|
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
|
||||||
|
|
|
@ -6,7 +6,7 @@ from django.test import TestCase
|
||||||
|
|
||||||
from bookwyrm import models
|
from bookwyrm import models
|
||||||
from bookwyrm.importers import CalibreImporter
|
from bookwyrm.importers import CalibreImporter
|
||||||
from bookwyrm.importers.importer import handle_imported_book
|
from bookwyrm.models.import_job import handle_imported_book
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=consider-using-with
|
# pylint: disable=consider-using-with
|
||||||
|
@ -16,6 +16,7 @@ from bookwyrm.importers.importer import handle_imported_book
|
||||||
class CalibreImport(TestCase):
|
class CalibreImport(TestCase):
|
||||||
"""importing from Calibre csv"""
|
"""importing from Calibre csv"""
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""use a test csv"""
|
"""use a test csv"""
|
||||||
self.importer = CalibreImporter()
|
self.importer = CalibreImporter()
|
||||||
|
|
|
@ -8,7 +8,7 @@ from django.test import TestCase
|
||||||
|
|
||||||
from bookwyrm import models
|
from bookwyrm import models
|
||||||
from bookwyrm.importers import GoodreadsImporter
|
from bookwyrm.importers import GoodreadsImporter
|
||||||
from bookwyrm.importers.importer import handle_imported_book
|
from bookwyrm.models.import_job import handle_imported_book
|
||||||
|
|
||||||
|
|
||||||
def make_date(*args):
|
def make_date(*args):
|
||||||
|
@ -23,6 +23,7 @@ def make_date(*args):
|
||||||
class GoodreadsImport(TestCase):
|
class GoodreadsImport(TestCase):
|
||||||
"""importing from goodreads csv"""
|
"""importing from goodreads csv"""
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""use a test csv"""
|
"""use a test csv"""
|
||||||
self.importer = GoodreadsImporter()
|
self.importer = GoodreadsImporter()
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
""" testing import """
|
""" testing import """
|
||||||
|
from collections import namedtuple
|
||||||
import pathlib
|
import pathlib
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
import datetime
|
import datetime
|
||||||
|
@ -9,8 +10,8 @@ import responses
|
||||||
|
|
||||||
from bookwyrm import models
|
from bookwyrm import models
|
||||||
from bookwyrm.importers import Importer
|
from bookwyrm.importers import Importer
|
||||||
from bookwyrm.importers.importer import start_import_task, import_item_task
|
from bookwyrm.models.import_job import start_import_task, import_item_task
|
||||||
from bookwyrm.importers.importer import handle_imported_book
|
from bookwyrm.models.import_job import handle_imported_book
|
||||||
|
|
||||||
|
|
||||||
def make_date(*args):
|
def make_date(*args):
|
||||||
|
@ -25,6 +26,7 @@ def make_date(*args):
|
||||||
class GenericImporter(TestCase):
|
class GenericImporter(TestCase):
|
||||||
"""importing from csv"""
|
"""importing from csv"""
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""use a test csv"""
|
"""use a test csv"""
|
||||||
|
|
||||||
|
@ -103,9 +105,13 @@ class GenericImporter(TestCase):
|
||||||
import_job = self.importer.create_job(
|
import_job = self.importer.create_job(
|
||||||
self.local_user, self.csv, False, "unlisted"
|
self.local_user, self.csv, False, "unlisted"
|
||||||
)
|
)
|
||||||
with patch("bookwyrm.importers.importer.start_import_task.delay") as mock:
|
MockTask = namedtuple("Task", ("id"))
|
||||||
self.importer.start_import(import_job)
|
with patch("bookwyrm.models.import_job.start_import_task.delay") as mock:
|
||||||
|
mock.return_value = MockTask(123)
|
||||||
|
import_job.start_job()
|
||||||
self.assertEqual(mock.call_count, 1)
|
self.assertEqual(mock.call_count, 1)
|
||||||
|
import_job.refresh_from_db()
|
||||||
|
self.assertEqual(import_job.task_id, "123")
|
||||||
|
|
||||||
@responses.activate
|
@responses.activate
|
||||||
def test_start_import_task(self, *_):
|
def test_start_import_task(self, *_):
|
||||||
|
@ -114,7 +120,9 @@ class GenericImporter(TestCase):
|
||||||
self.local_user, self.csv, False, "unlisted"
|
self.local_user, self.csv, False, "unlisted"
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("bookwyrm.importers.importer.import_item_task.delay") as mock:
|
MockTask = namedtuple("Task", ("id"))
|
||||||
|
with patch("bookwyrm.models.import_job.import_item_task.delay") as mock:
|
||||||
|
mock.return_value = MockTask(123)
|
||||||
start_import_task(import_job.id)
|
start_import_task(import_job.id)
|
||||||
|
|
||||||
self.assertEqual(mock.call_count, 4)
|
self.assertEqual(mock.call_count, 4)
|
||||||
|
|
|
@ -8,7 +8,7 @@ from django.test import TestCase
|
||||||
|
|
||||||
from bookwyrm import models
|
from bookwyrm import models
|
||||||
from bookwyrm.importers import LibrarythingImporter
|
from bookwyrm.importers import LibrarythingImporter
|
||||||
from bookwyrm.importers.importer import handle_imported_book
|
from bookwyrm.models.import_job import handle_imported_book
|
||||||
|
|
||||||
|
|
||||||
def make_date(*args):
|
def make_date(*args):
|
||||||
|
@ -23,6 +23,7 @@ def make_date(*args):
|
||||||
class LibrarythingImport(TestCase):
|
class LibrarythingImport(TestCase):
|
||||||
"""importing from librarything tsv"""
|
"""importing from librarything tsv"""
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""use a test tsv"""
|
"""use a test tsv"""
|
||||||
self.importer = LibrarythingImporter()
|
self.importer = LibrarythingImporter()
|
||||||
|
|
|
@ -8,7 +8,7 @@ from django.test import TestCase
|
||||||
|
|
||||||
from bookwyrm import models
|
from bookwyrm import models
|
||||||
from bookwyrm.importers import OpenLibraryImporter
|
from bookwyrm.importers import OpenLibraryImporter
|
||||||
from bookwyrm.importers.importer import handle_imported_book
|
from bookwyrm.models.import_job import handle_imported_book
|
||||||
|
|
||||||
|
|
||||||
def make_date(*args):
|
def make_date(*args):
|
||||||
|
@ -23,6 +23,7 @@ def make_date(*args):
|
||||||
class OpenLibraryImport(TestCase):
|
class OpenLibraryImport(TestCase):
|
||||||
"""importing from openlibrary csv"""
|
"""importing from openlibrary csv"""
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""use a test csv"""
|
"""use a test csv"""
|
||||||
self.importer = OpenLibraryImporter()
|
self.importer = OpenLibraryImporter()
|
||||||
|
|
|
@ -8,7 +8,7 @@ from django.test import TestCase
|
||||||
|
|
||||||
from bookwyrm import models
|
from bookwyrm import models
|
||||||
from bookwyrm.importers import StorygraphImporter
|
from bookwyrm.importers import StorygraphImporter
|
||||||
from bookwyrm.importers.importer import handle_imported_book
|
from bookwyrm.models.import_job import handle_imported_book
|
||||||
|
|
||||||
|
|
||||||
def make_date(*args):
|
def make_date(*args):
|
||||||
|
@ -23,6 +23,7 @@ def make_date(*args):
|
||||||
class StorygraphImport(TestCase):
|
class StorygraphImport(TestCase):
|
||||||
"""importing from storygraph csv"""
|
"""importing from storygraph csv"""
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""use a test csv"""
|
"""use a test csv"""
|
||||||
self.importer = StorygraphImporter()
|
self.importer = StorygraphImporter()
|
||||||
|
|
|
@ -12,6 +12,7 @@ from bookwyrm.models import base_model
|
||||||
from bookwyrm.models.activitypub_mixin import (
|
from bookwyrm.models.activitypub_mixin import (
|
||||||
ActivitypubMixin,
|
ActivitypubMixin,
|
||||||
ActivityMixin,
|
ActivityMixin,
|
||||||
|
broadcast_task,
|
||||||
ObjectMixin,
|
ObjectMixin,
|
||||||
OrderedCollectionMixin,
|
OrderedCollectionMixin,
|
||||||
to_ordered_collection_page,
|
to_ordered_collection_page,
|
||||||
|
@ -19,7 +20,7 @@ from bookwyrm.models.activitypub_mixin import (
|
||||||
from bookwyrm.settings import PAGE_LENGTH
|
from bookwyrm.settings import PAGE_LENGTH
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name,too-many-public-methods
|
||||||
@patch("bookwyrm.activitystreams.add_status_task.delay")
|
@patch("bookwyrm.activitystreams.add_status_task.delay")
|
||||||
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
|
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
|
||||||
class ActivitypubMixins(TestCase):
|
class ActivitypubMixins(TestCase):
|
||||||
|
@ -421,3 +422,14 @@ class ActivitypubMixins(TestCase):
|
||||||
self.assertEqual(page_2.id, "http://fish.com/?page=2")
|
self.assertEqual(page_2.id, "http://fish.com/?page=2")
|
||||||
self.assertEqual(page_2.orderedItems[0]["content"], "test status 14")
|
self.assertEqual(page_2.orderedItems[0]["content"], "test status 14")
|
||||||
self.assertEqual(page_2.orderedItems[-1]["content"], "test status 0")
|
self.assertEqual(page_2.orderedItems[-1]["content"], "test status 0")
|
||||||
|
|
||||||
|
def test_broadcast_task(self, *_):
|
||||||
|
"""Should be calling asyncio"""
|
||||||
|
recipients = [
|
||||||
|
"https://instance.example/user/inbox",
|
||||||
|
"https://instance.example/okay/inbox",
|
||||||
|
]
|
||||||
|
with patch("bookwyrm.models.activitypub_mixin.asyncio.run") as mock:
|
||||||
|
broadcast_task(self.local_user.id, {}, recipients)
|
||||||
|
self.assertTrue(mock.called)
|
||||||
|
self.assertEqual(mock.call_count, 1)
|
||||||
|
|
|
@ -14,6 +14,7 @@ from bookwyrm.models.antispam import automod_task
|
||||||
class AutomodModel(TestCase):
|
class AutomodModel(TestCase):
|
||||||
"""every response to a get request, html or json"""
|
"""every response to a get request, html or json"""
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""we need basic test data and mocks"""
|
"""we need basic test data and mocks"""
|
||||||
self.factory = RequestFactory()
|
self.factory = RequestFactory()
|
||||||
|
@ -26,6 +27,7 @@ class AutomodModel(TestCase):
|
||||||
"password",
|
"password",
|
||||||
local=True,
|
local=True,
|
||||||
localname="mouse",
|
localname="mouse",
|
||||||
|
is_superuser=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_automod_task_no_rules(self, *_):
|
def test_automod_task_no_rules(self, *_):
|
||||||
|
@ -33,6 +35,7 @@ class AutomodModel(TestCase):
|
||||||
self.assertFalse(models.Report.objects.exists())
|
self.assertFalse(models.Report.objects.exists())
|
||||||
automod_task()
|
automod_task()
|
||||||
self.assertFalse(models.Report.objects.exists())
|
self.assertFalse(models.Report.objects.exists())
|
||||||
|
self.assertFalse(models.Notification.objects.exists())
|
||||||
|
|
||||||
def test_automod_task_user(self, *_):
|
def test_automod_task_user(self, *_):
|
||||||
"""scan activity"""
|
"""scan activity"""
|
||||||
|
@ -52,6 +55,7 @@ class AutomodModel(TestCase):
|
||||||
reports = models.Report.objects.all()
|
reports = models.Report.objects.all()
|
||||||
self.assertEqual(reports.count(), 1)
|
self.assertEqual(reports.count(), 1)
|
||||||
self.assertEqual(reports.first().user, self.local_user)
|
self.assertEqual(reports.first().user, self.local_user)
|
||||||
|
self.assertEqual(models.Notification.objects.count(), 1)
|
||||||
|
|
||||||
def test_automod_status(self, *_):
|
def test_automod_status(self, *_):
|
||||||
"""scan activity"""
|
"""scan activity"""
|
||||||
|
@ -73,3 +77,4 @@ class AutomodModel(TestCase):
|
||||||
self.assertEqual(reports.count(), 1)
|
self.assertEqual(reports.count(), 1)
|
||||||
self.assertEqual(reports.first().status, status)
|
self.assertEqual(reports.first().status, status)
|
||||||
self.assertEqual(reports.first().user, self.local_user)
|
self.assertEqual(reports.first().user, self.local_user)
|
||||||
|
self.assertEqual(models.Notification.objects.count(), 1)
|
||||||
|
|
|
@ -24,6 +24,7 @@ from bookwyrm import activitypub, models, settings
|
||||||
class Status(TestCase):
|
class Status(TestCase):
|
||||||
"""lotta types of statuses"""
|
"""lotta types of statuses"""
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""useful things for creating a status"""
|
"""useful things for creating a status"""
|
||||||
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
|
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
|
||||||
|
@ -223,12 +224,12 @@ class Status(TestCase):
|
||||||
f'test content<p>(comment on <a href="{self.book.remote_id}">"Test Edition"</a>)</p>',
|
f'test content<p>(comment on <a href="{self.book.remote_id}">"Test Edition"</a>)</p>',
|
||||||
)
|
)
|
||||||
self.assertEqual(activity["attachment"][0].type, "Document")
|
self.assertEqual(activity["attachment"][0].type, "Document")
|
||||||
self.assertTrue(
|
# self.assertTrue(
|
||||||
re.match(
|
# re.match(
|
||||||
r"https:\/\/your.domain.here\/images\/covers\/test_[A-z0-9]+.jpg",
|
# r"https:\/\/your.domain.here\/images\/covers\/test_[A-z0-9]+.jpg",
|
||||||
activity["attachment"][0].url,
|
# activity["attachment"][0].url,
|
||||||
)
|
# )
|
||||||
)
|
# )
|
||||||
self.assertEqual(activity["attachment"][0].name, "Test Edition")
|
self.assertEqual(activity["attachment"][0].name, "Test Edition")
|
||||||
|
|
||||||
def test_quotation_to_activity(self, *_):
|
def test_quotation_to_activity(self, *_):
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue