Merge tag 'v0.5.3' into nix
This commit is contained in:
commit
bb0a390f49
263 changed files with 20628 additions and 8779 deletions
13
.env.example
13
.env.example
|
@ -21,8 +21,8 @@ MEDIA_ROOT=images/
|
|||
# Database configuration
|
||||
PGPORT=5432
|
||||
POSTGRES_PASSWORD=securedbypassword123
|
||||
POSTGRES_USER=fedireads
|
||||
POSTGRES_DB=fedireads
|
||||
POSTGRES_USER=bookwyrm
|
||||
POSTGRES_DB=bookwyrm
|
||||
POSTGRES_HOST=db
|
||||
|
||||
# Redis activity stream manager
|
||||
|
@ -79,7 +79,7 @@ AWS_SECRET_ACCESS_KEY=
|
|||
|
||||
|
||||
# Preview image generation can be computing and storage intensive
|
||||
# ENABLE_PREVIEW_IMAGES=True
|
||||
ENABLE_PREVIEW_IMAGES=False
|
||||
|
||||
# Specify RGB tuple or RGB hex strings,
|
||||
# or use_dominant_color_light / use_dominant_color_dark
|
||||
|
@ -108,3 +108,10 @@ OTEL_EXPORTER_OTLP_ENDPOINT=
|
|||
OTEL_EXPORTER_OTLP_HEADERS=
|
||||
# Service name to identify your app
|
||||
OTEL_SERVICE_NAME=
|
||||
|
||||
# Set HTTP_X_FORWARDED_PROTO ONLY to true if you know what you are doing.
|
||||
# Only use it if your proxy is "swallowing" if the original request was made
|
||||
# via https. Please refer to the Django-Documentation and assess the risks
|
||||
# for your instance:
|
||||
# https://docs.djangoproject.com/en/3.2/ref/settings/#secure-proxy-ssl-header
|
||||
HTTP_X_FORWARDED_PROTO=false
|
||||
|
|
1
.github/workflows/django-tests.yml
vendored
1
.github/workflows/django-tests.yml
vendored
|
@ -56,5 +56,6 @@ jobs:
|
|||
EMAIL_USE_TLS: true
|
||||
ENABLE_PREVIEW_IMAGES: false
|
||||
ENABLE_THUMBNAIL_GENERATION: true
|
||||
HTTP_X_FORWARDED_PROTO: false
|
||||
run: |
|
||||
pytest -n 3
|
||||
|
|
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
|
||||
|
||||
# See .stylelintignore for files that are not linted.
|
||||
- name: Run stylelint
|
||||
run: >
|
||||
npx stylelint bookwyrm/static/css/*.scss bookwyrm/static/css/bookwyrm/**/*.scss \
|
||||
--config dev-tools/.stylelintrc.js
|
||||
# - name: Run stylelint
|
||||
# run: >
|
||||
# npx stylelint bookwyrm/static/css/*.scss bookwyrm/static/css/bookwyrm/**/*.scss \
|
||||
# --config dev-tools/.stylelintrc.js
|
||||
|
||||
# See .eslintignore for files that are not linted.
|
||||
- name: Run ESLint
|
||||
|
|
|
@ -41,7 +41,7 @@ Keep track of what books you've read, and what books you'd like to read in the f
|
|||
Federation allows you to interact with users on other instances and services, and also shares metadata about books and authors, which collaboratively builds a decentralized database of books.
|
||||
|
||||
### Privacy and moderation
|
||||
Users and administrators can control who can see thier posts and what other instances to federate with.
|
||||
Users and administrators can control who can see their posts and what other instances to federate with.
|
||||
|
||||
## Tech Stack
|
||||
Web backend
|
||||
|
|
|
@ -7,7 +7,7 @@ from django.apps import apps
|
|||
from django.db import IntegrityError, transaction
|
||||
|
||||
from bookwyrm.connectors import ConnectorException, get_data
|
||||
from bookwyrm.tasks import app
|
||||
from bookwyrm.tasks import app, MEDIUM
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -194,6 +194,11 @@ class ActivityObject:
|
|||
try:
|
||||
if issubclass(type(v), ActivityObject):
|
||||
data[k] = v.serialize()
|
||||
elif isinstance(v, list):
|
||||
data[k] = [
|
||||
e.serialize() if issubclass(type(e), ActivityObject) else e
|
||||
for e in v
|
||||
]
|
||||
except TypeError:
|
||||
pass
|
||||
data = {k: v for (k, v) in data.items() if v is not None and k not in omit}
|
||||
|
@ -202,7 +207,7 @@ class ActivityObject:
|
|||
return data
|
||||
|
||||
|
||||
@app.task(queue="medium_priority")
|
||||
@app.task(queue=MEDIUM)
|
||||
@transaction.atomic
|
||||
def set_related_field(
|
||||
model_name, origin_model_name, related_field_name, related_remote_id, data
|
||||
|
@ -271,7 +276,7 @@ def resolve_remote_id(
|
|||
try:
|
||||
data = get_data(remote_id)
|
||||
except ConnectorException:
|
||||
logger.exception("Could not connect to host for remote_id: %s", remote_id)
|
||||
logger.info("Could not connect to host for remote_id: %s", remote_id)
|
||||
return None
|
||||
|
||||
# determine the model implicitly, if not provided
|
||||
|
@ -306,7 +311,9 @@ class Link(ActivityObject):
|
|||
|
||||
def serialize(self, **kwargs):
|
||||
"""remove fields"""
|
||||
omit = ("id", "type", "@context")
|
||||
omit = ("id", "@context")
|
||||
if self.type == "Link":
|
||||
omit += ("type",)
|
||||
return super().serialize(omit=omit)
|
||||
|
||||
|
||||
|
|
|
@ -19,6 +19,8 @@ class BookData(ActivityObject):
|
|||
viaf: str = None
|
||||
wikidata: str = None
|
||||
asin: str = None
|
||||
aasin: str = None
|
||||
isfdb: str = None
|
||||
lastEditedBy: str = None
|
||||
links: List[str] = field(default_factory=lambda: [])
|
||||
fileLinks: List[str] = field(default_factory=lambda: [])
|
||||
|
|
|
@ -117,6 +117,17 @@ class ActivityStream(RedisStore):
|
|||
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
|
||||
)
|
||||
|
||||
# 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
|
||||
elif status.privacy == "followers":
|
||||
audience = audience.filter(
|
||||
|
@ -287,6 +298,12 @@ def add_status_on_create(sender, instance, created, *args, **kwargs):
|
|||
remove_status_task.delay(instance.id)
|
||||
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
|
||||
transaction.on_commit(
|
||||
lambda: add_status_on_create_command(sender, instance, created)
|
||||
|
|
|
@ -4,9 +4,10 @@ from functools import reduce
|
|||
import operator
|
||||
|
||||
from django.contrib.postgres.search import SearchRank, SearchQuery
|
||||
from django.db.models import OuterRef, Subquery, F, Q
|
||||
from django.db.models import F, Q
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm import connectors
|
||||
from bookwyrm.settings import MEDIA_FULL_URL
|
||||
|
||||
|
||||
|
@ -16,8 +17,15 @@ def search(query, min_confidence=0, filters=None, return_first=False):
|
|||
filters = filters or []
|
||||
if not query:
|
||||
return []
|
||||
query = query.strip()
|
||||
|
||||
results = None
|
||||
# first, try searching unqiue identifiers
|
||||
results = search_identifiers(query, *filters, return_first=return_first)
|
||||
# unique identifiers never have spaces, title/author usually do
|
||||
if not " " in query:
|
||||
results = search_identifiers(query, *filters, return_first=return_first)
|
||||
|
||||
# if there were no identifier results...
|
||||
if not results:
|
||||
# then try searching title/author
|
||||
results = search_title_author(
|
||||
|
@ -30,26 +38,14 @@ def isbn_search(query):
|
|||
"""search your local database"""
|
||||
if not query:
|
||||
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"]]
|
||||
results = models.Edition.objects.filter(
|
||||
return models.Edition.objects.filter(
|
||||
reduce(operator.or_, (Q(**f) for f in filters))
|
||||
).distinct()
|
||||
|
||||
# when there are multiple editions of the same work, pick the default.
|
||||
# it would be odd for this to happen.
|
||||
|
||||
default_editions = models.Edition.objects.filter(
|
||||
parent_work=OuterRef("parent_work")
|
||||
).order_by("-edition_rank")
|
||||
results = (
|
||||
results.annotate(default_id=Subquery(default_editions.values("id")[:1])).filter(
|
||||
default_id=F("id")
|
||||
)
|
||||
or results
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def format_search_result(search_result):
|
||||
"""convert a book object into a search result object"""
|
||||
|
@ -72,6 +68,10 @@ def format_search_result(search_result):
|
|||
|
||||
def search_identifiers(query, *filters, return_first=False):
|
||||
"""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
|
||||
or_filters = [
|
||||
{f.name: query}
|
||||
|
@ -81,22 +81,7 @@ def search_identifiers(query, *filters, return_first=False):
|
|||
results = models.Edition.objects.filter(
|
||||
*filters, reduce(operator.or_, (Q(**f) for f in or_filters))
|
||||
).distinct()
|
||||
if results.count() <= 1:
|
||||
if return_first:
|
||||
return results.first()
|
||||
return results
|
||||
|
||||
# when there are multiple editions of the same work, pick the default.
|
||||
# it would be odd for this to happen.
|
||||
default_editions = models.Edition.objects.filter(
|
||||
parent_work=OuterRef("parent_work")
|
||||
).order_by("-edition_rank")
|
||||
results = (
|
||||
results.annotate(default_id=Subquery(default_editions.values("id")[:1])).filter(
|
||||
default_id=F("id")
|
||||
)
|
||||
or results
|
||||
)
|
||||
if return_first:
|
||||
return results.first()
|
||||
return results
|
||||
|
@ -113,19 +98,16 @@ def search_title_author(query, min_confidence, *filters, return_first=False):
|
|||
)
|
||||
|
||||
# when there are multiple editions of the same work, pick the closest
|
||||
editions_of_work = results.values("parent_work__id").values_list("parent_work__id")
|
||||
editions_of_work = results.values_list("parent_work__id", flat=True).distinct()
|
||||
|
||||
# filter out multiple editions of the same work
|
||||
list_results = []
|
||||
for work_id in set(editions_of_work):
|
||||
editions = results.filter(parent_work=work_id)
|
||||
default = editions.order_by("-edition_rank").first()
|
||||
default_rank = default.rank if default else 0
|
||||
# if mutliple books have the top rank, pick the default edition
|
||||
if default_rank == editions.first().rank:
|
||||
result = default
|
||||
else:
|
||||
result = editions.first()
|
||||
for work_id in set(editions_of_work[:30]):
|
||||
result = (
|
||||
results.filter(parent_work=work_id)
|
||||
.order_by("-rank", "-edition_rank")
|
||||
.first()
|
||||
)
|
||||
|
||||
if return_first:
|
||||
return result
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
""" bring connectors into the namespace """
|
||||
from .settings import CONNECTORS
|
||||
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
|
||||
|
|
|
@ -42,8 +42,10 @@ class AbstractMinimalConnector(ABC):
|
|||
"""format the query url"""
|
||||
# Check if the query resembles an ISBN
|
||||
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,
|
||||
# searched as free text. This, instead, only searches isbn if it's isbn-y
|
||||
return f"{self.search_url}{query}"
|
||||
|
@ -220,7 +222,7 @@ def dict_from_mappings(data, mappings):
|
|||
return result
|
||||
|
||||
|
||||
def get_data(url, params=None, timeout=10):
|
||||
def get_data(url, params=None, timeout=settings.QUERY_TIMEOUT):
|
||||
"""wrapper for request.get"""
|
||||
# check if the url is blocked
|
||||
raise_not_valid_url(url)
|
||||
|
@ -325,4 +327,11 @@ def unique_physical_format(format_text):
|
|||
def maybe_isbn(query):
|
||||
"""check if a query looks like an isbn"""
|
||||
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.settings import SEARCH_TIMEOUT, USER_AGENT
|
||||
from bookwyrm.tasks import app
|
||||
from bookwyrm.tasks import app, LOW
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -143,7 +143,7 @@ def get_or_create_connector(remote_id):
|
|||
return load_connector(connector_info)
|
||||
|
||||
|
||||
@app.task(queue="low_priority")
|
||||
@app.task(queue=LOW)
|
||||
def load_more_data(connector_id, book_id):
|
||||
"""background the work of getting all 10,000 editions of LoTR"""
|
||||
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)
|
||||
|
||||
|
||||
@app.task(queue="low_priority")
|
||||
@app.task(queue=LOW)
|
||||
def create_edition_task(connector_id, work_id, data):
|
||||
"""separate task for each of the 10,000 editions of LoTR"""
|
||||
connector_info = models.Connector.objects.get(id=connector_id)
|
||||
|
|
|
@ -165,8 +165,8 @@ class Connector(AbstractConnector):
|
|||
edition_data = self.get_book_data(edition_data)
|
||||
except ConnectorException:
|
||||
# who, indeed, knows
|
||||
return
|
||||
super().create_edition_from_data(work, edition_data, instance=instance)
|
||||
return None
|
||||
return super().create_edition_from_data(work, edition_data, instance=instance)
|
||||
|
||||
def get_cover_url(self, cover_blob, *_):
|
||||
"""format the relative cover url into an absolute one:
|
||||
|
|
|
@ -3,7 +3,7 @@ from django.core.mail import EmailMultiAlternatives
|
|||
from django.template.loader import get_template
|
||||
|
||||
from bookwyrm import models, settings
|
||||
from bookwyrm.tasks import app
|
||||
from bookwyrm.tasks import app, HIGH
|
||||
from bookwyrm.settings import DOMAIN
|
||||
|
||||
|
||||
|
@ -18,12 +18,18 @@ def email_data():
|
|||
}
|
||||
|
||||
|
||||
def test_email(user):
|
||||
"""Just an admin checking if emails are sending"""
|
||||
data = email_data()
|
||||
send_email(user.email, *format_email("test", data))
|
||||
|
||||
|
||||
def email_confirmation_email(user):
|
||||
"""newly registered users confirm email address"""
|
||||
data = email_data()
|
||||
data["confirmation_code"] = user.confirmation_code
|
||||
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):
|
||||
|
@ -38,7 +44,7 @@ def password_reset_email(reset_code):
|
|||
data = email_data()
|
||||
data["reset_link"] = reset_code.link
|
||||
data["user"] = reset_code.user.display_name
|
||||
send_email.delay(reset_code.user.email, *format_email("password_reset", data))
|
||||
send_email(reset_code.user.email, *format_email("password_reset", data))
|
||||
|
||||
|
||||
def moderation_report_email(report):
|
||||
|
@ -48,6 +54,7 @@ def moderation_report_email(report):
|
|||
if report.user:
|
||||
data["reportee"] = report.user.localname or report.user.username
|
||||
data["report_link"] = report.remote_id
|
||||
data["link_domain"] = report.links.exists()
|
||||
|
||||
for admin in models.User.objects.filter(
|
||||
groups__name__in=["admin", "moderator"]
|
||||
|
@ -68,7 +75,7 @@ def format_email(email_name, data):
|
|||
return (subject, html_content, text_content)
|
||||
|
||||
|
||||
@app.task(queue="high_priority")
|
||||
@app.task(queue=HIGH)
|
||||
def send_email(recipient, subject, html_content, text_content):
|
||||
"""use a task to send the email"""
|
||||
email = EmailMultiAlternatives(
|
||||
|
|
|
@ -2,13 +2,14 @@
|
|||
import datetime
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.forms import widgets
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_celery_beat.models import IntervalSchedule
|
||||
|
||||
from bookwyrm import models
|
||||
from .custom_form import CustomForm
|
||||
from .custom_form import CustomForm, StyledForm
|
||||
|
||||
|
||||
# pylint: disable=missing-class-docstring
|
||||
|
@ -54,11 +55,45 @@ class CreateInviteForm(CustomForm):
|
|||
class SiteForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.SiteSettings
|
||||
exclude = ["admin_code", "install_mode"]
|
||||
fields = [
|
||||
"name",
|
||||
"instance_tagline",
|
||||
"instance_description",
|
||||
"instance_short_description",
|
||||
"default_theme",
|
||||
"code_of_conduct",
|
||||
"privacy_policy",
|
||||
"impressum",
|
||||
"show_impressum",
|
||||
"logo",
|
||||
"logo_small",
|
||||
"favicon",
|
||||
"support_link",
|
||||
"support_title",
|
||||
"admin_email",
|
||||
"footer_item",
|
||||
]
|
||||
widgets = {
|
||||
"instance_short_description": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_instance_short_description"}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class RegistrationForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.SiteSettings
|
||||
fields = [
|
||||
"allow_registration",
|
||||
"allow_invite_requests",
|
||||
"registration_closed_text",
|
||||
"invite_request_text",
|
||||
"invite_request_question",
|
||||
"invite_question_text",
|
||||
"require_confirm_email",
|
||||
]
|
||||
|
||||
widgets = {
|
||||
"require_confirm_email": forms.CheckboxInput(
|
||||
attrs={"aria-describedby": "desc_require_confirm_email"}
|
||||
),
|
||||
|
@ -68,6 +103,23 @@ class SiteForm(CustomForm):
|
|||
}
|
||||
|
||||
|
||||
class RegistrationLimitedForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.SiteSettings
|
||||
fields = [
|
||||
"registration_closed_text",
|
||||
"invite_request_text",
|
||||
"invite_request_question",
|
||||
"invite_question_text",
|
||||
]
|
||||
|
||||
widgets = {
|
||||
"invite_request_text": forms.Textarea(
|
||||
attrs={"aria-describedby": "desc_invite_request_text"}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class ThemeForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Theme
|
||||
|
@ -130,7 +182,7 @@ class AutoModRuleForm(CustomForm):
|
|||
fields = ["string_match", "flag_users", "flag_statuses", "created_by"]
|
||||
|
||||
|
||||
class IntervalScheduleForm(CustomForm):
|
||||
class IntervalScheduleForm(StyledForm):
|
||||
class Meta:
|
||||
model = IntervalSchedule
|
||||
fields = ["every", "period"]
|
||||
|
@ -139,3 +191,10 @@ class IntervalScheduleForm(CustomForm):
|
|||
"every": forms.NumberInput(attrs={"aria-describedby": "desc_every"}),
|
||||
"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)
|
||||
|
|
|
@ -21,6 +21,7 @@ class AuthorForm(CustomForm):
|
|||
"inventaire_id",
|
||||
"librarything_key",
|
||||
"goodreads_key",
|
||||
"isfdb",
|
||||
"isni",
|
||||
]
|
||||
widgets = {
|
||||
|
|
|
@ -18,19 +18,30 @@ class CoverForm(CustomForm):
|
|||
class EditionForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Edition
|
||||
exclude = [
|
||||
"remote_id",
|
||||
"origin_id",
|
||||
"created_date",
|
||||
"updated_date",
|
||||
"edition_rank",
|
||||
"authors",
|
||||
"parent_work",
|
||||
"shelves",
|
||||
"connector",
|
||||
"search_vector",
|
||||
"links",
|
||||
"file_links",
|
||||
fields = [
|
||||
"title",
|
||||
"subtitle",
|
||||
"description",
|
||||
"series",
|
||||
"series_number",
|
||||
"languages",
|
||||
"subjects",
|
||||
"publishers",
|
||||
"first_published_date",
|
||||
"published_date",
|
||||
"cover",
|
||||
"physical_format",
|
||||
"physical_format_detail",
|
||||
"pages",
|
||||
"isbn_13",
|
||||
"isbn_10",
|
||||
"openlibrary_key",
|
||||
"inventaire_id",
|
||||
"goodreads_key",
|
||||
"oclc_number",
|
||||
"asin",
|
||||
"aasin",
|
||||
"isfdb",
|
||||
]
|
||||
widgets = {
|
||||
"title": forms.TextInput(attrs={"aria-describedby": "desc_title"}),
|
||||
|
@ -73,10 +84,15 @@ class EditionForm(CustomForm):
|
|||
"inventaire_id": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_inventaire_id"}
|
||||
),
|
||||
"goodreads_key": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_goodreads_key"}
|
||||
),
|
||||
"oclc_number": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_oclc_number"}
|
||||
),
|
||||
"ASIN": forms.TextInput(attrs={"aria-describedby": "desc_ASIN"}),
|
||||
"AASIN": forms.TextInput(attrs={"aria-describedby": "desc_AASIN"}),
|
||||
"isfdb": forms.TextInput(attrs={"aria-describedby": "desc_isfdb"}),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ from django.forms import ModelForm
|
|||
from django.forms.widgets import Textarea
|
||||
|
||||
|
||||
class CustomForm(ModelForm):
|
||||
class StyledForm(ModelForm):
|
||||
"""add css classes to the forms"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
@ -16,7 +16,7 @@ class CustomForm(ModelForm):
|
|||
css_classes["checkbox"] = "checkbox"
|
||||
css_classes["textarea"] = "textarea"
|
||||
# pylint: disable=super-with-arguments
|
||||
super(CustomForm, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
for visible in self.visible_fields():
|
||||
if hasattr(visible.field.widget, "input_type"):
|
||||
input_type = visible.field.widget.input_type
|
||||
|
@ -24,3 +24,13 @@ class CustomForm(ModelForm):
|
|||
input_type = "textarea"
|
||||
visible.field.widget.attrs["rows"] = 5
|
||||
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 .custom_form import CustomForm
|
||||
|
||||
|
||||
# pylint: disable=missing-class-docstring
|
||||
class EditUserForm(CustomForm):
|
||||
class Meta:
|
||||
|
@ -99,3 +98,21 @@ class ChangePasswordForm(CustomForm):
|
|||
validate_password(new_password)
|
||||
except ValidationError as 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 """
|
||||
import datetime
|
||||
from django import forms
|
||||
from django.forms import widgets
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
@ -7,7 +8,6 @@ from bookwyrm import models
|
|||
from bookwyrm.models.user import FeedFilterChoices
|
||||
from .custom_form import CustomForm
|
||||
|
||||
|
||||
# pylint: disable=missing-class-docstring
|
||||
class FeedStatusTypesForm(CustomForm):
|
||||
class Meta:
|
||||
|
@ -58,6 +58,21 @@ class ReadThroughForm(CustomForm):
|
|||
self.add_error(
|
||||
"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:
|
||||
model = models.ReadThrough
|
||||
|
|
|
@ -4,7 +4,10 @@ from django.contrib.auth.password_validation import validate_password
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import pyotp
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from .custom_form import CustomForm
|
||||
|
||||
|
||||
|
@ -18,6 +21,21 @@ class LoginForm(CustomForm):
|
|||
"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 Meta:
|
||||
|
@ -74,3 +92,40 @@ class PasswordResetForm(CustomForm):
|
|||
validate_password(new_password)
|
||||
except ValidationError as 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"))
|
||||
|
|
|
@ -36,13 +36,16 @@ class FileLinkForm(CustomForm):
|
|||
"This domain is blocked. Please contact your administrator if you think this is an error."
|
||||
),
|
||||
)
|
||||
elif models.FileLink.objects.filter(
|
||||
if (
|
||||
not self.instance
|
||||
and models.FileLink.objects.filter(
|
||||
url=url, book=book, filetype=filetype
|
||||
).exists():
|
||||
# pylint: disable=line-too-long
|
||||
self.add_error(
|
||||
"url",
|
||||
_(
|
||||
"This link with file type has already been added for this book. If it is not visible, the domain is still pending."
|
||||
),
|
||||
)
|
||||
).exists()
|
||||
):
|
||||
# pylint: disable=line-too-long
|
||||
self.add_error(
|
||||
"url",
|
||||
_(
|
||||
"This link with file type has already been added for this book. If it is not visible, the domain is still pending."
|
||||
),
|
||||
)
|
||||
|
|
|
@ -1,15 +1,7 @@
|
|||
""" handle reading a csv from an external service, defaults are from Goodreads """
|
||||
import csv
|
||||
import logging
|
||||
|
||||
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.tasks import app, LOW
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Importer:
|
||||
|
@ -24,8 +16,8 @@ class Importer:
|
|||
("id", ["id", "book id"]),
|
||||
("title", ["title"]),
|
||||
("authors", ["author", "authors", "primary author"]),
|
||||
("isbn_10", ["isbn10", "isbn"]),
|
||||
("isbn_13", ["isbn13", "isbn", "isbns"]),
|
||||
("isbn_10", ["isbn10", "isbn", "isbn/uid"]),
|
||||
("isbn_13", ["isbn13", "isbn", "isbns", "isbn/uid"]),
|
||||
("shelf", ["shelf", "exclusive shelf", "read status", "bookshelf"]),
|
||||
("review_name", ["review name"]),
|
||||
("review_body", ["my review", "review"]),
|
||||
|
@ -44,7 +36,11 @@ class Importer:
|
|||
def create_job(self, user, csv_file, include_reviews, privacy):
|
||||
"""check over a csv and creates a database entry for the job"""
|
||||
csv_reader = csv.DictReader(csv_file, delimiter=self.delimiter)
|
||||
rows = enumerate(list(csv_reader))
|
||||
rows = list(csv_reader)
|
||||
if len(rows) < 1:
|
||||
raise ValueError("CSV file is empty")
|
||||
rows = enumerate(rows)
|
||||
|
||||
job = ImportJob.objects.create(
|
||||
user=user,
|
||||
include_reviews=include_reviews,
|
||||
|
@ -118,127 +114,3 @@ class Importer:
|
|||
# this will re-normalize the raw data
|
||||
self.create_item(job, item.index, item.data)
|
||||
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()
|
||||
|
|
19
bookwyrm/management/commands/confirm_email.py
Normal file
19
bookwyrm/management/commands/confirm_email.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
""" manually confirm e-mail of user """
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from bookwyrm import models
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""command-line options"""
|
||||
|
||||
help = "Manually confirm email for 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.reactivate()
|
||||
self.stdout.write(self.style.SUCCESS("User's email is now confirmed."))
|
|
@ -8,54 +8,64 @@ from bookwyrm import models
|
|||
|
||||
def init_groups():
|
||||
"""permission levels"""
|
||||
groups = ["admin", "moderator", "editor"]
|
||||
groups = ["admin", "owner", "moderator", "editor"]
|
||||
for group in groups:
|
||||
Group.objects.create(name=group)
|
||||
Group.objects.get_or_create(name=group)
|
||||
|
||||
|
||||
def init_permissions():
|
||||
"""permission types"""
|
||||
permissions = [
|
||||
{
|
||||
"codename": "manage_registration",
|
||||
"name": "allow or prevent user registration",
|
||||
"groups": ["admin"],
|
||||
},
|
||||
{
|
||||
"codename": "system_administration",
|
||||
"name": "technical controls",
|
||||
"groups": ["admin"],
|
||||
},
|
||||
{
|
||||
"codename": "edit_instance_settings",
|
||||
"name": "change the instance info",
|
||||
"groups": ["admin"],
|
||||
"groups": ["admin", "owner"],
|
||||
},
|
||||
{
|
||||
"codename": "set_user_group",
|
||||
"name": "change what group a user is in",
|
||||
"groups": ["admin", "moderator"],
|
||||
"groups": ["admin", "owner", "moderator"],
|
||||
},
|
||||
{
|
||||
"codename": "control_federation",
|
||||
"name": "control who to federate with",
|
||||
"groups": ["admin", "moderator"],
|
||||
"groups": ["admin", "owner", "moderator"],
|
||||
},
|
||||
{
|
||||
"codename": "create_invites",
|
||||
"name": "issue invitations to join",
|
||||
"groups": ["admin", "moderator"],
|
||||
"groups": ["admin", "owner", "moderator"],
|
||||
},
|
||||
{
|
||||
"codename": "moderate_user",
|
||||
"name": "deactivate or silence a user",
|
||||
"groups": ["admin", "moderator"],
|
||||
"groups": ["admin", "owner", "moderator"],
|
||||
},
|
||||
{
|
||||
"codename": "moderate_post",
|
||||
"name": "delete other users' posts",
|
||||
"groups": ["admin", "moderator"],
|
||||
"groups": ["admin", "owner", "moderator"],
|
||||
},
|
||||
{
|
||||
"codename": "edit_book",
|
||||
"name": "edit book info",
|
||||
"groups": ["admin", "moderator", "editor"],
|
||||
"groups": ["admin", "owner", "moderator", "editor"],
|
||||
},
|
||||
]
|
||||
|
||||
content_type = ContentType.objects.get_for_model(models.User)
|
||||
for permission in permissions:
|
||||
permission_obj = Permission.objects.create(
|
||||
permission_obj, _ = Permission.objects.get_or_create(
|
||||
codename=permission["codename"],
|
||||
name=permission["name"],
|
||||
content_type=content_type,
|
||||
|
|
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),
|
||||
),
|
||||
]
|
18
bookwyrm/migrations/0166_sitesettings_imports_enabled.py
Normal file
18
bookwyrm/migrations/0166_sitesettings_imports_enabled.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.2.16 on 2022-11-17 21:50
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0165_alter_inviterequest_answer"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="sitesettings",
|
||||
name="imports_enabled",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
23
bookwyrm/migrations/0167_auto_20221125_1900.py
Normal file
23
bookwyrm/migrations/0167_auto_20221125_1900.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 3.2.16 on 2022-11-25 19:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0166_sitesettings_imports_enabled"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="sitesettings",
|
||||
name="impressum",
|
||||
field=models.TextField(default="Add a impressum here."),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="sitesettings",
|
||||
name="show_impressum",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
28
bookwyrm/migrations/0168_auto_20221205_1701.py
Normal file
28
bookwyrm/migrations/0168_auto_20221205_1701.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
# Generated by Django 3.2.16 on 2022-12-05 17:01
|
||||
|
||||
import bookwyrm.models.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0167_auto_20221125_1900"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="author",
|
||||
name="aasin",
|
||||
field=bookwyrm.models.fields.CharField(
|
||||
blank=True, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="book",
|
||||
name="aasin",
|
||||
field=bookwyrm.models.fields.CharField(
|
||||
blank=True, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
]
|
63
bookwyrm/migrations/0168_auto_20221205_2331.py
Normal file
63
bookwyrm/migrations/0168_auto_20221205_2331.py
Normal file
|
@ -0,0 +1,63 @@
|
|||
""" I added two new permission types and a new group to the management command that
|
||||
creates the database on install, this creates them for existing instances """
|
||||
# Generated by Django 3.2.16 on 2022-12-05 23:31
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def create_groups_and_perms(apps, schema_editor):
|
||||
"""create the new "owner" group and "system admin" permission"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
group_model = apps.get_model("auth", "Group")
|
||||
# Add the "owner" group, if needed
|
||||
owner_group, group_created = group_model.objects.using(db_alias).get_or_create(
|
||||
name="owner"
|
||||
)
|
||||
|
||||
# Create perms, if needed
|
||||
user_model = apps.get_model("bookwyrm", "User")
|
||||
content_type_model = apps.get_model("contenttypes", "ContentType")
|
||||
content_type = content_type_model.objects.get_for_model(user_model)
|
||||
perms_model = apps.get_model("auth", "Permission")
|
||||
reg_perm, perm_created = perms_model.objects.using(db_alias).get_or_create(
|
||||
codename="manage_registration",
|
||||
name="allow or prevent user registration",
|
||||
content_type=content_type,
|
||||
)
|
||||
admin_perm, admin_perm_created = perms_model.objects.using(db_alias).get_or_create(
|
||||
codename="system_administration",
|
||||
name="technical controls",
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
# Add perms to the group if anything was created
|
||||
if group_created or perm_created or admin_perm_created:
|
||||
perms = [
|
||||
"edit_instance_settings",
|
||||
"set_user_group",
|
||||
"control_federation",
|
||||
"create_invites",
|
||||
"moderate_user",
|
||||
"moderate_post",
|
||||
"edit_book",
|
||||
]
|
||||
owner_group.permissions.set(
|
||||
perms_model.objects.using(db_alias).filter(codename__in=perms).all()
|
||||
)
|
||||
|
||||
# also extend these perms to admins
|
||||
# This is get or create so the tests don't fail -- it should already exist
|
||||
admin_group, _ = group_model.objects.using(db_alias).get_or_create(name="admin")
|
||||
admin_group.permissions.add(reg_perm)
|
||||
admin_group.permissions.add(admin_perm)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0167_auto_20221125_1900"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_groups_and_perms, migrations.RunPython.noop)
|
||||
]
|
28
bookwyrm/migrations/0169_auto_20221206_0902.py
Normal file
28
bookwyrm/migrations/0169_auto_20221206_0902.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
# Generated by Django 3.2.16 on 2022-12-06 09:02
|
||||
|
||||
import bookwyrm.models.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0168_auto_20221205_1701"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="author",
|
||||
name="isfdb",
|
||||
field=bookwyrm.models.fields.CharField(
|
||||
blank=True, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="book",
|
||||
name="isfdb",
|
||||
field=bookwyrm.models.fields.CharField(
|
||||
blank=True, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,13 @@
|
|||
# Generated by Django 3.2.16 on 2022-12-11 20:00
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0168_auto_20221205_2331"),
|
||||
("bookwyrm", "0169_auto_20221206_0902"),
|
||||
]
|
||||
|
||||
operations = []
|
|
@ -1,14 +1,15 @@
|
|||
""" activitypub model functionality """
|
||||
import asyncio
|
||||
from base64 import b64encode
|
||||
from collections import namedtuple
|
||||
from functools import reduce
|
||||
import json
|
||||
import operator
|
||||
import logging
|
||||
from typing import List
|
||||
from uuid import uuid4
|
||||
import requests
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
import aiohttp
|
||||
from Crypto.PublicKey import RSA
|
||||
from Crypto.Signature import pkcs1_15
|
||||
from Crypto.Hash import SHA256
|
||||
|
@ -136,7 +137,7 @@ class ActivitypubMixin:
|
|||
queue=queue,
|
||||
)
|
||||
|
||||
def get_recipients(self, software=None):
|
||||
def get_recipients(self, software=None) -> List[str]:
|
||||
"""figure out which inbox urls to post to"""
|
||||
# first we have to figure out who should receive this activity
|
||||
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)
|
||||
def broadcast_task(sender_id, activity, recipients):
|
||||
def broadcast_task(sender_id: int, activity: str, recipients: List[str]):
|
||||
"""the celery task for broadcast"""
|
||||
user_model = apps.get_model("bookwyrm.User", require_ready=True)
|
||||
sender = user_model.objects.get(id=sender_id)
|
||||
for recipient in recipients:
|
||||
try:
|
||||
sign_and_send(sender, activity, recipient)
|
||||
except RequestException:
|
||||
pass
|
||||
sender = user_model.objects.select_related("key_pair").get(id=sender_id)
|
||||
asyncio.run(async_broadcast(recipients, sender, activity))
|
||||
|
||||
|
||||
def sign_and_send(sender, data, destination):
|
||||
"""crpyto whatever and http junk"""
|
||||
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:
|
||||
tasks.append(
|
||||
asyncio.ensure_future(sign_and_send(session, sender, data, recipient))
|
||||
)
|
||||
|
||||
results = await asyncio.gather(*tasks)
|
||||
return results
|
||||
|
||||
|
||||
async def sign_and_send(
|
||||
session: aiohttp.ClientSession, sender, data: str, destination: str
|
||||
):
|
||||
"""Sign the messages and send them in an asynchronous bundle"""
|
||||
now = http_date()
|
||||
|
||||
if not sender.key_pair.private_key:
|
||||
|
@ -527,20 +540,25 @@ def sign_and_send(sender, data, destination):
|
|||
|
||||
digest = make_digest(data)
|
||||
|
||||
response = requests.post(
|
||||
destination,
|
||||
data=data,
|
||||
headers={
|
||||
"Date": now,
|
||||
"Digest": digest,
|
||||
"Signature": make_signature(sender, destination, now, digest),
|
||||
"Content-Type": "application/activity+json; charset=utf-8",
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
)
|
||||
if not response.ok:
|
||||
response.raise_for_status()
|
||||
return response
|
||||
headers = {
|
||||
"Date": now,
|
||||
"Digest": digest,
|
||||
"Signature": make_signature(sender, destination, now, digest),
|
||||
"Content-Type": "application/activity+json; charset=utf-8",
|
||||
"User-Agent": USER_AGENT,
|
||||
}
|
||||
|
||||
try:
|
||||
async with session.post(destination, data=data, headers=headers) as response:
|
||||
if not response.ok:
|
||||
logger.exception(
|
||||
"Failed to send broadcast to %s: %s", destination, response.reason
|
||||
)
|
||||
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
|
||||
|
|
|
@ -3,18 +3,33 @@ from functools import reduce
|
|||
import operator
|
||||
|
||||
from django.apps import apps
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Q
|
||||
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
|
||||
|
||||
|
||||
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"""
|
||||
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
domain = models.CharField(max_length=255, unique=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
|
@ -29,10 +44,9 @@ class EmailBlocklist(models.Model):
|
|||
return User.objects.filter(email__endswith=f"@{self.domain}")
|
||||
|
||||
|
||||
class IPBlocklist(models.Model):
|
||||
class IPBlocklist(AdminModel):
|
||||
"""blocked ip addresses"""
|
||||
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
address = models.CharField(max_length=255, unique=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
|
@ -42,7 +56,7 @@ class IPBlocklist(models.Model):
|
|||
ordering = ("-created_date",)
|
||||
|
||||
|
||||
class AutoMod(models.Model):
|
||||
class AutoMod(AdminModel):
|
||||
"""rules to automatically flag suspicious activity"""
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@app.task(queue="low_priority")
|
||||
@app.task(queue=LOW)
|
||||
def automod_task():
|
||||
"""Create reports"""
|
||||
if not AutoMod.objects.exists():
|
||||
|
@ -61,17 +75,14 @@ def automod_task():
|
|||
if not reports:
|
||||
return
|
||||
|
||||
admins = User.objects.filter(
|
||||
models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
|
||||
| models.Q(is_superuser=True)
|
||||
).all()
|
||||
admins = User.admins()
|
||||
notification_model = apps.get_model("bookwyrm", "Notification", require_ready=True)
|
||||
with transaction.atomic():
|
||||
for admin in admins:
|
||||
notification, _ = notification_model.objects.get_or_create(
|
||||
user=admin, notification_type=notification_model.REPORT, read=False
|
||||
)
|
||||
notification.related_repors.add(reports)
|
||||
notification.related_reports.set(reports)
|
||||
|
||||
|
||||
def automod_users(reporter):
|
||||
|
|
|
@ -24,6 +24,9 @@ class Author(BookDataModel):
|
|||
gutenberg_id = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
isfdb = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
# idk probably other keys would be useful here?
|
||||
born = fields.DateTimeField(blank=True, null=True)
|
||||
died = fields.DateTimeField(blank=True, null=True)
|
||||
|
@ -42,6 +45,11 @@ class Author(BookDataModel):
|
|||
for book in self.book_set.values_list("id", flat=True)
|
||||
]
|
||||
cache.delete_many(cache_keys)
|
||||
|
||||
# normalize isni format
|
||||
if self.isni:
|
||||
self.isni = re.sub(r"\s", "", self.isni)
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
|
@ -55,6 +63,11 @@ class Author(BookDataModel):
|
|||
"""generate the url from the openlibrary id"""
|
||||
return f"https://openlibrary.org/authors/{self.openlibrary_key}"
|
||||
|
||||
@property
|
||||
def isfdb_link(self):
|
||||
"""generate the url from the isni id"""
|
||||
return f"https://www.isfdb.org/cgi-bin/ea.cgi?{self.isfdb}"
|
||||
|
||||
def get_remote_id(self):
|
||||
"""editions and works both use "book" instead of model_name"""
|
||||
return f"https://{DOMAIN}/author/{self.id}"
|
||||
|
|
|
@ -17,6 +17,7 @@ from .fields import RemoteIdField
|
|||
DeactivationReason = [
|
||||
("pending", _("Pending")),
|
||||
("self_deletion", _("Self deletion")),
|
||||
("self_deactivation", _("Self deactivation")),
|
||||
("moderator_suspension", _("Moderator suspension")),
|
||||
("moderator_deletion", _("Moderator deletion")),
|
||||
("domain_block", _("Domain block")),
|
||||
|
|
|
@ -55,6 +55,12 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
|
|||
asin = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
aasin = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
isfdb = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
search_vector = SearchVectorField(null=True)
|
||||
|
||||
last_edited_by = fields.ForeignKey(
|
||||
|
@ -73,6 +79,11 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
|
|||
"""generate the url from the inventaire id"""
|
||||
return f"https://inventaire.io/entity/{self.inventaire_id}"
|
||||
|
||||
@property
|
||||
def isfdb_link(self):
|
||||
"""generate the url from the isfdb id"""
|
||||
return f"https://www.isfdb.org/cgi-bin/title.cgi?{self.isfdb}"
|
||||
|
||||
class Meta:
|
||||
"""can't initialize this model, that wouldn't make sense"""
|
||||
|
||||
|
@ -241,6 +252,10 @@ class Work(OrderedCollectionPageMixin, Book):
|
|||
"""in case the default edition is not set"""
|
||||
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):
|
||||
"""an ordered collection of editions"""
|
||||
return self.to_ordered_collection(
|
||||
|
|
|
@ -13,6 +13,7 @@ from django.forms import ClearableFileInput, ImageField as DjangoImageField
|
|||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.encoding import filepath_to_uri
|
||||
from markdown import markdown
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm.connectors import get_image
|
||||
|
@ -499,6 +500,9 @@ class HtmlField(ActivitypubFieldMixin, models.TextField):
|
|||
return None
|
||||
return clean(value)
|
||||
|
||||
def field_to_activity(self, value):
|
||||
return markdown(value) if value else value
|
||||
|
||||
|
||||
class ArrayField(ActivitypubFieldMixin, DjangoArrayField):
|
||||
"""activitypub-aware array field"""
|
||||
|
|
|
@ -1,12 +1,25 @@
|
|||
""" track progress of goodreads imports """
|
||||
import math
|
||||
import re
|
||||
import dateutil.parser
|
||||
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
@ -30,6 +43,14 @@ def construct_search_term(title, author):
|
|||
return " ".join([title, author])
|
||||
|
||||
|
||||
ImportStatuses = [
|
||||
("pending", _("Pending")),
|
||||
("active", _("Active")),
|
||||
("complete", _("Complete")),
|
||||
("stopped", _("Stopped")),
|
||||
]
|
||||
|
||||
|
||||
class ImportJob(models.Model):
|
||||
"""entry for a specific request for book data import"""
|
||||
|
||||
|
@ -38,16 +59,78 @@ class ImportJob(models.Model):
|
|||
updated_date = models.DateTimeField(default=timezone.now)
|
||||
include_reviews = models.BooleanField(default=True)
|
||||
mappings = models.JSONField()
|
||||
complete = models.BooleanField(default=False)
|
||||
source = models.CharField(max_length=100)
|
||||
privacy = models.CharField(max_length=255, default="public", choices=PrivacyLevels)
|
||||
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
|
||||
def pending_items(self):
|
||||
"""items that haven't been processed yet"""
|
||||
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):
|
||||
"""a single line of a csv being imported"""
|
||||
|
@ -68,15 +151,18 @@ class ImportItem(models.Model):
|
|||
linked_review = models.ForeignKey(
|
||||
"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):
|
||||
"""let the job know when the items get work done"""
|
||||
job = self.job
|
||||
if job.complete:
|
||||
return
|
||||
|
||||
job.updated_date = timezone.now()
|
||||
job.save()
|
||||
if not job.pending_items.exists() and not job.complete:
|
||||
job.complete = True
|
||||
job.save(update_fields=["complete"])
|
||||
job.complete_job()
|
||||
|
||||
def resolve(self):
|
||||
"""try various ways to lookup a book"""
|
||||
|
@ -240,3 +326,136 @@ class ImportItem(models.Model):
|
|||
return "{} by {}".format(
|
||||
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 []
|
||||
if not instance.complete or "complete" not in update_fields:
|
||||
return
|
||||
Notification.objects.create(
|
||||
Notification.objects.get_or_create(
|
||||
user=instance.user,
|
||||
notification_type=Notification.IMPORT,
|
||||
related_import=instance,
|
||||
|
@ -231,10 +231,7 @@ def notify_admins_on_report(sender, instance, created, *args, **kwargs):
|
|||
return
|
||||
|
||||
# moderators and superusers should be notified
|
||||
admins = User.objects.filter(
|
||||
models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
|
||||
| models.Q(is_superuser=True)
|
||||
).all()
|
||||
admins = User.admins()
|
||||
for admin in admins:
|
||||
notification, _ = Notification.objects.get_or_create(
|
||||
user=admin,
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
""" flagged for moderation """
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import models
|
||||
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from .base_model import BookWyrmModel
|
||||
|
||||
|
@ -21,6 +23,12 @@ class Report(BookWyrmModel):
|
|||
links = models.ManyToManyField("Link", blank=True)
|
||||
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):
|
||||
return f"https://{DOMAIN}/settings/reports/{self.id}"
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import datetime
|
|||
from urllib.parse import urljoin
|
||||
import uuid
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import models, IntegrityError
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
|
@ -15,7 +16,23 @@ from .user import User
|
|||
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"""
|
||||
|
||||
name = models.CharField(default="BookWyrm", max_length=100)
|
||||
|
@ -45,6 +62,8 @@ class SiteSettings(models.Model):
|
|||
)
|
||||
code_of_conduct = models.TextField(default="Add a code of conduct here.")
|
||||
privacy_policy = models.TextField(default="Add a privacy policy here.")
|
||||
impressum = models.TextField(default="Add a impressum here.")
|
||||
show_impressum = models.BooleanField(default=False)
|
||||
|
||||
# registration
|
||||
allow_registration = models.BooleanField(default=False)
|
||||
|
@ -69,6 +88,9 @@ class SiteSettings(models.Model):
|
|||
admin_email = models.EmailField(max_length=255, null=True, blank=True)
|
||||
footer_item = models.TextField(null=True, blank=True)
|
||||
|
||||
# controls
|
||||
imports_enabled = models.BooleanField(default=True)
|
||||
|
||||
field_tracker = FieldTracker(fields=["name", "instance_tagline", "logo"])
|
||||
|
||||
@classmethod
|
||||
|
@ -115,7 +137,7 @@ class SiteSettings(models.Model):
|
|||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Theme(models.Model):
|
||||
class Theme(SiteModel):
|
||||
"""Theme files"""
|
||||
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
|
@ -138,6 +160,13 @@ class SiteInvite(models.Model):
|
|||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
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):
|
||||
"""make sure it hasn't expired or been used"""
|
||||
return (self.expiry is None or self.expiry > timezone.now()) and (
|
||||
|
@ -157,10 +186,16 @@ class InviteRequest(BookWyrmModel):
|
|||
invite = models.ForeignKey(
|
||||
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)
|
||||
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):
|
||||
"""don't create a request for a registered email"""
|
||||
if not self.id and User.objects.filter(email=self.email).exists():
|
||||
|
|
|
@ -63,6 +63,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
activitypub_field="inReplyTo",
|
||||
)
|
||||
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()
|
||||
|
||||
activity_serializer = activitypub.Note
|
||||
|
@ -83,8 +86,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
|
||||
if not self.reply_parent:
|
||||
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
|
||||
""" "delete" a status"""
|
||||
|
@ -363,7 +365,7 @@ class Review(BookStatus):
|
|||
default=None,
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(5)],
|
||||
validators=[MinValueValidator(0.5), MaxValueValidator(5)],
|
||||
decimal_places=2,
|
||||
max_digits=3,
|
||||
)
|
||||
|
@ -399,7 +401,7 @@ class ReviewRating(Review):
|
|||
def save(self, *args, **kwargs):
|
||||
if not self.rating:
|
||||
raise ValueError("ReviewRating object must include a numerical rating")
|
||||
return super().save(*args, **kwargs)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def pure_content(self):
|
||||
|
|
|
@ -5,6 +5,7 @@ from urllib.parse import urlparse
|
|||
from django.apps import apps
|
||||
from django.contrib.auth.models import AbstractUser, Group
|
||||
from django.contrib.postgres.fields import ArrayField, CICharField
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.dispatch import receiver
|
||||
from django.db import models, transaction
|
||||
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.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, USE_HTTPS, LANGUAGES
|
||||
from bookwyrm.signatures import create_key_pair
|
||||
from bookwyrm.tasks import app
|
||||
from bookwyrm.tasks import app, LOW
|
||||
from bookwyrm.utils import regex
|
||||
from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin
|
||||
from .base_model import BookWyrmModel, DeactivationReason, new_access_code
|
||||
|
@ -46,6 +47,7 @@ def site_link():
|
|||
return f"{protocol}://{DOMAIN}"
|
||||
|
||||
|
||||
# pylint: disable=too-many-public-methods
|
||||
class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
"""a user who wants to read books"""
|
||||
|
||||
|
@ -168,12 +170,19 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
max_length=255, choices=DeactivationReason, 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)
|
||||
|
||||
name_field = "username"
|
||||
property_fields = [("following_link", "following")]
|
||||
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
|
||||
def active_follower_requests(self):
|
||||
"""Follow requests from active users"""
|
||||
|
@ -231,6 +240,15 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
queryset = queryset.exclude(blocks=viewer)
|
||||
return queryset
|
||||
|
||||
@classmethod
|
||||
def admins(cls):
|
||||
"""Get a queryset of the admins for this instance"""
|
||||
return cls.objects.filter(
|
||||
models.Q(groups__name__in=["moderator", "admin"])
|
||||
| models.Q(is_superuser=True),
|
||||
is_active=True,
|
||||
).distinct()
|
||||
|
||||
def update_active_date(self):
|
||||
"""this user is here! they are doing things!"""
|
||||
self.last_active_date = timezone.now()
|
||||
|
@ -352,12 +370,31 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
self.create_shelves()
|
||||
|
||||
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
|
||||
self.is_active = False
|
||||
# skip the logic in this class's save()
|
||||
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,
|
||||
update_fields=["deactivation_reason", "is_active", "allow_reactivation"],
|
||||
)
|
||||
|
||||
@property
|
||||
def local_path(self):
|
||||
"""this model doesn't inherit bookwyrm model, so here we are"""
|
||||
|
@ -393,6 +430,12 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
editable=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):
|
||||
"""public and private keys for a user"""
|
||||
|
@ -419,7 +462,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
|
|||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@app.task(queue="low_priority")
|
||||
@app.task(queue=LOW)
|
||||
def set_remote_server(user_id):
|
||||
"""figure out the user's remote server in the background"""
|
||||
user = User.objects.get(id=user_id)
|
||||
|
@ -463,7 +506,7 @@ def get_or_create_remote_server(domain, refresh=False):
|
|||
return server
|
||||
|
||||
|
||||
@app.task(queue="low_priority")
|
||||
@app.task(queue=LOW)
|
||||
def get_remote_reviews(outbox):
|
||||
"""ingest reviews by a new remote bookwyrm user"""
|
||||
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 bookwyrm import models, settings
|
||||
from bookwyrm.tasks import app
|
||||
from bookwyrm.tasks import app, LOW
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -401,7 +401,7 @@ def save_and_cleanup(image, instance=None):
|
|||
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@app.task(queue="low_priority")
|
||||
@app.task(queue=LOW)
|
||||
def generate_site_preview_image_task():
|
||||
"""generate preview_image for the website"""
|
||||
if not settings.ENABLE_PREVIEW_IMAGES:
|
||||
|
@ -426,7 +426,7 @@ def generate_site_preview_image_task():
|
|||
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@app.task(queue="low_priority")
|
||||
@app.task(queue=LOW)
|
||||
def generate_edition_preview_image_task(book_id):
|
||||
"""generate preview_image for a book"""
|
||||
if not settings.ENABLE_PREVIEW_IMAGES:
|
||||
|
@ -451,7 +451,7 @@ def generate_edition_preview_image_task(book_id):
|
|||
save_and_cleanup(image, instance=book)
|
||||
|
||||
|
||||
@app.task(queue="low_priority")
|
||||
@app.task(queue=LOW)
|
||||
def generate_user_preview_image_task(user_id):
|
||||
"""generate preview_image for a book"""
|
||||
if not settings.ENABLE_PREVIEW_IMAGES:
|
||||
|
|
|
@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _
|
|||
env = Env()
|
||||
env.read_env()
|
||||
DOMAIN = env("DOMAIN")
|
||||
VERSION = "0.4.5"
|
||||
VERSION = "0.5.3"
|
||||
|
||||
RELEASE_API = env(
|
||||
"RELEASE_API",
|
||||
|
@ -21,7 +21,7 @@ RELEASE_API = env(
|
|||
PAGE_LENGTH = env("PAGE_LENGTH", 15)
|
||||
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
|
||||
|
||||
JS_CACHE = "e678183b"
|
||||
JS_CACHE = "ad848b97"
|
||||
|
||||
# email
|
||||
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
|
||||
|
@ -147,6 +147,9 @@ LOGGING = {
|
|||
"require_debug_true": {
|
||||
"()": "django.utils.log.RequireDebugTrue",
|
||||
},
|
||||
"ignore_missing_variable": {
|
||||
"()": "bookwyrm.utils.log.IgnoreVariableDoesNotExist",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
# Overrides the default handler to make it log to console
|
||||
|
@ -154,6 +157,7 @@ LOGGING = {
|
|||
# console if DEBUG=False)
|
||||
"console": {
|
||||
"level": LOG_LEVEL,
|
||||
"filters": ["ignore_missing_variable"],
|
||||
"class": "logging.StreamHandler",
|
||||
},
|
||||
# This is copied as-is from the default logger, and is
|
||||
|
@ -363,3 +367,9 @@ else:
|
|||
OTEL_EXPORTER_OTLP_ENDPOINT = env("OTEL_EXPORTER_OTLP_ENDPOINT", None)
|
||||
OTEL_EXPORTER_OTLP_HEADERS = env("OTEL_EXPORTER_OTLP_HEADERS", None)
|
||||
OTEL_SERVICE_NAME = env("OTEL_SERVICE_NAME", None)
|
||||
|
||||
TWO_FACTOR_LOGIN_MAX_SECONDS = 60
|
||||
|
||||
HTTP_X_FORWARDED_PROTO = env.bool("SECURE_PROXY_SSL_HEADER", False)
|
||||
if HTTP_X_FORWARDED_PROTO:
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
|
|
|
@ -140,6 +140,10 @@ button:focus-visible .button-invisible-overlay {
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
button.button-paragraph {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
|
||||
/** States
|
||||
******************************************************************************/
|
||||
|
|
|
@ -67,7 +67,7 @@ details.dropdown .dropdown-menu a:focus-visible {
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
z-index: 35;
|
||||
}
|
||||
|
||||
details .dropdown-menu > * {
|
||||
|
@ -81,7 +81,19 @@ details.dropdown .dropdown-menu a:focus-visible {
|
|||
details.details-panel {
|
||||
box-shadow: 0 0 0 1px $border;
|
||||
transition: box-shadow 0.2s ease;
|
||||
padding: 0.75rem;
|
||||
padding: 0;
|
||||
|
||||
> * {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
summary {
|
||||
position: relative;
|
||||
|
||||
.details-close {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
details[open].details-panel,
|
||||
|
@ -89,10 +101,6 @@ details.details-panel:hover {
|
|||
box-shadow: 0 0 0 1px $border;
|
||||
}
|
||||
|
||||
details.details-panel summary {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
details summary .details-close {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
|
|
|
@ -15,6 +15,8 @@ $danger: #872538;
|
|||
$danger-light: #481922;
|
||||
$light: #393939;
|
||||
$red: #ffa1b4;
|
||||
$black: #000;
|
||||
$white-ter: hsl(0, 0%, 90%);
|
||||
|
||||
/* book cover standins */
|
||||
$no-cover-color: #002549;
|
||||
|
@ -56,9 +58,12 @@ $link-active: $white-bis;
|
|||
$link-light: #0d1c26;
|
||||
|
||||
/* bulma overrides */
|
||||
$body-background-color: rgb(17, 18, 18);
|
||||
$background: $background-secondary;
|
||||
$menu-item-active-background-color: $link-background;
|
||||
$navbar-dropdown-item-hover-color: $white;
|
||||
$info-light: $background-body;
|
||||
$info-dark: #72b6ee;
|
||||
|
||||
/* These element's colors are hardcoded, probably a bug in bulma? */
|
||||
@media screen and (min-width: 769px) {
|
||||
|
@ -74,7 +79,7 @@ $navbar-dropdown-item-hover-color: $white;
|
|||
}
|
||||
|
||||
/* misc */
|
||||
$shadow: 0 0.5em 1em -0.125em rgba($black, 0.2), 0 0px 0 1px rgba($black, 0.02);
|
||||
$shadow: 0 0.5em 0.5em -0.125em rgba($black, 0.2), 0 0px 0 1px rgba($black, 0.02);
|
||||
$card-header-shadow: 0 0.125em 0.25em rgba($black, 0.1);
|
||||
$invisible-overlay-background-color: rgba($black, 0.66);
|
||||
$progress-value-background-color: $border-light;
|
||||
|
@ -92,6 +97,11 @@ $family-secondary: $family-sans-serif;
|
|||
color: $grey-light !important;
|
||||
}
|
||||
|
||||
|
||||
#qrcode svg {
|
||||
background-color: #a6a6a6;
|
||||
}
|
||||
|
||||
@import "../bookwyrm.scss";
|
||||
@import "../vendor/icons.css";
|
||||
@import "../vendor/shepherd.scss";
|
||||
|
|
|
@ -38,11 +38,12 @@ let BookWyrm = new (class {
|
|||
.querySelectorAll("[data-modal-open]")
|
||||
.forEach((node) => node.addEventListener("click", this.handleModalButton.bind(this)));
|
||||
|
||||
document
|
||||
.querySelectorAll("details.dropdown")
|
||||
.forEach((node) =>
|
||||
node.addEventListener("toggle", this.handleDetailsDropdown.bind(this))
|
||||
document.querySelectorAll("details.dropdown").forEach((node) => {
|
||||
node.addEventListener("toggle", this.handleDetailsDropdown.bind(this));
|
||||
node.querySelectorAll("[data-modal-open]").forEach((modal_node) =>
|
||||
modal_node.addEventListener("click", () => (node.open = false))
|
||||
);
|
||||
});
|
||||
|
||||
document
|
||||
.querySelector("#barcode-scanner-modal")
|
||||
|
@ -627,9 +628,9 @@ let BookWyrm = new (class {
|
|||
}
|
||||
|
||||
function toggleStatus(status) {
|
||||
for (const child of statusNode.children) {
|
||||
BookWyrm.toggleContainer(child, !child.classList.contains(status));
|
||||
}
|
||||
const template = document.querySelector(`#barcode-${status}`);
|
||||
|
||||
statusNode.replaceChildren(template ? template.content.cloneNode(true) : null);
|
||||
}
|
||||
|
||||
function initBarcodes(cameraId = null) {
|
||||
|
|
|
@ -7,7 +7,7 @@ from django.db.models import signals, Count, Q, Case, When, IntegerField
|
|||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.redis_store import RedisStore, r
|
||||
from bookwyrm.tasks import app
|
||||
from bookwyrm.tasks import app, LOW, MEDIUM
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -237,41 +237,41 @@ def domain_level_update(sender, instance, created, update_fields=None, **kwargs)
|
|||
# ------------------- TASKS
|
||||
|
||||
|
||||
@app.task(queue="low_priority")
|
||||
@app.task(queue=LOW)
|
||||
def rerank_suggestions_task(user_id):
|
||||
"""do the hard work in celery"""
|
||||
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):
|
||||
"""do the hard work in celery"""
|
||||
user = models.User.objects.get(id=user_id)
|
||||
suggested_users.rerank_obj(user, update_only=update_only)
|
||||
|
||||
|
||||
@app.task(queue="low_priority")
|
||||
@app.task(queue=LOW)
|
||||
def remove_user_task(user_id):
|
||||
"""do the hard work in celery"""
|
||||
user = models.User.objects.get(id=user_id)
|
||||
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):
|
||||
"""remove a specific user from a specific user's suggestions"""
|
||||
suggested_user = models.User.objects.get(id=suggested_user_id)
|
||||
suggested_users.remove_suggestion(user_id, suggested_user)
|
||||
|
||||
|
||||
@app.task(queue="low_priority")
|
||||
@app.task(queue=LOW)
|
||||
def bulk_remove_instance_task(instance_id):
|
||||
"""remove a bunch of users from recs"""
|
||||
for user in models.User.objects.filter(federated_server__id=instance_id):
|
||||
suggested_users.remove_object_from_related_stores(user)
|
||||
|
||||
|
||||
@app.task(queue="low_priority")
|
||||
@app.task(queue=LOW)
|
||||
def bulk_add_instance_task(instance_id):
|
||||
"""remove a bunch of users from recs"""
|
||||
for user in models.User.objects.filter(federated_server__id=instance_id):
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
{% block about_content %}
|
||||
{# seven day cache #}
|
||||
{% cache 604800 about_page %}
|
||||
{% cache 604800 about_page_superlatives %}
|
||||
|
||||
{% get_book_superlatives as superlatives %}
|
||||
<section class=" pb-4">
|
||||
|
@ -97,6 +97,7 @@
|
|||
</p>
|
||||
|
||||
</section>
|
||||
{% endcache %}
|
||||
|
||||
<section class="block">
|
||||
<header class="content">
|
||||
|
@ -145,5 +146,4 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{% endcache %}
|
||||
{% endblock %}
|
||||
|
|
15
bookwyrm/templates/about/impressum.html
Normal file
15
bookwyrm/templates/about/impressum.html
Normal file
|
@ -0,0 +1,15 @@
|
|||
{% extends 'about/layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Impressum" %}{% endblock %}
|
||||
|
||||
|
||||
{% block about_content %}
|
||||
<div class="block content">
|
||||
<h2>{% trans "Impressum" %}</h2>
|
||||
<div class="content">
|
||||
{{ site.impressum | safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
|
@ -47,6 +47,14 @@
|
|||
{% trans "Privacy Policy" %}
|
||||
</a>
|
||||
</li>
|
||||
{% if site.show_impressum %}
|
||||
<li>
|
||||
{% url 'impressum' as path %}
|
||||
<a href="{{ path }}" {% if request.path in path %}class="is-active"{% endif %}>
|
||||
{% trans "Impressum" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
|
|
|
@ -53,7 +53,7 @@
|
|||
{% trans "Share this page" %}
|
||||
</span>
|
||||
</summary>
|
||||
<div class="columns mt-3">
|
||||
<div class="columns">
|
||||
<div class="column is-three-fifths is-offset-one-fifth">
|
||||
|
||||
{% if year_key %}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
{% load markdown %}
|
||||
{% load humanize %}
|
||||
{% load utilities %}
|
||||
{% load book_display_tags %}
|
||||
|
||||
{% block title %}{{ author.name }}{% endblock %}
|
||||
|
||||
|
@ -27,7 +28,7 @@
|
|||
<meta itemprop="name" content="{{ author.name }}">
|
||||
|
||||
{% firstof author.aliases author.born author.died as details %}
|
||||
{% firstof author.wikipedia_link author.openlibrary_key author.inventaire_id author.isni as links %}
|
||||
{% firstof author.wikipedia_link author.openlibrary_key author.inventaire_id author.isni author.isfdb as links %}
|
||||
{% if details or links %}
|
||||
<div class="column is-3">
|
||||
{% if details %}
|
||||
|
@ -80,6 +81,14 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if author.isfdb %}
|
||||
<div class="mt-1">
|
||||
<a itemprop="sameAs" href="{{ author.isfdb_link }}" rel="nofollow noopener noreferrer" target="_blank">
|
||||
{% trans "View on ISFDB" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% trans "Load data" as button_text %}
|
||||
{% if author.openlibrary_key %}
|
||||
<div class="mt-1 is-flex">
|
||||
|
@ -127,6 +136,14 @@
|
|||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if author.isfdb %}
|
||||
<div>
|
||||
<a itemprop="sameAs" href="https://www.isfdb.org/cgi-bin/ea.cgi?{{ author.isfdb }}" target="_blank" rel="nofollow noopener noreferrer">
|
||||
{% trans "View ISFDB entry" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
@ -141,9 +158,9 @@
|
|||
<h2 class="title is-4">{% blocktrans with name=author.name %}Books by {{ name }}{% endblocktrans %}</h2>
|
||||
<div class="columns is-multiline is-mobile">
|
||||
{% 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="is-flex-grow-1">
|
||||
<div class="is-flex-grow-1 mb-3">
|
||||
{% include 'landing/small-book.html' with book=book %}
|
||||
</div>
|
||||
{% include 'snippets/shelve_button/shelve_button.html' with book=book %}
|
||||
|
|
|
@ -101,6 +101,13 @@
|
|||
{% include 'snippets/form_errors.html' with errors_list=form.goodreads_key.errors id="desc_goodreads_key" %}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="id_isfdb">{% trans "ISFDB:" %}</label>
|
||||
{{ form.isfdb }}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.isfdb.errors id="desc_isfdb" %}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="id_isni">{% trans "ISNI:" %}</label>
|
||||
{{ form.isni }}
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
<div class="block" itemscope itemtype="https://schema.org/Book">
|
||||
<div class="columns is-mobile">
|
||||
<div class="column">
|
||||
<h1 class="title" itemprop="name">
|
||||
<h1 class="title" itemprop="name" dir="auto">
|
||||
{{ book.title }}
|
||||
</h1>
|
||||
|
||||
|
@ -37,7 +37,7 @@
|
|||
content="{{ book.subtitle | escape }}"
|
||||
>
|
||||
|
||||
<span class="has-text-weight-bold">
|
||||
<span class="has-text-weight-bold" dir="auto">
|
||||
{{ book.subtitle }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
@ -52,7 +52,7 @@
|
|||
{% endif %}
|
||||
|
||||
{% if book.authors.exists %}
|
||||
<div class="subtitle">
|
||||
<div class="subtitle" dir="auto">
|
||||
{% trans "by" %} {% include 'snippets/authors.html' with book=book %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
@ -135,7 +135,7 @@
|
|||
{% trans "View on OpenLibrary" %}
|
||||
</a>
|
||||
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
|
||||
<button class="button is-small" type="button" data-modal-open="openlibrary_sync">
|
||||
<button class="button is-small button-paragraph" type="button" data-modal-open="openlibrary_sync">
|
||||
<span class="icon icon-download" title="{{ button_text }}"></span>
|
||||
<span class="is-sr-only-mobile">{{ button_text }}</span>
|
||||
</button>
|
||||
|
@ -150,7 +150,7 @@
|
|||
</a>
|
||||
|
||||
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
|
||||
<button class="button is-small" type="button" data-modal-open="inventaire_sync">
|
||||
<button class="button is-small button-paragraph" type="button" data-modal-open="inventaire_sync">
|
||||
<span class="icon icon-download" title="{{ button_text }}"></span>
|
||||
<span class="is-sr-only-mobile">{{ button_text }}</span>
|
||||
</button>
|
||||
|
@ -158,6 +158,13 @@
|
|||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if book.isfdb %}
|
||||
<p>
|
||||
<a href="{{ book.isfdb_link }}" target="_blank" rel="nofollow noopener noreferrer">
|
||||
{% trans "View on ISFDB" %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
@ -189,15 +196,15 @@
|
|||
|
||||
{% if user_authenticated and can_edit_book and not book|book_description %}
|
||||
{% trans 'Add Description' as button_text %}
|
||||
{% include 'snippets/toggle/open_button.html' with text=button_text controls_text="add_description" controls_uid=book.id focus="id_description" hide_active=True id="hide_description" %}
|
||||
{% include 'snippets/toggle/open_button.html' with class="mb-2" text=button_text controls_text="add_description" controls_uid=book.id focus="id_description" hide_active=True id="hide_description" %}
|
||||
|
||||
<div class="box is-hidden" id="add_description_{{ book.id }}">
|
||||
<form name="add-description" method="POST" action="{% url "add-description" book.id %}">
|
||||
{% csrf_token %}
|
||||
<p class="fields is-grouped">
|
||||
<div class="field">
|
||||
<label class="label" for="id_description_{{ book.id }}">{% trans "Description:" %}</label>
|
||||
<textarea name="description" cols="None" rows="None" class="textarea" id="id_description_{{ book.id }}"></textarea>
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<button class="button is-primary" type="submit">{% trans "Save" %}</button>
|
||||
{% trans "Cancel" as button_text %}
|
||||
|
@ -231,7 +238,7 @@
|
|||
{% for shelf in user_shelfbooks %}
|
||||
<li class="box">
|
||||
<a href="{{ shelf.shelf.local_path }}">{{ shelf.shelf.name }}</a>
|
||||
<div class="mb-3">
|
||||
<div class="is-pulled-right">
|
||||
{% include 'snippets/shelf_selector.html' with shelf=shelf.shelf class="is-small" readthrough=readthrough %}
|
||||
</div>
|
||||
</li>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{% spaceless %}
|
||||
{% load i18n %}
|
||||
|
||||
{% if book.isbn_13 or book.oclc_number or book.asin %}
|
||||
{% if book.isbn_13 or book.oclc_number or book.asin or book.aasin or book.isfdb %}
|
||||
<dl>
|
||||
{% if book.isbn_13 %}
|
||||
<div class="is-flex">
|
||||
|
@ -23,6 +23,27 @@
|
|||
<dd>{{ book.asin }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if book.aasin %}
|
||||
<div class="is-flex">
|
||||
<dt class="mr-1">{% trans "Audible ASIN:" %}</dt>
|
||||
<dd>{{ book.aasin }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if book.isfdb %}
|
||||
<div class="is-flex">
|
||||
<dt class="mr-1">{% trans "ISFDB ID:" %}</dt>
|
||||
<dd>{{ book.isfdb }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if book.goodreads_key %}
|
||||
<div class="is-flex">
|
||||
<dt class="mr-1">{% trans "Goodreads:" %}</dt>
|
||||
<dd>{{ book.goodreads_key }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
{% endif %}
|
||||
{% endspaceless %}
|
||||
|
|
|
@ -65,17 +65,17 @@
|
|||
<input type="hidden" name="author-match-count" value="{{ author_matches|length }}">
|
||||
<div class="column is-half">
|
||||
{% for author in author_matches %}
|
||||
<fieldset>
|
||||
<fieldset class="block">
|
||||
<legend class="title is-5 mb-1">
|
||||
{% blocktrans with name=author.name %}Is "{{ name }}" one of these authors?{% endblocktrans %}
|
||||
</legend>
|
||||
{% with forloop.counter0 as counter %}
|
||||
{% for match in author.matches %}
|
||||
<label class="label">
|
||||
<label class="label mb-0">
|
||||
<input type="radio" name="author_match-{{ counter }}" value="{{ match.id }}" required>
|
||||
{{ match.name }}
|
||||
</label>
|
||||
<p class="help ml-5 mb-2">
|
||||
<p class="help ml-5 mb-0 mt-0">
|
||||
{% with book_title=match.book_set.first.title alt_title=match.bio %}
|
||||
{% if book_title %}
|
||||
<a href="{{ match.local_path }}" target="_blank" rel="nofollow noopener noreferrer">{% blocktrans trimmed %}
|
||||
|
@ -98,6 +98,9 @@
|
|||
</label>
|
||||
{% endwith %}
|
||||
</fieldset>
|
||||
{% if not forloop.last %}
|
||||
<hr aria-hidden="true">
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
|
|
|
@ -81,7 +81,7 @@
|
|||
{% include 'snippets/form_errors.html' with errors_list=form.languages.errors id="desc_languages" %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="field">
|
||||
<label class="label" for="id_add_subjects">
|
||||
{% trans "Subjects:" %}
|
||||
</label>
|
||||
|
@ -327,6 +327,15 @@
|
|||
{% include 'snippets/form_errors.html' with errors_list=form.inventaire_id.errors id="desc_inventaire_id" %}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="id_goodreads_key">
|
||||
{% trans "Goodreads key:" %}
|
||||
</label>
|
||||
{{ form.goodreads_key }}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.goodreads_key.errors id="desc_goodreads_key" %}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="id_oclc_number">
|
||||
{% trans "OCLC Number:" %}
|
||||
|
@ -344,6 +353,24 @@
|
|||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.ASIN.errors id="desc_ASIN" %}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="id_aasin">
|
||||
{% trans "Audible ASIN:" %}
|
||||
</label>
|
||||
{{ form.aasin }}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.AASIN.errors id="desc_AASIN" %}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="id_isfdb">
|
||||
{% trans "ISFDB ID:" %}
|
||||
</label>
|
||||
{{ form.isfdb }}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.isfdb.errors id="desc_isfdb" %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
@ -86,6 +86,7 @@
|
|||
<button class="button is-primary" type="submit">{% trans "Save" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'snippets/form_errors.html' with errors_list=link.form.availability.errors id="desc_availability" %}
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
{% block content %}
|
||||
<p>
|
||||
{% if report_link %}
|
||||
{% if link_domain %}
|
||||
|
||||
{% blocktrans trimmed %}
|
||||
@{{ reporter }} has flagged a link domain for moderation.
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
{% load i18n %}
|
||||
{% block content %}
|
||||
|
||||
{% if report_link %}
|
||||
{% if link_domain %}
|
||||
{% blocktrans trimmed %}
|
||||
@{{ reporter }} has flagged a link domain for moderation.
|
||||
{% endblocktrans %}
|
||||
|
|
12
bookwyrm/templates/email/test/html_content.html
Normal file
12
bookwyrm/templates/email/test/html_content.html
Normal file
|
@ -0,0 +1,12 @@
|
|||
{% extends 'email/html_layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
This is a test email.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
|
||||
{% endblock %}
|
4
bookwyrm/templates/email/test/subject.html
Normal file
4
bookwyrm/templates/email/test/subject.html
Normal file
|
@ -0,0 +1,4 @@
|
|||
{% load i18n %}
|
||||
{% blocktrans trimmed %}
|
||||
Test email
|
||||
{% endblocktrans %}
|
9
bookwyrm/templates/email/test/text_content.html
Normal file
9
bookwyrm/templates/email/test/text_content.html
Normal file
|
@ -0,0 +1,9 @@
|
|||
{% extends 'email/text_layout.html' %}
|
||||
{% load i18n %}
|
||||
{% block content %}
|
||||
{% blocktrans trimmed %}
|
||||
This is a test email.
|
||||
{% endblocktrans %}
|
||||
|
||||
|
||||
{% endblock %}
|
|
@ -1,14 +1,13 @@
|
|||
{% load layout %}
|
||||
{% load i18n %}
|
||||
{% load sass_tags %}
|
||||
{% 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 rel="stylesheet" href="{% static "css/vendor/bulma.min.css" %}">
|
||||
<link rel="stylesheet" href="{% static "css/vendor/icons.css" %}">
|
||||
<link rel="stylesheet" href="{% static "css/bookwyrm.css" %}">
|
||||
<link href="{% sass_src site_theme %}" rel="stylesheet" type="text/css" />
|
||||
|
||||
<base target="_blank">
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% block filter %}
|
||||
<label class="label mt-2 mb-1">Status types</label>
|
||||
<label class="label mb-1">Status types</label>
|
||||
|
||||
<div class="is-flex is-flex-direction-row is-flex-direction-column-mobile">
|
||||
{% for name, value in feed_status_types_options %}
|
||||
|
|
|
@ -119,7 +119,7 @@
|
|||
},
|
||||
{
|
||||
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: {
|
||||
element: "#tour-manually-add-book",
|
||||
on: "right",
|
||||
|
|
|
@ -7,79 +7,162 @@
|
|||
{% block content %}
|
||||
<div class="block">
|
||||
<h1 class="title">{% trans "Import Books" %}</h1>
|
||||
<form class="box" name="import" action="/import" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-half">
|
||||
{% if invalid %}
|
||||
<div class="notification is-danger">
|
||||
{% trans "Not a valid CSV file" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="source">
|
||||
{% trans "Data source:" %}
|
||||
</label>
|
||||
{% if site.imports_enabled %}
|
||||
{% 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 %}
|
||||
|
||||
<div class="select">
|
||||
<select name="source" id="source" aria-describedby="desc_source">
|
||||
<option value="Goodreads" {% if current == 'Goodreads' %}selected{% endif %}>
|
||||
Goodreads (CSV)
|
||||
</option>
|
||||
<option value="Storygraph" {% if current == 'Storygraph' %}selected{% endif %}>
|
||||
Storygraph (CSV)
|
||||
</option>
|
||||
<option value="LibraryThing" {% if current == 'LibraryThing' %}selected{% endif %}>
|
||||
LibraryThing (TSV)
|
||||
</option>
|
||||
<option value="OpenLibrary" {% if current == 'OpenLibrary' %}selected{% endif %}>
|
||||
OpenLibrary (CSV)
|
||||
</option>
|
||||
<option value="Calibre" {% if current == 'Calibre' %}selected{% endif %}>
|
||||
Calibre (CSV)
|
||||
</option>
|
||||
</select>
|
||||
<form class="box" name="import" action="/import" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-half">
|
||||
<div class="field">
|
||||
<label class="label" for="source">
|
||||
{% trans "Data source:" %}
|
||||
</label>
|
||||
|
||||
<div class="select">
|
||||
<select name="source" id="source" aria-describedby="desc_source">
|
||||
<option value="Goodreads" {% if current == 'Goodreads' %}selected{% endif %}>
|
||||
{% trans "Goodreads (CSV)" %}
|
||||
</option>
|
||||
<option value="Storygraph" {% if current == 'Storygraph' %}selected{% endif %}>
|
||||
{% trans "Storygraph (CSV)" %}
|
||||
</option>
|
||||
<option value="LibraryThing" {% if current == 'LibraryThing' %}selected{% endif %}>
|
||||
{% trans "LibraryThing (TSV)" %}
|
||||
</option>
|
||||
<option value="OpenLibrary" {% if current == 'OpenLibrary' %}selected{% endif %}>
|
||||
{% trans "OpenLibrary (CSV)" %}
|
||||
</option>
|
||||
<option value="Calibre" {% if current == 'Calibre' %}selected{% endif %}>
|
||||
{% trans "Calibre (CSV)" %}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p class="help" id="desc_source">
|
||||
{% 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>
|
||||
</div>
|
||||
|
||||
<p class="help" id="desc_source">
|
||||
{% 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>
|
||||
<div class="field">
|
||||
<label class="label" for="id_csv_file">{% trans "Data file:" %}</label>
|
||||
{{ import_form.csv_file }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="id_csv_file">{% trans "Data file:" %}</label>
|
||||
{{ import_form.csv_file }}
|
||||
<div class="column is-half">
|
||||
<div class="field">
|
||||
<label class="label">
|
||||
<input type="checkbox" name="include_reviews" checked> {% trans "Include reviews" %}
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="privacy_import">
|
||||
{% trans "Privacy setting for imported reviews:" %}
|
||||
</label>
|
||||
{% include 'snippets/privacy_select.html' with no_label=True privacy_uuid="import" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-half">
|
||||
<div class="field">
|
||||
<label class="label">
|
||||
<input type="checkbox" name="include_reviews" checked> {% trans "Include reviews" %}
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="privacy_import">
|
||||
{% trans "Privacy setting for imported reviews:" %}
|
||||
</label>
|
||||
{% include 'snippets/privacy_select.html' with no_label=True privacy_uuid="import" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="button is-primary" type="submit">{% trans "Import" %}</button>
|
||||
</form>
|
||||
<button class="button is-primary" type="submit">{% trans "Import" %}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="box notification has-text-centered is-warning m-6 content">
|
||||
<p class="mt-5">
|
||||
<span class="icon icon-warning is-size-2" aria-hidden="true"></span>
|
||||
</p>
|
||||
<p class="mb-5">
|
||||
{% trans "Imports are temporarily disabled; thank you for your patience." %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="content block">
|
||||
<h2 class="title">{% trans "Recent Imports" %}</h2>
|
||||
{% if not jobs %}
|
||||
<p><em>{% trans "No recent imports" %}</em></p>
|
||||
{% endif %}
|
||||
<ul>
|
||||
{% for job in jobs %}
|
||||
<li><a href="{% url 'import-status' job.id %}">{{ job.created_date | naturaltime }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<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 %}
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<em>{% trans "No recent imports" %}</em>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% for job in jobs %}
|
||||
<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 %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% include 'snippets/pagination.html' with page=jobs path=request.path %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -41,7 +41,7 @@
|
|||
</dl>
|
||||
</div>
|
||||
|
||||
{% if not job.complete %}
|
||||
{% if not job.complete and show_progress %}
|
||||
<div class="box is-processing">
|
||||
<div class="block">
|
||||
<span class="icon icon-spinner is-pulled-left" aria-hidden="true"></span>
|
||||
|
@ -66,6 +66,13 @@
|
|||
</div>
|
||||
{% 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 %}
|
||||
<div class="notification">
|
||||
{% blocktrans trimmed count counter=manual_review_count with display_counter=manual_review_count|intcomma %}
|
||||
|
@ -94,7 +101,7 @@
|
|||
<div class="block">
|
||||
{% block actions %}{% endblock %}
|
||||
<div class="table-container">
|
||||
<table class="table is-striped">
|
||||
<table class="table is-striped is-fullwidth">
|
||||
<tr>
|
||||
<th>
|
||||
{% trans "Row" %}
|
||||
|
@ -137,6 +144,13 @@
|
|||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% if not items %}
|
||||
<tr>
|
||||
<td colspan="6">
|
||||
<em>{% trans "No items currently need review" %}</em>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
{% block index_col %}
|
||||
|
|
|
@ -73,7 +73,7 @@
|
|||
{% if site.invite_request_question %}
|
||||
<div class="block">
|
||||
<label for="id_answer_register" class="label">{{ site.invite_question_text }}</label>
|
||||
<input type="answer" name="answer" maxlength="50" class="input" required="true" id="id_answer_register" aria-describedby="desc_answer_register">
|
||||
<input type="text" name="answer" maxlength="255" class="input" required="true" id="id_answer_register" aria-describedby="desc_answer_register">
|
||||
{% include 'snippets/form_errors.html' with errors_list=request_form.answer.errors id="desc_answer_register" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
{% if show_confirmed_email %}
|
||||
<p class="notification is-success">{% trans "Success! Email address confirmed." %}</p>
|
||||
{% endif %}
|
||||
<form name="login-confirm" method="post" action="/login">
|
||||
<form name="login-confirm" method="post" action="{% url 'login' %}">
|
||||
{% csrf_token %}
|
||||
{% if show_confirmed_email %}<input type="hidden" name="first_login" value="true">{% endif %}
|
||||
<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 %}
|
|
@ -13,6 +13,7 @@
|
|||
<link rel="search" type="application/opensearchdescription+xml" href="{% url 'opensearch' %}" title="{% blocktrans with site_name=site.name %}{{ site_name }} search{% endblocktrans %}" />
|
||||
|
||||
<link rel="shortcut icon" type="image/x-icon" href="{% if site.favicon %}{% get_media_prefix %}{{ site.favicon }}{% else %}{% static "images/favicon.ico" %}{% endif %}">
|
||||
<link rel="apple-touch-icon" href="{% if site.logo %}{{ media_full_url }}{{ site.logo }}{% else %}{% static "images/logo.png" %}{% endif %}">
|
||||
|
||||
{% if preview_images_enabled is True %}
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
|
@ -168,7 +169,7 @@
|
|||
</nav>
|
||||
|
||||
{% 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">
|
||||
{% for announcement in active_announcements %}
|
||||
{% include 'snippets/announcement.html' with announcement=announcement %}
|
||||
|
@ -192,53 +193,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>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<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 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">
|
||||
<p>{{ site.footer_item|safe }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
{% include 'snippets/footer.html' %}
|
||||
{% endblock %}
|
||||
|
||||
<script>
|
||||
|
|
|
@ -5,7 +5,9 @@
|
|||
{% load group_tags %}
|
||||
{% load markdown %}
|
||||
|
||||
{% block title %}{% blocktrans with list_name=list.name owner=list.user.display_name %}{{ list_name }}, a list by {{owner}}{% endblocktrans %}{% endblock title %}
|
||||
{% block title %}{% blocktrans trimmed with list_name=list.name owner=list.user.display_name %}
|
||||
{{ list_name }}, a list by {{owner}}
|
||||
{% endblocktrans %}{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mt-3">
|
||||
|
|
|
@ -12,12 +12,16 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div class="column is-narrow is-flex">
|
||||
<div class="column is-narrow is-flex field is-grouped">
|
||||
{% if request.user == list.user %}
|
||||
<div class="control">
|
||||
{% trans "Edit List" as button_text %}
|
||||
{% include 'snippets/toggle/open_button.html' with text=button_text icon_with_text="pencil" controls_text="edit_list" focus="edit_list_header" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include "lists/bookmark_button.html" with list=list %}
|
||||
<div class="control">
|
||||
{% include "lists/bookmark_button.html" with list=list %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
|
@ -51,7 +51,7 @@
|
|||
{% endif %}
|
||||
|
||||
{% if not items.object_list.exists %}
|
||||
<p>{% trans "This list is currently empty" %}</p>
|
||||
<p class="block">{% trans "This list is currently empty." %}</p>
|
||||
{% else %}
|
||||
<ol start="{{ items.start_index }}" class="ordered-list">
|
||||
{% for item in items %}
|
||||
|
|
|
@ -61,7 +61,13 @@
|
|||
{% else %}
|
||||
{% with count=notification.related_list_items.count|add:"-2" %}
|
||||
{% with display_count=count|intcomma %}
|
||||
{% if related_list.curation != "curated" %}
|
||||
{% if count < 1 %}
|
||||
{# This happens if the list item was deleted #}
|
||||
{% blocktrans trimmed %}
|
||||
<a href="{{ related_user_link }}">{{ related_user }}</a>
|
||||
added a book to one of your lists
|
||||
{% endblocktrans %}
|
||||
{% elif related_list.curation != "curated" %}
|
||||
|
||||
{% blocktrans trimmed count counter=count %}
|
||||
<a href="{{ related_user_link }}">{{ related_user }}</a>
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
<h1 class="title">{% trans "Notifications" %}</h1>
|
||||
</div>
|
||||
|
||||
{% if notifications %}
|
||||
<form name="clear" action="/notifications" method="POST" class="column is-narrow">
|
||||
{% csrf_token %}
|
||||
{% spaceless %}
|
||||
|
@ -19,6 +20,7 @@
|
|||
</button>
|
||||
{% endspaceless %}
|
||||
</form>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
<div class="block">
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
{% load layout %}
|
||||
{% load sass_tags %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load utilities %}
|
||||
|
@ -9,9 +10,7 @@
|
|||
<head>
|
||||
<title>{% block title %}{% endblock %}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="{% static 'css/vendor/bulma.min.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/vendor/icons.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/bookwyrm.css' %}">
|
||||
<link href="{% sass_src site_theme %}" rel="stylesheet" type="text/css" />
|
||||
<script>
|
||||
function closeWindow() {
|
||||
window.close();
|
||||
|
|
101
bookwyrm/templates/preferences/2fa.html
Normal file
101
bookwyrm/templates/preferences/2fa.html
Normal file
|
@ -0,0 +1,101 @@
|
|||
{% 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">
|
||||
<figure class="m-4" id="qrcode">{{ qrcode | safe }}</figure>
|
||||
<details class="details-panel box">
|
||||
<summary>
|
||||
<span role="heading" aria-level="3" class="title is-6">
|
||||
{% trans "Use setup key" %}
|
||||
<span class="details-close icon icon-x" aria-hidden="true"></span>
|
||||
</span>
|
||||
</summary>
|
||||
<dl class="block">
|
||||
<dt class="has-text-weight-bold mr-5 is-pulled-left">
|
||||
{% trans "Account name:" %}
|
||||
</dt>
|
||||
<dd>
|
||||
<code>{{ user.username }}</code>
|
||||
</dd>
|
||||
|
||||
<dt class="has-text-weight-bold mr-5 is-pulled-left">
|
||||
{% trans "Code:" %}
|
||||
</dt>
|
||||
<dd>
|
||||
<code>{{ code | safe }}</code>
|
||||
</dd>
|
||||
</dl>
|
||||
</details>
|
||||
<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,22 +8,37 @@
|
|||
{% endblock %}
|
||||
|
||||
{% 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">
|
||||
<h2 class="title is-4">{% trans "Permanently delete account" %}</h2>
|
||||
<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." %}
|
||||
</p>
|
||||
<div class="box">
|
||||
<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." %}
|
||||
</p>
|
||||
|
||||
<form name="delete-user" action="{% url 'prefs-delete' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label class="label" for="id_password">{% trans "Confirm password:" %}</label>
|
||||
<input class="input {% if form.password.errors %}is-danger{% endif %}" type="password" name="password" id="id_password" required aria-describedby="desc_password">
|
||||
<form name="delete-user" action="{% url 'prefs-delete' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label class="label" for="id_password">{% trans "Confirm password:" %}</label>
|
||||
<input class="input {% if form.password.errors %}is-danger{% endif %}" type="password" name="password" id="id_password" required aria-describedby="desc_password">
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.password.errors id="desc_password" %}
|
||||
</div>
|
||||
<button type="submit" class="button is-danger">{% trans "Delete Account" %}</button>
|
||||
</form>
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.password.errors id="desc_password" %}
|
||||
</div>
|
||||
<button type="submit" class="button is-danger">{% trans "Delete Account" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% 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 %}
|
||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Change Password" %}</a>
|
||||
</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>
|
||||
{% url 'prefs-delete' as url %}
|
||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Delete Account" %}</a>
|
||||
|
|
|
@ -73,6 +73,14 @@ User-agent: PetalBot
|
|||
Disallow: /
|
||||
|
||||
|
||||
User-agent: DataForSeoBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: YisouSpider
|
||||
Disallow: /
|
||||
|
||||
|
||||
User-agent: *
|
||||
Crawl-delay: 10
|
||||
Disallow: /static/js/
|
||||
Disallow: /static/css/
|
||||
|
|
|
@ -1,48 +1,46 @@
|
|||
{% extends 'components/modal.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block modal-title %}
|
||||
{% blocktrans %}
|
||||
Scan Barcode
|
||||
{% endblocktrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-body %}
|
||||
<div class="block">
|
||||
<div id="barcode-scanner"></div>
|
||||
</div>
|
||||
|
||||
<div id="barcode-camera-list" class="select is-small">
|
||||
<select>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="barcode-status" class="block">
|
||||
<div class="grant-access is-hidden">
|
||||
<span class="icon icon-lock"></span>
|
||||
<span class="is-size-5">{% trans "Requesting camera..." %}</span><br/>
|
||||
<span>{% trans "Grant access to the camera to scan a book's barcode." %}</span>
|
||||
</div>
|
||||
<div class="access-denied is-hidden">
|
||||
<span class="icon icon-warning"></span>
|
||||
<span class="is-size-5">Access denied</span><br/>
|
||||
<span>{% trans "Could not access camera" %}</span>
|
||||
</div>
|
||||
<div class="scanning is-hidden">
|
||||
<span class="icon icon-barcode"></span>
|
||||
<span class="is-size-5">{% trans "Scanning..." context "barcode scanner" %}</span><br/>
|
||||
<span>{% trans "Align your book's barcode with the camera." %}</span>
|
||||
</div>
|
||||
<div class="found is-hidden">
|
||||
<span class="icon icon-check"></span>
|
||||
<span class="is-size-5">{% trans "ISBN scanned" context "barcode scanner" %}</span><br/>
|
||||
{% trans "Searching for book:" context "followed by ISBN" %} <span class="isbn"></span>...
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-footer %}
|
||||
<button class="button" type="button" data-modal-close>{% trans "Cancel" %}</button>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% extends 'components/modal.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block modal-title %}
|
||||
{% blocktrans %}
|
||||
Scan Barcode
|
||||
{% endblocktrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-body %}
|
||||
<div class="block">
|
||||
<div id="barcode-scanner"></div>
|
||||
</div>
|
||||
|
||||
<div id="barcode-camera-list" class="select is-small">
|
||||
<select>
|
||||
</select>
|
||||
</div>
|
||||
<template id="barcode-grant-access">
|
||||
<span class="icon icon-lock"></span>
|
||||
<span class="is-size-5">{% trans "Requesting camera..." %}</span><br/>
|
||||
<span>{% trans "Grant access to the camera to scan a book's barcode." %}</span>
|
||||
</template>
|
||||
<template id="barcode-access-denied">
|
||||
<span class="icon icon-warning"></span>
|
||||
<span class="is-size-5">Access denied</span><br/>
|
||||
<span>{% trans "Could not access camera" %}</span>
|
||||
</template>
|
||||
<template id="barcode-scanning">
|
||||
<span class="icon icon-barcode"></span>
|
||||
<span class="is-size-5">{% trans "Scanning..." context "barcode scanner" %}</span><br/>
|
||||
<span>{% trans "Align your book's barcode with the camera." %}</span>
|
||||
</template>
|
||||
<template id="barcode-found">
|
||||
<span class="icon icon-check"></span>
|
||||
<span class="is-size-5">{% trans "ISBN scanned" context "barcode scanner" %}</span><br/>
|
||||
{% trans "Searching for book:" context "followed by ISBN" %} <span class="isbn"></span>...
|
||||
</template>
|
||||
<div id="barcode-status" class="block"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-footer %}
|
||||
<button class="button" type="button" data-modal-close>{% trans "Cancel" %}</button>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends 'search/layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load humanize %}
|
||||
{% load book_display_tags %}
|
||||
|
||||
{% block panel %}
|
||||
|
||||
|
@ -19,8 +21,17 @@
|
|||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
{% with book_review_count=result|review_count %}
|
||||
{% blocktrans trimmed count counter=book_review_count with formatted_review_count=book_review_count|intcomma %}
|
||||
{{ formatted_review_count }} review
|
||||
{% plural %}
|
||||
{{ formatted_review_count }} reviews
|
||||
{% endblocktrans %}
|
||||
{% endwith %}
|
||||
|
||||
{% if result.first_published_date or result.published_date %}
|
||||
({% firstof result.first_published_date.year result.published_date.year %})
|
||||
{% firstof result.first_published_date.year result.published_date.year as pub_year %}
|
||||
{% blocktrans %}(published {{ pub_year }}){% endblocktrans %}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
@ -47,7 +58,7 @@
|
|||
<span class="details-close icon icon-x" aria-hidden="true"></span>
|
||||
</summary>
|
||||
|
||||
<div class="mt-5">
|
||||
<div>
|
||||
<div class="is-flex is-flex-direction-row-reverse">
|
||||
<ul class="is-flex-grow-1">
|
||||
{% for result in result_set.results %}
|
||||
|
|
|
@ -145,7 +145,7 @@
|
|||
|
||||
<div class="block content">
|
||||
<h2 class="title is-4">{% trans "Current Rules" %}</h2>
|
||||
<details class="details-panel">
|
||||
<details class="details-panel box">
|
||||
<summary>
|
||||
<span class="title is-5" role="heading" aria-level="3">
|
||||
{% trans "Show rules" %} ({{ rules.count }})
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue