mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-12-16 21:26:33 +00:00
Merge branch 'main' into image-absolute-url-getter
This commit is contained in:
commit
9d4e0851a0
537 changed files with 34775 additions and 18490 deletions
|
@ -32,7 +32,7 @@ indent_size = 2
|
|||
max_line_length = off
|
||||
|
||||
# Computer generated files
|
||||
[{package.json,*.lock,*.mo}]
|
||||
[{icons.css,package.json,*.lock,*.mo}]
|
||||
indent_size = unset
|
||||
indent_style = unset
|
||||
max_line_length = unset
|
||||
|
|
|
@ -16,7 +16,7 @@ DEFAULT_LANGUAGE="English"
|
|||
|
||||
MEDIA_ROOT=images/
|
||||
|
||||
POSTGRES_PORT=5432
|
||||
PGPORT=5432
|
||||
POSTGRES_PASSWORD=securedbypassword123
|
||||
POSTGRES_USER=fedireads
|
||||
POSTGRES_DB=fedireads
|
||||
|
@ -43,6 +43,9 @@ EMAIL_HOST_PASSWORD=emailpassword123
|
|||
EMAIL_USE_TLS=true
|
||||
EMAIL_USE_SSL=false
|
||||
|
||||
# Thumbnails Generation
|
||||
ENABLE_THUMBNAIL_GENERATION=false
|
||||
|
||||
# S3 configuration
|
||||
USE_S3=false
|
||||
AWS_ACCESS_KEY_ID=
|
||||
|
@ -58,6 +61,7 @@ AWS_SECRET_ACCESS_KEY=
|
|||
# AWS_S3_REGION_NAME=None # "fr-par"
|
||||
# AWS_S3_ENDPOINT_URL=None # "https://s3.fr-par.scw.cloud"
|
||||
|
||||
|
||||
# Preview image generation can be computing and storage intensive
|
||||
# ENABLE_PREVIEW_IMAGES=True
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ DEFAULT_LANGUAGE="English"
|
|||
|
||||
MEDIA_ROOT=images/
|
||||
|
||||
POSTGRES_PORT=5432
|
||||
PGPORT=5432
|
||||
POSTGRES_PASSWORD=securedbypassword123
|
||||
POSTGRES_USER=fedireads
|
||||
POSTGRES_DB=fedireads
|
||||
|
@ -43,6 +43,9 @@ EMAIL_HOST_PASSWORD=emailpassword123
|
|||
EMAIL_USE_TLS=true
|
||||
EMAIL_USE_SSL=false
|
||||
|
||||
# Thumbnails Generation
|
||||
ENABLE_THUMBNAIL_GENERATION=false
|
||||
|
||||
# S3 configuration
|
||||
USE_S3=false
|
||||
AWS_ACCESS_KEY_ID=
|
||||
|
@ -58,6 +61,7 @@ AWS_SECRET_ACCESS_KEY=
|
|||
# AWS_S3_REGION_NAME=None # "fr-par"
|
||||
# AWS_S3_ENDPOINT_URL=None # "https://s3.fr-par.scw.cloud"
|
||||
|
||||
|
||||
# Preview image generation can be computing and storage intensive
|
||||
# ENABLE_PREVIEW_IMAGES=True
|
||||
|
||||
|
|
28
.github/workflows/curlylint.yaml
vendored
Normal file
28
.github/workflows/curlylint.yaml
vendored
Normal file
|
@ -0,0 +1,28 @@
|
|||
name: Templates validator
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Install curlylint
|
||||
run: pip install curlylint
|
||||
|
||||
- name: Run linter
|
||||
run: >
|
||||
curlylint --rule 'aria_role: true' \
|
||||
--rule 'django_forms_rendering: true' \
|
||||
--rule 'html_has_lang: true' \
|
||||
--rule 'image_alt: true' \
|
||||
--rule 'meta_viewport: true' \
|
||||
--rule 'no_autofocus: true' \
|
||||
--rule 'tabindex_no_positive: true' \
|
||||
--exclude '_modal.html|create_status/layout.html|reading_modals/layout.html' \
|
||||
bookwyrm/templates
|
1
.github/workflows/django-tests.yml
vendored
1
.github/workflows/django-tests.yml
vendored
|
@ -36,6 +36,7 @@ jobs:
|
|||
env:
|
||||
SECRET_KEY: beepbeep
|
||||
DEBUG: false
|
||||
USE_HTTPS: true
|
||||
DOMAIN: your.domain.here
|
||||
BOOKWYRM_DATABASE_BACKEND: postgres
|
||||
MEDIA_ROOT: images/
|
||||
|
|
|
@ -8,4 +8,4 @@ WORKDIR /app
|
|||
|
||||
COPY requirements.txt /app/
|
||||
RUN pip install -r requirements.txt --no-cache-dir
|
||||
RUN apt-get update && apt-get install -y gettext libgettextpo-dev && apt-get clean
|
||||
RUN apt-get update && apt-get install -y gettext libgettextpo-dev tidy && apt-get clean
|
||||
|
|
|
@ -48,7 +48,7 @@ class Signature:
|
|||
|
||||
|
||||
def naive_parse(activity_objects, activity_json, serializer=None):
|
||||
"""this navigates circular import issues"""
|
||||
"""this navigates circular import issues by looking up models' serializers"""
|
||||
if not serializer:
|
||||
if activity_json.get("publicKeyPem"):
|
||||
# ugh
|
||||
|
@ -101,12 +101,15 @@ class ActivityObject:
|
|||
except KeyError:
|
||||
if field.default == MISSING and field.default_factory == MISSING:
|
||||
raise ActivitySerializerError(
|
||||
"Missing required field: %s" % field.name
|
||||
f"Missing required field: {field.name}"
|
||||
)
|
||||
value = field.default
|
||||
setattr(self, field.name, value)
|
||||
|
||||
def to_model(self, model=None, instance=None, allow_create=True, save=True):
|
||||
# pylint: disable=too-many-locals,too-many-branches,too-many-arguments
|
||||
def to_model(
|
||||
self, model=None, instance=None, allow_create=True, save=True, overwrite=True
|
||||
):
|
||||
"""convert from an activity to a model instance"""
|
||||
model = model or get_model_from_type(self.type)
|
||||
|
||||
|
@ -126,27 +129,41 @@ class ActivityObject:
|
|||
return None
|
||||
instance = instance or model()
|
||||
|
||||
# keep track of what we've changed
|
||||
update_fields = []
|
||||
# sets field on the model using the activity value
|
||||
for field in instance.simple_fields:
|
||||
try:
|
||||
field.set_field_from_activity(instance, self)
|
||||
changed = field.set_field_from_activity(
|
||||
instance, self, overwrite=overwrite
|
||||
)
|
||||
if changed:
|
||||
update_fields.append(field.name)
|
||||
except AttributeError as e:
|
||||
raise ActivitySerializerError(e)
|
||||
|
||||
# image fields have to be set after other fields because they can save
|
||||
# too early and jank up users
|
||||
for field in instance.image_fields:
|
||||
field.set_field_from_activity(instance, self, save=save)
|
||||
changed = field.set_field_from_activity(
|
||||
instance, self, save=save, overwrite=overwrite
|
||||
)
|
||||
if changed:
|
||||
update_fields.append(field.name)
|
||||
|
||||
if not save:
|
||||
return instance
|
||||
|
||||
with transaction.atomic():
|
||||
# can't force an update on fields unless the object already exists in the db
|
||||
if not instance.id:
|
||||
update_fields = None
|
||||
# we can't set many to many and reverse fields on an unsaved object
|
||||
try:
|
||||
try:
|
||||
instance.save(broadcast=False)
|
||||
instance.save(broadcast=False, update_fields=update_fields)
|
||||
except TypeError:
|
||||
instance.save()
|
||||
instance.save(update_fields=update_fields)
|
||||
except IntegrityError as e:
|
||||
raise ActivitySerializerError(e)
|
||||
|
||||
|
@ -196,14 +213,14 @@ class ActivityObject:
|
|||
return data
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(queue="medium_priority")
|
||||
@transaction.atomic
|
||||
def set_related_field(
|
||||
model_name, origin_model_name, related_field_name, related_remote_id, data
|
||||
):
|
||||
"""load reverse related fields (editions, attachments) without blocking"""
|
||||
model = apps.get_model("bookwyrm.%s" % model_name, require_ready=True)
|
||||
origin_model = apps.get_model("bookwyrm.%s" % origin_model_name, require_ready=True)
|
||||
model = apps.get_model(f"bookwyrm.{model_name}", require_ready=True)
|
||||
origin_model = apps.get_model(f"bookwyrm.{origin_model_name}", require_ready=True)
|
||||
|
||||
with transaction.atomic():
|
||||
if isinstance(data, str):
|
||||
|
@ -217,7 +234,7 @@ def set_related_field(
|
|||
# this must exist because it's the object that triggered this function
|
||||
instance = origin_model.find_existing_by_remote_id(related_remote_id)
|
||||
if not instance:
|
||||
raise ValueError("Invalid related remote id: %s" % related_remote_id)
|
||||
raise ValueError(f"Invalid related remote id: {related_remote_id}")
|
||||
|
||||
# set the origin's remote id on the activity so it will be there when
|
||||
# the model instance is created
|
||||
|
@ -248,7 +265,7 @@ def get_model_from_type(activity_type):
|
|||
]
|
||||
if not model:
|
||||
raise ActivitySerializerError(
|
||||
'No model found for activity type "%s"' % activity_type
|
||||
f'No model found for activity type "{activity_type}"'
|
||||
)
|
||||
return model[0]
|
||||
|
||||
|
@ -258,6 +275,8 @@ def resolve_remote_id(
|
|||
):
|
||||
"""take a remote_id and return an instance, creating if necessary"""
|
||||
if model: # a bonus check we can do if we already know the model
|
||||
if isinstance(model, str):
|
||||
model = apps.get_model(f"bookwyrm.{model}", require_ready=True)
|
||||
result = model.find_existing_by_remote_id(remote_id)
|
||||
if result and not refresh:
|
||||
return result if not get_activity else result.to_activity_dataclass()
|
||||
|
@ -267,7 +286,7 @@ def resolve_remote_id(
|
|||
data = get_data(remote_id)
|
||||
except ConnectorException:
|
||||
raise ActivitySerializerError(
|
||||
"Could not connect to host for remote_id in: %s" % (remote_id)
|
||||
f"Could not connect to host for remote_id: {remote_id}"
|
||||
)
|
||||
# determine the model implicitly, if not provided
|
||||
# or if it's a model with subclasses like Status, check again
|
||||
|
|
|
@ -54,6 +54,7 @@ class Edition(Book):
|
|||
asin: str = ""
|
||||
pages: int = None
|
||||
physicalFormat: str = ""
|
||||
physicalFormatDetail: str = ""
|
||||
publishers: List[str] = field(default_factory=lambda: [])
|
||||
editionRank: int = 0
|
||||
|
||||
|
|
|
@ -30,11 +30,12 @@ class Note(ActivityObject):
|
|||
to: List[str] = field(default_factory=lambda: [])
|
||||
cc: List[str] = field(default_factory=lambda: [])
|
||||
replies: Dict = field(default_factory=lambda: {})
|
||||
inReplyTo: str = ""
|
||||
summary: str = ""
|
||||
inReplyTo: str = None
|
||||
summary: str = None
|
||||
tag: List[Link] = field(default_factory=lambda: [])
|
||||
attachment: List[Document] = field(default_factory=lambda: [])
|
||||
sensitive: bool = False
|
||||
updated: str = None
|
||||
type: str = "Note"
|
||||
|
||||
|
||||
|
@ -59,6 +60,9 @@ class Comment(Note):
|
|||
"""like a note but with a book"""
|
||||
|
||||
inReplyToBook: str
|
||||
readingStatus: str = None
|
||||
progress: int = None
|
||||
progressMode: str = None
|
||||
type: str = "Comment"
|
||||
|
||||
|
||||
|
@ -67,6 +71,8 @@ class Quotation(Comment):
|
|||
"""a quote and commentary on a book"""
|
||||
|
||||
quote: str
|
||||
position: int = None
|
||||
positionMode: str = None
|
||||
type: str = "Quotation"
|
||||
|
||||
|
||||
|
|
|
@ -69,7 +69,8 @@ class Update(Verb):
|
|||
|
||||
def action(self):
|
||||
"""update a model instance from the dataclass"""
|
||||
if self.object:
|
||||
if not self.object:
|
||||
return
|
||||
self.object.to_model(allow_create=False)
|
||||
|
||||
|
||||
|
|
|
@ -1,32 +1,37 @@
|
|||
""" access the activity streams stored in redis """
|
||||
from datetime import timedelta
|
||||
from django.dispatch import receiver
|
||||
from django.db import transaction
|
||||
from django.db.models import signals, Q
|
||||
from django.utils import timezone
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.redis_store import RedisStore, r
|
||||
from bookwyrm.views.helpers import privacy_filter
|
||||
from bookwyrm.tasks import app
|
||||
|
||||
|
||||
class ActivityStream(RedisStore):
|
||||
"""a category of activity stream (like home, local, federated)"""
|
||||
"""a category of activity stream (like home, local, books)"""
|
||||
|
||||
def stream_id(self, user):
|
||||
"""the redis key for this user's instance of this stream"""
|
||||
return "{}-{}".format(user.id, self.key)
|
||||
return f"{user.id}-{self.key}"
|
||||
|
||||
def unread_id(self, user):
|
||||
"""the redis key for this user's unread count for this stream"""
|
||||
return "{}-unread".format(self.stream_id(user))
|
||||
stream_id = self.stream_id(user)
|
||||
return f"{stream_id}-unread"
|
||||
|
||||
def get_rank(self, obj): # pylint: disable=no-self-use
|
||||
"""statuses are sorted by date published"""
|
||||
return obj.published_date.timestamp()
|
||||
|
||||
def add_status(self, status):
|
||||
def add_status(self, status, increment_unread=False):
|
||||
"""add a status to users' feeds"""
|
||||
# the pipeline contains all the add-to-stream activities
|
||||
pipeline = self.add_object_to_related_stores(status, execute=False)
|
||||
|
||||
if increment_unread:
|
||||
for user in self.get_audience(status):
|
||||
# add to the unread status count
|
||||
pipeline.incr(self.unread_id(user))
|
||||
|
@ -37,7 +42,7 @@ class ActivityStream(RedisStore):
|
|||
def add_user_statuses(self, viewer, user):
|
||||
"""add a user's statuses to another user's feed"""
|
||||
# only add the statuses that the viewer should be able to see (ie, not dms)
|
||||
statuses = privacy_filter(viewer, user.status_set.all())
|
||||
statuses = models.Status.privacy_filter(viewer).filter(user=user)
|
||||
self.bulk_add_objects_to_store(statuses, self.stream_id(viewer))
|
||||
|
||||
def remove_user_statuses(self, viewer, user):
|
||||
|
@ -55,7 +60,13 @@ class ActivityStream(RedisStore):
|
|||
return (
|
||||
models.Status.objects.select_subclasses()
|
||||
.filter(id__in=statuses)
|
||||
.select_related("user", "reply_parent")
|
||||
.select_related(
|
||||
"user",
|
||||
"reply_parent",
|
||||
"comment__book",
|
||||
"review__book",
|
||||
"quotation__book",
|
||||
)
|
||||
.prefetch_related("mention_books", "mention_users")
|
||||
.order_by("-published_date")
|
||||
)
|
||||
|
@ -101,9 +112,8 @@ class ActivityStream(RedisStore):
|
|||
|
||||
def get_statuses_for_user(self, user): # pylint: disable=no-self-use
|
||||
"""given a user, what statuses should they see on this stream"""
|
||||
return privacy_filter(
|
||||
return models.Status.privacy_filter(
|
||||
user,
|
||||
models.Status.objects.select_subclasses(),
|
||||
privacy_levels=["public", "unlisted", "followers"],
|
||||
)
|
||||
|
||||
|
@ -127,11 +137,15 @@ class HomeStream(ActivityStream):
|
|||
).distinct()
|
||||
|
||||
def get_statuses_for_user(self, user):
|
||||
return privacy_filter(
|
||||
return models.Status.privacy_filter(
|
||||
user,
|
||||
models.Status.objects.select_subclasses(),
|
||||
privacy_levels=["public", "unlisted", "followers"],
|
||||
following_only=True,
|
||||
).exclude(
|
||||
~Q( # remove everything except
|
||||
Q(user__followers=user) # user following
|
||||
| Q(user=user) # is self
|
||||
| Q(mention_users=user) # mentions user
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
@ -148,36 +162,98 @@ class LocalStream(ActivityStream):
|
|||
|
||||
def get_statuses_for_user(self, user):
|
||||
# all public statuses by a local user
|
||||
return privacy_filter(
|
||||
return models.Status.privacy_filter(
|
||||
user,
|
||||
models.Status.objects.select_subclasses().filter(user__local=True),
|
||||
privacy_levels=["public"],
|
||||
)
|
||||
).filter(user__local=True)
|
||||
|
||||
|
||||
class FederatedStream(ActivityStream):
|
||||
"""users you follow"""
|
||||
class BooksStream(ActivityStream):
|
||||
"""books on your shelves"""
|
||||
|
||||
key = "federated"
|
||||
key = "books"
|
||||
|
||||
def get_audience(self, status):
|
||||
# this stream wants no part in non-public statuses
|
||||
if status.privacy != "public":
|
||||
"""anyone with the mentioned book on their shelves"""
|
||||
# only show public statuses on the books feed,
|
||||
# and only statuses that mention books
|
||||
if status.privacy != "public" or not (
|
||||
status.mention_books.exists() or hasattr(status, "book")
|
||||
):
|
||||
return []
|
||||
return super().get_audience(status)
|
||||
|
||||
def get_statuses_for_user(self, user):
|
||||
return privacy_filter(
|
||||
user,
|
||||
models.Status.objects.select_subclasses(),
|
||||
privacy_levels=["public"],
|
||||
work = (
|
||||
status.book.parent_work
|
||||
if hasattr(status, "book")
|
||||
else status.mention_books.first().parent_work
|
||||
)
|
||||
|
||||
audience = super().get_audience(status)
|
||||
if not audience:
|
||||
return []
|
||||
return audience.filter(shelfbook__book__parent_work=work).distinct()
|
||||
|
||||
def get_statuses_for_user(self, user):
|
||||
"""any public status that mentions the user's books"""
|
||||
books = user.shelfbook_set.values_list(
|
||||
"book__parent_work__id", flat=True
|
||||
).distinct()
|
||||
return (
|
||||
models.Status.privacy_filter(
|
||||
user,
|
||||
privacy_levels=["public"],
|
||||
)
|
||||
.filter(
|
||||
Q(comment__book__parent_work__id__in=books)
|
||||
| Q(quotation__book__parent_work__id__in=books)
|
||||
| Q(review__book__parent_work__id__in=books)
|
||||
| Q(mention_books__parent_work__id__in=books)
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def add_book_statuses(self, user, book):
|
||||
"""add statuses about a book to a user's feed"""
|
||||
work = book.parent_work
|
||||
statuses = (
|
||||
models.Status.privacy_filter(
|
||||
user,
|
||||
privacy_levels=["public"],
|
||||
)
|
||||
.filter(
|
||||
Q(comment__book__parent_work=work)
|
||||
| Q(quotation__book__parent_work=work)
|
||||
| Q(review__book__parent_work=work)
|
||||
| Q(mention_books__parent_work=work)
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
self.bulk_add_objects_to_store(statuses, self.stream_id(user))
|
||||
|
||||
def remove_book_statuses(self, user, book):
|
||||
"""add statuses about a book to a user's feed"""
|
||||
work = book.parent_work
|
||||
statuses = (
|
||||
models.Status.privacy_filter(
|
||||
user,
|
||||
privacy_levels=["public"],
|
||||
)
|
||||
.filter(
|
||||
Q(comment__book__parent_work=work)
|
||||
| Q(quotation__book__parent_work=work)
|
||||
| Q(review__book__parent_work=work)
|
||||
| Q(mention_books__parent_work=work)
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
self.bulk_remove_objects_from_store(statuses, self.stream_id(user))
|
||||
|
||||
|
||||
# determine which streams are enabled in settings.py
|
||||
streams = {
|
||||
"home": HomeStream(),
|
||||
"local": LocalStream(),
|
||||
"federated": FederatedStream(),
|
||||
"books": BooksStream(),
|
||||
}
|
||||
|
||||
|
||||
|
@ -190,41 +266,31 @@ def add_status_on_create(sender, instance, created, *args, **kwargs):
|
|||
return
|
||||
|
||||
if instance.deleted:
|
||||
for stream in streams.values():
|
||||
stream.remove_object_from_related_stores(instance)
|
||||
remove_status_task.delay(instance.id)
|
||||
return
|
||||
|
||||
if not created:
|
||||
return
|
||||
|
||||
# iterates through Home, Local, Federated
|
||||
for stream in streams.values():
|
||||
stream.add_status(instance)
|
||||
|
||||
if sender != models.Boost:
|
||||
return
|
||||
# remove the original post and other, earlier boosts
|
||||
boosted = instance.boost.boosted_status
|
||||
old_versions = models.Boost.objects.filter(
|
||||
boosted_status__id=boosted.id,
|
||||
created_date__lt=instance.created_date,
|
||||
# when creating new things, gotta wait on the transaction
|
||||
transaction.on_commit(
|
||||
lambda: add_status_on_create_command(sender, instance, created)
|
||||
)
|
||||
for stream in streams.values():
|
||||
stream.remove_object_from_related_stores(boosted)
|
||||
for status in old_versions:
|
||||
stream.remove_object_from_related_stores(status)
|
||||
|
||||
|
||||
def add_status_on_create_command(sender, instance, created):
|
||||
"""runs this code only after the database commit completes"""
|
||||
add_status_task.delay(instance.id, increment_unread=created)
|
||||
|
||||
if sender == models.Boost:
|
||||
handle_boost_task.delay(instance.id)
|
||||
|
||||
|
||||
@receiver(signals.post_delete, sender=models.Boost)
|
||||
# pylint: disable=unused-argument
|
||||
def remove_boost_on_delete(sender, instance, *args, **kwargs):
|
||||
"""boosts are deleted"""
|
||||
# we're only interested in new statuses
|
||||
for stream in streams.values():
|
||||
# remove the boost
|
||||
stream.remove_object_from_related_stores(instance)
|
||||
remove_status_task.delay(instance.id)
|
||||
# re-add the original status
|
||||
stream.add_status(instance.boosted_status)
|
||||
add_status_task.delay(instance.boosted_status.id)
|
||||
|
||||
|
||||
@receiver(signals.post_save, sender=models.UserFollows)
|
||||
|
@ -233,7 +299,9 @@ def add_statuses_on_follow(sender, instance, created, *args, **kwargs):
|
|||
"""add a newly followed user's statuses to feeds"""
|
||||
if not created or not instance.user_subject.local:
|
||||
return
|
||||
HomeStream().add_user_statuses(instance.user_subject, instance.user_object)
|
||||
add_user_statuses_task.delay(
|
||||
instance.user_subject.id, instance.user_object.id, stream_list=["home"]
|
||||
)
|
||||
|
||||
|
||||
@receiver(signals.post_delete, sender=models.UserFollows)
|
||||
|
@ -242,7 +310,9 @@ def remove_statuses_on_unfollow(sender, instance, *args, **kwargs):
|
|||
"""remove statuses from a feed on unfollow"""
|
||||
if not instance.user_subject.local:
|
||||
return
|
||||
HomeStream().remove_user_statuses(instance.user_subject, instance.user_object)
|
||||
remove_user_statuses_task.delay(
|
||||
instance.user_subject.id, instance.user_object.id, stream_list=["home"]
|
||||
)
|
||||
|
||||
|
||||
@receiver(signals.post_save, sender=models.UserBlocks)
|
||||
|
@ -251,29 +321,45 @@ def remove_statuses_on_block(sender, instance, *args, **kwargs):
|
|||
"""remove statuses from all feeds on block"""
|
||||
# blocks apply ot all feeds
|
||||
if instance.user_subject.local:
|
||||
for stream in streams.values():
|
||||
stream.remove_user_statuses(instance.user_subject, instance.user_object)
|
||||
remove_user_statuses_task.delay(
|
||||
instance.user_subject.id, instance.user_object.id
|
||||
)
|
||||
|
||||
# and in both directions
|
||||
if instance.user_object.local:
|
||||
for stream in streams.values():
|
||||
stream.remove_user_statuses(instance.user_object, instance.user_subject)
|
||||
remove_user_statuses_task.delay(
|
||||
instance.user_object.id, instance.user_subject.id
|
||||
)
|
||||
|
||||
|
||||
@receiver(signals.post_delete, sender=models.UserBlocks)
|
||||
# pylint: disable=unused-argument
|
||||
def add_statuses_on_unblock(sender, instance, *args, **kwargs):
|
||||
"""remove statuses from all feeds on block"""
|
||||
public_streams = [LocalStream(), FederatedStream()]
|
||||
"""add statuses back to all feeds on unblock"""
|
||||
# make sure there isn't a block in the other direction
|
||||
if models.UserBlocks.objects.filter(
|
||||
user_subject=instance.user_object,
|
||||
user_object=instance.user_subject,
|
||||
).exists():
|
||||
return
|
||||
|
||||
public_streams = [k for (k, v) in streams.items() if k != "home"]
|
||||
|
||||
# add statuses back to streams with statuses from anyone
|
||||
if instance.user_subject.local:
|
||||
for stream in public_streams:
|
||||
stream.add_user_statuses(instance.user_subject, instance.user_object)
|
||||
add_user_statuses_task.delay(
|
||||
instance.user_subject.id,
|
||||
instance.user_object.id,
|
||||
stream_list=public_streams,
|
||||
)
|
||||
|
||||
# add statuses back to streams with statuses from anyone
|
||||
if instance.user_object.local:
|
||||
for stream in public_streams:
|
||||
stream.add_user_statuses(instance.user_object, instance.user_subject)
|
||||
add_user_statuses_task.delay(
|
||||
instance.user_object.id,
|
||||
instance.user_subject.id,
|
||||
stream_list=public_streams,
|
||||
)
|
||||
|
||||
|
||||
@receiver(signals.post_save, sender=models.User)
|
||||
|
@ -283,5 +369,130 @@ def populate_streams_on_account_create(sender, instance, created, *args, **kwarg
|
|||
if not created or not instance.local:
|
||||
return
|
||||
|
||||
for stream in streams:
|
||||
populate_stream_task.delay(stream, instance.id)
|
||||
|
||||
|
||||
@receiver(signals.pre_save, sender=models.ShelfBook)
|
||||
# pylint: disable=unused-argument
|
||||
def add_statuses_on_shelve(sender, instance, *args, **kwargs):
|
||||
"""update books stream when user shelves a book"""
|
||||
if not instance.user.local:
|
||||
return
|
||||
book = instance.book
|
||||
|
||||
# check if the book is already on the user's shelves
|
||||
editions = book.parent_work.editions.all()
|
||||
if models.ShelfBook.objects.filter(user=instance.user, book__in=editions).exists():
|
||||
return
|
||||
|
||||
add_book_statuses_task.delay(instance.user.id, book.id)
|
||||
|
||||
|
||||
@receiver(signals.post_delete, sender=models.ShelfBook)
|
||||
# pylint: disable=unused-argument
|
||||
def remove_statuses_on_unshelve(sender, instance, *args, **kwargs):
|
||||
"""update books stream when user unshelves a book"""
|
||||
if not instance.user.local:
|
||||
return
|
||||
|
||||
book = instance.book
|
||||
|
||||
# check if the book is actually unshelved, not just moved
|
||||
editions = book.parent_work.editions.all()
|
||||
if models.ShelfBook.objects.filter(user=instance.user, book__in=editions).exists():
|
||||
return
|
||||
|
||||
remove_book_statuses_task.delay(instance.user.id, book.id)
|
||||
|
||||
|
||||
# ---- TASKS
|
||||
|
||||
|
||||
@app.task(queue="low_priority")
|
||||
def add_book_statuses_task(user_id, book_id):
|
||||
"""add statuses related to a book on shelve"""
|
||||
user = models.User.objects.get(id=user_id)
|
||||
book = models.Edition.objects.get(id=book_id)
|
||||
BooksStream().add_book_statuses(user, book)
|
||||
|
||||
|
||||
@app.task(queue="low_priority")
|
||||
def remove_book_statuses_task(user_id, book_id):
|
||||
"""remove statuses about a book from a user's books feed"""
|
||||
user = models.User.objects.get(id=user_id)
|
||||
book = models.Edition.objects.get(id=book_id)
|
||||
BooksStream().remove_book_statuses(user, book)
|
||||
|
||||
|
||||
@app.task(queue="medium_priority")
|
||||
def populate_stream_task(stream, user_id):
|
||||
"""background task for populating an empty activitystream"""
|
||||
user = models.User.objects.get(id=user_id)
|
||||
stream = streams[stream]
|
||||
stream.populate_streams(user)
|
||||
|
||||
|
||||
@app.task(queue="medium_priority")
|
||||
def remove_status_task(status_ids):
|
||||
"""remove a status from any stream it might be in"""
|
||||
# this can take an id or a list of ids
|
||||
if not isinstance(status_ids, list):
|
||||
status_ids = [status_ids]
|
||||
statuses = models.Status.objects.filter(id__in=status_ids)
|
||||
|
||||
for stream in streams.values():
|
||||
stream.populate_streams(instance)
|
||||
for status in statuses:
|
||||
stream.remove_object_from_related_stores(status)
|
||||
|
||||
|
||||
@app.task(queue="high_priority")
|
||||
def add_status_task(status_id, increment_unread=False):
|
||||
"""add a status to any stream it should be in"""
|
||||
status = models.Status.objects.get(id=status_id)
|
||||
# we don't want to tick the unread count for csv import statuses, idk how better
|
||||
# to check than just to see if the states is more than a few days old
|
||||
if status.created_date < timezone.now() - timedelta(days=2):
|
||||
increment_unread = False
|
||||
for stream in streams.values():
|
||||
stream.add_status(status, increment_unread=increment_unread)
|
||||
|
||||
|
||||
@app.task(queue="medium_priority")
|
||||
def remove_user_statuses_task(viewer_id, user_id, stream_list=None):
|
||||
"""remove all statuses by a user from a viewer's stream"""
|
||||
stream_list = [streams[s] for s in stream_list] if stream_list else streams.values()
|
||||
viewer = models.User.objects.get(id=viewer_id)
|
||||
user = models.User.objects.get(id=user_id)
|
||||
for stream in stream_list:
|
||||
stream.remove_user_statuses(viewer, user)
|
||||
|
||||
|
||||
@app.task(queue="medium_priority")
|
||||
def add_user_statuses_task(viewer_id, user_id, stream_list=None):
|
||||
"""add all statuses by a user to a viewer's stream"""
|
||||
stream_list = [streams[s] for s in stream_list] if stream_list else streams.values()
|
||||
viewer = models.User.objects.get(id=viewer_id)
|
||||
user = models.User.objects.get(id=user_id)
|
||||
for stream in stream_list:
|
||||
stream.add_user_statuses(viewer, user)
|
||||
|
||||
|
||||
@app.task(queue="medium_priority")
|
||||
def handle_boost_task(boost_id):
|
||||
"""remove the original post and other, earlier boosts"""
|
||||
instance = models.Status.objects.get(id=boost_id)
|
||||
boosted = instance.boost.boosted_status
|
||||
|
||||
# previous boosts of this status
|
||||
old_versions = models.Boost.objects.filter(
|
||||
boosted_status__id=boosted.id,
|
||||
created_date__lt=instance.created_date,
|
||||
)
|
||||
|
||||
for stream in streams.values():
|
||||
# people who should see the boost (not people who see the original status)
|
||||
audience = stream.get_stores_for_object(instance)
|
||||
stream.remove_object_from_related_stores(boosted, stores=audience)
|
||||
for status in old_versions:
|
||||
stream.remove_object_from_related_stores(status, stores=audience)
|
||||
|
|
156
bookwyrm/book_search.py
Normal file
156
bookwyrm/book_search.py
Normal file
|
@ -0,0 +1,156 @@
|
|||
""" using a bookwyrm instance as a source of book data """
|
||||
from dataclasses import asdict, dataclass
|
||||
from functools import reduce
|
||||
import operator
|
||||
|
||||
from django.contrib.postgres.search import SearchRank, SearchQuery
|
||||
from django.db.models import OuterRef, Subquery, F, Q
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.settings import MEDIA_FULL_URL
|
||||
|
||||
|
||||
# pylint: disable=arguments-differ
|
||||
def search(query, min_confidence=0, filters=None, return_first=False):
|
||||
"""search your local database"""
|
||||
filters = filters or []
|
||||
if not query:
|
||||
return []
|
||||
# first, try searching unqiue identifiers
|
||||
results = search_identifiers(query, *filters, return_first=return_first)
|
||||
if not results:
|
||||
# then try searching title/author
|
||||
results = search_title_author(
|
||||
query, min_confidence, *filters, return_first=return_first
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def isbn_search(query):
|
||||
"""search your local database"""
|
||||
if not query:
|
||||
return []
|
||||
|
||||
filters = [{f: query} for f in ["isbn_10", "isbn_13"]]
|
||||
results = 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"""
|
||||
cover = None
|
||||
if search_result.cover:
|
||||
cover = f"{MEDIA_FULL_URL}{search_result.cover}"
|
||||
|
||||
return SearchResult(
|
||||
title=search_result.title,
|
||||
key=search_result.remote_id,
|
||||
author=search_result.author_text,
|
||||
year=search_result.published_date.year
|
||||
if search_result.published_date
|
||||
else None,
|
||||
cover=cover,
|
||||
confidence=search_result.rank if hasattr(search_result, "rank") else 1,
|
||||
connector="",
|
||||
).json()
|
||||
|
||||
|
||||
def search_identifiers(query, *filters, return_first=False):
|
||||
"""tries remote_id, isbn; defined as dedupe fields on the model"""
|
||||
# pylint: disable=W0212
|
||||
or_filters = [
|
||||
{f.name: query}
|
||||
for f in models.Edition._meta.get_fields()
|
||||
if hasattr(f, "deduplication_field") and f.deduplication_field
|
||||
]
|
||||
results = models.Edition.objects.filter(
|
||||
*filters, reduce(operator.or_, (Q(**f) for f in or_filters))
|
||||
).distinct()
|
||||
if results.count() <= 1:
|
||||
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
|
||||
|
||||
|
||||
def search_title_author(query, min_confidence, *filters, return_first=False):
|
||||
"""searches for title and author"""
|
||||
query = SearchQuery(query, config="simple") | SearchQuery(query, config="english")
|
||||
results = (
|
||||
models.Edition.objects.filter(*filters, search_vector=query)
|
||||
.annotate(rank=SearchRank(F("search_vector"), query))
|
||||
.filter(rank__gt=min_confidence)
|
||||
.order_by("-rank")
|
||||
)
|
||||
|
||||
# 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")
|
||||
|
||||
# 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()
|
||||
if return_first:
|
||||
return result
|
||||
list_results.append(result)
|
||||
return list_results
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchResult:
|
||||
"""standardized search result object"""
|
||||
|
||||
title: str
|
||||
key: str
|
||||
connector: object
|
||||
view_link: str = None
|
||||
author: str = None
|
||||
year: str = None
|
||||
cover: str = None
|
||||
confidence: int = 1
|
||||
|
||||
def __repr__(self):
|
||||
# pylint: disable=consider-using-f-string
|
||||
return "<SearchResult key={!r} title={!r} author={!r}>".format(
|
||||
self.key, self.title, self.author
|
||||
)
|
||||
|
||||
def json(self):
|
||||
"""serialize a connector for json response"""
|
||||
serialized = asdict(self)
|
||||
del serialized["connector"]
|
||||
return serialized
|
|
@ -3,4 +3,4 @@ from .settings import CONNECTORS
|
|||
from .abstract_connector import ConnectorException
|
||||
from .abstract_connector import get_data, get_image
|
||||
|
||||
from .connector_manager import search, local_search, first_search_result
|
||||
from .connector_manager import search, first_search_result
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
""" functionality outline for a book data connector """
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import asdict, dataclass
|
||||
import logging
|
||||
|
||||
from django.db import transaction
|
||||
|
@ -9,6 +8,7 @@ from requests.exceptions import RequestException
|
|||
|
||||
from bookwyrm import activitypub, models, settings
|
||||
from .connector_manager import load_more_data, ConnectorException
|
||||
from .format_mappings import format_mappings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -31,7 +31,6 @@ class AbstractMinimalConnector(ABC):
|
|||
"isbn_search_url",
|
||||
"name",
|
||||
"identifier",
|
||||
"local",
|
||||
]
|
||||
for field in self_fields:
|
||||
setattr(self, field, getattr(info, field))
|
||||
|
@ -43,7 +42,7 @@ class AbstractMinimalConnector(ABC):
|
|||
params["min_confidence"] = min_confidence
|
||||
|
||||
data = self.get_search_data(
|
||||
"%s%s" % (self.search_url, query),
|
||||
f"{self.search_url}{query}",
|
||||
params=params,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
@ -57,7 +56,7 @@ class AbstractMinimalConnector(ABC):
|
|||
"""isbn search"""
|
||||
params = {}
|
||||
data = self.get_search_data(
|
||||
"%s%s" % (self.isbn_search_url, query),
|
||||
f"{self.isbn_search_url}{query}",
|
||||
params=params,
|
||||
)
|
||||
results = []
|
||||
|
@ -131,7 +130,7 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
work_data = data
|
||||
|
||||
if not work_data or not edition_data:
|
||||
raise ConnectorException("Unable to load book data: %s" % remote_id)
|
||||
raise ConnectorException(f"Unable to load book data: {remote_id}")
|
||||
|
||||
with transaction.atomic():
|
||||
# create activitypub object
|
||||
|
@ -139,7 +138,7 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
**dict_from_mappings(work_data, self.book_mappings)
|
||||
)
|
||||
# this will dedupe automatically
|
||||
work = work_activity.to_model(model=models.Work)
|
||||
work = work_activity.to_model(model=models.Work, overwrite=False)
|
||||
for author in self.get_authors_from_data(work_data):
|
||||
work.authors.add(author)
|
||||
|
||||
|
@ -156,7 +155,7 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
mapped_data = dict_from_mappings(edition_data, self.book_mappings)
|
||||
mapped_data["work"] = work.remote_id
|
||||
edition_activity = activitypub.Edition(**mapped_data)
|
||||
edition = edition_activity.to_model(model=models.Edition)
|
||||
edition = edition_activity.to_model(model=models.Edition, overwrite=False)
|
||||
edition.connector = self.connector
|
||||
edition.save()
|
||||
|
||||
|
@ -182,7 +181,7 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
return None
|
||||
|
||||
# this will dedupe
|
||||
return activity.to_model(model=models.Author)
|
||||
return activity.to_model(model=models.Author, overwrite=False)
|
||||
|
||||
@abstractmethod
|
||||
def is_work_data(self, data):
|
||||
|
@ -222,9 +221,7 @@ def get_data(url, params=None, timeout=10):
|
|||
"""wrapper for request.get"""
|
||||
# check if the url is blocked
|
||||
if models.FederatedServer.is_blocked(url):
|
||||
raise ConnectorException(
|
||||
"Attempting to load data from blocked url: {:s}".format(url)
|
||||
)
|
||||
raise ConnectorException(f"Attempting to load data from blocked url: {url}")
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
|
@ -269,31 +266,6 @@ def get_image(url, timeout=10):
|
|||
return resp
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchResult:
|
||||
"""standardized search result object"""
|
||||
|
||||
title: str
|
||||
key: str
|
||||
connector: object
|
||||
view_link: str = None
|
||||
author: str = None
|
||||
year: str = None
|
||||
cover: str = None
|
||||
confidence: int = 1
|
||||
|
||||
def __repr__(self):
|
||||
return "<SearchResult key={!r} title={!r} author={!r}>".format(
|
||||
self.key, self.title, self.author
|
||||
)
|
||||
|
||||
def json(self):
|
||||
"""serialize a connector for json response"""
|
||||
serialized = asdict(self)
|
||||
del serialized["connector"]
|
||||
return serialized
|
||||
|
||||
|
||||
class Mapping:
|
||||
"""associate a local database field with a field in an external dataset"""
|
||||
|
||||
|
@ -313,3 +285,25 @@ class Mapping:
|
|||
return self.formatter(value)
|
||||
except: # pylint: disable=bare-except
|
||||
return None
|
||||
|
||||
|
||||
def infer_physical_format(format_text):
|
||||
"""try to figure out what the standardized format is from the free value"""
|
||||
format_text = format_text.lower()
|
||||
if format_text in format_mappings:
|
||||
# try a direct match
|
||||
return format_mappings[format_text]
|
||||
# failing that, try substring
|
||||
matches = [v for k, v in format_mappings.items() if k in format_text]
|
||||
if not matches:
|
||||
return None
|
||||
return matches[0]
|
||||
|
||||
|
||||
def unique_physical_format(format_text):
|
||||
"""only store the format if it isn't diretly in the format mappings"""
|
||||
format_text = format_text.lower()
|
||||
if format_text in format_mappings:
|
||||
# try a direct match, so saving this would be redundant
|
||||
return None
|
||||
return format_text
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
""" using another bookwyrm instance as a source of book data """
|
||||
from bookwyrm import activitypub, models
|
||||
from .abstract_connector import AbstractMinimalConnector, SearchResult
|
||||
from bookwyrm.book_search import SearchResult
|
||||
from .abstract_connector import AbstractMinimalConnector
|
||||
|
||||
|
||||
class Connector(AbstractMinimalConnector):
|
||||
|
|
|
@ -10,7 +10,7 @@ from django.db.models import signals
|
|||
|
||||
from requests import HTTPError
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm import book_search, models
|
||||
from bookwyrm.tasks import app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -55,7 +55,7 @@ def search(query, min_confidence=0.1, return_first=False):
|
|||
# if we found anything, return it
|
||||
return result_set[0]
|
||||
|
||||
if result_set or connector.local:
|
||||
if result_set:
|
||||
results.append(
|
||||
{
|
||||
"connector": connector,
|
||||
|
@ -71,22 +71,13 @@ def search(query, min_confidence=0.1, return_first=False):
|
|||
return results
|
||||
|
||||
|
||||
def local_search(query, min_confidence=0.1, raw=False, filters=None):
|
||||
"""only look at local search results"""
|
||||
connector = load_connector(models.Connector.objects.get(local=True))
|
||||
return connector.search(
|
||||
query, min_confidence=min_confidence, raw=raw, filters=filters
|
||||
)
|
||||
|
||||
|
||||
def isbn_local_search(query, raw=False):
|
||||
"""only look at local search results"""
|
||||
connector = load_connector(models.Connector.objects.get(local=True))
|
||||
return connector.isbn_search(query, raw=raw)
|
||||
|
||||
|
||||
def first_search_result(query, min_confidence=0.1):
|
||||
"""search until you find a result that fits"""
|
||||
# try local search first
|
||||
result = book_search.search(query, min_confidence=min_confidence, return_first=True)
|
||||
if result:
|
||||
return result
|
||||
# otherwise, try remote endpoints
|
||||
return search(query, min_confidence=min_confidence, return_first=True) or None
|
||||
|
||||
|
||||
|
@ -109,17 +100,17 @@ def get_or_create_connector(remote_id):
|
|||
connector_info = models.Connector.objects.create(
|
||||
identifier=identifier,
|
||||
connector_file="bookwyrm_connector",
|
||||
base_url="https://%s" % identifier,
|
||||
books_url="https://%s/book" % identifier,
|
||||
covers_url="https://%s/images/covers" % identifier,
|
||||
search_url="https://%s/search?q=" % identifier,
|
||||
base_url=f"https://{identifier}",
|
||||
books_url=f"https://{identifier}/book",
|
||||
covers_url=f"https://{identifier}/images/covers",
|
||||
search_url=f"https://{identifier}/search?q=",
|
||||
priority=2,
|
||||
)
|
||||
|
||||
return load_connector(connector_info)
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(queue="low_priority")
|
||||
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)
|
||||
|
@ -131,7 +122,7 @@ def load_more_data(connector_id, book_id):
|
|||
def load_connector(connector_info):
|
||||
"""instantiate the connector class"""
|
||||
connector = importlib.import_module(
|
||||
"bookwyrm.connectors.%s" % connector_info.connector_file
|
||||
f"bookwyrm.connectors.{connector_info.connector_file}"
|
||||
)
|
||||
return connector.Connector(connector_info.identifier)
|
||||
|
||||
|
@ -141,4 +132,4 @@ def load_connector(connector_info):
|
|||
def create_connector(sender, instance, created, *args, **kwargs):
|
||||
"""create a connector to an external bookwyrm server"""
|
||||
if instance.application_type == "bookwyrm":
|
||||
get_or_create_connector("https://{:s}".format(instance.server_name))
|
||||
get_or_create_connector(f"https://{instance.server_name}")
|
||||
|
|
43
bookwyrm/connectors/format_mappings.py
Normal file
43
bookwyrm/connectors/format_mappings.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
""" comparing a free text format to the standardized one """
|
||||
format_mappings = {
|
||||
"paperback": "Paperback",
|
||||
"soft": "Paperback",
|
||||
"pamphlet": "Paperback",
|
||||
"peperback": "Paperback",
|
||||
"tapa blanda": "Paperback",
|
||||
"turtleback": "Paperback",
|
||||
"pocket": "Paperback",
|
||||
"spiral": "Paperback",
|
||||
"ring": "Paperback",
|
||||
"平装": "Paperback",
|
||||
"简装": "Paperback",
|
||||
"hardcover": "Hardcover",
|
||||
"hardcocer": "Hardcover",
|
||||
"hardover": "Hardcover",
|
||||
"hardback": "Hardcover",
|
||||
"library": "Hardcover",
|
||||
"tapa dura": "Hardcover",
|
||||
"leather": "Hardcover",
|
||||
"clothbound": "Hardcover",
|
||||
"精装": "Hardcover",
|
||||
"ebook": "EBook",
|
||||
"e-book": "EBook",
|
||||
"digital": "EBook",
|
||||
"computer file": "EBook",
|
||||
"epub": "EBook",
|
||||
"online": "EBook",
|
||||
"pdf": "EBook",
|
||||
"elektronische": "EBook",
|
||||
"electronic": "EBook",
|
||||
"audiobook": "AudiobookFormat",
|
||||
"audio": "AudiobookFormat",
|
||||
"cd": "AudiobookFormat",
|
||||
"dvd": "AudiobookFormat",
|
||||
"mp3": "AudiobookFormat",
|
||||
"cassette": "AudiobookFormat",
|
||||
"kindle": "AudiobookFormat",
|
||||
"talking": "AudiobookFormat",
|
||||
"sound": "AudiobookFormat",
|
||||
"comic": "GraphicNovel",
|
||||
"graphic": "GraphicNovel",
|
||||
}
|
|
@ -2,13 +2,14 @@
|
|||
import re
|
||||
|
||||
from bookwyrm import models
|
||||
from .abstract_connector import AbstractConnector, SearchResult, Mapping
|
||||
from bookwyrm.book_search import SearchResult
|
||||
from .abstract_connector import AbstractConnector, Mapping
|
||||
from .abstract_connector import get_data
|
||||
from .connector_manager import ConnectorException
|
||||
|
||||
|
||||
class Connector(AbstractConnector):
|
||||
"""instantiate a connector for OL"""
|
||||
"""instantiate a connector for inventaire"""
|
||||
|
||||
def __init__(self, identifier):
|
||||
super().__init__(identifier)
|
||||
|
@ -59,7 +60,7 @@ class Connector(AbstractConnector):
|
|||
|
||||
def get_remote_id(self, value):
|
||||
"""convert an id/uri into a url"""
|
||||
return "{:s}?action=by-uris&uris={:s}".format(self.books_url, value)
|
||||
return f"{self.books_url}?action=by-uris&uris={value}"
|
||||
|
||||
def get_book_data(self, remote_id):
|
||||
data = get_data(remote_id)
|
||||
|
@ -71,7 +72,7 @@ class Connector(AbstractConnector):
|
|||
# flatten the data so that images, uri, and claims are on the same level
|
||||
return {
|
||||
**data.get("claims", {}),
|
||||
**{k: data.get(k) for k in ["uri", "image", "labels", "sitelinks"]},
|
||||
**{k: data.get(k) for k in ["uri", "image", "labels", "sitelinks", "type"]},
|
||||
}
|
||||
|
||||
def search(self, query, min_confidence=None): # pylint: disable=arguments-differ
|
||||
|
@ -87,11 +88,7 @@ class Connector(AbstractConnector):
|
|||
|
||||
def format_search_result(self, search_result):
|
||||
images = search_result.get("image")
|
||||
cover = (
|
||||
"{:s}/img/entities/{:s}".format(self.covers_url, images[0])
|
||||
if images
|
||||
else None
|
||||
)
|
||||
cover = f"{self.covers_url}/img/entities/{images[0]}" if images else None
|
||||
# a deeply messy translation of inventaire's scores
|
||||
confidence = float(search_result.get("_score", 0.1))
|
||||
confidence = 0.1 if confidence < 150 else 0.999
|
||||
|
@ -99,9 +96,7 @@ class Connector(AbstractConnector):
|
|||
title=search_result.get("label"),
|
||||
key=self.get_remote_id(search_result.get("uri")),
|
||||
author=search_result.get("description"),
|
||||
view_link="{:s}/entity/{:s}".format(
|
||||
self.base_url, search_result.get("uri")
|
||||
),
|
||||
view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
|
||||
cover=cover,
|
||||
confidence=confidence,
|
||||
connector=self,
|
||||
|
@ -123,9 +118,7 @@ class Connector(AbstractConnector):
|
|||
title=title[0],
|
||||
key=self.get_remote_id(search_result.get("uri")),
|
||||
author=search_result.get("description"),
|
||||
view_link="{:s}/entity/{:s}".format(
|
||||
self.base_url, search_result.get("uri")
|
||||
),
|
||||
view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
|
||||
cover=self.get_cover_url(search_result.get("image")),
|
||||
connector=self,
|
||||
)
|
||||
|
@ -135,18 +128,14 @@ class Connector(AbstractConnector):
|
|||
|
||||
def load_edition_data(self, work_uri):
|
||||
"""get a list of editions for a work"""
|
||||
url = (
|
||||
"{:s}?action=reverse-claims&property=wdt:P629&value={:s}&sort=true".format(
|
||||
self.books_url, work_uri
|
||||
)
|
||||
)
|
||||
url = f"{self.books_url}?action=reverse-claims&property=wdt:P629&value={work_uri}&sort=true"
|
||||
return get_data(url)
|
||||
|
||||
def get_edition_from_work_data(self, data):
|
||||
data = self.load_edition_data(data.get("uri"))
|
||||
try:
|
||||
uri = data["uris"][0]
|
||||
except KeyError:
|
||||
uri = data.get("uris", [])[0]
|
||||
except IndexError:
|
||||
raise ConnectorException("Invalid book data")
|
||||
return self.get_book_data(self.get_remote_id(uri))
|
||||
|
||||
|
@ -195,7 +184,7 @@ class Connector(AbstractConnector):
|
|||
# cover may or may not be an absolute url already
|
||||
if re.match(r"^http", cover_id):
|
||||
return cover_id
|
||||
return "%s%s" % (self.covers_url, cover_id)
|
||||
return f"{self.covers_url}{cover_id}"
|
||||
|
||||
def resolve_keys(self, keys):
|
||||
"""cool, it's "wd:Q3156592" now what the heck does that mean"""
|
||||
|
@ -213,9 +202,7 @@ class Connector(AbstractConnector):
|
|||
link = links.get("enwiki")
|
||||
if not link:
|
||||
return ""
|
||||
url = "{:s}/api/data?action=wp-extract&lang=en&title={:s}".format(
|
||||
self.base_url, link
|
||||
)
|
||||
url = f"{self.base_url}/api/data?action=wp-extract&lang=en&title={link}"
|
||||
try:
|
||||
data = get_data(url)
|
||||
except ConnectorException:
|
||||
|
|
|
@ -2,8 +2,9 @@
|
|||
import re
|
||||
|
||||
from bookwyrm import models
|
||||
from .abstract_connector import AbstractConnector, SearchResult, Mapping
|
||||
from .abstract_connector import get_data
|
||||
from bookwyrm.book_search import SearchResult
|
||||
from .abstract_connector import AbstractConnector, Mapping
|
||||
from .abstract_connector import get_data, infer_physical_format, unique_physical_format
|
||||
from .connector_manager import ConnectorException
|
||||
from .openlibrary_languages import languages
|
||||
|
||||
|
@ -43,7 +44,16 @@ class Connector(AbstractConnector):
|
|||
),
|
||||
Mapping("publishedDate", remote_field="publish_date"),
|
||||
Mapping("pages", remote_field="number_of_pages"),
|
||||
Mapping("physicalFormat", remote_field="physical_format"),
|
||||
Mapping(
|
||||
"physicalFormat",
|
||||
remote_field="physical_format",
|
||||
formatter=infer_physical_format,
|
||||
),
|
||||
Mapping(
|
||||
"physicalFormatDetail",
|
||||
remote_field="physical_format",
|
||||
formatter=unique_physical_format,
|
||||
),
|
||||
Mapping("publishers"),
|
||||
]
|
||||
|
||||
|
@ -71,7 +81,7 @@ class Connector(AbstractConnector):
|
|||
key = data["key"]
|
||||
except KeyError:
|
||||
raise ConnectorException("Invalid book data")
|
||||
return "%s%s" % (self.books_url, key)
|
||||
return f"{self.books_url}{key}"
|
||||
|
||||
def is_work_data(self, data):
|
||||
return bool(re.match(r"^[\/\w]+OL\d+W$", data["key"]))
|
||||
|
@ -81,7 +91,7 @@ class Connector(AbstractConnector):
|
|||
key = data["key"]
|
||||
except KeyError:
|
||||
raise ConnectorException("Invalid book data")
|
||||
url = "%s%s/editions" % (self.books_url, key)
|
||||
url = f"{self.books_url}{key}/editions"
|
||||
data = self.get_book_data(url)
|
||||
edition = pick_default_edition(data["entries"])
|
||||
if not edition:
|
||||
|
@ -93,7 +103,7 @@ class Connector(AbstractConnector):
|
|||
key = data["works"][0]["key"]
|
||||
except (IndexError, KeyError):
|
||||
raise ConnectorException("No work found for edition")
|
||||
url = "%s%s" % (self.books_url, key)
|
||||
url = f"{self.books_url}{key}"
|
||||
return self.get_book_data(url)
|
||||
|
||||
def get_authors_from_data(self, data):
|
||||
|
@ -102,7 +112,7 @@ class Connector(AbstractConnector):
|
|||
author_blob = author_blob.get("author", author_blob)
|
||||
# this id is "/authors/OL1234567A"
|
||||
author_id = author_blob["key"]
|
||||
url = "%s%s" % (self.base_url, author_id)
|
||||
url = f"{self.base_url}{author_id}"
|
||||
author = self.get_or_create_author(url)
|
||||
if not author:
|
||||
continue
|
||||
|
@ -113,8 +123,8 @@ class Connector(AbstractConnector):
|
|||
if not cover_blob:
|
||||
return None
|
||||
cover_id = cover_blob[0]
|
||||
image_name = "%s-%s.jpg" % (cover_id, size)
|
||||
return "%s/b/id/%s" % (self.covers_url, image_name)
|
||||
image_name = f"{cover_id}-{size}.jpg"
|
||||
return f"{self.covers_url}/b/id/{image_name}"
|
||||
|
||||
def parse_search_data(self, data):
|
||||
return data.get("docs")
|
||||
|
@ -152,7 +162,7 @@ class Connector(AbstractConnector):
|
|||
|
||||
def load_edition_data(self, olkey):
|
||||
"""query openlibrary for editions of a work"""
|
||||
url = "%s/works/%s/editions" % (self.books_url, olkey)
|
||||
url = f"{self.books_url}/works/{olkey}/editions"
|
||||
return self.get_book_data(url)
|
||||
|
||||
def expand_book_data(self, book):
|
||||
|
|
|
@ -1,164 +0,0 @@
|
|||
""" using a bookwyrm instance as a source of book data """
|
||||
from functools import reduce
|
||||
import operator
|
||||
|
||||
from django.contrib.postgres.search import SearchRank, SearchQuery
|
||||
from django.db.models import OuterRef, Subquery, F, Q
|
||||
|
||||
from bookwyrm import models
|
||||
from .abstract_connector import AbstractConnector, SearchResult
|
||||
|
||||
|
||||
class Connector(AbstractConnector):
|
||||
"""instantiate a connector"""
|
||||
|
||||
# pylint: disable=arguments-differ
|
||||
def search(self, query, min_confidence=0, raw=False, filters=None):
|
||||
"""search your local database"""
|
||||
filters = filters or []
|
||||
if not query:
|
||||
return []
|
||||
# first, try searching unqiue identifiers
|
||||
results = search_identifiers(query, *filters)
|
||||
if not results:
|
||||
# then try searching title/author
|
||||
results = search_title_author(query, min_confidence, *filters)
|
||||
search_results = []
|
||||
for result in results:
|
||||
if raw:
|
||||
search_results.append(result)
|
||||
else:
|
||||
search_results.append(self.format_search_result(result))
|
||||
if len(search_results) >= 10:
|
||||
break
|
||||
if not raw:
|
||||
search_results.sort(key=lambda r: r.confidence, reverse=True)
|
||||
return search_results
|
||||
|
||||
def isbn_search(self, query, raw=False):
|
||||
"""search your local database"""
|
||||
if not query:
|
||||
return []
|
||||
|
||||
filters = [{f: query} for f in ["isbn_10", "isbn_13"]]
|
||||
results = 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
|
||||
)
|
||||
|
||||
search_results = []
|
||||
for result in results:
|
||||
if raw:
|
||||
search_results.append(result)
|
||||
else:
|
||||
search_results.append(self.format_search_result(result))
|
||||
if len(search_results) >= 10:
|
||||
break
|
||||
return search_results
|
||||
|
||||
def format_search_result(self, search_result):
|
||||
cover = None
|
||||
if search_result.cover:
|
||||
cover = "%s%s" % (self.covers_url, search_result.cover)
|
||||
|
||||
return SearchResult(
|
||||
title=search_result.title,
|
||||
key=search_result.remote_id,
|
||||
author=search_result.author_text,
|
||||
year=search_result.published_date.year
|
||||
if search_result.published_date
|
||||
else None,
|
||||
connector=self,
|
||||
cover=cover,
|
||||
confidence=search_result.rank if hasattr(search_result, "rank") else 1,
|
||||
)
|
||||
|
||||
def format_isbn_search_result(self, search_result):
|
||||
return self.format_search_result(search_result)
|
||||
|
||||
def is_work_data(self, data):
|
||||
pass
|
||||
|
||||
def get_edition_from_work_data(self, data):
|
||||
pass
|
||||
|
||||
def get_work_from_edition_data(self, data):
|
||||
pass
|
||||
|
||||
def get_authors_from_data(self, data):
|
||||
return None
|
||||
|
||||
def parse_isbn_search_data(self, data):
|
||||
"""it's already in the right format, don't even worry about it"""
|
||||
return data
|
||||
|
||||
def parse_search_data(self, data):
|
||||
"""it's already in the right format, don't even worry about it"""
|
||||
return data
|
||||
|
||||
def expand_book_data(self, book):
|
||||
pass
|
||||
|
||||
|
||||
def search_identifiers(query, *filters):
|
||||
"""tries remote_id, isbn; defined as dedupe fields on the model"""
|
||||
# pylint: disable=W0212
|
||||
or_filters = [
|
||||
{f.name: query}
|
||||
for f in models.Edition._meta.get_fields()
|
||||
if hasattr(f, "deduplication_field") and f.deduplication_field
|
||||
]
|
||||
results = models.Edition.objects.filter(
|
||||
*filters, reduce(operator.or_, (Q(**f) for f in or_filters))
|
||||
).distinct()
|
||||
if results.count() <= 1:
|
||||
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")
|
||||
return (
|
||||
results.annotate(default_id=Subquery(default_editions.values("id")[:1])).filter(
|
||||
default_id=F("id")
|
||||
)
|
||||
or results
|
||||
)
|
||||
|
||||
|
||||
def search_title_author(query, min_confidence, *filters):
|
||||
"""searches for title and author"""
|
||||
query = SearchQuery(query, config="simple") | SearchQuery(query, config="english")
|
||||
results = (
|
||||
models.Edition.objects.filter(*filters, search_vector=query)
|
||||
.annotate(rank=SearchRank(F("search_vector"), query))
|
||||
.filter(rank__gt=min_confidence)
|
||||
.order_by("-rank")
|
||||
)
|
||||
|
||||
# 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")
|
||||
|
||||
# filter out multiple editions of the same work
|
||||
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:
|
||||
yield default
|
||||
else:
|
||||
yield editions.first()
|
|
@ -1,3 +1,3 @@
|
|||
""" settings book data connectors """
|
||||
|
||||
CONNECTORS = ["openlibrary", "inventaire", "self_connector", "bookwyrm_connector"]
|
||||
CONNECTORS = ["openlibrary", "inventaire", "bookwyrm_connector"]
|
||||
|
|
|
@ -11,7 +11,9 @@ def site_settings(request): # pylint: disable=unused-argument
|
|||
return {
|
||||
"site": models.SiteSettings.objects.get(),
|
||||
"active_announcements": models.Announcement.active_announcements(),
|
||||
"thumbnail_generation_enabled": settings.ENABLE_THUMBNAIL_GENERATION,
|
||||
"media_full_url": settings.MEDIA_FULL_URL,
|
||||
"preview_images_enabled": settings.ENABLE_PREVIEW_IMAGES,
|
||||
"request_protocol": request_protocol,
|
||||
"js_cache": settings.JS_CACHE,
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ def email_data():
|
|||
"""fields every email needs"""
|
||||
site = models.SiteSettings.objects.get()
|
||||
if site.logo_small:
|
||||
logo_path = "/images/{}".format(site.logo_small.url)
|
||||
logo_path = f"/images/{site.logo_small.url}"
|
||||
else:
|
||||
logo_path = "/static/images/logo-small.png"
|
||||
|
||||
|
@ -23,6 +23,14 @@ def email_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))
|
||||
|
||||
|
||||
def invite_email(invite_request):
|
||||
"""send out an invite code"""
|
||||
data = email_data()
|
||||
|
@ -40,23 +48,17 @@ def password_reset_email(reset_code):
|
|||
|
||||
def format_email(email_name, data):
|
||||
"""render the email templates"""
|
||||
subject = (
|
||||
get_template("email/{}/subject.html".format(email_name)).render(data).strip()
|
||||
)
|
||||
subject = get_template(f"email/{email_name}/subject.html").render(data).strip()
|
||||
html_content = (
|
||||
get_template("email/{}/html_content.html".format(email_name))
|
||||
.render(data)
|
||||
.strip()
|
||||
get_template(f"email/{email_name}/html_content.html").render(data).strip()
|
||||
)
|
||||
text_content = (
|
||||
get_template("email/{}/text_content.html".format(email_name))
|
||||
.render(data)
|
||||
.strip()
|
||||
get_template(f"email/{email_name}/text_content.html").render(data).strip()
|
||||
)
|
||||
return (subject, html_content, text_content)
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(queue="high_priority")
|
||||
def send_email(recipient, subject, html_content, text_content):
|
||||
"""use a task to send the email"""
|
||||
email = EmailMultiAlternatives(
|
||||
|
|
|
@ -29,8 +29,7 @@ class CustomForm(ModelForm):
|
|||
input_type = visible.field.widget.input_type
|
||||
if isinstance(visible.field.widget, Textarea):
|
||||
input_type = "textarea"
|
||||
visible.field.widget.attrs["cols"] = None
|
||||
visible.field.widget.attrs["rows"] = None
|
||||
visible.field.widget.attrs["rows"] = 5
|
||||
visible.field.widget.attrs["class"] = css_classes[input_type]
|
||||
|
||||
|
||||
|
@ -86,6 +85,7 @@ class CommentForm(CustomForm):
|
|||
"privacy",
|
||||
"progress",
|
||||
"progress_mode",
|
||||
"reading_status",
|
||||
]
|
||||
|
||||
|
||||
|
@ -100,6 +100,8 @@ class QuotationForm(CustomForm):
|
|||
"content_warning",
|
||||
"sensitive",
|
||||
"privacy",
|
||||
"position",
|
||||
"position_mode",
|
||||
]
|
||||
|
||||
|
||||
|
@ -122,6 +124,12 @@ class StatusForm(CustomForm):
|
|||
fields = ["user", "content", "content_warning", "sensitive", "privacy"]
|
||||
|
||||
|
||||
class DirectForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Status
|
||||
fields = ["user", "content", "content_warning", "sensitive", "privacy"]
|
||||
|
||||
|
||||
class EditUserForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.User
|
||||
|
@ -131,9 +139,12 @@ class EditUserForm(CustomForm):
|
|||
"email",
|
||||
"summary",
|
||||
"show_goal",
|
||||
"show_suggested_users",
|
||||
"manually_approves_followers",
|
||||
"default_post_privacy",
|
||||
"discoverable",
|
||||
"preferred_timezone",
|
||||
"preferred_language",
|
||||
]
|
||||
help_texts = {f: None for f in fields}
|
||||
|
||||
|
@ -217,7 +228,7 @@ class ExpiryWidget(widgets.Select):
|
|||
elif selected_string == "forever":
|
||||
return None
|
||||
else:
|
||||
return selected_string # "This will raise
|
||||
return selected_string # This will raise
|
||||
|
||||
return timezone.now() + interval
|
||||
|
||||
|
@ -249,10 +260,7 @@ class CreateInviteForm(CustomForm):
|
|||
]
|
||||
),
|
||||
"use_limit": widgets.Select(
|
||||
choices=[
|
||||
(i, _("%(count)d uses" % {"count": i}))
|
||||
for i in [1, 5, 10, 25, 50, 100]
|
||||
]
|
||||
choices=[(i, _(f"{i} uses")) for i in [1, 5, 10, 25, 50, 100]]
|
||||
+ [(None, _("Unlimited"))]
|
||||
),
|
||||
}
|
||||
|
@ -261,7 +269,7 @@ class CreateInviteForm(CustomForm):
|
|||
class ShelfForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Shelf
|
||||
fields = ["user", "name", "privacy"]
|
||||
fields = ["user", "name", "privacy", "description"]
|
||||
|
||||
|
||||
class GoalForm(CustomForm):
|
||||
|
@ -285,7 +293,13 @@ class AnnouncementForm(CustomForm):
|
|||
class ListForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.List
|
||||
fields = ["user", "name", "description", "curation", "privacy"]
|
||||
fields = ["user", "name", "description", "curation", "privacy", "group"]
|
||||
|
||||
|
||||
class GroupForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Group
|
||||
fields = ["user", "privacy", "name", "description"]
|
||||
|
||||
|
||||
class ReportForm(CustomForm):
|
||||
|
@ -294,6 +308,18 @@ class ReportForm(CustomForm):
|
|||
fields = ["user", "reporter", "statuses", "note"]
|
||||
|
||||
|
||||
class EmailBlocklistForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.EmailBlocklist
|
||||
fields = ["domain"]
|
||||
|
||||
|
||||
class IPBlocklistForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.IPBlocklist
|
||||
fields = ["address"]
|
||||
|
||||
|
||||
class ServerForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.FederatedServer
|
||||
|
|
113
bookwyrm/imagegenerators.py
Normal file
113
bookwyrm/imagegenerators.py
Normal file
|
@ -0,0 +1,113 @@
|
|||
"""Generators for all the different thumbnail sizes"""
|
||||
from imagekit import ImageSpec, register
|
||||
from imagekit.processors import ResizeToFit
|
||||
|
||||
|
||||
class BookXSmallWebp(ImageSpec):
|
||||
"""Handles XSmall size in Webp format"""
|
||||
|
||||
processors = [ResizeToFit(80, 80)]
|
||||
format = "WEBP"
|
||||
options = {"quality": 95}
|
||||
|
||||
|
||||
class BookXSmallJpg(ImageSpec):
|
||||
"""Handles XSmall size in Jpeg format"""
|
||||
|
||||
processors = [ResizeToFit(80, 80)]
|
||||
format = "JPEG"
|
||||
options = {"quality": 95}
|
||||
|
||||
|
||||
class BookSmallWebp(ImageSpec):
|
||||
"""Handles Small size in Webp format"""
|
||||
|
||||
processors = [ResizeToFit(100, 100)]
|
||||
format = "WEBP"
|
||||
options = {"quality": 95}
|
||||
|
||||
|
||||
class BookSmallJpg(ImageSpec):
|
||||
"""Handles Small size in Jpeg format"""
|
||||
|
||||
processors = [ResizeToFit(100, 100)]
|
||||
format = "JPEG"
|
||||
options = {"quality": 95}
|
||||
|
||||
|
||||
class BookMediumWebp(ImageSpec):
|
||||
"""Handles Medium size in Webp format"""
|
||||
|
||||
processors = [ResizeToFit(150, 150)]
|
||||
format = "WEBP"
|
||||
options = {"quality": 95}
|
||||
|
||||
|
||||
class BookMediumJpg(ImageSpec):
|
||||
"""Handles Medium size in Jpeg format"""
|
||||
|
||||
processors = [ResizeToFit(150, 150)]
|
||||
format = "JPEG"
|
||||
options = {"quality": 95}
|
||||
|
||||
|
||||
class BookLargeWebp(ImageSpec):
|
||||
"""Handles Large size in Webp format"""
|
||||
|
||||
processors = [ResizeToFit(200, 200)]
|
||||
format = "WEBP"
|
||||
options = {"quality": 95}
|
||||
|
||||
|
||||
class BookLargeJpg(ImageSpec):
|
||||
"""Handles Large size in Jpeg format"""
|
||||
|
||||
processors = [ResizeToFit(200, 200)]
|
||||
format = "JPEG"
|
||||
options = {"quality": 95}
|
||||
|
||||
|
||||
class BookXLargeWebp(ImageSpec):
|
||||
"""Handles XLarge size in Webp format"""
|
||||
|
||||
processors = [ResizeToFit(250, 250)]
|
||||
format = "WEBP"
|
||||
options = {"quality": 95}
|
||||
|
||||
|
||||
class BookXLargeJpg(ImageSpec):
|
||||
"""Handles XLarge size in Jpeg format"""
|
||||
|
||||
processors = [ResizeToFit(250, 250)]
|
||||
format = "JPEG"
|
||||
options = {"quality": 95}
|
||||
|
||||
|
||||
class BookXxLargeWebp(ImageSpec):
|
||||
"""Handles XxLarge size in Webp format"""
|
||||
|
||||
processors = [ResizeToFit(500, 500)]
|
||||
format = "WEBP"
|
||||
options = {"quality": 95}
|
||||
|
||||
|
||||
class BookXxLargeJpg(ImageSpec):
|
||||
"""Handles XxLarge size in Jpeg format"""
|
||||
|
||||
processors = [ResizeToFit(500, 500)]
|
||||
format = "JPEG"
|
||||
options = {"quality": 95}
|
||||
|
||||
|
||||
register.generator("bw:book:xsmall:webp", BookXSmallWebp)
|
||||
register.generator("bw:book:xsmall:jpg", BookXSmallJpg)
|
||||
register.generator("bw:book:small:webp", BookSmallWebp)
|
||||
register.generator("bw:book:small:jpg", BookSmallJpg)
|
||||
register.generator("bw:book:medium:webp", BookMediumWebp)
|
||||
register.generator("bw:book:medium:jpg", BookMediumJpg)
|
||||
register.generator("bw:book:large:webp", BookLargeWebp)
|
||||
register.generator("bw:book:large:jpg", BookLargeJpg)
|
||||
register.generator("bw:book:xlarge:webp", BookXLargeWebp)
|
||||
register.generator("bw:book:xlarge:jpg", BookXLargeJpg)
|
||||
register.generator("bw:book:xxlarge:webp", BookXxLargeWebp)
|
||||
register.generator("bw:book:xxlarge:jpg", BookXxLargeJpg)
|
|
@ -3,10 +3,10 @@ from . import Importer
|
|||
|
||||
|
||||
class GoodreadsImporter(Importer):
|
||||
"""GoodReads is the default importer, thus Importer follows its structure.
|
||||
"""Goodreads is the default importer, thus Importer follows its structure.
|
||||
For a more complete example of overriding see librarything_import.py"""
|
||||
|
||||
service = "GoodReads"
|
||||
service = "Goodreads"
|
||||
|
||||
def parse_fields(self, entry):
|
||||
"""handle the specific fields in goodreads csvs"""
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
""" handle reading a csv from an external service, defaults are from GoodReads """
|
||||
""" handle reading a csv from an external service, defaults are from Goodreads """
|
||||
import csv
|
||||
import logging
|
||||
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.models import ImportJob, ImportItem
|
||||
|
@ -61,7 +62,7 @@ class Importer:
|
|||
job.save()
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(queue="low_priority")
|
||||
def import_data(source, job_id):
|
||||
"""does the actual lookup work in a celery task"""
|
||||
job = ImportJob.objects.get(id=job_id)
|
||||
|
@ -71,19 +72,20 @@ def import_data(source, job_id):
|
|||
item.resolve()
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
logger.exception(err)
|
||||
item.fail_reason = "Error loading book"
|
||||
item.fail_reason = _("Error loading book")
|
||||
item.save()
|
||||
continue
|
||||
|
||||
if item.book:
|
||||
if item.book or item.book_guess:
|
||||
item.save()
|
||||
|
||||
if item.book:
|
||||
# shelves book and handles reviews
|
||||
handle_imported_book(
|
||||
source, job.user, item, job.include_reviews, job.privacy
|
||||
)
|
||||
else:
|
||||
item.fail_reason = "Could not find a match for book"
|
||||
item.fail_reason = _("Could not find a match for book")
|
||||
item.save()
|
||||
finally:
|
||||
job.complete = True
|
||||
|
@ -125,6 +127,7 @@ def handle_imported_book(source, user, item, include_reviews, privacy):
|
|||
# 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,
|
||||
|
|
|
@ -4,7 +4,6 @@ from django.contrib.auth.models import Group, Permission
|
|||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from bookwyrm.models import Connector, FederatedServer, SiteSettings, User
|
||||
from bookwyrm.settings import DOMAIN
|
||||
|
||||
|
||||
def init_groups():
|
||||
|
@ -73,19 +72,6 @@ def init_permissions():
|
|||
|
||||
def init_connectors():
|
||||
"""access book data sources"""
|
||||
Connector.objects.create(
|
||||
identifier=DOMAIN,
|
||||
name="Local",
|
||||
local=True,
|
||||
connector_file="self_connector",
|
||||
base_url="https://%s" % DOMAIN,
|
||||
books_url="https://%s/book" % DOMAIN,
|
||||
covers_url="https://%s/images/" % DOMAIN,
|
||||
search_url="https://%s/search?q=" % DOMAIN,
|
||||
isbn_search_url="https://%s/isbn/" % DOMAIN,
|
||||
priority=1,
|
||||
)
|
||||
|
||||
Connector.objects.create(
|
||||
identifier="bookwyrm.social",
|
||||
name="BookWyrm dot Social",
|
||||
|
|
|
@ -1,30 +1,37 @@
|
|||
""" Re-create user streams """
|
||||
from django.core.management.base import BaseCommand
|
||||
import redis
|
||||
|
||||
from bookwyrm import activitystreams, models, settings
|
||||
|
||||
r = redis.Redis(
|
||||
host=settings.REDIS_ACTIVITY_HOST, port=settings.REDIS_ACTIVITY_PORT, db=0
|
||||
)
|
||||
from bookwyrm import activitystreams, models
|
||||
|
||||
|
||||
def populate_streams():
|
||||
def populate_streams(stream=None):
|
||||
"""build all the streams for all the users"""
|
||||
streams = [stream] if stream else activitystreams.streams.keys()
|
||||
print("Populations streams", streams)
|
||||
users = models.User.objects.filter(
|
||||
local=True,
|
||||
is_active=True,
|
||||
)
|
||||
).order_by("-last_active_date")
|
||||
print("This may take a long time! Please be patient.")
|
||||
for user in users:
|
||||
for stream in activitystreams.streams.values():
|
||||
stream.populate_streams(user)
|
||||
for stream_key in streams:
|
||||
print(".", end="")
|
||||
activitystreams.populate_stream_task.delay(stream_key, user.id)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""start all over with user streams"""
|
||||
|
||||
help = "Populate streams for all users"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--stream",
|
||||
default=None,
|
||||
help="Specifies which time of stream to populate",
|
||||
)
|
||||
|
||||
# pylint: disable=no-self-use,unused-argument
|
||||
def handle(self, *args, **options):
|
||||
"""run feed builder"""
|
||||
populate_streams()
|
||||
stream = options.get("stream")
|
||||
populate_streams(stream=stream)
|
||||
|
|
25
bookwyrm/management/commands/populate_suggestions.py
Normal file
25
bookwyrm/management/commands/populate_suggestions.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
""" Populate suggested users """
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.suggested_users import rerank_suggestions_task
|
||||
|
||||
|
||||
def populate_suggestions():
|
||||
"""build all the streams for all the users"""
|
||||
users = models.User.objects.filter(
|
||||
local=True,
|
||||
is_active=True,
|
||||
).values_list("id", flat=True)
|
||||
for user in users:
|
||||
rerank_suggestions_task.delay(user)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""start all over with user suggestions"""
|
||||
|
||||
help = "Populate suggested users for all users"
|
||||
# pylint: disable=no-self-use,unused-argument
|
||||
def handle(self, *args, **options):
|
||||
"""run builder"""
|
||||
populate_suggestions()
|
3
bookwyrm/middleware/__init__.py
Normal file
3
bookwyrm/middleware/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
""" look at all this nice middleware! """
|
||||
from .timezone_middleware import TimezoneMiddleware
|
||||
from .ip_middleware import IPBlocklistMiddleware
|
16
bookwyrm/middleware/ip_middleware.py
Normal file
16
bookwyrm/middleware/ip_middleware.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
""" Block IP addresses """
|
||||
from django.http import Http404
|
||||
from bookwyrm import models
|
||||
|
||||
|
||||
class IPBlocklistMiddleware:
|
||||
"""check incoming traffic against an IP block-list"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
address = request.META.get("REMOTE_ADDR")
|
||||
if models.IPBlocklist.objects.filter(address=address).exists():
|
||||
raise Http404()
|
||||
return self.get_response(request)
|
27
bookwyrm/migrations/0046_user_default_post_privacy.py
Normal file
27
bookwyrm/migrations/0046_user_default_post_privacy.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
# Generated by Django 3.0.7 on 2021-02-14 00:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0045_auto_20210210_2114"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="default_post_privacy",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("unlisted", "Unlisted"),
|
||||
("followers", "Followers"),
|
||||
("direct", "Direct"),
|
||||
],
|
||||
default="public",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
]
|
13
bookwyrm/migrations/0079_merge_20210804_1746.py
Normal file
13
bookwyrm/migrations/0079_merge_20210804_1746.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
# Generated by Django 3.2.4 on 2021-08-04 17:46
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0046_user_default_post_privacy"),
|
||||
("bookwyrm", "0078_add_shelved_date"),
|
||||
]
|
||||
|
||||
operations = []
|
17
bookwyrm/migrations/0080_alter_shelfbook_options.py
Normal file
17
bookwyrm/migrations/0080_alter_shelfbook_options.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 3.2.4 on 2021-08-05 00:00
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0079_merge_20210804_1746"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="shelfbook",
|
||||
options={"ordering": ("-shelved_date", "-created_date", "-updated_date")},
|
||||
),
|
||||
]
|
19
bookwyrm/migrations/0081_alter_user_last_active_date.py
Normal file
19
bookwyrm/migrations/0081_alter_user_last_active_date.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 3.2.4 on 2021-08-06 02:51
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0080_alter_shelfbook_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="last_active_date",
|
||||
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
]
|
56
bookwyrm/migrations/0082_auto_20210806_2324.py
Normal file
56
bookwyrm/migrations/0082_auto_20210806_2324.py
Normal file
|
@ -0,0 +1,56 @@
|
|||
# Generated by Django 3.2.4 on 2021-08-06 23:24
|
||||
|
||||
import bookwyrm.models.base_model
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0081_alter_user_last_active_date"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="sitesettings",
|
||||
name="require_confirm_email",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="confirmation_code",
|
||||
field=models.CharField(
|
||||
default=bookwyrm.models.base_model.new_access_code, max_length=32
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="connector",
|
||||
name="deactivation_reason",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("self_deletion", "Self Deletion"),
|
||||
("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"),
|
||||
("moderator_deletion", "Moderator Deletion"),
|
||||
("domain_block", "Domain Block"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
56
bookwyrm/migrations/0083_auto_20210816_2022.py
Normal file
56
bookwyrm/migrations/0083_auto_20210816_2022.py
Normal file
|
@ -0,0 +1,56 @@
|
|||
# Generated by Django 3.2.4 on 2021-08-16 20:22
|
||||
|
||||
import bookwyrm.models.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0082_auto_20210806_2324"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="comment",
|
||||
name="reading_status",
|
||||
field=bookwyrm.models.fields.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("to-read", "Toread"),
|
||||
("reading", "Reading"),
|
||||
("read", "Read"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="quotation",
|
||||
name="reading_status",
|
||||
field=bookwyrm.models.fields.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("to-read", "Toread"),
|
||||
("reading", "Reading"),
|
||||
("read", "Read"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="review",
|
||||
name="reading_status",
|
||||
field=bookwyrm.models.fields.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("to-read", "Toread"),
|
||||
("reading", "Reading"),
|
||||
("read", "Read"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
56
bookwyrm/migrations/0084_auto_20210817_1916.py
Normal file
56
bookwyrm/migrations/0084_auto_20210817_1916.py
Normal file
|
@ -0,0 +1,56 @@
|
|||
# Generated by Django 3.2.4 on 2021-08-17 19:16
|
||||
|
||||
import bookwyrm.models.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0083_auto_20210816_2022"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="comment",
|
||||
name="reading_status",
|
||||
field=bookwyrm.models.fields.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("to-read", "To-Read"),
|
||||
("reading", "Reading"),
|
||||
("read", "Read"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="quotation",
|
||||
name="reading_status",
|
||||
field=bookwyrm.models.fields.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("to-read", "To-Read"),
|
||||
("reading", "Reading"),
|
||||
("read", "Read"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="review",
|
||||
name="reading_status",
|
||||
field=bookwyrm.models.fields.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("to-read", "To-Read"),
|
||||
("reading", "Reading"),
|
||||
("read", "Read"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
20
bookwyrm/migrations/0085_user_saved_lists.py
Normal file
20
bookwyrm/migrations/0085_user_saved_lists.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
# Generated by Django 3.2.4 on 2021-08-23 18:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0084_auto_20210817_1916"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="saved_lists",
|
||||
field=models.ManyToManyField(
|
||||
related_name="saved_lists", to="bookwyrm.List"
|
||||
),
|
||||
),
|
||||
]
|
40
bookwyrm/migrations/0086_auto_20210827_1727.py
Normal file
40
bookwyrm/migrations/0086_auto_20210827_1727.py
Normal file
|
@ -0,0 +1,40 @@
|
|||
# Generated by Django 3.2.4 on 2021-08-27 17:27
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.expressions
|
||||
|
||||
|
||||
def normalize_readthrough_dates(app_registry, schema_editor):
|
||||
"""Find any invalid dates and reset them"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
app_registry.get_model("bookwyrm", "ReadThrough").objects.using(db_alias).filter(
|
||||
start_date__gt=models.F("finish_date")
|
||||
).update(start_date=models.F("finish_date"))
|
||||
|
||||
|
||||
def reverse_func(apps, schema_editor):
|
||||
"""nothing to do here"""
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0085_user_saved_lists"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(normalize_readthrough_dates, reverse_func),
|
||||
migrations.AlterModelOptions(
|
||||
name="readthrough",
|
||||
options={"ordering": ("-start_date",)},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="readthrough",
|
||||
constraint=models.CheckConstraint(
|
||||
check=models.Q(
|
||||
("finish_date__gte", django.db.models.expressions.F("start_date"))
|
||||
),
|
||||
name="chronology",
|
||||
),
|
||||
),
|
||||
]
|
49
bookwyrm/migrations/0086_auto_20210828_1724.py
Normal file
49
bookwyrm/migrations/0086_auto_20210828_1724.py
Normal file
|
@ -0,0 +1,49 @@
|
|||
# Generated by Django 3.2.4 on 2021-08-28 17:24
|
||||
|
||||
import bookwyrm.models.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
from django.db.models import F, Value, CharField
|
||||
from django.db.models.functions import Concat
|
||||
|
||||
|
||||
def forwards_func(apps, schema_editor):
|
||||
"""generate followers url"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
apps.get_model("bookwyrm", "User").objects.using(db_alias).annotate(
|
||||
generated_url=Concat(
|
||||
F("remote_id"), Value("/followers"), output_field=CharField()
|
||||
)
|
||||
).update(followers_url=models.F("generated_url"))
|
||||
|
||||
|
||||
def reverse_func(apps, schema_editor):
|
||||
"""noop"""
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0085_user_saved_lists"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="followers_url",
|
||||
field=bookwyrm.models.fields.CharField(
|
||||
default="/followers", max_length=255
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.RunPython(forwards_func, reverse_func),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="followers",
|
||||
field=models.ManyToManyField(
|
||||
related_name="following",
|
||||
through="bookwyrm.UserFollows",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,13 @@
|
|||
# Generated by Django 3.2.4 on 2021-08-29 18:19
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0086_auto_20210827_1727"),
|
||||
("bookwyrm", "0086_auto_20210828_1724"),
|
||||
]
|
||||
|
||||
operations = []
|
34
bookwyrm/migrations/0088_auto_20210905_2233.py
Normal file
34
bookwyrm/migrations/0088_auto_20210905_2233.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-05 22:33
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0087_merge_0086_auto_20210827_1727_0086_auto_20210828_1724"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="quotation",
|
||||
name="position",
|
||||
field=models.IntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[django.core.validators.MinValueValidator(0)],
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="quotation",
|
||||
name="position_mode",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[("PG", "page"), ("PCT", "percent")],
|
||||
default="PG",
|
||||
max_length=3,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
18
bookwyrm/migrations/0089_user_show_suggested_users.py
Normal file
18
bookwyrm/migrations/0089_user_show_suggested_users.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-08 16:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0088_auto_20210905_2233"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="show_suggested_users",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
45
bookwyrm/migrations/0090_auto_20210908_2346.py
Normal file
45
bookwyrm/migrations/0090_auto_20210908_2346.py
Normal file
|
@ -0,0 +1,45 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-08 23:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0089_user_show_suggested_users"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="connector",
|
||||
name="deactivation_reason",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("self_deletion", "Self Deletion"),
|
||||
("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"),
|
||||
("moderator_suspension", "Moderator Suspension"),
|
||||
("moderator_deletion", "Moderator Deletion"),
|
||||
("domain_block", "Domain Block"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
32
bookwyrm/migrations/0090_emailblocklist.py
Normal file
32
bookwyrm/migrations/0090_emailblocklist.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-08 22:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0089_user_show_suggested_users"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="EmailBlocklist",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_date", models.DateTimeField(auto_now_add=True)),
|
||||
("domain", models.CharField(max_length=255, unique=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ("-created_date",),
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,13 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-09 00:43
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0090_auto_20210908_2346"),
|
||||
("bookwyrm", "0090_emailblocklist"),
|
||||
]
|
||||
|
||||
operations = []
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-10 18:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0091_merge_0090_auto_20210908_2346_0090_emailblocklist"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="sitesettings",
|
||||
name="instance_short_description",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-10 19:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0092_sitesettings_instance_short_description"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="sitesettings",
|
||||
name="instance_short_description",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
39
bookwyrm/migrations/0094_auto_20210911_1550.py
Normal file
39
bookwyrm/migrations/0094_auto_20210911_1550.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-11 15:50
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db.models import F, Value, CharField
|
||||
|
||||
|
||||
def set_deactivate_date(apps, schema_editor):
|
||||
"""best-guess for deactivation date"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
apps.get_model("bookwyrm", "User").objects.using(db_alias).filter(
|
||||
is_active=False
|
||||
).update(deactivation_date=models.F("last_active_date"))
|
||||
|
||||
|
||||
def reverse_func(apps, schema_editor):
|
||||
"""noop"""
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0093_alter_sitesettings_instance_short_description"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="deactivation_date",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="saved_lists",
|
||||
field=models.ManyToManyField(
|
||||
blank=True, related_name="saved_lists", to="bookwyrm.List"
|
||||
),
|
||||
),
|
||||
migrations.RunPython(set_deactivate_date, reverse_func),
|
||||
]
|
25
bookwyrm/migrations/0094_importitem_book_guess.py
Normal file
25
bookwyrm/migrations/0094_importitem_book_guess.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-11 14:22
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0093_alter_sitesettings_instance_short_description"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="importitem",
|
||||
name="book_guess",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="book_guess",
|
||||
to="bookwyrm.book",
|
||||
),
|
||||
),
|
||||
]
|
45
bookwyrm/migrations/0095_auto_20210911_2053.py
Normal file
45
bookwyrm/migrations/0095_auto_20210911_2053.py
Normal file
|
@ -0,0 +1,45 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-11 20:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0094_auto_20210911_1550"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="connector",
|
||||
name="deactivation_reason",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("self_deletion", "Self deletion"),
|
||||
("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"),
|
||||
("moderator_suspension", "Moderator suspension"),
|
||||
("moderator_deletion", "Moderator deletion"),
|
||||
("domain_block", "Domain block"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
13
bookwyrm/migrations/0095_merge_20210911_2143.py
Normal file
13
bookwyrm/migrations/0095_merge_20210911_2143.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-11 21:43
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0094_auto_20210911_1550"),
|
||||
("bookwyrm", "0094_importitem_book_guess"),
|
||||
]
|
||||
|
||||
operations = []
|
13
bookwyrm/migrations/0096_merge_20210912_0044.py
Normal file
13
bookwyrm/migrations/0096_merge_20210912_0044.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-12 00:44
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0095_auto_20210911_2053"),
|
||||
("bookwyrm", "0095_merge_20210911_2143"),
|
||||
]
|
||||
|
||||
operations = []
|
38
bookwyrm/migrations/0097_auto_20210917_1858.py
Normal file
38
bookwyrm/migrations/0097_auto_20210917_1858.py
Normal file
|
@ -0,0 +1,38 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-17 18:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0096_merge_20210912_0044"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="IPBlocklist",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_date", models.DateTimeField(auto_now_add=True)),
|
||||
("address", models.CharField(max_length=255, unique=True)),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ("-created_date",),
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="emailblocklist",
|
||||
name="is_active",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
27
bookwyrm/migrations/0098_auto_20210918_2238.py
Normal file
27
bookwyrm/migrations/0098_auto_20210918_2238.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-18 22:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0097_auto_20210917_1858"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="sitesettings",
|
||||
name="invite_request_text",
|
||||
field=models.TextField(
|
||||
default="If your request is approved, you will receive an email with a registration link."
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="sitesettings",
|
||||
name="registration_closed_text",
|
||||
field=models.TextField(
|
||||
default='We aren\'t taking new users at this time. You can find an open instance at <a href="https://joinbookwyrm.com/instances">joinbookwyrm.com/instances</a>.'
|
||||
),
|
||||
),
|
||||
]
|
37
bookwyrm/migrations/0099_readthrough_is_active.py
Normal file
37
bookwyrm/migrations/0099_readthrough_is_active.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
# Generated by Django 3.2.4 on 2021-09-22 16:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def set_active_readthrough(apps, schema_editor):
|
||||
"""best-guess for deactivation date"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
apps.get_model("bookwyrm", "ReadThrough").objects.using(db_alias).filter(
|
||||
start_date__isnull=False,
|
||||
finish_date__isnull=True,
|
||||
).update(is_active=True)
|
||||
|
||||
|
||||
def reverse_func(apps, schema_editor):
|
||||
"""noop"""
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0098_auto_20210918_2238"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="readthrough",
|
||||
name="is_active",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.RunPython(set_active_readthrough, reverse_func),
|
||||
migrations.AlterField(
|
||||
model_name="readthrough",
|
||||
name="is_active",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
18
bookwyrm/migrations/0100_shelf_description.py
Normal file
18
bookwyrm/migrations/0100_shelf_description.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.2.5 on 2021-09-28 23:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0099_readthrough_is_active"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="shelf",
|
||||
name="description",
|
||||
field=models.TextField(blank=True, max_length=500, null=True),
|
||||
),
|
||||
]
|
56
bookwyrm/migrations/0101_auto_20210929_1847.py
Normal file
56
bookwyrm/migrations/0101_auto_20210929_1847.py
Normal file
|
@ -0,0 +1,56 @@
|
|||
# Generated by Django 3.2 on 2021-05-21 00:17
|
||||
|
||||
from django.db import migrations
|
||||
import bookwyrm
|
||||
from bookwyrm.connectors.abstract_connector import infer_physical_format
|
||||
|
||||
|
||||
def infer_format(app_registry, schema_editor):
|
||||
"""set the new phsyical format field based on existing format data"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
editions = (
|
||||
app_registry.get_model("bookwyrm", "Edition")
|
||||
.objects.using(db_alias)
|
||||
.filter(physical_format_detail__isnull=False)
|
||||
)
|
||||
for edition in editions:
|
||||
free_format = edition.physical_format_detail.lower()
|
||||
edition.physical_format = infer_physical_format(free_format)
|
||||
edition.save()
|
||||
|
||||
|
||||
def reverse(app_registry, schema_editor):
|
||||
"""doesn't need to do anything"""
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0100_shelf_description"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="edition",
|
||||
old_name="physical_format",
|
||||
new_name="physical_format_detail",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="edition",
|
||||
name="physical_format",
|
||||
field=bookwyrm.models.fields.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("AudiobookFormat", "Audiobook"),
|
||||
("EBook", "eBook"),
|
||||
("GraphicNovel", "Graphic novel"),
|
||||
("Hardcover", "Hardcover"),
|
||||
("Paperback", "Paperback"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.RunPython(infer_format, reverse),
|
||||
]
|
41
bookwyrm/migrations/0102_remove_connector_local.py
Normal file
41
bookwyrm/migrations/0102_remove_connector_local.py
Normal file
|
@ -0,0 +1,41 @@
|
|||
# Generated by Django 3.2.5 on 2021-09-30 17:46
|
||||
|
||||
from django.db import migrations
|
||||
from bookwyrm.settings import DOMAIN
|
||||
|
||||
|
||||
def remove_self_connector(app_registry, schema_editor):
|
||||
"""set the new phsyical format field based on existing format data"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
app_registry.get_model("bookwyrm", "Connector").objects.using(db_alias).filter(
|
||||
connector_file="self_connector"
|
||||
).delete()
|
||||
|
||||
|
||||
def reverse(app_registry, schema_editor):
|
||||
"""doesn't need to do anything"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
model = app_registry.get_model("bookwyrm", "Connector")
|
||||
model.objects.using(db_alias).create(
|
||||
identifier=DOMAIN,
|
||||
name="Local",
|
||||
local=True,
|
||||
connector_file="self_connector",
|
||||
base_url=f"https://{DOMAIN}",
|
||||
books_url=f"https://{DOMAIN}/book",
|
||||
covers_url=f"https://{DOMAIN}/images/",
|
||||
search_url=f"https://{DOMAIN}/search?q=",
|
||||
isbn_search_url=f"https://{DOMAIN}/isbn/",
|
||||
priority=1,
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0101_auto_20210929_1847"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(remove_self_connector, reverse),
|
||||
]
|
17
bookwyrm/migrations/0103_remove_connector_local.py
Normal file
17
bookwyrm/migrations/0103_remove_connector_local.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 3.2.5 on 2021-09-30 18:03
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0102_remove_connector_local"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="connector",
|
||||
name="local",
|
||||
),
|
||||
]
|
53
bookwyrm/migrations/0104_auto_20211001_2012.py
Normal file
53
bookwyrm/migrations/0104_auto_20211001_2012.py
Normal file
|
@ -0,0 +1,53 @@
|
|||
# Generated by Django 3.2.5 on 2021-10-01 20:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def set_thread_id(app_registry, schema_editor):
|
||||
"""set thread ids"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
# set the thread id on parent nodes
|
||||
model = app_registry.get_model("bookwyrm", "Status")
|
||||
model.objects.using(db_alias).filter(reply_parent__isnull=True).update(
|
||||
thread_id=models.F("id")
|
||||
)
|
||||
|
||||
queryset = model.objects.using(db_alias).filter(
|
||||
reply_parent__isnull=False,
|
||||
reply_parent__thread_id__isnull=False,
|
||||
thread_id__isnull=True,
|
||||
)
|
||||
iters = 0
|
||||
while queryset.exists():
|
||||
queryset.update(
|
||||
thread_id=models.Subquery(
|
||||
model.objects.filter(id=models.OuterRef("reply_parent")).values_list(
|
||||
"thread_id"
|
||||
)[:1]
|
||||
)
|
||||
)
|
||||
print(iters)
|
||||
iters += 1
|
||||
if iters > 50:
|
||||
print("exceeded query depth")
|
||||
break
|
||||
|
||||
|
||||
def reverse(*_):
|
||||
"""do nothing"""
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0103_remove_connector_local"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="status",
|
||||
name="thread_id",
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.RunPython(set_thread_id, reverse),
|
||||
]
|
25
bookwyrm/migrations/0105_alter_connector_connector_file.py
Normal file
25
bookwyrm/migrations/0105_alter_connector_connector_file.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
# Generated by Django 3.2.5 on 2021-10-03 19:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0104_auto_20211001_2012"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="connector",
|
||||
name="connector_file",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("openlibrary", "Openlibrary"),
|
||||
("inventaire", "Inventaire"),
|
||||
("bookwyrm_connector", "Bookwyrm Connector"),
|
||||
],
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
]
|
30
bookwyrm/migrations/0106_user_preferred_language.py
Normal file
30
bookwyrm/migrations/0106_user_preferred_language.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
# Generated by Django 3.2.5 on 2021-10-06 19:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0105_alter_connector_connector_file"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="preferred_language",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("en-us", "English"),
|
||||
("de-de", "German"),
|
||||
("es", "Spanish"),
|
||||
("fr-fr", "French"),
|
||||
("zh-hans", "Simplified Chinese"),
|
||||
("zh-hant", "Traditional Chinese"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
31
bookwyrm/migrations/0107_alter_user_preferred_language.py
Normal file
31
bookwyrm/migrations/0107_alter_user_preferred_language.py
Normal file
|
@ -0,0 +1,31 @@
|
|||
# Generated by Django 3.2.5 on 2021-10-11 16:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0106_user_preferred_language"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="preferred_language",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("en-us", "English"),
|
||||
("de-de", "Deutsch (German)"),
|
||||
("es", "Español (Spanish)"),
|
||||
("fr-fr", "Français (French)"),
|
||||
("pt-br", "Português - Brasil (Brazilian Portugues)"),
|
||||
("zh-hans", "简体中文 (Simplified Chinese)"),
|
||||
("zh-hant", "繁體中文 (Traditional Chinese)"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
871
bookwyrm/migrations/0107_auto_20211016_0639.py
Normal file
871
bookwyrm/migrations/0107_auto_20211016_0639.py
Normal file
|
@ -0,0 +1,871 @@
|
|||
# Generated by Django 3.2.5 on 2021-10-16 06:39
|
||||
|
||||
import bookwyrm.models.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0106_user_preferred_language"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Group",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_date", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_date", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"remote_id",
|
||||
bookwyrm.models.fields.RemoteIdField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
validators=[bookwyrm.models.fields.validate_remote_id],
|
||||
),
|
||||
),
|
||||
("name", bookwyrm.models.fields.CharField(max_length=100)),
|
||||
(
|
||||
"description",
|
||||
bookwyrm.models.fields.TextField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"privacy",
|
||||
bookwyrm.models.fields.PrivacyField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("unlisted", "Unlisted"),
|
||||
("followers", "Followers"),
|
||||
("direct", "Direct"),
|
||||
],
|
||||
default="public",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="GroupMember",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_date", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_date", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="GroupMemberInvitation",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_date", models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name="notification",
|
||||
name="notification_type_valid",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="list",
|
||||
name="curation",
|
||||
field=bookwyrm.models.fields.CharField(
|
||||
choices=[
|
||||
("closed", "Closed"),
|
||||
("open", "Open"),
|
||||
("curated", "Curated"),
|
||||
("group", "Group"),
|
||||
],
|
||||
default="closed",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="notification",
|
||||
name="notification_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("FAVORITE", "Favorite"),
|
||||
("REPLY", "Reply"),
|
||||
("MENTION", "Mention"),
|
||||
("TAG", "Tag"),
|
||||
("FOLLOW", "Follow"),
|
||||
("FOLLOW_REQUEST", "Follow Request"),
|
||||
("BOOST", "Boost"),
|
||||
("IMPORT", "Import"),
|
||||
("ADD", "Add"),
|
||||
("REPORT", "Report"),
|
||||
("INVITE", "Invite"),
|
||||
("ACCEPT", "Accept"),
|
||||
("JOIN", "Join"),
|
||||
("LEAVE", "Leave"),
|
||||
("REMOVE", "Remove"),
|
||||
],
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="preferred_language",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("en-us", "English"),
|
||||
("de-de", "Deutsch (German)"),
|
||||
("es", "Español (Spanish)"),
|
||||
("fr-fr", "Français (French)"),
|
||||
("zh-hans", "简体中文 (Simplified Chinese)"),
|
||||
("zh-hant", "繁體中文 (Traditional Chinese)"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
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/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,
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="notification",
|
||||
constraint=models.CheckConstraint(
|
||||
check=models.Q(
|
||||
(
|
||||
"notification_type__in",
|
||||
[
|
||||
"FAVORITE",
|
||||
"REPLY",
|
||||
"MENTION",
|
||||
"TAG",
|
||||
"FOLLOW",
|
||||
"FOLLOW_REQUEST",
|
||||
"BOOST",
|
||||
"IMPORT",
|
||||
"ADD",
|
||||
"REPORT",
|
||||
"INVITE",
|
||||
"ACCEPT",
|
||||
"JOIN",
|
||||
"LEAVE",
|
||||
"REMOVE",
|
||||
],
|
||||
)
|
||||
),
|
||||
name="notification_type_valid",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="groupmemberinvitation",
|
||||
name="group",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="user_invitations",
|
||||
to="bookwyrm.group",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="groupmemberinvitation",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="group_invitations",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="groupmember",
|
||||
name="group",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="memberships",
|
||||
to="bookwyrm.group",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="groupmember",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="memberships",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="group",
|
||||
name="user",
|
||||
field=bookwyrm.models.fields.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="list",
|
||||
name="group",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="bookwyrm.group",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="notification",
|
||||
name="related_group",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="notifications",
|
||||
to="bookwyrm.group",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="groupmemberinvitation",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("group", "user"), name="unique_invitation"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="groupmember",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("group", "user"), name="unique_membership"
|
||||
),
|
||||
),
|
||||
]
|
31
bookwyrm/migrations/0108_alter_user_preferred_language.py
Normal file
31
bookwyrm/migrations/0108_alter_user_preferred_language.py
Normal file
|
@ -0,0 +1,31 @@
|
|||
# Generated by Django 3.2.5 on 2021-10-11 17:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0107_alter_user_preferred_language"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="preferred_language",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("en-us", "English"),
|
||||
("de-de", "Deutsch (German)"),
|
||||
("es-es", "Español (Spanish)"),
|
||||
("fr-fr", "Français (French)"),
|
||||
("pt-br", "Português - Brasil (Brazilian Portuguese)"),
|
||||
("zh-hans", "简体中文 (Simplified Chinese)"),
|
||||
("zh-hant", "繁體中文 (Traditional Chinese)"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
19
bookwyrm/migrations/0109_status_edited_date.py
Normal file
19
bookwyrm/migrations/0109_status_edited_date.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 3.2.5 on 2021-10-15 15:54
|
||||
|
||||
import bookwyrm.models.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0108_alter_user_preferred_language"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="status",
|
||||
name="edited_date",
|
||||
field=bookwyrm.models.fields.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
23
bookwyrm/migrations/0110_auto_20211015_1734.py
Normal file
23
bookwyrm/migrations/0110_auto_20211015_1734.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 3.2.5 on 2021-10-15 17:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0109_status_edited_date"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="quotation",
|
||||
name="raw_quote",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="status",
|
||||
name="raw_content",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,13 @@
|
|||
# Generated by Django 3.2.5 on 2021-10-16 19:30
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0107_auto_20211016_0639"),
|
||||
("bookwyrm", "0110_auto_20211015_1734"),
|
||||
]
|
||||
|
||||
operations = []
|
|
@ -14,7 +14,6 @@ from .status import Review, ReviewRating
|
|||
from .status import Boost
|
||||
from .attachment import Image
|
||||
from .favorite import Favorite
|
||||
from .notification import Notification
|
||||
from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
|
||||
|
||||
from .user import User, KeyPair, AnnualGoal
|
||||
|
@ -22,10 +21,16 @@ from .relationship import UserFollows, UserFollowRequest, UserBlocks
|
|||
from .report import Report, ReportComment
|
||||
from .federated_server import FederatedServer
|
||||
|
||||
from .group import Group, GroupMember, GroupMemberInvitation
|
||||
|
||||
from .import_job import ImportJob, ImportItem
|
||||
|
||||
from .site import SiteSettings, SiteInvite, PasswordReset, InviteRequest
|
||||
from .site import SiteSettings, SiteInvite
|
||||
from .site import PasswordReset, InviteRequest
|
||||
from .announcement import Announcement
|
||||
from .antispam import EmailBlocklist, IPBlocklist
|
||||
|
||||
from .notification import Notification
|
||||
|
||||
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
|
||||
activity_models = {
|
||||
|
|
|
@ -7,7 +7,7 @@ import operator
|
|||
import logging
|
||||
from uuid import uuid4
|
||||
import requests
|
||||
from requests.exceptions import HTTPError, SSLError
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from Crypto.PublicKey import RSA
|
||||
from Crypto.Signature import pkcs1_15
|
||||
|
@ -43,7 +43,7 @@ class ActivitypubMixin:
|
|||
reverse_unfurl = False
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""collect some info on model fields"""
|
||||
"""collect some info on model fields for later use"""
|
||||
self.image_fields = []
|
||||
self.many_to_many_fields = []
|
||||
self.simple_fields = [] # "simple"
|
||||
|
@ -266,7 +266,7 @@ class ObjectMixin(ActivitypubMixin):
|
|||
signed_message = signer.sign(SHA256.new(content.encode("utf8")))
|
||||
|
||||
signature = activitypub.Signature(
|
||||
creator="%s#main-key" % user.remote_id,
|
||||
creator=f"{user.remote_id}#main-key",
|
||||
created=activity_object.published,
|
||||
signatureValue=b64encode(signed_message).decode("utf8"),
|
||||
)
|
||||
|
@ -285,16 +285,16 @@ class ObjectMixin(ActivitypubMixin):
|
|||
return activitypub.Delete(
|
||||
id=self.remote_id + "/activity",
|
||||
actor=user.remote_id,
|
||||
to=["%s/followers" % user.remote_id],
|
||||
to=[f"{user.remote_id}/followers"],
|
||||
cc=["https://www.w3.org/ns/activitystreams#Public"],
|
||||
object=self,
|
||||
).serialize()
|
||||
|
||||
def to_update_activity(self, user):
|
||||
"""wrapper for Updates to an activity"""
|
||||
activity_id = "%s#update/%s" % (self.remote_id, uuid4())
|
||||
uuid = uuid4()
|
||||
return activitypub.Update(
|
||||
id=activity_id,
|
||||
id=f"{self.remote_id}#update/{uuid}",
|
||||
actor=user.remote_id,
|
||||
to=["https://www.w3.org/ns/activitystreams#Public"],
|
||||
object=self,
|
||||
|
@ -337,8 +337,8 @@ class OrderedCollectionPageMixin(ObjectMixin):
|
|||
paginated = Paginator(queryset, PAGE_LENGTH)
|
||||
# add computed fields specific to orderd collections
|
||||
activity["totalItems"] = paginated.count
|
||||
activity["first"] = "%s?page=1" % remote_id
|
||||
activity["last"] = "%s?page=%d" % (remote_id, paginated.num_pages)
|
||||
activity["first"] = f"{remote_id}?page=1"
|
||||
activity["last"] = f"{remote_id}?page={paginated.num_pages}"
|
||||
|
||||
return serializer(**activity)
|
||||
|
||||
|
@ -362,6 +362,13 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin):
|
|||
self.collection_queryset, **kwargs
|
||||
).serialize()
|
||||
|
||||
def delete(self, *args, broadcast=True, **kwargs):
|
||||
"""Delete the object"""
|
||||
activity = self.to_delete_activity(self.user)
|
||||
super().delete(*args, **kwargs)
|
||||
if self.user.local and broadcast:
|
||||
self.broadcast(activity, self.user)
|
||||
|
||||
|
||||
class CollectionItemMixin(ActivitypubMixin):
|
||||
"""for items that are part of an (Ordered)Collection"""
|
||||
|
@ -413,7 +420,7 @@ class CollectionItemMixin(ActivitypubMixin):
|
|||
"""AP for shelving a book"""
|
||||
collection_field = getattr(self, self.collection_field)
|
||||
return activitypub.Add(
|
||||
id="{:s}#add".format(collection_field.remote_id),
|
||||
id=f"{collection_field.remote_id}#add",
|
||||
actor=user.remote_id,
|
||||
object=self.to_activity_dataclass(),
|
||||
target=collection_field.remote_id,
|
||||
|
@ -423,7 +430,7 @@ class CollectionItemMixin(ActivitypubMixin):
|
|||
"""AP for un-shelving a book"""
|
||||
collection_field = getattr(self, self.collection_field)
|
||||
return activitypub.Remove(
|
||||
id="{:s}#remove".format(collection_field.remote_id),
|
||||
id=f"{collection_field.remote_id}#remove",
|
||||
actor=user.remote_id,
|
||||
object=self.to_activity_dataclass(),
|
||||
target=collection_field.remote_id,
|
||||
|
@ -451,7 +458,7 @@ class ActivityMixin(ActivitypubMixin):
|
|||
"""undo an action"""
|
||||
user = self.user if hasattr(self, "user") else self.user_subject
|
||||
return activitypub.Undo(
|
||||
id="%s#undo" % self.remote_id,
|
||||
id=f"{self.remote_id}#undo",
|
||||
actor=user.remote_id,
|
||||
object=self,
|
||||
).serialize()
|
||||
|
@ -495,7 +502,7 @@ def unfurl_related_field(related_field, sort_field=None):
|
|||
return related_field.remote_id
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(queue="medium_priority")
|
||||
def broadcast_task(sender_id, activity, recipients):
|
||||
"""the celery task for broadcast"""
|
||||
user_model = apps.get_model("bookwyrm.User", require_ready=True)
|
||||
|
@ -503,7 +510,7 @@ def broadcast_task(sender_id, activity, recipients):
|
|||
for recipient in recipients:
|
||||
try:
|
||||
sign_and_send(sender, activity, recipient)
|
||||
except (HTTPError, SSLError, requests.exceptions.ConnectionError):
|
||||
except RequestException:
|
||||
pass
|
||||
|
||||
|
||||
|
@ -548,11 +555,11 @@ def to_ordered_collection_page(
|
|||
|
||||
prev_page = next_page = None
|
||||
if activity_page.has_next():
|
||||
next_page = "%s?page=%d" % (remote_id, activity_page.next_page_number())
|
||||
next_page = f"{remote_id}?page={activity_page.next_page_number()}"
|
||||
if activity_page.has_previous():
|
||||
prev_page = "%s?page=%d" % (remote_id, activity_page.previous_page_number())
|
||||
prev_page = f"{remote_id}?page=%d{activity_page.previous_page_number()}"
|
||||
return activitypub.OrderedCollectionPage(
|
||||
id="%s?page=%s" % (remote_id, page),
|
||||
id=f"{remote_id}?page={page}",
|
||||
partOf=remote_id,
|
||||
orderedItems=items,
|
||||
next=next_page,
|
||||
|
|
35
bookwyrm/models/antispam.py
Normal file
35
bookwyrm/models/antispam.py
Normal file
|
@ -0,0 +1,35 @@
|
|||
""" Lets try NOT to sell viagra """
|
||||
from django.db import models
|
||||
|
||||
from .user import User
|
||||
|
||||
|
||||
class EmailBlocklist(models.Model):
|
||||
"""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)
|
||||
|
||||
class Meta:
|
||||
"""default sorting"""
|
||||
|
||||
ordering = ("-created_date",)
|
||||
|
||||
@property
|
||||
def users(self):
|
||||
"""find the users associated with this address"""
|
||||
return User.objects.filter(email__endswith=f"@{self.domain}")
|
||||
|
||||
|
||||
class IPBlocklist(models.Model):
|
||||
"""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)
|
||||
|
||||
class Meta:
|
||||
"""default sorting"""
|
||||
|
||||
ordering = ("-created_date",)
|
|
@ -35,7 +35,7 @@ class Author(BookDataModel):
|
|||
|
||||
def get_remote_id(self):
|
||||
"""editions and works both use "book" instead of model_name"""
|
||||
return "https://%s/author/%s" % (DOMAIN, self.id)
|
||||
return f"https://{DOMAIN}/author/{self.id}"
|
||||
|
||||
activity_serializer = activitypub.Author
|
||||
|
||||
|
|
|
@ -1,19 +1,30 @@
|
|||
""" base model with default fields """
|
||||
import base64
|
||||
from Crypto import Random
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.dispatch import receiver
|
||||
from django.http import Http404
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from .fields import RemoteIdField
|
||||
|
||||
|
||||
DeactivationReason = models.TextChoices(
|
||||
"DeactivationReason",
|
||||
[
|
||||
"self_deletion",
|
||||
"moderator_deletion",
|
||||
"domain_block",
|
||||
],
|
||||
)
|
||||
DeactivationReason = [
|
||||
("pending", _("Pending")),
|
||||
("self_deletion", _("Self deletion")),
|
||||
("moderator_suspension", _("Moderator suspension")),
|
||||
("moderator_deletion", _("Moderator deletion")),
|
||||
("domain_block", _("Domain block")),
|
||||
]
|
||||
|
||||
|
||||
def new_access_code():
|
||||
"""the identifier for a user invite"""
|
||||
return base64.b32encode(Random.get_random_bytes(5)).decode("ascii")
|
||||
|
||||
|
||||
class BookWyrmModel(models.Model):
|
||||
|
@ -25,11 +36,11 @@ class BookWyrmModel(models.Model):
|
|||
|
||||
def get_remote_id(self):
|
||||
"""generate a url that resolves to the local object"""
|
||||
base_path = "https://%s" % DOMAIN
|
||||
base_path = f"https://{DOMAIN}"
|
||||
if hasattr(self, "user"):
|
||||
base_path = "%s%s" % (base_path, self.user.local_path)
|
||||
base_path = f"{base_path}{self.user.local_path}"
|
||||
model_name = type(self).__name__.lower()
|
||||
return "%s/%s/%d" % (base_path, model_name, self.id)
|
||||
return f"{base_path}/{model_name}/{self.id}"
|
||||
|
||||
class Meta:
|
||||
"""this is just here to provide default fields for other models"""
|
||||
|
@ -39,37 +50,123 @@ class BookWyrmModel(models.Model):
|
|||
@property
|
||||
def local_path(self):
|
||||
"""how to link to this object in the local app"""
|
||||
return self.get_remote_id().replace("https://%s" % DOMAIN, "")
|
||||
return self.get_remote_id().replace(f"https://{DOMAIN}", "")
|
||||
|
||||
def visible_to_user(self, viewer):
|
||||
def raise_visible_to_user(self, viewer):
|
||||
"""is a user authorized to view an object?"""
|
||||
# make sure this is an object with privacy owned by a user
|
||||
if not hasattr(self, "user") or not hasattr(self, "privacy"):
|
||||
return None
|
||||
return
|
||||
|
||||
# viewer can't see it if the object's owner blocked them
|
||||
if viewer in self.user.blocks.all():
|
||||
return False
|
||||
raise Http404()
|
||||
|
||||
# you can see your own posts and any public or unlisted posts
|
||||
if viewer == self.user or self.privacy in ["public", "unlisted"]:
|
||||
return True
|
||||
return
|
||||
|
||||
# you can see the followers only posts of people you follow
|
||||
if (
|
||||
self.privacy == "followers"
|
||||
and self.user.followers.filter(id=viewer.id).first()
|
||||
if self.privacy == "followers" and (
|
||||
self.user.followers.filter(id=viewer.id).first()
|
||||
):
|
||||
return True
|
||||
return
|
||||
|
||||
# you can see dms you are tagged in
|
||||
if hasattr(self, "mention_users"):
|
||||
if (
|
||||
self.privacy == "direct"
|
||||
self.privacy in ["direct", "followers"]
|
||||
and self.mention_users.filter(id=viewer.id).first()
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
return
|
||||
|
||||
# you can see groups of which you are a member
|
||||
if (
|
||||
hasattr(self, "memberships")
|
||||
and self.memberships.filter(user=viewer).exists()
|
||||
):
|
||||
return
|
||||
|
||||
# you can see objects which have a group of which you are a member
|
||||
if hasattr(self, "group"):
|
||||
if (
|
||||
hasattr(self.group, "memberships")
|
||||
and self.group.memberships.filter(user=viewer).exists()
|
||||
):
|
||||
return
|
||||
|
||||
raise Http404()
|
||||
|
||||
def raise_not_editable(self, viewer):
|
||||
"""does this user have permission to edit this object? liable to be overwritten
|
||||
by models that inherit this base model class"""
|
||||
if not hasattr(self, "user"):
|
||||
return
|
||||
|
||||
# generally moderators shouldn't be able to edit other people's stuff
|
||||
if self.user == viewer:
|
||||
return
|
||||
|
||||
raise PermissionDenied()
|
||||
|
||||
def raise_not_deletable(self, viewer):
|
||||
"""does this user have permission to delete this object? liable to be
|
||||
overwritten by models that inherit this base model class"""
|
||||
if not hasattr(self, "user"):
|
||||
return
|
||||
|
||||
# but generally moderators can delete other people's stuff
|
||||
if self.user == viewer or viewer.has_perm("moderate_post"):
|
||||
return
|
||||
|
||||
raise PermissionDenied()
|
||||
|
||||
@classmethod
|
||||
def privacy_filter(cls, viewer, privacy_levels=None):
|
||||
"""filter objects that have "user" and "privacy" fields"""
|
||||
queryset = cls.objects
|
||||
if hasattr(queryset, "select_subclasses"):
|
||||
queryset = queryset.select_subclasses()
|
||||
|
||||
privacy_levels = privacy_levels or ["public", "unlisted", "followers", "direct"]
|
||||
# you can't see followers only or direct messages if you're not logged in
|
||||
if viewer.is_anonymous:
|
||||
privacy_levels = [
|
||||
p for p in privacy_levels if not p in ["followers", "direct"]
|
||||
]
|
||||
else:
|
||||
# exclude blocks from both directions
|
||||
queryset = queryset.exclude(
|
||||
Q(user__blocked_by=viewer) | Q(user__blocks=viewer)
|
||||
)
|
||||
|
||||
# filter to only provided privacy levels
|
||||
queryset = queryset.filter(privacy__in=privacy_levels)
|
||||
|
||||
if "followers" in privacy_levels:
|
||||
queryset = cls.followers_filter(queryset, viewer)
|
||||
|
||||
# exclude direct messages not intended for the user
|
||||
if "direct" in privacy_levels:
|
||||
queryset = cls.direct_filter(queryset, viewer)
|
||||
|
||||
return queryset
|
||||
|
||||
@classmethod
|
||||
def followers_filter(cls, queryset, viewer):
|
||||
"""Override-able filter for "followers" privacy level"""
|
||||
return queryset.exclude(
|
||||
~Q( # user isn't following and it isn't their own status
|
||||
Q(user__followers=viewer) | Q(user=viewer)
|
||||
),
|
||||
privacy="followers", # and the status is followers only
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def direct_filter(cls, queryset, viewer):
|
||||
"""Override-able filter for "direct" privacy level"""
|
||||
return queryset.exclude(~Q(user=viewer), privacy="direct")
|
||||
|
||||
|
||||
@receiver(models.signals.post_save)
|
||||
|
|
|
@ -3,14 +3,22 @@ import re
|
|||
|
||||
from django.contrib.postgres.search import SearchVectorField
|
||||
from django.contrib.postgres.indexes import GinIndex
|
||||
from django.db import models
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Prefetch
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from model_utils import FieldTracker
|
||||
from model_utils.managers import InheritanceManager
|
||||
from imagekit.models import ImageSpecField
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm.preview_images import generate_edition_preview_image_task
|
||||
from bookwyrm.settings import DOMAIN, DEFAULT_LANGUAGE, ENABLE_PREVIEW_IMAGES
|
||||
from bookwyrm.settings import (
|
||||
DOMAIN,
|
||||
DEFAULT_LANGUAGE,
|
||||
ENABLE_PREVIEW_IMAGES,
|
||||
ENABLE_THUMBNAIL_GENERATION,
|
||||
)
|
||||
|
||||
from .activitypub_mixin import OrderedCollectionPageMixin, ObjectMixin
|
||||
from .base_model import BookWyrmModel
|
||||
|
@ -97,6 +105,40 @@ class Book(BookDataModel):
|
|||
objects = InheritanceManager()
|
||||
field_tracker = FieldTracker(fields=["authors", "title", "subtitle", "cover"])
|
||||
|
||||
if ENABLE_THUMBNAIL_GENERATION:
|
||||
cover_bw_book_xsmall_webp = ImageSpecField(
|
||||
source="cover", id="bw:book:xsmall:webp"
|
||||
)
|
||||
cover_bw_book_xsmall_jpg = ImageSpecField(
|
||||
source="cover", id="bw:book:xsmall:jpg"
|
||||
)
|
||||
cover_bw_book_small_webp = ImageSpecField(
|
||||
source="cover", id="bw:book:small:webp"
|
||||
)
|
||||
cover_bw_book_small_jpg = ImageSpecField(source="cover", id="bw:book:small:jpg")
|
||||
cover_bw_book_medium_webp = ImageSpecField(
|
||||
source="cover", id="bw:book:medium:webp"
|
||||
)
|
||||
cover_bw_book_medium_jpg = ImageSpecField(
|
||||
source="cover", id="bw:book:medium:jpg"
|
||||
)
|
||||
cover_bw_book_large_webp = ImageSpecField(
|
||||
source="cover", id="bw:book:large:webp"
|
||||
)
|
||||
cover_bw_book_large_jpg = ImageSpecField(source="cover", id="bw:book:large:jpg")
|
||||
cover_bw_book_xlarge_webp = ImageSpecField(
|
||||
source="cover", id="bw:book:xlarge:webp"
|
||||
)
|
||||
cover_bw_book_xlarge_jpg = ImageSpecField(
|
||||
source="cover", id="bw:book:xlarge:jpg"
|
||||
)
|
||||
cover_bw_book_xxlarge_webp = ImageSpecField(
|
||||
source="cover", id="bw:book:xxlarge:webp"
|
||||
)
|
||||
cover_bw_book_xxlarge_jpg = ImageSpecField(
|
||||
source="cover", id="bw:book:xxlarge:jpg"
|
||||
)
|
||||
|
||||
@property
|
||||
def author_text(self):
|
||||
"""format a list of authors"""
|
||||
|
@ -123,9 +165,9 @@ class Book(BookDataModel):
|
|||
@property
|
||||
def alt_text(self):
|
||||
"""image alt test"""
|
||||
text = "%s" % self.title
|
||||
text = self.title
|
||||
if self.edition_info:
|
||||
text += " (%s)" % self.edition_info
|
||||
text += f" ({self.edition_info})"
|
||||
return text
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
@ -136,9 +178,10 @@ class Book(BookDataModel):
|
|||
|
||||
def get_remote_id(self):
|
||||
"""editions and works both use "book" instead of model_name"""
|
||||
return "https://%s/book/%d" % (DOMAIN, self.id)
|
||||
return f"https://{DOMAIN}/book/{self.id}"
|
||||
|
||||
def __repr__(self):
|
||||
# pylint: disable=consider-using-f-string
|
||||
return "<{} key={!r} title={!r}>".format(
|
||||
self.__class__,
|
||||
self.openlibrary_key,
|
||||
|
@ -175,7 +218,7 @@ class Work(OrderedCollectionPageMixin, Book):
|
|||
"""an ordered collection of editions"""
|
||||
return self.to_ordered_collection(
|
||||
self.editions.order_by("-edition_rank").all(),
|
||||
remote_id="%s/editions" % self.remote_id,
|
||||
remote_id=f"{self.remote_id}/editions",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
@ -184,6 +227,16 @@ class Work(OrderedCollectionPageMixin, Book):
|
|||
deserialize_reverse_fields = [("editions", "editions")]
|
||||
|
||||
|
||||
# https://schema.org/BookFormatType
|
||||
FormatChoices = [
|
||||
("AudiobookFormat", _("Audiobook")),
|
||||
("EBook", _("eBook")),
|
||||
("GraphicNovel", _("Graphic novel")),
|
||||
("Hardcover", _("Hardcover")),
|
||||
("Paperback", _("Paperback")),
|
||||
]
|
||||
|
||||
|
||||
class Edition(Book):
|
||||
"""an edition of a book"""
|
||||
|
||||
|
@ -201,7 +254,10 @@ class Edition(Book):
|
|||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
pages = fields.IntegerField(blank=True, null=True)
|
||||
physical_format = fields.CharField(max_length=255, blank=True, null=True)
|
||||
physical_format = fields.CharField(
|
||||
max_length=255, choices=FormatChoices, null=True, blank=True
|
||||
)
|
||||
physical_format_detail = fields.CharField(max_length=255, blank=True, null=True)
|
||||
publishers = fields.ArrayField(
|
||||
models.CharField(max_length=255), blank=True, default=list
|
||||
)
|
||||
|
@ -265,6 +321,27 @@ class Edition(Book):
|
|||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def viewer_aware_objects(cls, viewer):
|
||||
"""annotate a book query with metadata related to the user"""
|
||||
queryset = cls.objects
|
||||
if not viewer or not viewer.is_authenticated:
|
||||
return queryset
|
||||
|
||||
queryset = queryset.prefetch_related(
|
||||
Prefetch(
|
||||
"shelfbook_set",
|
||||
queryset=viewer.shelfbook_set.all(),
|
||||
to_attr="current_shelves",
|
||||
),
|
||||
Prefetch(
|
||||
"readthrough_set",
|
||||
queryset=viewer.readthrough_set.filter(is_active=True).all(),
|
||||
to_attr="active_readthroughs",
|
||||
),
|
||||
)
|
||||
return queryset
|
||||
|
||||
|
||||
def isbn_10_to_13(isbn_10):
|
||||
"""convert an isbn 10 into an isbn 13"""
|
||||
|
@ -321,4 +398,6 @@ def preview_image(instance, *args, **kwargs):
|
|||
changed_fields = instance.field_tracker.changed()
|
||||
|
||||
if len(changed_fields) > 0:
|
||||
generate_edition_preview_image_task.delay(instance.id)
|
||||
transaction.on_commit(
|
||||
lambda: generate_edition_preview_image_task.delay(instance.id)
|
||||
)
|
||||
|
|
|
@ -14,12 +14,11 @@ class Connector(BookWyrmModel):
|
|||
identifier = models.CharField(max_length=255, unique=True)
|
||||
priority = models.IntegerField(default=2)
|
||||
name = models.CharField(max_length=255, null=True, blank=True)
|
||||
local = models.BooleanField(default=False)
|
||||
connector_file = models.CharField(max_length=255, choices=ConnectorFiles.choices)
|
||||
api_key = models.CharField(max_length=255, null=True, blank=True)
|
||||
active = models.BooleanField(default=True)
|
||||
deactivation_reason = models.CharField(
|
||||
max_length=255, choices=DeactivationReason.choices, null=True, blank=True
|
||||
max_length=255, choices=DeactivationReason, null=True, blank=True
|
||||
)
|
||||
|
||||
base_url = models.CharField(max_length=255)
|
||||
|
@ -29,7 +28,4 @@ class Connector(BookWyrmModel):
|
|||
isbn_search_url = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return "{} ({})".format(
|
||||
self.identifier,
|
||||
self.id,
|
||||
)
|
||||
return f"{self.identifier} ({self.id})"
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
""" like/fav/star a status """
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from .activitypub_mixin import ActivityMixin
|
||||
|
@ -29,38 +27,9 @@ class Favorite(ActivityMixin, BookWyrmModel):
|
|||
|
||||
def save(self, *args, **kwargs):
|
||||
"""update user active time"""
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save(broadcast=False)
|
||||
self.user.update_active_date()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if self.status.user.local and self.status.user != self.user:
|
||||
notification_model = apps.get_model(
|
||||
"bookwyrm.Notification", require_ready=True
|
||||
)
|
||||
notification_model.objects.create(
|
||||
user=self.status.user,
|
||||
notification_type="FAVORITE",
|
||||
related_user=self.user,
|
||||
related_status=self.status,
|
||||
)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""delete and delete notifications"""
|
||||
# check for notification
|
||||
if self.status.user.local:
|
||||
notification_model = apps.get_model(
|
||||
"bookwyrm.Notification", require_ready=True
|
||||
)
|
||||
notification = notification_model.objects.filter(
|
||||
user=self.status.user,
|
||||
related_user=self.user,
|
||||
related_status=self.status,
|
||||
notification_type="FAVORITE",
|
||||
).first()
|
||||
if notification:
|
||||
notification.delete()
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
"""can't fav things twice"""
|
||||
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
""" connections to external ActivityPub servers """
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .base_model import BookWyrmModel
|
||||
|
||||
FederationStatus = models.TextChoices(
|
||||
"Status",
|
||||
[
|
||||
"federated",
|
||||
"blocked",
|
||||
],
|
||||
)
|
||||
FederationStatus = [
|
||||
("federated", _("Federated")),
|
||||
("blocked", _("Blocked")),
|
||||
]
|
||||
|
||||
|
||||
class FederatedServer(BookWyrmModel):
|
||||
|
@ -18,7 +18,7 @@ class FederatedServer(BookWyrmModel):
|
|||
|
||||
server_name = models.CharField(max_length=255, unique=True)
|
||||
status = models.CharField(
|
||||
max_length=255, default="federated", choices=FederationStatus.choices
|
||||
max_length=255, default="federated", choices=FederationStatus
|
||||
)
|
||||
# is it mastodon, bookwyrm, etc
|
||||
application_type = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
@ -28,7 +28,7 @@ class FederatedServer(BookWyrmModel):
|
|||
def block(self):
|
||||
"""block a server"""
|
||||
self.status = "blocked"
|
||||
self.save()
|
||||
self.save(update_fields=["status"])
|
||||
|
||||
# deactivate all associated users
|
||||
self.user_set.filter(is_active=True).update(
|
||||
|
@ -45,7 +45,7 @@ class FederatedServer(BookWyrmModel):
|
|||
def unblock(self):
|
||||
"""unblock a server"""
|
||||
self.status = "federated"
|
||||
self.save()
|
||||
self.save(update_fields=["status"])
|
||||
|
||||
self.user_set.filter(deactivation_reason="domain_block").update(
|
||||
is_active=True, deactivation_reason=None
|
||||
|
|
|
@ -58,7 +58,7 @@ class ActivitypubFieldMixin:
|
|||
activitypub_field=None,
|
||||
activitypub_wrapper=None,
|
||||
deduplication_field=False,
|
||||
**kwargs
|
||||
**kwargs,
|
||||
):
|
||||
self.deduplication_field = deduplication_field
|
||||
if activitypub_wrapper:
|
||||
|
@ -68,8 +68,8 @@ class ActivitypubFieldMixin:
|
|||
self.activitypub_field = activitypub_field
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def set_field_from_activity(self, instance, data):
|
||||
"""helper function for assinging a value to the field"""
|
||||
def set_field_from_activity(self, instance, data, overwrite=True):
|
||||
"""helper function for assinging a value to the field. Returns if changed"""
|
||||
try:
|
||||
value = getattr(data, self.get_activitypub_field())
|
||||
except AttributeError:
|
||||
|
@ -79,8 +79,21 @@ class ActivitypubFieldMixin:
|
|||
value = getattr(data, "actor")
|
||||
formatted = self.field_from_activity(value)
|
||||
if formatted is None or formatted is MISSING or formatted == {}:
|
||||
return
|
||||
return False
|
||||
|
||||
current_value = (
|
||||
getattr(instance, self.name) if hasattr(instance, self.name) else None
|
||||
)
|
||||
# if we're not in overwrite mode, only continue updating the field if its unset
|
||||
if current_value and not overwrite:
|
||||
return False
|
||||
|
||||
# the field is unchanged
|
||||
if current_value == formatted:
|
||||
return False
|
||||
|
||||
setattr(instance, self.name, formatted)
|
||||
return True
|
||||
|
||||
def set_activity_from_field(self, activity, instance):
|
||||
"""update the json object"""
|
||||
|
@ -206,17 +219,34 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
|
|||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def set_field_from_activity(self, instance, data):
|
||||
def set_field_from_activity(self, instance, data, overwrite=True):
|
||||
if not overwrite:
|
||||
return False
|
||||
|
||||
original = getattr(instance, self.name)
|
||||
to = data.to
|
||||
cc = data.cc
|
||||
|
||||
# we need to figure out who this is to get their followers link
|
||||
for field in ["attributedTo", "owner", "actor"]:
|
||||
if hasattr(data, field):
|
||||
user_field = field
|
||||
break
|
||||
if not user_field:
|
||||
raise ValidationError("No user field found for privacy", data)
|
||||
user = activitypub.resolve_remote_id(getattr(data, user_field), model="User")
|
||||
|
||||
if to == [self.public]:
|
||||
setattr(instance, self.name, "public")
|
||||
elif to == [user.followers_url]:
|
||||
setattr(instance, self.name, "followers")
|
||||
elif cc == []:
|
||||
setattr(instance, self.name, "direct")
|
||||
elif self.public in cc:
|
||||
setattr(instance, self.name, "unlisted")
|
||||
else:
|
||||
setattr(instance, self.name, "followers")
|
||||
return original == getattr(instance, self.name)
|
||||
|
||||
def set_activity_from_field(self, activity, instance):
|
||||
# explicitly to anyone mentioned (statuses only)
|
||||
|
@ -225,9 +255,7 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
|
|||
mentions = [u.remote_id for u in instance.mention_users.all()]
|
||||
# this is a link to the followers list
|
||||
# pylint: disable=protected-access
|
||||
followers = instance.user.__class__._meta.get_field(
|
||||
"followers"
|
||||
).field_to_activity(instance.user.followers)
|
||||
followers = instance.user.followers_url
|
||||
if instance.privacy == "public":
|
||||
activity["to"] = [self.public]
|
||||
activity["cc"] = [followers] + mentions
|
||||
|
@ -267,18 +295,22 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
|
|||
self.link_only = link_only
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def set_field_from_activity(self, instance, data):
|
||||
def set_field_from_activity(self, instance, data, overwrite=True):
|
||||
"""helper function for assinging a value to the field"""
|
||||
if not overwrite and getattr(instance, self.name).exists():
|
||||
return False
|
||||
|
||||
value = getattr(data, self.get_activitypub_field())
|
||||
formatted = self.field_from_activity(value)
|
||||
if formatted is None or formatted is MISSING:
|
||||
return
|
||||
return False
|
||||
getattr(instance, self.name).set(formatted)
|
||||
instance.save(broadcast=False)
|
||||
return True
|
||||
|
||||
def field_to_activity(self, value):
|
||||
if self.link_only:
|
||||
return "%s/%s" % (value.instance.remote_id, self.name)
|
||||
return f"{value.instance.remote_id}/{self.name}"
|
||||
return [i.remote_id for i in value.all()]
|
||||
|
||||
def field_from_activity(self, value):
|
||||
|
@ -368,13 +400,18 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
|
|||
super().__init__(*args, **kwargs)
|
||||
|
||||
# pylint: disable=arguments-differ
|
||||
def set_field_from_activity(self, instance, data, save=True):
|
||||
def set_field_from_activity(self, instance, data, save=True, overwrite=True):
|
||||
"""helper function for assinging a value to the field"""
|
||||
value = getattr(data, self.get_activitypub_field())
|
||||
formatted = self.field_from_activity(value)
|
||||
if formatted is None or formatted is MISSING:
|
||||
return
|
||||
return False
|
||||
|
||||
if not overwrite and hasattr(instance, self.name):
|
||||
return False
|
||||
|
||||
getattr(instance, self.name).save(*formatted, save=save)
|
||||
return True
|
||||
|
||||
def set_activity_from_field(self, activity, instance):
|
||||
value = getattr(instance, self.name)
|
||||
|
@ -412,7 +449,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
|
|||
|
||||
image_content = ContentFile(response.content)
|
||||
extension = imghdr.what(None, image_content.read()) or ""
|
||||
image_name = "{:s}.{:s}".format(str(uuid4()), extension)
|
||||
image_name = f"{uuid4()}.{extension}"
|
||||
return [image_name, image_content]
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
|
|
182
bookwyrm/models/group.py
Normal file
182
bookwyrm/models/group.py
Normal file
|
@ -0,0 +1,182 @@
|
|||
""" do book related things with other users """
|
||||
from django.apps import apps
|
||||
from django.db import models, IntegrityError, transaction
|
||||
from django.db.models import Q
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from .base_model import BookWyrmModel
|
||||
from . import fields
|
||||
from .relationship import UserBlocks
|
||||
|
||||
|
||||
class Group(BookWyrmModel):
|
||||
"""A group of users"""
|
||||
|
||||
name = fields.CharField(max_length=100)
|
||||
user = fields.ForeignKey("User", on_delete=models.CASCADE)
|
||||
description = fields.TextField(blank=True, null=True)
|
||||
privacy = fields.PrivacyField()
|
||||
|
||||
def get_remote_id(self):
|
||||
"""don't want the user to be in there in this case"""
|
||||
return f"https://{DOMAIN}/group/{self.id}"
|
||||
|
||||
@classmethod
|
||||
def followers_filter(cls, queryset, viewer):
|
||||
"""Override filter for "followers" privacy level to allow non-following
|
||||
group members to see the existence of groups and group lists"""
|
||||
|
||||
return queryset.exclude(
|
||||
~Q( # user is not a group member
|
||||
Q(user__followers=viewer) | Q(user=viewer) | Q(memberships__user=viewer)
|
||||
),
|
||||
privacy="followers", # and the status of the group is followers only
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def direct_filter(cls, queryset, viewer):
|
||||
"""Override filter for "direct" privacy level to allow group members
|
||||
to see the existence of groups and group lists"""
|
||||
|
||||
return queryset.exclude(~Q(memberships__user=viewer), privacy="direct")
|
||||
|
||||
|
||||
class GroupMember(models.Model):
|
||||
"""Users who are members of a group"""
|
||||
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
updated_date = models.DateTimeField(auto_now=True)
|
||||
group = models.ForeignKey(
|
||||
"Group", on_delete=models.CASCADE, related_name="memberships"
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
"User", on_delete=models.CASCADE, related_name="memberships"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Users can only have one membership per group"""
|
||||
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["group", "user"], name="unique_membership")
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""don't let a user invite someone who blocked them"""
|
||||
# blocking in either direction is a no-go
|
||||
if UserBlocks.objects.filter(
|
||||
Q(
|
||||
user_subject=self.group.user,
|
||||
user_object=self.user,
|
||||
)
|
||||
| Q(
|
||||
user_subject=self.user,
|
||||
user_object=self.group.user,
|
||||
)
|
||||
).exists():
|
||||
raise IntegrityError()
|
||||
# accepts and requests are handled by the GroupInvitation model
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def from_request(cls, join_request):
|
||||
"""converts a join request into a member relationship"""
|
||||
|
||||
# remove the invite
|
||||
join_request.delete()
|
||||
|
||||
# make a group member
|
||||
return cls.objects.create(
|
||||
user=join_request.user,
|
||||
group=join_request.group,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def remove(cls, owner, user):
|
||||
"""remove a user from a group"""
|
||||
|
||||
memberships = cls.objects.filter(group__user=owner, user=user).all()
|
||||
for member in memberships:
|
||||
member.delete()
|
||||
|
||||
|
||||
class GroupMemberInvitation(models.Model):
|
||||
"""adding a user to a group requires manual confirmation"""
|
||||
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
group = models.ForeignKey(
|
||||
"Group", on_delete=models.CASCADE, related_name="user_invitations"
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
"User", on_delete=models.CASCADE, related_name="group_invitations"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Users can only have one outstanding invitation per group"""
|
||||
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["group", "user"], name="unique_invitation")
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""make sure the membership doesn't already exist"""
|
||||
# if there's an invitation for a membership that already exists, accept it
|
||||
# without changing the local database state
|
||||
if GroupMember.objects.filter(user=self.user, group=self.group).exists():
|
||||
self.accept()
|
||||
return
|
||||
|
||||
# blocking in either direction is a no-go
|
||||
if UserBlocks.objects.filter(
|
||||
Q(
|
||||
user_subject=self.group.user,
|
||||
user_object=self.user,
|
||||
)
|
||||
| Q(
|
||||
user_subject=self.user,
|
||||
user_object=self.group.user,
|
||||
)
|
||||
).exists():
|
||||
raise IntegrityError()
|
||||
|
||||
# make an invitation
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# now send the invite
|
||||
model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
notification_type = "INVITE"
|
||||
model.objects.create(
|
||||
user=self.user,
|
||||
related_user=self.group.user,
|
||||
related_group=self.group,
|
||||
notification_type=notification_type,
|
||||
)
|
||||
|
||||
def accept(self):
|
||||
"""turn this request into the real deal"""
|
||||
|
||||
with transaction.atomic():
|
||||
GroupMember.from_request(self)
|
||||
|
||||
model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
# tell the group owner
|
||||
model.objects.create(
|
||||
user=self.group.user,
|
||||
related_user=self.user,
|
||||
related_group=self.group,
|
||||
notification_type="ACCEPT",
|
||||
)
|
||||
|
||||
# let the other members know about it
|
||||
for membership in self.group.memberships.all():
|
||||
member = membership.user
|
||||
if member not in (self.user, self.group.user):
|
||||
model.objects.create(
|
||||
user=member,
|
||||
related_user=self.user,
|
||||
related_group=self.group,
|
||||
notification_type="JOIN",
|
||||
)
|
||||
|
||||
def reject(self):
|
||||
"""generate a Reject for this membership request"""
|
||||
|
||||
self.delete()
|
|
@ -2,7 +2,6 @@
|
|||
import re
|
||||
import dateutil.parser
|
||||
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
@ -50,19 +49,6 @@ class ImportJob(models.Model):
|
|||
)
|
||||
retry = models.BooleanField(default=False)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""save and notify"""
|
||||
super().save(*args, **kwargs)
|
||||
if self.complete:
|
||||
notification_model = apps.get_model(
|
||||
"bookwyrm.Notification", require_ready=True
|
||||
)
|
||||
notification_model.objects.create(
|
||||
user=self.user,
|
||||
notification_type="IMPORT",
|
||||
related_import=self,
|
||||
)
|
||||
|
||||
|
||||
class ImportItem(models.Model):
|
||||
"""a single line of a csv being imported"""
|
||||
|
@ -71,6 +57,13 @@ class ImportItem(models.Model):
|
|||
index = models.IntegerField()
|
||||
data = models.JSONField()
|
||||
book = models.ForeignKey(Book, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
book_guess = models.ForeignKey(
|
||||
Book,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="book_guess",
|
||||
)
|
||||
fail_reason = models.TextField(null=True)
|
||||
|
||||
def resolve(self):
|
||||
|
@ -78,9 +71,13 @@ class ImportItem(models.Model):
|
|||
if self.isbn:
|
||||
self.book = self.get_book_from_isbn()
|
||||
else:
|
||||
# don't fall back on title/author search is isbn is present.
|
||||
# don't fall back on title/author search if isbn is present.
|
||||
# you're too likely to mismatch
|
||||
self.get_book_from_title_author()
|
||||
book, confidence = self.get_book_from_title_author()
|
||||
if confidence > 0.999:
|
||||
self.book = book
|
||||
else:
|
||||
self.book_guess = book
|
||||
|
||||
def get_book_from_isbn(self):
|
||||
"""search by isbn"""
|
||||
|
@ -96,12 +93,15 @@ class ImportItem(models.Model):
|
|||
"""search by title and author"""
|
||||
search_term = construct_search_term(self.title, self.author)
|
||||
search_result = connector_manager.first_search_result(
|
||||
search_term, min_confidence=0.999
|
||||
search_term, min_confidence=0.1
|
||||
)
|
||||
if search_result:
|
||||
# raises ConnectorException
|
||||
return search_result.connector.get_or_create_book(search_result.key)
|
||||
return None
|
||||
return (
|
||||
search_result.connector.get_or_create_book(search_result.key),
|
||||
search_result.confidence,
|
||||
)
|
||||
return None, 0
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
|
@ -174,6 +174,7 @@ class ImportItem(models.Model):
|
|||
if start_date and start_date is not None and not self.date_read:
|
||||
return [ReadThrough(start_date=start_date)]
|
||||
if self.date_read:
|
||||
start_date = start_date if start_date < self.date_read else None
|
||||
return [
|
||||
ReadThrough(
|
||||
start_date=start_date,
|
||||
|
@ -183,7 +184,9 @@ class ImportItem(models.Model):
|
|||
return []
|
||||
|
||||
def __repr__(self):
|
||||
# pylint: disable=consider-using-f-string
|
||||
return "<{!r}Item {!r}>".format(self.data["import_source"], self.data["Title"])
|
||||
|
||||
def __str__(self):
|
||||
# pylint: disable=consider-using-f-string
|
||||
return "{} by {}".format(self.data["Title"], self.data["Author"])
|
||||
|
|
|
@ -1,22 +1,20 @@
|
|||
""" make a list of books!! """
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm.settings import DOMAIN
|
||||
|
||||
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
|
||||
from .base_model import BookWyrmModel
|
||||
from .group import GroupMember
|
||||
from . import fields
|
||||
|
||||
|
||||
CurationType = models.TextChoices(
|
||||
"Curation",
|
||||
[
|
||||
"closed",
|
||||
"open",
|
||||
"curated",
|
||||
],
|
||||
["closed", "open", "curated", "group"],
|
||||
)
|
||||
|
||||
|
||||
|
@ -32,6 +30,13 @@ class List(OrderedCollectionMixin, BookWyrmModel):
|
|||
curation = fields.CharField(
|
||||
max_length=255, default="closed", choices=CurationType.choices
|
||||
)
|
||||
group = models.ForeignKey(
|
||||
"Group",
|
||||
on_delete=models.SET_NULL,
|
||||
default=None,
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
books = models.ManyToManyField(
|
||||
"Edition",
|
||||
symmetrical=False,
|
||||
|
@ -42,7 +47,7 @@ class List(OrderedCollectionMixin, BookWyrmModel):
|
|||
|
||||
def get_remote_id(self):
|
||||
"""don't want the user to be in there in this case"""
|
||||
return "https://%s/list/%d" % (DOMAIN, self.id)
|
||||
return f"https://{DOMAIN}/list/{self.id}"
|
||||
|
||||
@property
|
||||
def collection_queryset(self):
|
||||
|
@ -54,6 +59,52 @@ class List(OrderedCollectionMixin, BookWyrmModel):
|
|||
|
||||
ordering = ("-updated_date",)
|
||||
|
||||
def raise_not_editable(self, viewer):
|
||||
"""the associated user OR the list owner can edit"""
|
||||
if self.user == viewer:
|
||||
return
|
||||
# group members can edit items in group lists
|
||||
is_group_member = GroupMember.objects.filter(
|
||||
group=self.group, user=viewer
|
||||
).exists()
|
||||
if is_group_member:
|
||||
return
|
||||
super().raise_not_editable(viewer)
|
||||
|
||||
@classmethod
|
||||
def followers_filter(cls, queryset, viewer):
|
||||
"""Override filter for "followers" privacy level to allow non-following
|
||||
group members to see the existence of group lists"""
|
||||
|
||||
return queryset.exclude(
|
||||
~Q( # user isn't following or group member
|
||||
Q(user__followers=viewer)
|
||||
| Q(user=viewer)
|
||||
| Q(group__memberships__user=viewer)
|
||||
),
|
||||
privacy="followers", # and the status (of the list) is followers only
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def direct_filter(cls, queryset, viewer):
|
||||
"""Override filter for "direct" privacy level to allow
|
||||
group members to see the existence of group lists"""
|
||||
|
||||
return queryset.exclude(
|
||||
~Q( # user not self and not in the group if this is a group list
|
||||
Q(user=viewer) | Q(group__memberships__user=viewer)
|
||||
),
|
||||
privacy="direct",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def remove_from_group(cls, owner, user):
|
||||
"""remove a list from a group"""
|
||||
|
||||
cls.objects.filter(group__user=owner, user=user).all().update(
|
||||
group=None, curation="closed"
|
||||
)
|
||||
|
||||
|
||||
class ListItem(CollectionItemMixin, BookWyrmModel):
|
||||
"""ok"""
|
||||
|
@ -82,9 +133,9 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
|
|||
self.book_list.save(broadcast=False)
|
||||
|
||||
list_owner = self.book_list.user
|
||||
model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
# create a notification if somoene ELSE added to a local user's list
|
||||
if created and list_owner.local and list_owner != self.user:
|
||||
model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
model.objects.create(
|
||||
user=list_owner,
|
||||
related_user=self.user,
|
||||
|
@ -92,6 +143,28 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
|
|||
notification_type="ADD",
|
||||
)
|
||||
|
||||
if self.book_list.group:
|
||||
for membership in self.book_list.group.memberships.all():
|
||||
if membership.user != self.user:
|
||||
model.objects.create(
|
||||
user=membership.user,
|
||||
related_user=self.user,
|
||||
related_list_item=self,
|
||||
notification_type="ADD",
|
||||
)
|
||||
|
||||
def raise_not_deletable(self, viewer):
|
||||
"""the associated user OR the list owner can delete"""
|
||||
if self.book_list.user == viewer:
|
||||
return
|
||||
# group members can delete items in group lists
|
||||
is_group_member = GroupMember.objects.filter(
|
||||
group=self.book_list.group, user=viewer
|
||||
).exists()
|
||||
if is_group_member:
|
||||
return
|
||||
super().raise_not_deletable(viewer)
|
||||
|
||||
class Meta:
|
||||
"""A book may only be placed into a list once,
|
||||
and each order in the list may be used only once"""
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
""" alert a user to activity """
|
||||
from django.db import models
|
||||
from django.dispatch import receiver
|
||||
from .base_model import BookWyrmModel
|
||||
from . import Boost, Favorite, ImportJob, Report, Status, User
|
||||
|
||||
|
||||
# pylint: disable=line-too-long
|
||||
NotificationType = models.TextChoices(
|
||||
"NotificationType",
|
||||
"FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD REPORT",
|
||||
"FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD REPORT INVITE ACCEPT JOIN LEAVE REMOVE",
|
||||
)
|
||||
|
||||
|
||||
|
@ -17,6 +19,9 @@ class Notification(BookWyrmModel):
|
|||
related_user = models.ForeignKey(
|
||||
"User", on_delete=models.CASCADE, null=True, related_name="related_user"
|
||||
)
|
||||
related_group = models.ForeignKey(
|
||||
"Group", on_delete=models.CASCADE, null=True, related_name="notifications"
|
||||
)
|
||||
related_status = models.ForeignKey("Status", on_delete=models.CASCADE, null=True)
|
||||
related_import = models.ForeignKey("ImportJob", on_delete=models.CASCADE, null=True)
|
||||
related_list_item = models.ForeignKey(
|
||||
|
@ -35,6 +40,7 @@ class Notification(BookWyrmModel):
|
|||
user=self.user,
|
||||
related_book=self.related_book,
|
||||
related_user=self.related_user,
|
||||
related_group=self.related_group,
|
||||
related_status=self.related_status,
|
||||
related_import=self.related_import,
|
||||
related_list_item=self.related_list_item,
|
||||
|
@ -53,3 +59,127 @@ class Notification(BookWyrmModel):
|
|||
name="notification_type_valid",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=Favorite)
|
||||
# pylint: disable=unused-argument
|
||||
def notify_on_fav(sender, instance, *args, **kwargs):
|
||||
"""someone liked your content, you ARE loved"""
|
||||
if not instance.status.user.local or instance.status.user == instance.user:
|
||||
return
|
||||
Notification.objects.create(
|
||||
user=instance.status.user,
|
||||
notification_type="FAVORITE",
|
||||
related_user=instance.user,
|
||||
related_status=instance.status,
|
||||
)
|
||||
|
||||
|
||||
@receiver(models.signals.post_delete, sender=Favorite)
|
||||
# pylint: disable=unused-argument
|
||||
def notify_on_unfav(sender, instance, *args, **kwargs):
|
||||
"""oops, didn't like that after all"""
|
||||
if not instance.status.user.local:
|
||||
return
|
||||
Notification.objects.filter(
|
||||
user=instance.status.user,
|
||||
related_user=instance.user,
|
||||
related_status=instance.status,
|
||||
notification_type="FAVORITE",
|
||||
).delete()
|
||||
|
||||
|
||||
@receiver(models.signals.post_save)
|
||||
# pylint: disable=unused-argument
|
||||
def notify_user_on_mention(sender, instance, *args, **kwargs):
|
||||
"""creating and deleting statuses with @ mentions and replies"""
|
||||
if not issubclass(sender, Status):
|
||||
return
|
||||
|
||||
if instance.deleted:
|
||||
Notification.objects.filter(related_status=instance).delete()
|
||||
return
|
||||
|
||||
if (
|
||||
instance.reply_parent
|
||||
and instance.reply_parent.user != instance.user
|
||||
and instance.reply_parent.user.local
|
||||
):
|
||||
Notification.objects.create(
|
||||
user=instance.reply_parent.user,
|
||||
notification_type="REPLY",
|
||||
related_user=instance.user,
|
||||
related_status=instance,
|
||||
)
|
||||
for mention_user in instance.mention_users.all():
|
||||
# avoid double-notifying about this status
|
||||
if not mention_user.local or (
|
||||
instance.reply_parent and mention_user == instance.reply_parent.user
|
||||
):
|
||||
continue
|
||||
Notification.objects.create(
|
||||
user=mention_user,
|
||||
notification_type="MENTION",
|
||||
related_user=instance.user,
|
||||
related_status=instance,
|
||||
)
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=Boost)
|
||||
# pylint: disable=unused-argument
|
||||
def notify_user_on_boost(sender, instance, *args, **kwargs):
|
||||
"""boosting a status"""
|
||||
if (
|
||||
not instance.boosted_status.user.local
|
||||
or instance.boosted_status.user == instance.user
|
||||
):
|
||||
return
|
||||
|
||||
Notification.objects.create(
|
||||
user=instance.boosted_status.user,
|
||||
related_status=instance.boosted_status,
|
||||
related_user=instance.user,
|
||||
notification_type="BOOST",
|
||||
)
|
||||
|
||||
|
||||
@receiver(models.signals.post_delete, sender=Boost)
|
||||
# pylint: disable=unused-argument
|
||||
def notify_user_on_unboost(sender, instance, *args, **kwargs):
|
||||
"""unboosting a status"""
|
||||
Notification.objects.filter(
|
||||
user=instance.boosted_status.user,
|
||||
related_status=instance.boosted_status,
|
||||
related_user=instance.user,
|
||||
notification_type="BOOST",
|
||||
).delete()
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=ImportJob)
|
||||
# pylint: disable=unused-argument
|
||||
def notify_user_on_import_complete(sender, instance, *args, **kwargs):
|
||||
"""we imported your books! aren't you proud of us"""
|
||||
if not instance.complete:
|
||||
return
|
||||
Notification.objects.create(
|
||||
user=instance.user,
|
||||
notification_type="IMPORT",
|
||||
related_import=instance,
|
||||
)
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=Report)
|
||||
# pylint: disable=unused-argument
|
||||
def notify_admins_on_report(sender, instance, *args, **kwargs):
|
||||
"""something is up, make sure the admins know"""
|
||||
# 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()
|
||||
for admin in admins:
|
||||
Notification.objects.create(
|
||||
user=admin,
|
||||
related_report=instance,
|
||||
notification_type="REPORT",
|
||||
)
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
""" progress in a book """
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.core import validators
|
||||
from django.db import models
|
||||
from django.db.models import F, Q
|
||||
|
||||
from .base_model import BookWyrmModel
|
||||
|
||||
|
@ -26,11 +26,14 @@ class ReadThrough(BookWyrmModel):
|
|||
)
|
||||
start_date = models.DateTimeField(blank=True, null=True)
|
||||
finish_date = models.DateTimeField(blank=True, null=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""update user active time"""
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save(broadcast=False)
|
||||
self.user.update_active_date()
|
||||
# an active readthrough must have an unset finish date
|
||||
if self.finish_date:
|
||||
self.is_active = False
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def create_update(self):
|
||||
|
@ -41,6 +44,16 @@ class ReadThrough(BookWyrmModel):
|
|||
)
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
"""Don't let readthroughs end before they start"""
|
||||
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=Q(finish_date__gte=F("start_date")), name="chronology"
|
||||
)
|
||||
]
|
||||
ordering = ("-start_date",)
|
||||
|
||||
|
||||
class ProgressUpdate(BookWyrmModel):
|
||||
"""Store progress through a book in the database."""
|
||||
|
@ -54,6 +67,5 @@ class ProgressUpdate(BookWyrmModel):
|
|||
|
||||
def save(self, *args, **kwargs):
|
||||
"""update user active time"""
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save(broadcast=False)
|
||||
self.user.update_active_date()
|
||||
super().save(*args, **kwargs)
|
||||
|
|
|
@ -53,7 +53,7 @@ class UserRelationship(BookWyrmModel):
|
|||
def get_remote_id(self):
|
||||
"""use shelf identifier in remote_id"""
|
||||
base_path = self.user_subject.remote_id
|
||||
return "%s#follows/%d" % (base_path, self.id)
|
||||
return f"{base_path}#follows/{self.id}"
|
||||
|
||||
|
||||
class UserFollows(ActivityMixin, UserRelationship):
|
||||
|
@ -144,7 +144,8 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
|||
"""get id for sending an accept or reject of a local user"""
|
||||
|
||||
base_path = self.user_object.remote_id
|
||||
return "%s#%s/%d" % (base_path, status, self.id or 0)
|
||||
status_id = self.id or 0
|
||||
return f"{base_path}#{status}/{status_id}"
|
||||
|
||||
def accept(self, broadcast_only=False):
|
||||
"""turn this request into the real deal"""
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
""" flagged for moderation """
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.db.models import F, Q
|
||||
from .base_model import BookWyrmModel
|
||||
|
@ -16,23 +15,6 @@ class Report(BookWyrmModel):
|
|||
statuses = models.ManyToManyField("Status", blank=True)
|
||||
resolved = models.BooleanField(default=False)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""notify admins when a report is created"""
|
||||
super().save(*args, **kwargs)
|
||||
user_model = apps.get_model("bookwyrm.User", require_ready=True)
|
||||
# moderators and superusers should be notified
|
||||
admins = user_model.objects.filter(
|
||||
Q(user_permissions__name__in=["moderate_user", "moderate_post"])
|
||||
| Q(is_superuser=True)
|
||||
).all()
|
||||
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
for admin in admins:
|
||||
notification_model.objects.create(
|
||||
user=admin,
|
||||
related_report=self,
|
||||
notification_type="REPORT",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""don't let users report themselves"""
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
""" puttin' books on shelves """
|
||||
import re
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
@ -20,6 +21,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
|||
|
||||
name = fields.CharField(max_length=100)
|
||||
identifier = models.CharField(max_length=100)
|
||||
description = models.TextField(blank=True, null=True, max_length=500)
|
||||
user = fields.ForeignKey(
|
||||
"User", on_delete=models.PROTECT, activitypub_field="owner"
|
||||
)
|
||||
|
@ -44,18 +46,29 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
|||
def get_identifier(self):
|
||||
"""custom-shelf-123 for the url"""
|
||||
slug = re.sub(r"[^\w]", "", self.name).lower()
|
||||
return "{:s}-{:d}".format(slug, self.id)
|
||||
return f"{slug}-{self.id}"
|
||||
|
||||
@property
|
||||
def collection_queryset(self):
|
||||
"""list of books for this shelf, overrides OrderedCollectionMixin"""
|
||||
return self.books.order_by("shelfbook")
|
||||
|
||||
@property
|
||||
def deletable(self):
|
||||
"""can the shelf be safely deleted?"""
|
||||
return self.editable and not self.shelfbook_set.exists()
|
||||
|
||||
def get_remote_id(self):
|
||||
"""shelf identifier instead of id"""
|
||||
base_path = self.user.remote_id
|
||||
identifier = self.identifier or self.get_identifier()
|
||||
return "%s/books/%s" % (base_path, identifier)
|
||||
return f"{base_path}/books/{identifier}"
|
||||
|
||||
def raise_not_deletable(self, viewer):
|
||||
"""don't let anyone delete a default shelf"""
|
||||
super().raise_not_deletable(viewer)
|
||||
if not self.deletable:
|
||||
raise PermissionDenied()
|
||||
|
||||
class Meta:
|
||||
"""user/shelf unqiueness"""
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
""" the particulars for this instance of BookWyrm """
|
||||
import base64
|
||||
import datetime
|
||||
|
||||
from Crypto import Random
|
||||
from django.db import models, IntegrityError
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
|
@ -10,7 +8,7 @@ from model_utils import FieldTracker
|
|||
|
||||
from bookwyrm.preview_images import generate_site_preview_image_task
|
||||
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES
|
||||
from .base_model import BookWyrmModel
|
||||
from .base_model import BookWyrmModel, new_access_code
|
||||
from .user import User
|
||||
|
||||
|
||||
|
@ -22,10 +20,17 @@ class SiteSettings(models.Model):
|
|||
max_length=150, default="Social Reading and Reviewing"
|
||||
)
|
||||
instance_description = models.TextField(default="This instance has no description.")
|
||||
instance_short_description = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
# about page
|
||||
registration_closed_text = models.TextField(
|
||||
default="Contact an administrator to get an invite"
|
||||
default="We aren't taking new users at this time. You can find an open "
|
||||
'instance at <a href="https://joinbookwyrm.com/instances">'
|
||||
"joinbookwyrm.com/instances</a>."
|
||||
)
|
||||
invite_request_text = models.TextField(
|
||||
default="If your request is approved, you will receive an email with a "
|
||||
"registration link."
|
||||
)
|
||||
code_of_conduct = models.TextField(default="Add a code of conduct here.")
|
||||
privacy_policy = models.TextField(default="Add a privacy policy here.")
|
||||
|
@ -33,6 +38,7 @@ class SiteSettings(models.Model):
|
|||
# registration
|
||||
allow_registration = models.BooleanField(default=True)
|
||||
allow_invite_requests = models.BooleanField(default=True)
|
||||
require_confirm_email = models.BooleanField(default=True)
|
||||
|
||||
# images
|
||||
logo = models.ImageField(upload_to="logos/", null=True, blank=True)
|
||||
|
@ -61,11 +67,6 @@ class SiteSettings(models.Model):
|
|||
return default_settings
|
||||
|
||||
|
||||
def new_access_code():
|
||||
"""the identifier for a user invite"""
|
||||
return base64.b32encode(Random.get_random_bytes(5)).decode("ascii")
|
||||
|
||||
|
||||
class SiteInvite(models.Model):
|
||||
"""gives someone access to create an account on the instance"""
|
||||
|
||||
|
@ -86,7 +87,7 @@ class SiteInvite(models.Model):
|
|||
@property
|
||||
def link(self):
|
||||
"""formats the invite link"""
|
||||
return "https://{}/invite/{}".format(DOMAIN, self.code)
|
||||
return f"https://{DOMAIN}/invite/{self.code}"
|
||||
|
||||
|
||||
class InviteRequest(BookWyrmModel):
|
||||
|
@ -126,7 +127,7 @@ class PasswordReset(models.Model):
|
|||
@property
|
||||
def link(self):
|
||||
"""formats the invite link"""
|
||||
return "https://{}/password-reset/{}".format(DOMAIN, self.code)
|
||||
return f"https://{DOMAIN}/password-reset/{self.code}"
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
|
|
@ -3,8 +3,10 @@ from dataclasses import MISSING
|
|||
import re
|
||||
|
||||
from django.apps import apps
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.dispatch import receiver
|
||||
from django.template.loader import get_template
|
||||
from django.utils import timezone
|
||||
|
@ -29,6 +31,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
"User", on_delete=models.PROTECT, activitypub_field="attributedTo"
|
||||
)
|
||||
content = fields.HtmlField(blank=True, null=True)
|
||||
raw_content = models.TextField(blank=True, null=True)
|
||||
mention_users = fields.TagField("User", related_name="mention_user")
|
||||
mention_books = fields.TagField("Edition", related_name="mention_book")
|
||||
local = models.BooleanField(default=True)
|
||||
|
@ -41,6 +44,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
published_date = fields.DateTimeField(
|
||||
default=timezone.now, activitypub_field="published"
|
||||
)
|
||||
edited_date = fields.DateTimeField(
|
||||
blank=True, null=True, activitypub_field="updated"
|
||||
)
|
||||
deleted = models.BooleanField(default=False)
|
||||
deleted_date = models.DateTimeField(blank=True, null=True)
|
||||
favorites = models.ManyToManyField(
|
||||
|
@ -56,6 +62,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
on_delete=models.PROTECT,
|
||||
activitypub_field="inReplyTo",
|
||||
)
|
||||
thread_id = models.IntegerField(blank=True, null=True)
|
||||
objects = InheritanceManager()
|
||||
|
||||
activity_serializer = activitypub.Note
|
||||
|
@ -69,37 +76,14 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
|
||||
def save(self, *args, **kwargs):
|
||||
"""save and notify"""
|
||||
if self.reply_parent:
|
||||
self.thread_id = self.reply_parent.thread_id or self.reply_parent.id
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
|
||||
if self.deleted:
|
||||
notification_model.objects.filter(related_status=self).delete()
|
||||
return
|
||||
|
||||
if (
|
||||
self.reply_parent
|
||||
and self.reply_parent.user != self.user
|
||||
and self.reply_parent.user.local
|
||||
):
|
||||
notification_model.objects.create(
|
||||
user=self.reply_parent.user,
|
||||
notification_type="REPLY",
|
||||
related_user=self.user,
|
||||
related_status=self,
|
||||
)
|
||||
for mention_user in self.mention_users.all():
|
||||
# avoid double-notifying about this status
|
||||
if not mention_user.local or (
|
||||
self.reply_parent and mention_user == self.reply_parent.user
|
||||
):
|
||||
continue
|
||||
notification_model.objects.create(
|
||||
user=mention_user,
|
||||
notification_type="MENTION",
|
||||
related_user=self.user,
|
||||
related_status=self,
|
||||
)
|
||||
if not self.reply_parent:
|
||||
self.thread_id = self.id
|
||||
super().save(broadcast=False, update_fields=["thread_id"])
|
||||
|
||||
def delete(self, *args, **kwargs): # pylint: disable=unused-argument
|
||||
""" "delete" a status"""
|
||||
|
@ -108,6 +92,10 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
super().delete(*args, **kwargs)
|
||||
return
|
||||
self.deleted = True
|
||||
# clear user content
|
||||
self.content = None
|
||||
if hasattr(self, "quotation"):
|
||||
self.quotation = None # pylint: disable=attribute-defined-outside-init
|
||||
self.deleted_date = timezone.now()
|
||||
self.save()
|
||||
|
||||
|
@ -179,9 +167,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
"""helper function for loading AP serialized replies to a status"""
|
||||
return self.to_ordered_collection(
|
||||
self.replies(self),
|
||||
remote_id="%s/replies" % self.remote_id,
|
||||
remote_id=f"{self.remote_id}/replies",
|
||||
collection_only=True,
|
||||
**kwargs
|
||||
**kwargs,
|
||||
).serialize()
|
||||
|
||||
def to_activity_dataclass(self, pure=False): # pylint: disable=arguments-differ
|
||||
|
@ -217,6 +205,35 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
"""json serialized activitypub class"""
|
||||
return self.to_activity_dataclass(pure=pure).serialize()
|
||||
|
||||
def raise_not_editable(self, viewer):
|
||||
"""certain types of status aren't editable"""
|
||||
# first, the standard raise
|
||||
super().raise_not_editable(viewer)
|
||||
if isinstance(self, (GeneratedNote, ReviewRating)):
|
||||
raise PermissionDenied()
|
||||
|
||||
@classmethod
|
||||
def privacy_filter(cls, viewer, privacy_levels=None):
|
||||
queryset = super().privacy_filter(viewer, privacy_levels=privacy_levels)
|
||||
return queryset.filter(deleted=False)
|
||||
|
||||
@classmethod
|
||||
def direct_filter(cls, queryset, viewer):
|
||||
"""Overridden filter for "direct" privacy level"""
|
||||
return queryset.exclude(
|
||||
~Q(Q(user=viewer) | Q(mention_users=viewer)), privacy="direct"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def followers_filter(cls, queryset, viewer):
|
||||
"""Override-able filter for "followers" privacy level"""
|
||||
return queryset.exclude(
|
||||
~Q( # not yourself, a follower, or someone who is tagged
|
||||
Q(user__followers=viewer) | Q(user=viewer) | Q(mention_users=viewer)
|
||||
),
|
||||
privacy="followers", # and the status is followers only
|
||||
)
|
||||
|
||||
|
||||
class GeneratedNote(Status):
|
||||
"""these are app-generated messages about user activity"""
|
||||
|
@ -226,21 +243,40 @@ class GeneratedNote(Status):
|
|||
"""indicate the book in question for mastodon (or w/e) users"""
|
||||
message = self.content
|
||||
books = ", ".join(
|
||||
'<a href="%s">"%s"</a>' % (book.remote_id, book.title)
|
||||
f'<a href="{book.remote_id}">"{book.title}"</a>'
|
||||
for book in self.mention_books.all()
|
||||
)
|
||||
return "%s %s %s" % (self.user.display_name, message, books)
|
||||
return f"{self.user.display_name} {message} {books}"
|
||||
|
||||
activity_serializer = activitypub.GeneratedNote
|
||||
pure_type = "Note"
|
||||
|
||||
|
||||
class Comment(Status):
|
||||
"""like a review but without a rating and transient"""
|
||||
ReadingStatusChoices = models.TextChoices(
|
||||
"ReadingStatusChoices", ["to-read", "reading", "read"]
|
||||
)
|
||||
|
||||
|
||||
class BookStatus(Status):
|
||||
"""Shared fields for comments, quotes, reviews"""
|
||||
|
||||
book = fields.ForeignKey(
|
||||
"Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook"
|
||||
)
|
||||
pure_type = "Note"
|
||||
|
||||
reading_status = fields.CharField(
|
||||
max_length=255, choices=ReadingStatusChoices.choices, null=True, blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""not a real model, sorry"""
|
||||
|
||||
abstract = True
|
||||
|
||||
|
||||
class Comment(BookStatus):
|
||||
"""like a review but without a rating and transient"""
|
||||
|
||||
# this is it's own field instead of a foreign key to the progress update
|
||||
# so that the update can be deleted without impacting the status
|
||||
|
@ -258,22 +294,28 @@ class Comment(Status):
|
|||
@property
|
||||
def pure_content(self):
|
||||
"""indicate the book in question for mastodon (or w/e) users"""
|
||||
return '%s<p>(comment on <a href="%s">"%s"</a>)</p>' % (
|
||||
self.content,
|
||||
self.book.remote_id,
|
||||
self.book.title,
|
||||
return (
|
||||
f'{self.content}<p>(comment on <a href="{self.book.remote_id}">'
|
||||
f'"{self.book.title}"</a>)</p>'
|
||||
)
|
||||
|
||||
activity_serializer = activitypub.Comment
|
||||
pure_type = "Note"
|
||||
|
||||
|
||||
class Quotation(Status):
|
||||
class Quotation(BookStatus):
|
||||
"""like a review but without a rating and transient"""
|
||||
|
||||
quote = fields.HtmlField()
|
||||
book = fields.ForeignKey(
|
||||
"Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook"
|
||||
raw_quote = models.TextField(blank=True, null=True)
|
||||
position = models.IntegerField(
|
||||
validators=[MinValueValidator(0)], null=True, blank=True
|
||||
)
|
||||
position_mode = models.CharField(
|
||||
max_length=3,
|
||||
choices=ProgressMode.choices,
|
||||
default=ProgressMode.PAGE,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
@property
|
||||
|
@ -281,24 +323,18 @@ class Quotation(Status):
|
|||
"""indicate the book in question for mastodon (or w/e) users"""
|
||||
quote = re.sub(r"^<p>", '<p>"', self.quote)
|
||||
quote = re.sub(r"</p>$", '"</p>', quote)
|
||||
return '%s <p>-- <a href="%s">"%s"</a></p>%s' % (
|
||||
quote,
|
||||
self.book.remote_id,
|
||||
self.book.title,
|
||||
self.content,
|
||||
return (
|
||||
f'{quote} <p>-- <a href="{self.book.remote_id}">'
|
||||
f'"{self.book.title}"</a></p>{self.content}'
|
||||
)
|
||||
|
||||
activity_serializer = activitypub.Quotation
|
||||
pure_type = "Note"
|
||||
|
||||
|
||||
class Review(Status):
|
||||
class Review(BookStatus):
|
||||
"""a book review"""
|
||||
|
||||
name = fields.CharField(max_length=255, null=True)
|
||||
book = fields.ForeignKey(
|
||||
"Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook"
|
||||
)
|
||||
rating = fields.DecimalField(
|
||||
default=None,
|
||||
null=True,
|
||||
|
@ -368,27 +404,6 @@ class Boost(ActivityMixin, Status):
|
|||
return
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
if not self.boosted_status.user.local or self.boosted_status.user == self.user:
|
||||
return
|
||||
|
||||
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
notification_model.objects.create(
|
||||
user=self.boosted_status.user,
|
||||
related_status=self.boosted_status,
|
||||
related_user=self.user,
|
||||
notification_type="BOOST",
|
||||
)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""delete and un-notify"""
|
||||
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
notification_model.objects.filter(
|
||||
user=self.boosted_status.user,
|
||||
related_status=self.boosted_status,
|
||||
related_user=self.user,
|
||||
notification_type="BOOST",
|
||||
).delete()
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""the user field is "actor" here instead of "attributedTo" """
|
||||
|
@ -401,10 +416,6 @@ class Boost(ActivityMixin, Status):
|
|||
self.image_fields = []
|
||||
self.deserialize_reverse_fields = []
|
||||
|
||||
# This constraint can't work as it would cross tables.
|
||||
# class Meta:
|
||||
# unique_together = ('user', 'boosted_status')
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@receiver(models.signals.post_save)
|
||||
|
|
|
@ -7,7 +7,7 @@ from django.contrib.auth.models import AbstractUser, Group
|
|||
from django.contrib.postgres.fields import CICharField
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.dispatch import receiver
|
||||
from django.db import models
|
||||
from django.db import models, transaction
|
||||
from django.utils import timezone
|
||||
from model_utils import FieldTracker
|
||||
import pytz
|
||||
|
@ -17,16 +17,22 @@ from bookwyrm.connectors import get_data, ConnectorException
|
|||
from bookwyrm.models.shelf import Shelf
|
||||
from bookwyrm.models.status import Status, Review
|
||||
from bookwyrm.preview_images import generate_user_preview_image_task
|
||||
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES
|
||||
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.utils import regex
|
||||
from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin
|
||||
from .base_model import BookWyrmModel, DeactivationReason
|
||||
from .base_model import BookWyrmModel, DeactivationReason, new_access_code
|
||||
from .federated_server import FederatedServer
|
||||
from . import fields, Review
|
||||
|
||||
|
||||
def site_link():
|
||||
"""helper for generating links to the site"""
|
||||
protocol = "https" if USE_HTTPS else "http"
|
||||
return f"{protocol}://{DOMAIN}"
|
||||
|
||||
|
||||
class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
"""a user who wants to read books"""
|
||||
|
||||
|
@ -76,9 +82,9 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
preview_image = models.ImageField(
|
||||
upload_to="previews/avatars/", blank=True, null=True
|
||||
)
|
||||
followers = fields.ManyToManyField(
|
||||
followers_url = fields.CharField(max_length=255, activitypub_field="followers")
|
||||
followers = models.ManyToManyField(
|
||||
"self",
|
||||
link_only=True,
|
||||
symmetrical=False,
|
||||
through="UserFollows",
|
||||
through_fields=("user_object", "user_subject"),
|
||||
|
@ -98,6 +104,9 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
through_fields=("user_subject", "user_object"),
|
||||
related_name="blocked_by",
|
||||
)
|
||||
saved_lists = models.ManyToManyField(
|
||||
"List", symmetrical=False, related_name="saved_lists", blank=True
|
||||
)
|
||||
favorites = models.ManyToManyField(
|
||||
"Status",
|
||||
symmetrical=False,
|
||||
|
@ -105,35 +114,57 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
through_fields=("user", "status"),
|
||||
related_name="favorite_statuses",
|
||||
)
|
||||
default_post_privacy = models.CharField(
|
||||
max_length=255, default="public", choices=fields.PrivacyLevels.choices
|
||||
)
|
||||
remote_id = fields.RemoteIdField(null=True, unique=True, activitypub_field="id")
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
updated_date = models.DateTimeField(auto_now=True)
|
||||
last_active_date = models.DateTimeField(auto_now=True)
|
||||
last_active_date = models.DateTimeField(default=timezone.now)
|
||||
manually_approves_followers = fields.BooleanField(default=False)
|
||||
|
||||
# options to turn features on and off
|
||||
show_goal = models.BooleanField(default=True)
|
||||
show_suggested_users = models.BooleanField(default=True)
|
||||
discoverable = fields.BooleanField(default=False)
|
||||
|
||||
preferred_timezone = models.CharField(
|
||||
choices=[(str(tz), str(tz)) for tz in pytz.all_timezones],
|
||||
default=str(pytz.utc),
|
||||
max_length=255,
|
||||
)
|
||||
deactivation_reason = models.CharField(
|
||||
max_length=255, choices=DeactivationReason.choices, null=True, blank=True
|
||||
preferred_language = models.CharField(
|
||||
choices=LANGUAGES,
|
||||
null=True,
|
||||
blank=True,
|
||||
max_length=255,
|
||||
)
|
||||
deactivation_reason = models.CharField(
|
||||
max_length=255, choices=DeactivationReason, null=True, blank=True
|
||||
)
|
||||
deactivation_date = models.DateTimeField(null=True, blank=True)
|
||||
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"])
|
||||
|
||||
@property
|
||||
def confirmation_link(self):
|
||||
"""helper for generating confirmation links"""
|
||||
link = site_link()
|
||||
return f"{link}/confirm-email/{self.confirmation_code}"
|
||||
|
||||
@property
|
||||
def following_link(self):
|
||||
"""just how to find out the following info"""
|
||||
return "{:s}/following".format(self.remote_id)
|
||||
return f"{self.remote_id}/following"
|
||||
|
||||
@property
|
||||
def alt_text(self):
|
||||
"""alt text with username"""
|
||||
return "avatar for %s" % (self.localname or self.username)
|
||||
# pylint: disable=consider-using-f-string
|
||||
return "avatar for {:s}".format(self.localname or self.username)
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
|
@ -170,12 +201,15 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
queryset = queryset.exclude(blocks=viewer)
|
||||
return queryset
|
||||
|
||||
def update_active_date(self):
|
||||
"""this user is here! they are doing things!"""
|
||||
self.last_active_date = timezone.now()
|
||||
self.save(broadcast=False, update_fields=["last_active_date"])
|
||||
|
||||
def to_outbox(self, filter_type=None, **kwargs):
|
||||
"""an ordered collection of statuses"""
|
||||
if filter_type:
|
||||
filter_class = apps.get_model(
|
||||
"bookwyrm.%s" % filter_type, require_ready=True
|
||||
)
|
||||
filter_class = apps.get_model(f"bookwyrm.{filter_type}", require_ready=True)
|
||||
if not issubclass(filter_class, Status):
|
||||
raise TypeError(
|
||||
"filter_status_class must be a subclass of models.Status"
|
||||
|
@ -199,22 +233,22 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
|
||||
def to_following_activity(self, **kwargs):
|
||||
"""activitypub following list"""
|
||||
remote_id = "%s/following" % self.remote_id
|
||||
remote_id = f"{self.remote_id}/following"
|
||||
return self.to_ordered_collection(
|
||||
self.following.order_by("-updated_date").all(),
|
||||
remote_id=remote_id,
|
||||
id_only=True,
|
||||
**kwargs
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def to_followers_activity(self, **kwargs):
|
||||
"""activitypub followers list"""
|
||||
remote_id = "%s/followers" % self.remote_id
|
||||
remote_id = self.followers_url
|
||||
return self.to_ordered_collection(
|
||||
self.followers.order_by("-updated_date").all(),
|
||||
remote_id=remote_id,
|
||||
id_only=True,
|
||||
**kwargs
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def to_activity(self, **kwargs):
|
||||
|
@ -242,25 +276,32 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
if not self.local and not re.match(regex.FULL_USERNAME, self.username):
|
||||
# generate a username that uses the domain (webfinger format)
|
||||
actor_parts = urlparse(self.remote_id)
|
||||
self.username = "%s@%s" % (self.username, actor_parts.netloc)
|
||||
super().save(*args, **kwargs)
|
||||
self.username = f"{self.username}@{actor_parts.netloc}"
|
||||
|
||||
# this user already exists, no need to populate fields
|
||||
if not created:
|
||||
if self.is_active:
|
||||
self.deactivation_date = None
|
||||
elif not self.deactivation_date:
|
||||
self.deactivation_date = timezone.now()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
return
|
||||
|
||||
# this is a new remote user, we need to set their remote server field
|
||||
if not self.local:
|
||||
super().save(*args, **kwargs)
|
||||
set_remote_server.delay(self.id)
|
||||
transaction.on_commit(lambda: set_remote_server.delay(self.id))
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
# populate fields for local users
|
||||
self.remote_id = "https://%s/user/%s" % (DOMAIN, self.localname)
|
||||
self.inbox = "%s/inbox" % self.remote_id
|
||||
self.shared_inbox = "https://%s/inbox" % DOMAIN
|
||||
self.outbox = "%s/outbox" % self.remote_id
|
||||
link = site_link()
|
||||
self.remote_id = f"{link}/user/{self.localname}"
|
||||
self.followers_url = f"{self.remote_id}/followers"
|
||||
self.inbox = f"{self.remote_id}/inbox"
|
||||
self.shared_inbox = f"{link}/inbox"
|
||||
self.outbox = f"{self.remote_id}/outbox"
|
||||
|
||||
# an id needs to be set before we can proceed with related models
|
||||
super().save(*args, **kwargs)
|
||||
|
@ -274,10 +315,26 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
|
||||
# create keys and shelves for new local users
|
||||
self.key_pair = KeyPair.objects.create(
|
||||
remote_id="%s/#main-key" % self.remote_id
|
||||
remote_id=f"{self.remote_id}/#main-key"
|
||||
)
|
||||
self.save(broadcast=False)
|
||||
self.save(broadcast=False, update_fields=["key_pair"])
|
||||
|
||||
self.create_shelves()
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""deactivate rather than delete a user"""
|
||||
self.is_active = False
|
||||
# skip the logic in this class's save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def local_path(self):
|
||||
"""this model doesn't inherit bookwyrm model, so here we are"""
|
||||
# pylint: disable=consider-using-f-string
|
||||
return "/user/{:s}".format(self.localname or self.username)
|
||||
|
||||
def create_shelves(self):
|
||||
"""default shelves for a new user"""
|
||||
shelves = [
|
||||
{
|
||||
"name": "To Read",
|
||||
|
@ -301,17 +358,6 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
editable=False,
|
||||
).save(broadcast=False)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""deactivate rather than delete a user"""
|
||||
self.is_active = False
|
||||
# skip the logic in this class's save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def local_path(self):
|
||||
"""this model doesn't inherit bookwyrm model, so here we are"""
|
||||
return "/user/%s" % (self.localname or self.username)
|
||||
|
||||
|
||||
class KeyPair(ActivitypubMixin, BookWyrmModel):
|
||||
"""public and private keys for a user"""
|
||||
|
@ -326,7 +372,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
|
|||
|
||||
def get_remote_id(self):
|
||||
# self.owner is set by the OneToOneField on User
|
||||
return "%s/#main-key" % self.owner.remote_id
|
||||
return f"{self.owner.remote_id}/#main-key"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""create a key pair"""
|
||||
|
@ -363,7 +409,7 @@ class AnnualGoal(BookWyrmModel):
|
|||
|
||||
def get_remote_id(self):
|
||||
"""put the year in the path"""
|
||||
return "{:s}/goal/{:d}".format(self.user.remote_id, self.year)
|
||||
return f"{self.user.remote_id}/goal/{self.year}"
|
||||
|
||||
@property
|
||||
def books(self):
|
||||
|
@ -400,13 +446,13 @@ class AnnualGoal(BookWyrmModel):
|
|||
}
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(queue="low_priority")
|
||||
def set_remote_server(user_id):
|
||||
"""figure out the user's remote server in the background"""
|
||||
user = User.objects.get(id=user_id)
|
||||
actor_parts = urlparse(user.remote_id)
|
||||
user.federated_server = get_or_create_remote_server(actor_parts.netloc)
|
||||
user.save(broadcast=False)
|
||||
user.save(broadcast=False, update_fields=["federated_server"])
|
||||
if user.bookwyrm_user and user.outbox:
|
||||
get_remote_reviews.delay(user.outbox)
|
||||
|
||||
|
@ -419,7 +465,7 @@ def get_or_create_remote_server(domain):
|
|||
pass
|
||||
|
||||
try:
|
||||
data = get_data("https://%s/.well-known/nodeinfo" % domain)
|
||||
data = get_data(f"https://{domain}/.well-known/nodeinfo")
|
||||
try:
|
||||
nodeinfo_url = data.get("links")[0].get("href")
|
||||
except (TypeError, KeyError):
|
||||
|
@ -439,7 +485,7 @@ def get_or_create_remote_server(domain):
|
|||
return server
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(queue="low_priority")
|
||||
def get_remote_reviews(outbox):
|
||||
"""ingest reviews by a new remote bookwyrm user"""
|
||||
outbox_page = outbox + "?page=true&type=Review"
|
||||
|
|
|
@ -220,6 +220,7 @@ def generate_default_inner_img():
|
|||
|
||||
|
||||
# pylint: disable=too-many-locals
|
||||
# pylint: disable=too-many-statements
|
||||
def generate_preview_image(
|
||||
texts=None, picture=None, rating=None, show_instance_layer=True
|
||||
):
|
||||
|
@ -237,7 +238,8 @@ def generate_preview_image(
|
|||
|
||||
# Color
|
||||
if BG_COLOR in ["use_dominant_color_light", "use_dominant_color_dark"]:
|
||||
image_bg_color = "rgb(%s, %s, %s)" % dominant_color
|
||||
red, green, blue = dominant_color
|
||||
image_bg_color = f"rgb({red}, {green}, {blue})"
|
||||
|
||||
# Adjust color
|
||||
image_bg_color_rgb = [x / 255.0 for x in ImageColor.getrgb(image_bg_color)]
|
||||
|
@ -315,7 +317,8 @@ def save_and_cleanup(image, instance=None):
|
|||
"""Save and close the file"""
|
||||
if not isinstance(instance, (models.Book, models.User, models.SiteSettings)):
|
||||
return False
|
||||
file_name = "%s-%s.jpg" % (str(instance.id), str(uuid4()))
|
||||
uuid = uuid4()
|
||||
file_name = f"{instance.id}-{uuid}.jpg"
|
||||
image_buffer = BytesIO()
|
||||
|
||||
try:
|
||||
|
@ -338,9 +341,9 @@ def save_and_cleanup(image, instance=None):
|
|||
|
||||
save_without_broadcast = isinstance(instance, (models.Book, models.User))
|
||||
if save_without_broadcast:
|
||||
instance.save(broadcast=False)
|
||||
instance.save(broadcast=False, update_fields=["preview_image"])
|
||||
else:
|
||||
instance.save()
|
||||
instance.save(update_fields=["preview_image"])
|
||||
|
||||
# Clean up old file after saving
|
||||
if old_path and default_storage.exists(old_path):
|
||||
|
@ -352,7 +355,7 @@ def save_and_cleanup(image, instance=None):
|
|||
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@app.task
|
||||
@app.task(queue="low_priority")
|
||||
def generate_site_preview_image_task():
|
||||
"""generate preview_image for the website"""
|
||||
if not settings.ENABLE_PREVIEW_IMAGES:
|
||||
|
@ -377,7 +380,7 @@ def generate_site_preview_image_task():
|
|||
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@app.task
|
||||
@app.task(queue="low_priority")
|
||||
def generate_edition_preview_image_task(book_id):
|
||||
"""generate preview_image for a book"""
|
||||
if not settings.ENABLE_PREVIEW_IMAGES:
|
||||
|
@ -402,7 +405,7 @@ def generate_edition_preview_image_task(book_id):
|
|||
save_and_cleanup(image, instance=book)
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(queue="low_priority")
|
||||
def generate_user_preview_image_task(user_id):
|
||||
"""generate preview_image for a book"""
|
||||
if not settings.ENABLE_PREVIEW_IMAGES:
|
||||
|
@ -412,7 +415,7 @@ def generate_user_preview_image_task(user_id):
|
|||
|
||||
texts = {
|
||||
"text_one": user.display_name,
|
||||
"text_three": "@{}@{}".format(user.localname, settings.DOMAIN),
|
||||
"text_three": f"@{user.localname}@{settings.DOMAIN}",
|
||||
}
|
||||
|
||||
if user.avatar:
|
||||
|
|
|
@ -33,10 +33,11 @@ class RedisStore(ABC):
|
|||
# and go!
|
||||
return pipeline.execute()
|
||||
|
||||
def remove_object_from_related_stores(self, obj):
|
||||
def remove_object_from_related_stores(self, obj, stores=None):
|
||||
"""remove an object from all stores"""
|
||||
stores = self.get_stores_for_object(obj) if stores is None else stores
|
||||
pipeline = r.pipeline()
|
||||
for store in self.get_stores_for_object(obj):
|
||||
for store in stores:
|
||||
pipeline.zrem(store, -1, obj.id)
|
||||
pipeline.execute()
|
||||
|
||||
|
@ -50,15 +51,15 @@ class RedisStore(ABC):
|
|||
pipeline.execute()
|
||||
|
||||
def bulk_remove_objects_from_store(self, objs, store):
|
||||
"""remoev a list of objects from a given store"""
|
||||
"""remove a list of objects from a given store"""
|
||||
pipeline = r.pipeline()
|
||||
for obj in objs[: self.max_length]:
|
||||
pipeline.zrem(store, -1, obj.id)
|
||||
pipeline.execute()
|
||||
|
||||
def get_store(self, store): # pylint: disable=no-self-use
|
||||
def get_store(self, store, **kwargs): # pylint: disable=no-self-use
|
||||
"""load the values in a store"""
|
||||
return r.zrevrange(store, 0, -1)
|
||||
return r.zrevrange(store, 0, -1, **kwargs)
|
||||
|
||||
def populate_store(self, store):
|
||||
"""go from zero to a store"""
|
||||
|
|
|
@ -48,7 +48,7 @@ class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method
|
|||
return
|
||||
|
||||
self.tag_stack = self.tag_stack[:-1]
|
||||
self.output.append(("tag", "</%s>" % tag))
|
||||
self.output.append(("tag", f"</{tag}>"))
|
||||
|
||||
def handle_data(self, data):
|
||||
"""extract the answer, if we're in an answer tag"""
|
||||
|
|
|
@ -7,22 +7,14 @@ from django.utils.translation import gettext_lazy as _
|
|||
|
||||
|
||||
env = Env()
|
||||
env.read_env()
|
||||
DOMAIN = env("DOMAIN")
|
||||
VERSION = "0.0.1"
|
||||
VERSION = "0.1.0"
|
||||
|
||||
PAGE_LENGTH = env("PAGE_LENGTH", 15)
|
||||
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
|
||||
|
||||
# celery
|
||||
CELERY_BROKER = "redis://:{}@redis_broker:{}/0".format(
|
||||
requests.utils.quote(env("REDIS_BROKER_PASSWORD", "")), env("REDIS_BROKER_PORT")
|
||||
)
|
||||
CELERY_RESULT_BACKEND = "redis://:{}@redis_broker:{}/0".format(
|
||||
requests.utils.quote(env("REDIS_BROKER_PASSWORD", "")), env("REDIS_BROKER_PORT")
|
||||
)
|
||||
CELERY_ACCEPT_CONTENT = ["application/json"]
|
||||
CELERY_TASK_SERIALIZER = "json"
|
||||
CELERY_RESULT_SERIALIZER = "json"
|
||||
JS_CACHE = "3eb4edb1"
|
||||
|
||||
# email
|
||||
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
|
||||
|
@ -32,13 +24,14 @@ EMAIL_HOST_USER = env("EMAIL_HOST_USER")
|
|||
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
|
||||
EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", True)
|
||||
EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", False)
|
||||
DEFAULT_FROM_EMAIL = "admin@{:s}".format(env("DOMAIN"))
|
||||
DEFAULT_FROM_EMAIL = f"admin@{DOMAIN}"
|
||||
|
||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
LOCALE_PATHS = [
|
||||
os.path.join(BASE_DIR, "locale"),
|
||||
]
|
||||
LANGUAGE_COOKIE_NAME = env.str("LANGUAGE_COOKIE_NAME", "django_language")
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
|
@ -75,6 +68,7 @@ INSTALLED_APPS = [
|
|||
"django_rename_app",
|
||||
"bookwyrm",
|
||||
"celery",
|
||||
"imagekit",
|
||||
"storages",
|
||||
]
|
||||
|
||||
|
@ -85,7 +79,8 @@ MIDDLEWARE = [
|
|||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"bookwyrm.timezone_middleware.TimezoneMiddleware",
|
||||
"bookwyrm.middleware.TimezoneMiddleware",
|
||||
"bookwyrm.middleware.IPBlocklistMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
@ -118,7 +113,11 @@ REDIS_ACTIVITY_PORT = env("REDIS_ACTIVITY_PORT", 6379)
|
|||
REDIS_ACTIVITY_PASSWORD = env("REDIS_ACTIVITY_PASSWORD", None)
|
||||
|
||||
MAX_STREAM_LENGTH = int(env("MAX_STREAM_LENGTH", 200))
|
||||
STREAMS = ["home", "local", "federated"]
|
||||
|
||||
STREAMS = [
|
||||
{"key": "home", "name": _("Home Timeline"), "shortname": _("Home")},
|
||||
{"key": "books", "name": _("Books Timeline"), "shortname": _("Books")},
|
||||
]
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
|
||||
|
@ -130,7 +129,7 @@ DATABASES = {
|
|||
"USER": env("POSTGRES_USER", "fedireads"),
|
||||
"PASSWORD": env("POSTGRES_PASSWORD", "fedireads"),
|
||||
"HOST": env("POSTGRES_HOST", ""),
|
||||
"PORT": env("POSTGRES_PORT", 5432),
|
||||
"PORT": env("PGPORT", 5432),
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -164,11 +163,12 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||
LANGUAGE_CODE = "en-us"
|
||||
LANGUAGES = [
|
||||
("en-us", _("English")),
|
||||
("de-de", _("German")),
|
||||
("es", _("Spanish")),
|
||||
("fr-fr", _("French")),
|
||||
("zh-hans", _("Simplified Chinese")),
|
||||
("zh-hant", _("Traditional Chinese")),
|
||||
("de-de", _("Deutsch (German)")),
|
||||
("es-es", _("Español (Spanish)")),
|
||||
("fr-fr", _("Français (French)")),
|
||||
("pt-br", _("Português - Brasil (Brazilian Portuguese)")),
|
||||
("zh-hans", _("简体中文 (Simplified Chinese)")),
|
||||
("zh-hant", _("繁體中文 (Traditional Chinese)")),
|
||||
]
|
||||
|
||||
|
||||
|
@ -181,12 +181,12 @@ USE_L10N = True
|
|||
USE_TZ = True
|
||||
|
||||
|
||||
USER_AGENT = "%s (BookWyrm/%s; +https://%s/)" % (
|
||||
requests.utils.default_user_agent(),
|
||||
VERSION,
|
||||
DOMAIN,
|
||||
)
|
||||
agent = requests.utils.default_user_agent()
|
||||
USER_AGENT = f"{agent} (BookWyrm/{VERSION}; +https://{DOMAIN}/)"
|
||||
|
||||
# Imagekit generated thumbnails
|
||||
ENABLE_THUMBNAIL_GENERATION = env.bool("ENABLE_THUMBNAIL_GENERATION", False)
|
||||
IMAGEKIT_CACHEFILE_DIR = "thumbnails"
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/3.2/howto/static-files/
|
||||
|
@ -213,12 +213,13 @@ if USE_S3:
|
|||
AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"}
|
||||
# S3 Static settings
|
||||
STATIC_LOCATION = "static"
|
||||
STATIC_URL = "https://%s/%s/" % (AWS_S3_CUSTOM_DOMAIN, STATIC_LOCATION)
|
||||
STATIC_URL = f"{PROTOCOL}://{AWS_S3_CUSTOM_DOMAIN}/{STATIC_LOCATION}/"
|
||||
STATICFILES_STORAGE = "bookwyrm.storage_backends.StaticStorage"
|
||||
# S3 Media settings
|
||||
MEDIA_LOCATION = "images"
|
||||
MEDIA_URL = "https://%s/%s/" % (AWS_S3_CUSTOM_DOMAIN, MEDIA_LOCATION)
|
||||
MEDIA_URL = f"{PROTOCOL}://{AWS_S3_CUSTOM_DOMAIN}/{MEDIA_LOCATION}/"
|
||||
MEDIA_FULL_URL = MEDIA_URL
|
||||
STATIC_FULL_URL = STATIC_URL
|
||||
DEFAULT_FILE_STORAGE = "bookwyrm.storage_backends.ImagesStorage"
|
||||
# I don't know if it's used, but the site crashes without it
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static"))
|
||||
|
@ -227,5 +228,6 @@ else:
|
|||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static"))
|
||||
MEDIA_URL = "/images/"
|
||||
MEDIA_FULL_URL = "%s://%s%s" % (PROTOCOL, DOMAIN, MEDIA_URL)
|
||||
MEDIA_FULL_URL = f"{PROTOCOL}://{DOMAIN}{MEDIA_URL}"
|
||||
STATIC_FULL_URL = f"{PROTOCOL}://{DOMAIN}{STATIC_URL}"
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images"))
|
||||
|
|
|
@ -26,21 +26,21 @@ def make_signature(sender, destination, date, digest):
|
|||
"""uses a private key to sign an outgoing message"""
|
||||
inbox_parts = urlparse(destination)
|
||||
signature_headers = [
|
||||
"(request-target): post %s" % inbox_parts.path,
|
||||
"host: %s" % inbox_parts.netloc,
|
||||
"date: %s" % date,
|
||||
"digest: %s" % digest,
|
||||
f"(request-target): post {inbox_parts.path}",
|
||||
f"host: {inbox_parts.netloc}",
|
||||
f"date: {date}",
|
||||
f"digest: {digest}",
|
||||
]
|
||||
message_to_sign = "\n".join(signature_headers)
|
||||
signer = pkcs1_15.new(RSA.import_key(sender.key_pair.private_key))
|
||||
signed_message = signer.sign(SHA256.new(message_to_sign.encode("utf8")))
|
||||
signature = {
|
||||
"keyId": "%s#main-key" % sender.remote_id,
|
||||
"keyId": f"{sender.remote_id}#main-key",
|
||||
"algorithm": "rsa-sha256",
|
||||
"headers": "(request-target) host date digest",
|
||||
"signature": b64encode(signed_message).decode("utf8"),
|
||||
}
|
||||
return ",".join('%s="%s"' % (k, v) for (k, v) in signature.items())
|
||||
return ",".join(f'{k}="{v}"' for (k, v) in signature.items())
|
||||
|
||||
|
||||
def make_digest(data):
|
||||
|
@ -58,7 +58,7 @@ def verify_digest(request):
|
|||
elif algorithm == "SHA-512":
|
||||
hash_function = hashlib.sha512
|
||||
else:
|
||||
raise ValueError("Unsupported hash function: {}".format(algorithm))
|
||||
raise ValueError(f"Unsupported hash function: {algorithm}")
|
||||
|
||||
expected = hash_function(request.body).digest()
|
||||
if b64decode(digest) != expected:
|
||||
|
@ -95,18 +95,18 @@ class Signature:
|
|||
def verify(self, public_key, request):
|
||||
"""verify rsa signature"""
|
||||
if http_date_age(request.headers["date"]) > MAX_SIGNATURE_AGE:
|
||||
raise ValueError("Request too old: %s" % (request.headers["date"],))
|
||||
raise ValueError(f"Request too old: {request.headers['date']}")
|
||||
public_key = RSA.import_key(public_key)
|
||||
|
||||
comparison_string = []
|
||||
for signed_header_name in self.headers.split(" "):
|
||||
if signed_header_name == "(request-target)":
|
||||
comparison_string.append("(request-target): post %s" % request.path)
|
||||
comparison_string.append(f"(request-target): post {request.path}")
|
||||
else:
|
||||
if signed_header_name == "digest":
|
||||
verify_digest(request)
|
||||
comparison_string.append(
|
||||
"%s: %s" % (signed_header_name, request.headers[signed_header_name])
|
||||
f"{signed_header_name}: {request.headers[signed_header_name]}"
|
||||
)
|
||||
comparison_string = "\n".join(comparison_string)
|
||||
|
||||
|
|
|
@ -29,6 +29,15 @@ body {
|
|||
min-width: 75% !important;
|
||||
}
|
||||
|
||||
.modal-card-body {
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.clip-text {
|
||||
max-height: 35em;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/** Utilities not covered by Bulma
|
||||
******************************************************************************/
|
||||
|
||||
|
@ -80,6 +89,32 @@ body {
|
|||
display: inline !important;
|
||||
}
|
||||
|
||||
input[type=file]::file-selector-button {
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-color: #fff;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #dbdbdb;
|
||||
box-shadow: none;
|
||||
color: #363636;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
height: 2.5em;
|
||||
justify-content: center;
|
||||
line-height: 1.5;
|
||||
padding-bottom: calc(0.5em - 1px);
|
||||
padding-left: 1em;
|
||||
padding-right: 1em;
|
||||
padding-top: calc(0.5em - 1px);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
input[type=file]::file-selector-button:hover {
|
||||
border-color: #b5b5b5;
|
||||
color: #363636;
|
||||
}
|
||||
|
||||
/** Shelving
|
||||
******************************************************************************/
|
||||
|
||||
|
@ -87,7 +122,7 @@ body {
|
|||
@see https://www.youtube.com/watch?v=9xXBYcWgCHA */
|
||||
.shelf-option:disabled > *::after {
|
||||
font-family: "icomoon"; /* stylelint-disable font-family-no-missing-generic-family-keyword */
|
||||
content: "\e918";
|
||||
content: "\e919"; /* icon-check */
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
|
@ -158,21 +193,36 @@ body {
|
|||
|
||||
/* All stars are visually filled by default. */
|
||||
.form-rate-stars .icon::before {
|
||||
content: '\e9d9';
|
||||
content: '\e9d9'; /* icon-star-full */
|
||||
}
|
||||
|
||||
/* Icons directly following half star inputs are marked as half */
|
||||
.form-rate-stars input.half:checked ~ .icon::before {
|
||||
content: '\e9d8'; /* icon-star-half */
|
||||
}
|
||||
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
.form-rate-stars input.half:checked + input + .icon:hover::before {
|
||||
content: '\e9d8' !important; /* icon-star-half */
|
||||
}
|
||||
|
||||
/* Icons directly following half check inputs that follow the checked input are emptied. */
|
||||
.form-rate-stars input.half:checked + input + .icon ~ .icon::before {
|
||||
content: '\e9d7'; /* icon-star-empty */
|
||||
}
|
||||
|
||||
/* Icons directly following inputs that follow the checked input are emptied. */
|
||||
.form-rate-stars input:checked ~ input + .icon::before {
|
||||
content: '\e9d7';
|
||||
content: '\e9d7'; /* icon-star-empty */
|
||||
}
|
||||
|
||||
/* When a label is hovered, repeat the fill-all-then-empty-following pattern. */
|
||||
.form-rate-stars:hover .icon.icon::before {
|
||||
content: '\e9d9';
|
||||
content: '\e9d9' !important; /* icon-star-full */
|
||||
}
|
||||
|
||||
.form-rate-stars .icon:hover ~ .icon::before {
|
||||
content: '\e9d7';
|
||||
content: '\e9d7' !important; /* icon-star-empty */
|
||||
}
|
||||
|
||||
/** Book covers
|
||||
|
@ -227,16 +277,21 @@ body {
|
|||
/* Cover caption
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
.no-cover .cover_caption {
|
||||
.no-cover .cover-caption {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
padding: 0.25em;
|
||||
padding: 0.5em;
|
||||
font-size: 0.75em;
|
||||
color: white;
|
||||
background-color: #002549;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: initial;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/** Avatars
|
||||
|
@ -278,17 +333,59 @@ body {
|
|||
}
|
||||
|
||||
.quote > blockquote::before {
|
||||
content: "\e906";
|
||||
content: "\e907"; /* icon-quote-open */
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.quote > blockquote::after {
|
||||
content: "\e905";
|
||||
content: "\e906"; /* icon-quote-close */
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* States
|
||||
/** Animations and transitions
|
||||
******************************************************************************/
|
||||
|
||||
@keyframes turning {
|
||||
from { transform: rotateZ(0deg); }
|
||||
to { transform: rotateZ(360deg); }
|
||||
}
|
||||
|
||||
.is-processing .icon-spinner::before {
|
||||
animation: turning 1.5s infinite linear;
|
||||
}
|
||||
|
||||
.icon-spinner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.is-processing .icon-spinner {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.is-processing .icon::before {
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/** Transient notification
|
||||
******************************************************************************/
|
||||
|
||||
#live-messages {
|
||||
position: fixed;
|
||||
bottom: 1em;
|
||||
right: 1em;
|
||||
}
|
||||
|
||||
/** Tooltips
|
||||
******************************************************************************/
|
||||
|
||||
.tooltip {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/** States
|
||||
******************************************************************************/
|
||||
|
||||
/* "disabled" for non-buttons */
|
||||
|
@ -395,6 +492,37 @@ ol.ordered-list li::before {
|
|||
}
|
||||
}
|
||||
|
||||
/* Threads
|
||||
******************************************************************************/
|
||||
|
||||
.thread .is-main .card {
|
||||
box-shadow: 0 0.5em 1em -0.125em rgb(50 115 220 / 35%), 0 0 0 1px rgb(50 115 220 / 2%);
|
||||
}
|
||||
|
||||
.thread::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 2.5em;
|
||||
border-left: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
/* Breadcrumbs
|
||||
******************************************************************************/
|
||||
|
||||
.breadcrumb li:first-child * {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.breadcrumb li > * {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0 0.75em;
|
||||
}
|
||||
|
||||
/* Dimensions
|
||||
* @todo These could be in rem.
|
||||
******************************************************************************/
|
||||
|
|
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue