Merge branch 'main' into csv-import-failures

This commit is contained in:
Mouse Reeve 2021-09-11 07:19:40 -07:00
commit b36f7d7b69
280 changed files with 10272 additions and 4934 deletions

View file

@ -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

View file

@ -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

View file

@ -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
View 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' \
bookwyrm/templates

View file

@ -106,8 +106,10 @@ class ActivityObject:
value = field.default
setattr(self, field.name, value)
# pylint: disable=too-many-locals,too-many-branches
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)
@ -129,9 +131,12 @@ class ActivityObject:
# 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:
changed = 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:
@ -140,7 +145,9 @@ class ActivityObject:
# 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:
changed = 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)
@ -206,7 +213,7 @@ 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
@ -268,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()

View file

@ -30,8 +30,8 @@ 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
@ -59,6 +59,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 +70,8 @@ class Quotation(Comment):
"""a quote and commentary on a book"""
quote: str
position: int = None
positionMode: str = None
type: str = "Quotation"

View file

@ -1,5 +1,6 @@
""" access the activity streams stored in redis """
from django.dispatch import receiver
from django.db import transaction
from django.db.models import signals, Q
from bookwyrm import models
@ -23,14 +24,15 @@ class ActivityStream(RedisStore):
"""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)
for user in self.get_audience(status):
# add to the unread status count
pipeline.incr(self.unread_id(user))
if increment_unread:
for user in self.get_audience(status):
# add to the unread status count
pipeline.incr(self.unread_id(user))
# and go!
pipeline.execute()
@ -257,37 +259,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
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)
# re-add the original status
stream.add_status(instance.boosted_status)
# remove the boost
remove_status_task.delay(instance.id)
# re-add the original status
add_status_task.delay(instance.boosted_status.id)
@receiver(signals.post_save, sender=models.UserFollows)
@ -296,7 +292,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)
@ -305,7 +303,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)
@ -314,13 +314,15 @@ 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)
@ -328,15 +330,22 @@ def remove_statuses_on_block(sender, instance, *args, **kwargs):
def add_statuses_on_unblock(sender, instance, *args, **kwargs):
"""remove statuses from all feeds on block"""
public_streams = [v 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)
@ -346,8 +355,8 @@ def populate_streams_on_account_create(sender, instance, created, *args, **kwarg
if not created or not instance.local:
return
for stream in streams.values():
stream.populate_streams(instance)
for stream in streams:
populate_stream_task.delay(stream, instance.id)
@receiver(signals.pre_save, sender=models.ShelfBook)
@ -356,20 +365,14 @@ def add_statuses_on_shelve(sender, instance, *args, **kwargs):
"""update books stream when user shelves a book"""
if not instance.user.local:
return
book = None
if hasattr(instance, "book"):
book = instance.book
elif instance.mention_books.exists():
book = instance.mention_books.first()
if not book:
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
BooksStream().add_book_statuses(instance.user, book)
add_book_statuses_task.delay(instance.user.id, book.id)
@receiver(signals.post_delete, sender=models.ShelfBook)
@ -379,24 +382,97 @@ def remove_statuses_on_unshelve(sender, instance, *args, **kwargs):
if not instance.user.local:
return
book = None
if hasattr(instance, "book"):
book = instance.book
elif instance.mention_books.exists():
book = instance.mention_books.first()
if not book:
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
BooksStream().remove_book_statuses(instance.user, instance.book)
remove_book_statuses_task.delay(instance.user.id, book.id)
@app.task
# ---- 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():
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)
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
old_versions = models.Boost.objects.filter(
boosted_status__id=boosted.id,
created_date__lt=instance.created_date,
)
for stream in streams.values():
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)

View file

@ -139,7 +139,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 +156,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 +182,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):

View file

@ -119,7 +119,7 @@ def get_or_create_connector(remote_id):
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)

View file

@ -145,8 +145,8 @@ class Connector(AbstractConnector):
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))

View file

@ -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,
}

View file

@ -64,7 +64,7 @@ def format_email(email_name, data):
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(

View file

@ -86,6 +86,7 @@ class CommentForm(CustomForm):
"privacy",
"progress",
"progress_mode",
"reading_status",
]
@ -100,6 +101,8 @@ class QuotationForm(CustomForm):
"content_warning",
"sensitive",
"privacy",
"position",
"position_mode",
]
@ -131,6 +134,7 @@ class EditUserForm(CustomForm):
"email",
"summary",
"show_goal",
"show_suggested_users",
"manually_approves_followers",
"default_post_privacy",
"discoverable",
@ -295,6 +299,12 @@ class ReportForm(CustomForm):
fields = ["user", "reporter", "statuses", "note"]
class EmailBlocklistForm(CustomForm):
class Meta:
model = models.EmailBlocklist
fields = ["domain"]
class ServerForm(CustomForm):
class Meta:
model = models.FederatedServer

113
bookwyrm/imagegenerators.py Normal file
View 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)

View file

@ -62,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)

View 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,
),
),
]

View 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,
),
),
]

View 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"
),
),
]

View 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",
),
),
]

View 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,
),
),
]

View file

@ -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 = []

View 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,
),
),
]

View 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),
),
]

View 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,
),
),
]

View 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",),
},
),
]

View file

@ -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 = []

View file

@ -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),
),
]

View file

@ -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),
),
]

View file

@ -24,7 +24,9 @@ from .federated_server import FederatedServer
from .import_job import ImportJob, ImportItem
from .site import SiteSettings, SiteInvite, PasswordReset, InviteRequest
from .site import SiteSettings, SiteInvite
from .site import PasswordReset, InviteRequest
from .site import EmailBlocklist
from .announcement import Announcement
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)

View file

@ -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"""
@ -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)

View file

@ -13,6 +13,7 @@ DeactivationReason = models.TextChoices(
[
"pending",
"self_deletion",
"moderator_suspension",
"moderator_deletion",
"domain_block",
],

View file

@ -4,13 +4,20 @@ 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 transaction
from django.dispatch import receiver
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 +104,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"""
@ -321,4 +362,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)
)

View file

@ -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

View file

@ -66,7 +66,7 @@ class ActivitypubFieldMixin:
self.activitypub_field = activitypub_field
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. Returns if changed"""
try:
value = getattr(data, self.get_activitypub_field())
@ -79,8 +79,15 @@ class ActivitypubFieldMixin:
if formatted is None or formatted is MISSING or formatted == {}:
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 hasattr(instance, self.name) and getattr(instance, self.name) == formatted:
if current_value == formatted:
return False
setattr(instance, self.name, formatted)
@ -210,12 +217,27 @@ 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:
@ -231,9 +253,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
@ -273,8 +293,11 @@ 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:
@ -377,13 +400,16 @@ 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 False
if not overwrite and hasattr(instance, self.name):
return False
getattr(instance, self.name).save(*formatted, save=save)
return True

View file

@ -188,6 +188,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,

View file

@ -1,7 +1,8 @@
""" 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 django.utils import timezone
from .base_model import BookWyrmModel
@ -41,6 +42,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."""

View file

@ -20,6 +20,7 @@ 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(
@ -123,6 +124,23 @@ class PasswordReset(models.Model):
return "https://{}/password-reset/{}".format(DOMAIN, self.code)
class EmailBlocklist(models.Model):
"""blocked email addresses"""
created_date = models.DateTimeField(auto_now_add=True)
domain = models.CharField(max_length=255, unique=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}")
# pylint: disable=unused-argument
@receiver(models.signals.post_save, sender=SiteSettings)
def preview_image(instance, *args, **kwargs):

View file

@ -235,12 +235,31 @@ class GeneratedNote(Status):
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
@ -265,15 +284,21 @@ class Comment(Status):
)
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"
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
@ -289,16 +314,12 @@ class Quotation(Status):
)
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,

View file

@ -82,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"),
@ -104,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"
)
favorites = models.ManyToManyField(
"Status",
symmetrical=False,
@ -119,8 +122,12 @@ class User(OrderedCollectionPageMixin, AbstractUser):
updated_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),
@ -225,7 +232,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
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,
@ -271,28 +278,46 @@ class User(OrderedCollectionPageMixin, AbstractUser):
transaction.on_commit(lambda: set_remote_server.delay(self.id))
return
# populate fields for local users
self.remote_id = "%s/user/%s" % (site_link(), self.localname)
self.inbox = "%s/inbox" % self.remote_id
self.shared_inbox = "%s/inbox" % site_link()
self.outbox = "%s/outbox" % self.remote_id
with transaction.atomic():
# populate fields for local users
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
# an id needs to be set before we can proceed with related models
super().save(*args, **kwargs)
# make users editors by default
try:
self.groups.add(Group.objects.get(name="editor"))
except Group.DoesNotExist:
# this should only happen in tests
pass
# create keys and shelves for new local users
self.key_pair = KeyPair.objects.create(
remote_id=f"{self.remote_id}/#main-key"
)
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)
# make users editors by default
try:
self.groups.add(Group.objects.get(name="editor"))
except Group.DoesNotExist:
# this should only happen in tests
pass
# create keys and shelves for new local users
self.key_pair = KeyPair.objects.create(
remote_id="%s/#main-key" % self.remote_id
)
self.save(broadcast=False, update_fields=["key_pair"])
@property
def local_path(self):
"""this model doesn't inherit bookwyrm model, so here we are"""
return "/user/%s" % (self.localname or self.username)
def create_shelves(self):
"""default shelves for a new user"""
shelves = [
{
"name": "To Read",
@ -316,17 +341,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"""
@ -415,7 +429,7 @@ 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)
@ -454,7 +468,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"

View file

@ -352,7 +352,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 +377,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 +402,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:

View file

@ -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 = stores or self.get_stores_for_object(obj)
pipeline = r.pipeline()
for store in self.get_stores_for_object(obj):
for store in stores:
pipeline.zrem(store, -1, obj.id)
pipeline.execute()

View file

@ -13,16 +13,7 @@ VERSION = "0.0.1"
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 = "19447742"
# email
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
@ -75,6 +66,7 @@ INSTALLED_APPS = [
"django_rename_app",
"bookwyrm",
"celery",
"imagekit",
"storages",
]
@ -191,6 +183,9 @@ USER_AGENT = "%s (BookWyrm/%s; +https://%s/)" % (
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/

View file

@ -29,6 +29,10 @@ body {
min-width: 75% !important;
}
.modal-card-body {
max-height: 70vh;
}
.clip-text {
max-height: 35em;
overflow: hidden;
@ -92,7 +96,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;
}
@ -163,21 +167,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
@ -232,16 +251,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
@ -283,17 +307,52 @@ 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;
}
/** States
******************************************************************************/
/* "disabled" for non-buttons */

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View file

@ -1,156 +1,144 @@
/** @todo Replace icons with SVG symbols.
@see https://www.youtube.com/watch?v=9xXBYcWgCHA */
@font-face {
font-family: 'icomoon';
src: url('../fonts/icomoon.eot?n5x55');
src: url('../fonts/icomoon.eot?n5x55#iefix') format('embedded-opentype'),
url('../fonts/icomoon.ttf?n5x55') format('truetype'),
url('../fonts/icomoon.woff?n5x55') format('woff'),
url('../fonts/icomoon.svg?n5x55#icomoon') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
font-family: 'icomoon';
src: url('../fonts/icomoon.eot?36x4a3');
src: url('../fonts/icomoon.eot?36x4a3#iefix') format('embedded-opentype'),
url('../fonts/icomoon.ttf?36x4a3') format('truetype'),
url('../fonts/icomoon.woff?36x4a3') format('woff'),
url('../fonts/icomoon.svg?36x4a3#icomoon') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
}
[class^="icon-"], [class*=" icon-"] {
/* use !important to prevent issues with browser extensions that change fonts */
font-family: 'icomoon' !important;
speak: never;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
/* use !important to prevent issues with browser extensions that change fonts */
font-family: 'icomoon' !important;
speak: never;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
/* Better Font Rendering =========== */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* Better Font Rendering =========== */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-graphic-heart:before {
content: "\e91e";
}
.icon-graphic-paperplane:before {
content: "\e91f";
}
.icon-graphic-banknote:before {
content: "\e920";
}
.icon-stars:before {
content: "\e91a";
}
.icon-warning:before {
content: "\e91b";
}
.icon-book:before {
content: "\e900";
}
.icon-bookmark:before {
content: "\e91c";
}
.icon-rss:before {
content: "\e91d";
content: "\e901";
}
.icon-envelope:before {
content: "\e901";
content: "\e902";
}
.icon-arrow-right:before {
content: "\e902";
content: "\e903";
}
.icon-bell:before {
content: "\e903";
content: "\e904";
}
.icon-x:before {
content: "\e904";
content: "\e905";
}
.icon-quote-close:before {
content: "\e905";
content: "\e906";
}
.icon-quote-open:before {
content: "\e906";
content: "\e907";
}
.icon-image:before {
content: "\e907";
content: "\e908";
}
.icon-pencil:before {
content: "\e908";
content: "\e909";
}
.icon-list:before {
content: "\e909";
content: "\e90a";
}
.icon-unlock:before {
content: "\e90a";
}
.icon-unlisted:before {
content: "\e90a";
content: "\e90b";
}
.icon-globe:before {
content: "\e90b";
}
.icon-public:before {
content: "\e90b";
content: "\e90c";
}
.icon-lock:before {
content: "\e90c";
}
.icon-followers:before {
content: "\e90c";
content: "\e90d";
}
.icon-chain-broken:before {
content: "\e90d";
content: "\e90e";
}
.icon-chain:before {
content: "\e90e";
content: "\e90f";
}
.icon-comments:before {
content: "\e90f";
content: "\e910";
}
.icon-comment:before {
content: "\e910";
content: "\e911";
}
.icon-boost:before {
content: "\e911";
content: "\e912";
}
.icon-arrow-left:before {
content: "\e912";
content: "\e913";
}
.icon-arrow-up:before {
content: "\e913";
content: "\e914";
}
.icon-arrow-down:before {
content: "\e914";
}
.icon-home:before {
content: "\e915";
content: "\e915";
}
.icon-local:before {
content: "\e916";
content: "\e917";
}
.icon-dots-three:before {
content: "\e917";
content: "\e918";
}
.icon-check:before {
content: "\e918";
content: "\e919";
}
.icon-dots-three-vertical:before {
content: "\e919";
content: "\e91a";
}
.icon-bookmark:before {
content: "\e91b";
}
.icon-warning:before {
content: "\e91c";
}
.icon-rss:before {
content: "\e91d";
}
.icon-graphic-heart:before {
content: "\e91e";
}
.icon-graphic-paperplane:before {
content: "\e91f";
}
.icon-graphic-banknote:before {
content: "\e920";
}
.icon-search:before {
content: "\e986";
content: "\e986";
}
.icon-star-empty:before {
content: "\e9d7";
content: "\e9d7";
}
.icon-star-half:before {
content: "\e9d8";
content: "\e9d8";
}
.icon-star-full:before {
content: "\e9d9";
content: "\e9d9";
}
.icon-heart:before {
content: "\e9da";
content: "\e9da";
}
.icon-plus:before {
content: "\ea0a";
content: "\ea0a";
}
.icon-question-circle:before {
content: "\e900";
}
.icon-spinner:before {
content: "\e97a";
}

View file

@ -138,8 +138,11 @@ let BookWyrm = new class {
* @return {undefined}
*/
toggleAction(event) {
event.preventDefault();
let trigger = event.currentTarget;
if (!trigger.dataset.allowDefault || event.currentTarget == event.target) {
event.preventDefault();
}
let pressed = trigger.getAttribute('aria-pressed') === 'false';
let targetId = trigger.dataset.controls;
@ -177,6 +180,13 @@ let BookWyrm = new class {
this.toggleCheckbox(checkbox, pressed);
}
// Toggle form disabled, if appropriate
let disable = trigger.dataset.disables;
if (disable) {
this.toggleDisabled(disable, !pressed);
}
// Set focus, if appropriate.
let focus = trigger.dataset.focusTarget;
@ -227,6 +237,17 @@ let BookWyrm = new class {
document.getElementById(checkbox).checked = !!pressed;
}
/**
* Enable or disable a form element or fieldset
*
* @param {string} form_element - id of the element
* @param {boolean} pressed - Is the trigger pressed?
* @return {undefined}
*/
toggleDisabled(form_element, pressed) {
document.getElementById(form_element).disabled = !!pressed;
}
/**
* Give the focus to an element.
* Only move the focus based on user interactions.
@ -280,7 +301,10 @@ let BookWyrm = new class {
ajaxPost(form) {
return fetch(form.action, {
method : "POST",
body: new FormData(form)
body: new FormData(form),
headers: {
'Accept': 'application/json',
}
});
}

View file

@ -0,0 +1,224 @@
/* exported StatusCache */
/* globals BookWyrm */
let StatusCache = new class {
constructor() {
document.querySelectorAll('[data-cache-draft]')
.forEach(t => t.addEventListener('change', this.updateDraft.bind(this)));
document.querySelectorAll('[data-cache-draft]')
.forEach(t => this.populateDraft(t));
document.querySelectorAll('.submit-status')
.forEach(button => button.addEventListener(
'submit',
this.submitStatus.bind(this))
);
document.querySelectorAll('.form-rate-stars label.icon')
.forEach(button => button.addEventListener('click', this.toggleStar.bind(this)));
}
/**
* Update localStorage copy of drafted status
*
* @param {Event} event
* @return {undefined}
*/
updateDraft(event) {
// Used in set reading goal
let key = event.target.dataset.cacheDraft;
let value = event.target.value;
if (!value) {
window.localStorage.removeItem(key);
return;
}
window.localStorage.setItem(key, value);
}
/**
* Toggle display of a DOM node based on its value in the localStorage.
*
* @param {object} node - DOM node to toggle.
* @return {undefined}
*/
populateDraft(node) {
// Used in set reading goal
let key = node.dataset.cacheDraft;
let value = window.localStorage.getItem(key);
if (!value) {
return;
}
node.value = value;
}
/**
* Post a status with ajax
*
* @param {} event
* @return {undefined}
*/
submitStatus(event) {
event.preventDefault();
const form = event.currentTarget;
const trigger = event.submitter;
BookWyrm.addRemoveClass(form, 'is-processing', true);
trigger.setAttribute('disabled', null);
BookWyrm.ajaxPost(form).finally(() => {
// Change icon to remove ongoing activity on the current UI.
// Enable back the element used to submit the form.
BookWyrm.addRemoveClass(form, 'is-processing', false);
trigger.removeAttribute('disabled');
})
.then(response => {
if (!response.ok) {
throw new Error();
}
this.submitStatusSuccess(form);
})
.catch(error => {
console.warn(error);
this.announceMessage('status-error-message');
});
}
/**
* Show a message in the live region
*
* @param {String} the id of the message dom element
* @return {undefined}
*/
announceMessage(message_id) {
const element = document.getElementById(message_id);
let copy = element.cloneNode(true);
copy.id = null;
element.insertAdjacentElement('beforebegin', copy);
BookWyrm.addRemoveClass(copy, 'is-hidden', false);
setTimeout(function() {
copy.remove();
}, 10000, copy);
}
/**
* Success state for a posted status
*
* @param {Object} the html form that was submitted
* @return {undefined}
*/
submitStatusSuccess(form) {
// Clear form data
form.reset();
// Clear localstorage
form.querySelectorAll('[data-cache-draft]')
.forEach(node => window.localStorage.removeItem(node.dataset.cacheDraft));
// Close modals
let modal = form.closest(".modal.is-active");
if (modal) {
modal.getElementsByClassName("modal-close")[0].click();
// Update shelve buttons
document.querySelectorAll("[data-shelve-button-book='" + form.book.value +"']")
.forEach(button => this.cycleShelveButtons(button, form.reading_status.value));
return;
}
// Close reply panel
let reply = form.closest(".reply-panel");
if (reply) {
document.querySelector("[data-controls=" + reply.id + "]").click();
}
this.announceMessage('status-success-message');
}
/**
* Change which buttons are available for a shelf
*
* @param {Object} html button dom element
* @param {String} the identifier of the selected shelf
* @return {undefined}
*/
cycleShelveButtons(button, identifier) {
// Pressed button
let shelf = button.querySelector("[data-shelf-identifier='" + identifier + "']");
let next_identifier = shelf.dataset.shelfNext;
// Set all buttons to hidden
button.querySelectorAll("[data-shelf-identifier]")
.forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", true));
// Button that should be visible now
let next = button.querySelector("[data-shelf-identifier=" + next_identifier + "]");
// Show the desired button
BookWyrm.addRemoveClass(next, "is-hidden", false);
// ------ update the dropdown buttons
// Remove existing hidden class
button.querySelectorAll("[data-shelf-dropdown-identifier]")
.forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", false));
// Remove existing disabled states
button.querySelectorAll("[data-shelf-dropdown-identifier] button")
.forEach(item => item.disabled = false);
next_identifier = next_identifier == 'complete' ? 'read' : next_identifier;
// Disable the current state
button.querySelector(
"[data-shelf-dropdown-identifier=" + identifier + "] button"
).disabled = true;
let main_button = button.querySelector(
"[data-shelf-dropdown-identifier=" + next_identifier + "]"
);
// Hide the option that's shown as the main button
BookWyrm.addRemoveClass(main_button, "is-hidden", true);
// Just hide the other two menu options, idk what to do with them
button.querySelectorAll("[data-extra-options]")
.forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", true));
// Close menu
let menu = button.querySelector(".dropdown-trigger[aria-expanded=true]");
if (menu) {
menu.click();
}
}
/**
* Reveal half-stars
*
* @param {Event} event
* @return {undefined}
*/
toggleStar(event) {
const label = event.currentTarget;
let wholeStar = document.getElementById(label.getAttribute("for"));
if (wholeStar.checked) {
event.preventDefault();
let halfStar = document.getElementById(label.dataset.forHalf);
wholeStar.checked = null;
halfStar.checked = "checked";
}
}
}();

View file

@ -1,4 +1,6 @@
"""Handles backends for storages"""
import os
from tempfile import SpooledTemporaryFile
from storages.backends.s3boto3 import S3Boto3Storage
@ -15,3 +17,33 @@ class ImagesStorage(S3Boto3Storage): # pylint: disable=abstract-method
location = "images"
default_acl = "public-read"
file_overwrite = False
"""
This is our custom version of S3Boto3Storage that fixes a bug in
boto3 where the passed in file is closed upon upload.
From:
https://github.com/matthewwithanm/django-imagekit/issues/391#issuecomment-275367006
https://github.com/boto/boto3/issues/929
https://github.com/matthewwithanm/django-imagekit/issues/391
"""
def _save(self, name, content):
"""
We create a clone of the content file as when this is passed to
boto3 it wrongly closes the file upon upload where as the storage
backend expects it to still be open
"""
# Seek our content back to the start
content.seek(0, os.SEEK_SET)
# Create a temporary file that will write to disk after a specified
# size. This file will be automatically deleted when closed by
# boto3 or after exiting the `with` statement if the boto3 is fixed
with SpooledTemporaryFile() as content_autoclose:
# Write our original content into our copy that will be closed by boto3
content_autoclose.write(content.read())
# Upload the object which will auto close the
# content_autoclose instance
return super()._save(name, content_autoclose)

View file

@ -86,10 +86,12 @@ class SuggestedUsers(RedisStore):
values = self.get_store(self.store_id(user), withscores=True)
results = []
# annotate users with mutuals and shared book counts
for user_id, rank in values[:5]:
for user_id, rank in values:
counts = self.get_counts_from_rank(rank)
try:
user = models.User.objects.get(id=user_id)
user = models.User.objects.get(
id=user_id, is_active=True, bookwyrm_user=True
)
except models.User.DoesNotExist as err:
# if this happens, the suggestions are janked way up
logger.exception(err)
@ -97,6 +99,8 @@ class SuggestedUsers(RedisStore):
user.mutuals = counts["mutuals"]
# user.shared_books = counts["shared_books"]
results.append(user)
if len(results) >= 5:
break
return results
@ -178,13 +182,21 @@ def update_suggestions_on_unfollow(sender, instance, **kwargs):
@receiver(signals.post_save, sender=models.User)
# pylint: disable=unused-argument, too-many-arguments
def add_new_user(sender, instance, created, update_fields=None, **kwargs):
"""a new user, wow how cool"""
def update_user(sender, instance, created, update_fields=None, **kwargs):
"""an updated user, neat"""
# a new user is found, create suggestions for them
if created and instance.local:
rerank_suggestions_task.delay(instance.id)
if update_fields and not "discoverable" in update_fields:
# we know what fields were updated and discoverability didn't change
if not instance.bookwyrm_user or (
update_fields and not "discoverable" in update_fields
):
return
# deleted the user
if not created and not instance.is_active:
remove_user_task.delay(instance.id)
return
# this happens on every save, not just when discoverability changes, annoyingly
@ -194,28 +206,61 @@ def add_new_user(sender, instance, created, update_fields=None, **kwargs):
remove_user_task.delay(instance.id)
@app.task
@receiver(signals.post_save, sender=models.FederatedServer)
def domain_level_update(sender, instance, created, update_fields=None, **kwargs):
"""remove users on a domain block"""
if (
not update_fields
or "status" not in update_fields
or instance.application_type != "bookwyrm"
):
return
if instance.status == "blocked":
bulk_remove_instance_task.delay(instance.id)
return
bulk_add_instance_task.delay(instance.id)
# ------------------- TASKS
@app.task(queue="low_priority")
def rerank_suggestions_task(user_id):
"""do the hard work in celery"""
suggested_users.rerank_user_suggestions(user_id)
@app.task
@app.task(queue="low_priority")
def rerank_user_task(user_id, update_only=False):
"""do the hard work in celery"""
user = models.User.objects.get(id=user_id)
suggested_users.rerank_obj(user, update_only=update_only)
@app.task
@app.task(queue="low_priority")
def remove_user_task(user_id):
"""do the hard work in celery"""
user = models.User.objects.get(id=user_id)
suggested_users.remove_object_from_related_stores(user)
@app.task
@app.task(queue="medium_priority")
def remove_suggestion_task(user_id, suggested_user_id):
"""remove a specific user from a specific user's suggestions"""
suggested_user = models.User.objects.get(id=suggested_user_id)
suggested_users.remove_suggestion(user_id, suggested_user)
@app.task(queue="low_priority")
def bulk_remove_instance_task(instance_id):
"""remove a bunch of users from recs"""
for user in models.User.objects.filter(federated_server__id=instance_id):
suggested_users.remove_object_from_related_stores(user)
@app.task(queue="low_priority")
def bulk_add_instance_task(instance_id):
"""remove a bunch of users from recs"""
for user in models.User.objects.filter(federated_server__id=instance_id):
suggested_users.rerank_obj(user, update_only=False)

View file

@ -2,10 +2,10 @@
import os
from celery import Celery
from bookwyrm import settings
from celerywyrm import settings
# set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "celerywyrm.settings")
app = Celery(
"tasks", broker=settings.CELERY_BROKER, backend=settings.CELERY_RESULT_BACKEND
"tasks", broker=settings.CELERY_BROKER_URL, backend=settings.CELERY_RESULT_BACKEND
)

View file

@ -57,7 +57,7 @@
{% if author.wikipedia_link %}
<p class="my-1">
<a itemprop="sameAs" href="{{ author.wikipedia_link }}" rel=”noopener” target="_blank">
<a itemprop="sameAs" href="{{ author.wikipedia_link }}" rel="noopener" target="_blank">
{% trans "Wikipedia" %}
</a>
</p>
@ -70,7 +70,7 @@
</a>
</p>
{% endif %}
{% if author.inventaire_id %}
<p class="my-1">
<a itemprop="sameAs" href="https://inventaire.io/entity/{{ author.inventaire_id }}" target="_blank" rel="noopener">
@ -86,7 +86,7 @@
</a>
</p>
{% endif %}
{% if author.goodreads_key %}
<p class="my-1">
<a itemprop="sameAs" href="https://www.goodreads.com/author/show/{{ author.goodreads_key }}" target="_blank" rel="noopener">
@ -109,7 +109,7 @@
<div class="columns is-multiline is-mobile">
{% for book in books %}
<div class="column is-one-fifth">
{% include 'discover/small-book.html' with book=book %}
{% include 'landing/small-book.html' with book=book %}
</div>
{% endfor %}
</div>

View file

@ -4,7 +4,6 @@
{% load humanize %}
{% load utilities %}
{% load static %}
{% load layout %}
{% block title %}{{ book|book_title }}{% endblock %}
@ -43,7 +42,7 @@
</p>
{% endif %}
{% if book.authors %}
{% if book.authors.exists %}
<div class="subtitle">
{% trans "by" %} {% include 'snippets/authors.html' with book=book %}
</div>
@ -62,7 +61,7 @@
<div class="columns">
<div class="column is-one-fifth">
{% include 'snippets/book_cover.html' with book=book cover_class='is-h-m-mobile' %}
{% include 'snippets/book_cover.html' with size='xxlarge' size_mobile='medium' book=book cover_class='is-h-m-mobile' %}
{% include 'snippets/rate_action.html' with user=request.user book=book %}
<div class="mb-3">
@ -134,7 +133,7 @@
<form name="add-description" method="POST" action="/add-description/{{ book.id }}">
{% csrf_token %}
<p class="fields is-grouped">
<label class="label"for="id_description_{{ book.id }}">{% trans "Description:" %}</label>
<label class="label" for="id_description_{{ book.id }}">{% trans "Description:" %}</label>
<textarea name="description" cols="None" rows="None" class="textarea" id="id_description_{{ book.id }}"></textarea>
</p>
<div class="field">
@ -326,5 +325,5 @@
{% endblock %}
{% block scripts %}
<script src="{% static "js/vendor/tabs.js" %}"></script>
<script src="{% static "js/vendor/tabs.js" %}?v={{ js_cache }}"></script>
{% endblock %}

View file

@ -42,11 +42,18 @@
</div>
{% endif %}
{% if book %}
<form class="block" name="edit-book" action="{{ book.local_path }}/{% if confirm_mode %}confirm{% else %}edit{% endif %}" method="post" enctype="multipart/form-data">
{% else %}
<form class="block" name="create-book" action="/create-book{% if confirm_mode %}/confirm{% endif %}" method="post" enctype="multipart/form-data">
{% endif %}
<form
class="block"
{% if book %}
name="edit-book"
action="{{ book.local_path }}/{% if confirm_mode %}confirm{% else %}edit{% endif %}"
{% else %}
name="create-book"
action="/create-book{% if confirm_mode %}/confirm{% endif %}"
{% endif %}
method="post"
enctype="multipart/form-data"
>
{% csrf_token %}
{% if confirm_mode %}
@ -220,7 +227,7 @@
<h2 class="title is-4">{% trans "Cover" %}</h2>
<div class="columns">
<div class="column is-3 is-cover">
{% include 'snippets/book_cover.html' with book=book cover_class='is-h-xl-mobile is-w-auto-tablet' %}
{% include 'snippets/book_cover.html' with book=book cover_class='is-h-xl-mobile is-w-auto-tablet' size_mobile='xlarge' size='large' %}
</div>
<div class="column">

View file

@ -1,6 +1,7 @@
{% extends 'snippets/filters_panel/filters_panel.html' %}
{% block filter_fields %}
{% include 'book/search_filter.html' %}
{% include 'book/language_filter.html' %}
{% include 'book/format_filter.html' %}
{% endblock %}

View file

@ -15,7 +15,7 @@
<div class="columns is-gapless mb-6">
<div class="column is-cover">
<a href="{{ book.local_path }}">
{% include 'snippets/book_cover.html' with book=book cover_class='is-w-m is-h-m align to-l-mobile' %}
{% include 'snippets/book_cover.html' with size='medium' book=book cover_class='is-w-m is-h-m align to-l-mobile' %}
</a>
</div>

View file

@ -6,7 +6,6 @@
<div class="columns">
<div class="column">
{% trans "Progress Updates:" %}
</dl>
<ul>
{% if readthrough.finish_date or readthrough.progress %}
<li>

View file

@ -0,0 +1,8 @@
{% extends 'snippets/filters_panel/filter_field.html' %}
{% load i18n %}
{% block filter %}
<label class="label" for="id_search">{% trans "Search editions" %}</label>
<input type="text" class="input" name="q" value="{{ request.GET.q|default:'' }}" id="id_search">
{% endblock %}

View file

@ -14,7 +14,11 @@
<h2 class="modal-card-title is-flex-shrink-1" id="modal_card_title_{{ controls_text }}_{{ controls_uid }}">
{% block modal-title %}{% endblock %}
</h2>
{% include 'snippets/toggle/toggle_button.html' with label=label class="delete" nonbutton=True %}
{% if static %}
<a href="/" class="delete">{{ label }}</a>
{% else %}
{% include 'snippets/toggle/toggle_button.html' with label=label class="delete" nonbutton=True %}
{% endif %}
</header>
{% block modal-form-open %}{% endblock %}
{% if not no_body %}
@ -27,6 +31,10 @@
</footer>
{% block modal-form-close %}{% endblock %}
</div>
{% include 'snippets/toggle/toggle_button.html' with label=label class="modal-close is-large" nonbutton=True %}
{% if static %}
<a href="/" class="modal-close is-large">{{ label }}</a>
{% else %}
{% include 'snippets/toggle/toggle_button.html' with label=label class="modal-close is-large" nonbutton=True %}
{% endif %}
</div>

View file

@ -0,0 +1,11 @@
{% load i18n %}
{% trans "Help" as button_text %}
{% include 'snippets/toggle/open_button.html' with text=button_text class="ml-3 is-rounded is-small is-white p-0 pb-1" icon="question-circle is-size-6" controls_text=controls_text controls_uid=controls_uid %}
<aside class="notification is-hidden transition-y is-pulled-left mb-2" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}">
{% trans "Close" as button_text %}
{% include 'snippets/toggle/close_button.html' with label=button_text class="delete" nonbutton=True controls_text=controls_text controls_uid=controls_uid %}
{% block tooltip_content %}{% endblock %}
</aside>

View file

@ -10,7 +10,7 @@
<a
class="align to-b to-l"
href="{{ book.local_path }}"
>{% include 'snippets/book_cover.html' with cover_class='is-w-l-mobile is-w-auto-tablet' %}</a>
>{% include 'snippets/book_cover.html' with cover_class='is-w-l-mobile is-w-auto-tablet' size='xxlarge' %}</a>
{% include 'snippets/stars.html' with rating=book|rating:request.user %}
<h3 class="title is-6">

View file

@ -6,7 +6,7 @@
{% if status.book or status.mention_books.exists %}
{% load_book status as book %}
<a href="{{ book.local_path }}">
{% include 'snippets/book_cover.html' with cover_class='is-w-l-mobile is-w-auto align to-b to-l' %}
{% include 'snippets/book_cover.html' with cover_class='is-w-l-mobile is-w-auto align to-b to-l' size='xxlarge' %}
</a>
<div class="block mt-2">

View file

@ -1,4 +1,4 @@
<html>
<html lang="{% get_lang %}">
<body>
<div>
<strong>Subject:</strong> {% include subject_path %}

View file

@ -19,10 +19,10 @@
{# announcements and system messages #}
{% if not activities.number > 1 %}
<a href="{{ request.path }}" class="transition-y is-hidden notification is-primary is-block" data-poll-wrapper>
{% blocktrans %}load <span data-poll="stream/{{ tab.key }}">0</span> unread status(es){% endblocktrans %}
{% blocktrans with tab_key=tab.key %}load <span data-poll="stream/{{ tab_key }}">0</span> unread status(es){% endblocktrans %}
</a>
{% if request.user.show_goal and not goal and tab.key == streams.first.key %}
{% if request.user.show_goal and not goal and tab.key == 'home' %}
{% now 'Y' as year %}
<section class="block">
{% include 'snippets/goal_card.html' with year=year %}
@ -37,17 +37,16 @@
<div class="block content">
<p>{% trans "There aren't any activities right now! Try following a user to get started" %}</p>
{% if suggested_users %}
{% if request.user.show_suggested_users and suggested_users %}
{# suggested users for when things are very lonely #}
{% include 'feed/suggested_users.html' with suggested_users=suggested_users %}
{% endif %}
</div>
{% endif %}
{% endif %}
{% for activity in activities %}
{% if not activities.number > 1 and forloop.counter0 == 2 and suggested_users %}
{% if request.user.show_suggested_users and not activities.number > 1 and forloop.counter0 == 2 and suggested_users %}
{# suggested users on the first page, two statuses down #}
{% include 'feed/suggested_users.html' with suggested_users=suggested_users %}
{% endif %}

View file

@ -106,5 +106,5 @@
{% endblock %}
{% block scripts %}
<script src="{% static "js/vendor/tabs.js" %}"></script>
<script src="{% static "js/vendor/tabs.js" %}?v={{ js_cache }}"></script>
{% endblock %}

View file

@ -1,6 +1,15 @@
{% load i18n %}
<section class="block">
<h2 class="title is-5">{% trans "Who to follow" %}</h2>
<header class="columns">
<div class="column">
<h2 class="title is-5">{% trans "Who to follow" %}</h2>
</div>
<form class="column is-narrow" action="{% url 'hide-suggestions' %}" method="POST">
{% csrf_token %}
{% trans "Don't show suggested users" as button_text %}
<button type="submit" class="delete" title="{{ button_text }}">{{ button_text }}</button>
</form>
</header>
{% include 'snippets/suggested_users.html' with suggested_users=suggested_users %}
<a class="help" href="{% url 'directory' %}">View directory <span class="icon icon-arrow-right"></a>
<a class="help" href="{% url 'directory' %}">{% trans "View directory" %} <span class="icon icon-arrow-right"></span></a>
</section>

View file

@ -1,6 +1,6 @@
{% load i18n %}
<div class="column is-cover">
{% include 'snippets/book_cover.html' with book=book cover_class='is-h-l is-h-m-mobile' %}
{% include 'snippets/book_cover.html' with book=book cover_class='is-h-l is-h-m-mobile' size_mobile='medium' size='large' %}
<div class="select is-small mt-1 mb-3">
<select name="{{ book.id }}" aria-label="{% blocktrans with book_title=book.title %}Have you read {{ book_title }}?{% endblocktrans %}">

View file

@ -12,9 +12,14 @@
<div class="columns">
<div class="column is-half">
<label class="label" for="source">
{% trans "Data source:" %}
</label>
<div class="field">
<label class="label is-pulled-left" for="source">
{% trans "Data source:" %}
</label>
{% include 'import/tooltip.html' with controls_text="goodreads-tooltip" %}
</div>
<div class="select block">
<select name="source" id="source">
<option value="GoodReads" {% if current == 'GoodReads' %}selected{% endif %}>

View file

@ -20,10 +20,10 @@
<dt class="has-text-weight-medium">{% trans "Import completed:" %}</dt>
<dd class="ml-2">{{ task.date_done | naturaltime }}</dd>
</div>
{% elif task.failed %}
<div class="notification is-danger">{% trans "TASK FAILED" %}</div>
{% endif %}
</dl>
{% elif task.failed %}
<div class="notification is-danger">{% trans "TASK FAILED" %}</div>
{% endif %}
</div>
<div class="block">
@ -40,7 +40,7 @@
<div class="block">
<h2 class="title is-4">{% trans "Failed to load" %}</h2>
{% if not job.retry %}
<form name="retry" action="/import/{{ job.id }}" method="post">
<form name="retry" action="/import/{{ job.id }}" method="post" class="box">
{% csrf_token %}
{% with failed_count=failed_items|length %}
@ -84,26 +84,26 @@
<button class="button is-block mt-3" type="submit">{% trans "Retry items" %}</button>
</fieldset>
<hr>
{% else %}
<ul>
{% for item in failed_items %}
<li class="pb-1">
<p>
Line {{ item.index }}:
<strong>{{ item.data.Title }}</strong> by
{{ item.data.Author }}
</p>
<p>
{{ item.fail_reason }}.
</p>
</li>
{% endfor %}
</ul>
{% endif %}
</form>
<hr>
{% else %}
<ul>
{% for item in failed_items %}
<li class="pb-1">
<p>
Line {{ item.index }}:
<strong>{{ item.data.Title }}</strong> by
{{ item.data.Author }}
</p>
<p>
{{ item.fail_reason }}.
</p>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endif %}
@ -132,7 +132,7 @@
<td>
{% if item.book %}
<a href="{{ item.book.local_path }}">
{% include 'snippets/book_cover.html' with book=item.book cover_class='is-h-s' %}
{% include 'snippets/book_cover.html' with book=item.book cover_class='is-h-s' size='small' %}
</a>
{% endif %}
</td>
@ -153,9 +153,8 @@
{% endfor %}
</table>
</div>
</div>
{% endspaceless %}{% endblock %}
{% block scripts %}
<script src="{% static "js/check_all.js" %}"></script>
<script src="{% static "js/check_all.js" %}?v={{ js_cache }}"></script>
{% endblock %}

View file

@ -0,0 +1,8 @@
{% extends 'components/tooltip.html' %}
{% load i18n %}
{% block tooltip_content %}
{% trans 'You can download your GoodReads data from the <a href="https://www.goodreads.com/review/import" target="_blank" rel="noopener">Import/Export page</a> of your GoodReads account.' %}
{% endblock %}

View file

@ -1,33 +0,0 @@
{% extends 'layout.html' %}
{% load i18n %}
{% block title %}{% trans "Search Results" %}{% endblock %}
{% block content %}
{% with book_results|first as local_results %}
<div class="block">
<h1 class="title">{% blocktrans %}Search Results for "{{ query }}"{% endblocktrans %}</h1>
</div>
<div class="block columns">
<div class="column">
<h2 class="title">{% trans "Matching Books" %}</h2>
<section class="block">
{% if not results %}
<p>{% blocktrans %}No books found for "{{ query }}"{% endblocktrans %}</p>
{% else %}
<ul>
{% for result in results %}
<li class="pd-4">
<a href="{{ result.key }}">{% include 'snippets/search_result_text.html' with result=result link=True %}</a>
</li>
{% endfor %}
</ul>
{% endif %}
</section>
<div class="column">
</div>
</div>
{% endwith %}
{% endblock %}

View file

@ -9,7 +9,7 @@
<a
class="align to-b to-l"
href="{{ book.local_path }}"
>{% include 'snippets/book_cover.html' with cover_class='is-w-l-mobile is-w-auto-tablet' %}</a>
>{% include 'snippets/book_cover.html' with cover_class='is-w-l-mobile is-w-auto-tablet' size='xxlarge' %}</a>
{% include 'snippets/stars.html' with rating=book|rating:request.user %}
</div>

View file

@ -4,7 +4,7 @@
{% if book %}
{% with book=book %}
<a href="{{ book.local_path }}">
{% include 'snippets/book_cover.html' with cover_class='is-w-l-mobile is-w-auto align to-b to-l' %}
{% include 'snippets/book_cover.html' with cover_class='is-w-l-mobile is-w-auto align to-b to-l' size='xxlarge' %}
</a>
{% include 'snippets/stars.html' with rating=book|rating:request.user %}

View file

@ -49,7 +49,7 @@
</div>
</form>
<div role="button" tabindex="0" class="navbar-burger pulldown-menu" data-controls="main-nav" aria-expanded="false">
<div role="button" tabindex="0" class="navbar-burger pulldown-menu" data-controls="main_nav" aria-expanded="false">
<div class="navbar-item mt-3">
<div class="icon icon-dots-three-vertical" title="{% trans 'Main navigation menu' %}">
<span class="is-sr-only">{% trans "Main navigation menu" %}</span>
@ -109,17 +109,17 @@
{% trans 'Settings' %}
</a>
</li>
{% if perms.bookwyrm.create_invites or perms.moderate_users %}
{% if perms.bookwyrm.create_invites or perms.moderate_user %}
<li class="navbar-divider" role="presentation"></li>
{% endif %}
{% if perms.bookwyrm.create_invites %}
{% if perms.bookwyrm.create_invites and not site.allow_registration %}
<li>
<a href="{% url 'settings-invite-requests' %}" class="navbar-item">
{% trans 'Invites' %}
</a>
</li>
{% endif %}
{% if perms.bookwyrm.moderate_users %}
{% if perms.bookwyrm.moderate_user %}
<li>
<a href="{% url 'settings-users' %}" class="navbar-item">
{% trans 'Admin' %}
@ -210,6 +210,11 @@
</div>
</div>
<div role="region" aria-live="polite" id="live-messages">
<p id="status-success-message" class="live-message is-sr-only is-hidden">{% trans "Successfully posted status" %}</p>
<p id="status-error-message" class="live-message notification is-danger p-3 pr-5 pl-5 is-hidden">{% trans "Error posting status" %}</p>
</div>
<footer class="footer">
<div class="container">
<div class="columns">
@ -249,8 +254,11 @@
<script>
var csrf_token = '{{ csrf_token }}';
</script>
<script src="{% static "js/bookwyrm.js" %}"></script>
<script src="{% static "js/localstorage.js" %}"></script>
<script src="{% static "js/bookwyrm.js" %}?v={{ js_cache }}"></script>
<script src="{% static "js/localstorage.js" %}?v={{ js_cache }}"></script>
<script src="{% static "js/status_cache.js" %}?v={{ js_cache }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,39 @@
{% load i18n %}
{% load interaction %}
{% if request.user.is_authenticated %}
{% with request.user|saved:list as saved %}
<form
name="save-{{ list.id }}"
action="{% url 'list-save' list.id %}"
method="POST"
class="interaction save_{{ list.id }} {% if saved %}is-hidden{% endif %}"
data-id="save_{{ list.id }}"
>
{% csrf_token %}
{% trans "Save" as text %}
<button class="button" type="submit">
<span class="icon icon-bookmark m-0-mobile" title="{{ text }}"></span>
<span class="is-sr-only-mobile">{{ text }}</span>
</button>
</form>
<form
name="unsave-{{ list.id }}"
action="{% url 'list-unsave' list.id %}"
method="POST"
class="interaction save_{{ list.id }} active {% if not saved %}is-hidden{% endif %}"
data-id="save_{{ list.id }}"
>
{% csrf_token %}
{% trans "Un-save" as text %}
<button class="button" type="submit">
<span class="icon icon-bookmark m-0-mobile has-text-primary" title="{{ text }}"></span>
<span class="is-sr-only-mobile">{{ text }}</span>
</button>
</form>
{% endwith %}
{% endif %}

View file

@ -1,4 +1,4 @@
{% extends 'lists/list_layout.html' %}
{% extends 'lists/layout.html' %}
{% load i18n %}
{% block panel %}
@ -32,7 +32,7 @@
href="{{ book.local_path }}"
aria-hidden="true"
>
{% include 'snippets/book_cover.html' with cover_class='is-w-xs-mobile is-w-s is-h-xs-mobile is-h-s' %}
{% include 'snippets/book_cover.html' with cover_class='is-w-xs-mobile is-w-s is-h-xs-mobile is-h-s' size_mobile='xsmall' size='small' %}
</a>
<div class="column ml-3">

View file

@ -0,0 +1,21 @@
{% extends 'components/modal.html' %}
{% load i18n %}
{% block modal-title %}{% trans "Delete this list?" %}{% endblock %}
{% block modal-body %}
{% trans "This action cannot be un-done" %}
{% endblock %}
{% block modal-footer %}
<form name="delete-list-{{ list.id }}" action="{% url 'delete-list' list.id %}" method="POST">
{% csrf_token %}
<input type="hidden" name="id" value="{{ list.id }}">
<button class="button is-danger" type="submit">
{% trans "Delete" %}
</button>
{% trans "Cancel" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="delete_list" controls_uid=list.id %}
</form>
{% endblock %}

View file

@ -9,4 +9,5 @@
<form name="edit-list" method="post" action="{% url 'list' list.id %}">
{% include 'lists/form.html' %}
</form>
{% include "lists/delete_list_modal.html" with controls_text="delete_list" controls_uid=list.id %}
{% endblock %}

View file

@ -3,7 +3,7 @@
<input type="hidden" name="user" value="{{ request.user.id }}">
<div class="columns">
<div class="column">
<div class="column is-two-thirds">
<div class="field">
<label class="label" for="id_name">{% trans "Name:" %}</label>
{{ list_form.name }}
@ -34,12 +34,19 @@
</fieldset>
</div>
</div>
<div class="field has-addons">
<div class="control">
{% include 'snippets/privacy_select.html' with current=list.privacy %}
<div class="columns is-mobile">
<div class="column">
<div class="field has-addons">
<div class="control">
{% include 'snippets/privacy_select.html' with current=list.privacy %}
</div>
<div class="control">
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
</div>
</div>
</div>
<div class="control">
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
<div class="column is-narrow">
{% trans "Delete list" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with class="is-danger" text=button_text icon_with_text="x" controls_text="delete_list" controls_uid=list.id focus="modal_title_delete_list" %}
</div>
</div>

View file

@ -11,12 +11,13 @@
{% include 'lists/created_text.html' with list=list %}
</p>
</div>
{% if request.user == list.user %}
<div class="column is-narrow">
{% trans "Edit List" as button_text %}
{% include 'snippets/toggle/open_button.html' with text=button_text icon_with_text="pencil" controls_text="edit_list" focus="edit_list_header" %}
<div class="column is-narrow is-flex">
{% if request.user == list.user %}
{% trans "Edit List" as button_text %}
{% include 'snippets/toggle/open_button.html' with text=button_text icon_with_text="pencil" controls_text="edit_list" focus="edit_list_header" %}
{% endif %}
{% include "lists/bookmark_button.html" with list=list %}
</div>
{% endif %}
</header>
<div class="block content">

View file

@ -1,4 +1,4 @@
{% extends 'lists/list_layout.html' %}
{% extends 'lists/layout.html' %}
{% load i18n %}
{% load bookwyrm_tags %}
{% load markdown %}
@ -41,7 +41,7 @@
>
<div class="column is-3-mobile is-2-tablet is-cover align to-t">
<a href="{{ item.book.local_path }}" aria-hidden="true">
{% include 'snippets/book_cover.html' with cover_class='is-w-auto is-h-m-tablet is-align-items-flex-start' %}
{% include 'snippets/book_cover.html' with cover_class='is-w-auto is-h-m-tablet is-align-items-flex-start' size='medium' %}
</a>
</div>
@ -161,7 +161,7 @@
href="{{ book.local_path }}"
aria-hidden="true"
>
{% include 'snippets/book_cover.html' with book=book cover_class='is-w-auto is-h-s-mobile align to-t' %}
{% include 'snippets/book_cover.html' with book=book cover_class='is-w-auto is-h-s-mobile align to-t' size='small' %}
</a>
<div class="column ml-3">

View file

@ -1,4 +1,7 @@
{% load i18n %}
{% load markdown %}
{% load interaction %}
<div class="columns is-multiline">
{% for list in lists %}
<div class="column is-one-quarter">
@ -7,6 +10,14 @@
<h4 class="card-header-title">
<a href="{{ list.local_path }}">{{ list.name }}</a> <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=list %}</span>
</h4>
{% if request.user.is_authenticated and request.user|saved:list %}
<div class="card-header-icon">
{% trans "Saved" as text %}
<span class="icon icon-bookmark has-text-grey" title="{{ text }}">
<span class="is-sr-only">{{ text }}</span>
</span>
</div>
{% endif %}
</header>
{% with list_books=list.listitem_set.all|slice:5 %}
@ -14,7 +25,7 @@
<div class="card-image columns is-mobile is-gapless is-clipped">
{% for book in list_books %}
<a class="column is-cover" href="{{ book.book.local_path }}">
{% include 'snippets/book_cover.html' with book=book.book cover_class='is-h-s' aria='show' %}
{% include 'snippets/book_cover.html' with book=book.book cover_class='is-h-s' size='small' aria='show' %}
</a>
{% endfor %}
</div>

View file

@ -27,6 +27,21 @@
{% include 'lists/create_form.html' with controls_text="create_list" %}
</div>
{% if request.user.is_authenticated %}
<nav class="tabs">
<ul>
{% url 'lists' as url %}
<li{% if request.path in url %} class="is-active"{% endif %}>
<a href="{{ url }}">{% trans "All Lists" %}</a>
</li>
{% url 'saved-lists' as url %}
<li{% if url in request.path %} class="is-active"{% endif %}>
<a href="{{ url }}">{% trans "Saved Lists" %}</a>
</li>
</ul>
</nav>
{% endif %}
{% if lists %}
<section class="block">
{% include 'lists/list_items.html' with lists=lists %}

View file

@ -1,4 +1,4 @@
{% extends 'settings/admin_layout.html' %}
{% extends 'settings/layout.html' %}
{% load i18n %}
{% load humanize %}

View file

@ -1,4 +1,4 @@
{% extends 'settings/admin_layout.html' %}
{% extends 'settings/layout.html' %}
{% load i18n %}
{% block title %}

View file

@ -43,9 +43,19 @@
</div>
<div class="block">
<label class="checkbox label" for="id_show_goal">
{% trans "Show set reading goal prompt in feed:" %}
{% trans "Show reading goal prompt in feed:" %}
{{ form.show_goal }}
</label>
<label class="checkbox label" for="id_show_goal">
{% trans "Show suggested users:" %}
{{ form.show_suggested_users }}
</label>
<label class="checkbox label" for="id_discoverable">
{% trans "Show this account in suggested users:" %}
{{ form.discoverable }}
</label>
{% url 'directory' as path %}
<p class="help">{% blocktrans %}Your account will show up in the <a href="{{ path }}">directory</a>, and may be recommended to other BookWyrm users.{% endblocktrans %}</p>
</div>
<div class="block">
<label class="checkbox label" for="id_manually_approves_followers">
@ -61,14 +71,6 @@
{{ form.default_post_privacy }}
</div>
</div>
<div class="block">
<label class="checkbox label" for="id_discoverable">
{% trans "Show this account in suggested users:" %}
{{ form.discoverable }}
</label>
{% url 'directory' as path %}
<p class="help">{% blocktrans %}Your account will show up in the <a href="{{ path }}">directory</a>, and may be recommended to other BookWyrm users.{% endblocktrans %}</p>
</div>
<div class="block">
<label class="label" for="id_preferred_timezone">{% trans "Preferred Timezone: " %}</label>
<div class="select">

View file

@ -9,6 +9,6 @@ Finish "{{ book_title }}"
{% block content %}
{% include "snippets/reading_modals/finish_reading_modal.html" with book=book active=True %}
{% include "snippets/reading_modals/finish_reading_modal.html" with book=book active=True static=True %}
{% endblock %}

View file

@ -9,6 +9,6 @@ Start "{{ book_title }}"
{% block content %}
{% include "snippets/reading_modals/start_reading_modal.html" with book=book active=True %}
{% include "snippets/reading_modals/start_reading_modal.html" with book=book active=True static=True %}
{% endblock %}

View file

@ -9,6 +9,6 @@ Want to Read "{{ book_title }}"
{% block content %}
{% include "snippets/reading_modals/want_to_read_modal.html" with book=book active=True %}
{% include "snippets/reading_modals/want_to_read_modal.html" with book=book active=True static=True %}
{% endblock %}

View file

@ -1,4 +1,4 @@
{% extends 'settings/admin_layout.html' %}
{% extends 'settings/layout.html' %}
{% load i18n %}{% load humanize %}
{% block title %}{% trans "Announcement" %} - {{ announcement.preview }}{% endblock %}

View file

@ -13,21 +13,21 @@
{% csrf_token %}
<input type="hidden" name="user" value="{{ request.user.id }}">
<p>
<label class="label" for="id_preview">Preview:</label>
<label class="label" for="id_preview">{% trans "Preview:" %}</label>
{{ form.preview }}
{% for error in form.preview.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</p>
<p>
<label class="label" for="id_content">Content:</label>
<label class="label" for="id_content">{% trans "Content:" %}</label>
{{ form.content }}
{% for error in form.content.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</p>
<p>
<label class="label" for="id_event_date">Event date:</label>
<label class="label" for="id_event_date">{% trans "Event date:" %}</label>
<input type="date" name="event_date" value="{{ form.event_date.value|date:'Y-m-d' }}" class="input" id="id_event_date">
{% for error in form.event_date.errors %}
<p class="help is-danger">{{ error | escape }}</p>
@ -37,7 +37,7 @@
<div class="columns">
<div class="column">
<p>
<label class="label" for="id_start_date">Start date:</label>
<label class="label" for="id_start_date">{% trans "Start date:" %}</label>
<input type="date" name="start_date" class="input" value="{{ form.start_date.value|date:'Y-m-d' }}" id="id_start_date">
{% for error in form.start_date.errors %}
<p class="help is-danger">{{ error | escape }}</p>
@ -46,7 +46,7 @@
</div>
<div class="column">
<p>
<label class="label" for="id_end_date">End date:</label>
<label class="label" for="id_end_date">{% trans "End date:" %}</label>
<input type="date" name="end_date" class="input" id="id_end_date" value="{{ form.end_date.value|date:'Y-m-d' }}">
{% for error in form.end_date.errors %}
<p class="help is-danger">{{ error | escape }}</p>
@ -55,7 +55,7 @@
</div>
<div class="column is-narrow">
<p>
<label class="label" for="id_active">Active:</label>
<label class="label" for="id_active">{% trans "Active:" %}</label>
{{ form.active }}
{% for error in form.active.errors %}
<p class="help is-danger">{{ error | escape }}</p>

View file

@ -1,4 +1,4 @@
{% extends 'settings/admin_layout.html' %}
{% extends 'settings/layout.html' %}
{% load i18n %}{% load humanize %}
{% block title %}{% trans "Announcements" %}{% endblock %}
@ -7,7 +7,6 @@
{% block edit-button %}
{% trans "Create Announcement" as button_text %}
{% include 'snippets/toggle/open_button.html' with controls_text="create_announcement" icon_with_text="plus" text=button_text focus="create_announcement_header" %}
</a>
{% endblock %}
{% block panel %}

View file

@ -0,0 +1,30 @@
{% extends 'components/inline_form.html' %}
{% load i18n %}
{% block header %}
{% trans "Add domain" %}
{% endblock %}
{% block form %}
<form name="add-domain" method="post" action="{% url 'settings-email-blocks' %}">
{% csrf_token %}
<label class="label" for="id_event_date">{% trans "Domain:" %}</label>
<div class="field has-addons">
<div class="control">
<div class="button is-disabled">@</div>
</div>
<div class="control">
{{ form.domain }}
</div>
</div>
{% for error in form.domain.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<div class="field">
<div class="control">
<button type="submit" class="button is-primary">{% trans "Add" %}</button>
</div>
</div>
</form>
{% endblock %}

View file

@ -1,4 +1,4 @@
{% extends 'settings/admin_layout.html' %}
{% extends 'settings/layout.html' %}
{% load i18n %}
{% block title %}{% trans "Add instance" %}{% endblock %}

View file

@ -0,0 +1,62 @@
{% extends 'settings/layout.html' %}
{% load i18n %}
{% load humanize %}
{% block title %}{% trans "Email Blocklist" %}{% endblock %}
{% block header %}{% trans "Email Blocklist" %}{% endblock %}
{% block edit-button %}
{% trans "Add domain" as button_text %}
{% include 'snippets/toggle/open_button.html' with controls_text="add_domain" icon_with_text="plus" text=button_text focus="add_domain_header" %}
{% endblock %}
{% block panel %}
{% include 'settings/domain_form.html' with controls_text="add_domain" class="block" %}
<p class="notification block">
{% trans "When someone tries to register with an email from this domain, no account will be created. The registration process will appear to have worked." %}
</p>
<table class="table is-striped is-fullwidth">
<tr>
{% url 'settings-federation' as url %}
<th>
{% trans "Domain" as text %}
{% include 'snippets/table-sort-header.html' with field="server_name" sort=sort text=text %}
</th>
<th></th>
<th>
{% trans "Options" %}
</th>
</tr>
{% for domain in domains %}
<tr>
<td>{{ domain.domain }}</td>
<td>
<a href="{% url 'settings-users' %}?email=@{{ domain.domain }}">
{% with user_count=domain.users.count %}
{% blocktrans trimmed count conter=user_count with display_count=user_count|intcomma %}
{{ display_count }} user
{% plural %}
{{ display_count }} users
{% endblocktrans %}
{% endwith %}
</a>
</td>
<td>
<form name="remove-{{ domain.id }}" action="{% url 'settings-email-blocks-delete' domain.id %}" method="post">
{% csrf_token %}
{% trans "Delete" as button_text %}
<button class="button" type="submit">
<span class="icon icon-x" title="{{ button_text }}" aria-hidden="true"></span>
<span class="is-hidden-mobile">{{ button_text }}</span>
</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% endblock %}

View file

@ -1,4 +1,4 @@
{% extends 'settings/admin_layout.html' %}
{% extends 'settings/layout.html' %}
{% load i18n %}
{% load markdown %}

View file

@ -1,4 +1,4 @@
{% extends 'settings/admin_layout.html' %}
{% extends 'settings/layout.html' %}
{% load i18n %}
{% block title %}{% trans "Federated Instances" %}{% endblock %}

View file

@ -21,22 +21,35 @@
{% if perms.bookwyrm.create_invites %}
<h2 class="menu-label">{% trans "Manage Users" %}</h2>
<ul class="menu-list">
{% if perms.bookwyrm.moderate_user %}
<li>
{% url 'settings-users' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Users" %}</a>
</li>
{% endif %}
<li>
{% url 'settings-invite-requests' as url %}
{% url 'settings-invites' as alt_url %}
<a href="{{ url }}"{% if url in request.path or request.path in alt_url %} class="is-active" aria-selected="true"{% endif %}>{% trans "Invites" %}</a>
</li>
{% if perms.bookwyrm.control_federation %}
<li>
{% url 'settings-federation' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Federated Instances" %}</a>
</li>
{% endif %}
</ul>
{% endif %}
{% if perms.bookwyrm.moderate_user %}
<h2 class="menu-label">{% trans "Moderation" %}</h2>
<ul class="menu-list">
<li>
{% url 'settings-reports' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Reports" %}</a>
</li>
<li>
{% url 'settings-federation' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Federated Instances" %}</a>
{% url 'settings-email-blocks' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Email Blocklist" %}</a>
</li>
</ul>
{% endif %}

Some files were not shown because too many files have changed in this diff Show more