Merge branch 'main' into url-names

This commit is contained in:
Mouse Reeve 2022-03-16 16:32:07 -07:00 committed by GitHub
commit 0cf2c07069
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
78 changed files with 5090 additions and 3682 deletions

1
.prettierignore Normal file
View file

@ -0,0 +1 @@
**/vendor/*

View file

@ -1,576 +0,0 @@
""" using django model forms """
import datetime
from collections import defaultdict
from urllib.parse import urlparse
from django import forms
from django.forms import ModelForm, PasswordInput, widgets, ChoiceField
from django.forms.widgets import Textarea
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from bookwyrm import models
from bookwyrm.models.fields import ClearableFileInputWithWarning
from bookwyrm.models.user import FeedFilterChoices
class CustomForm(ModelForm):
"""add css classes to the forms"""
def __init__(self, *args, **kwargs):
css_classes = defaultdict(lambda: "")
css_classes["text"] = "input"
css_classes["password"] = "input"
css_classes["email"] = "input"
css_classes["number"] = "input"
css_classes["checkbox"] = "checkbox"
css_classes["textarea"] = "textarea"
# pylint: disable=super-with-arguments
super(CustomForm, self).__init__(*args, **kwargs)
for visible in self.visible_fields():
if hasattr(visible.field.widget, "input_type"):
input_type = visible.field.widget.input_type
if isinstance(visible.field.widget, Textarea):
input_type = "textarea"
visible.field.widget.attrs["rows"] = 5
visible.field.widget.attrs["class"] = css_classes[input_type]
# pylint: disable=missing-class-docstring
class LoginForm(CustomForm):
class Meta:
model = models.User
fields = ["localname", "password"]
help_texts = {f: None for f in fields}
widgets = {
"password": PasswordInput(),
}
class RegisterForm(CustomForm):
class Meta:
model = models.User
fields = ["localname", "email", "password"]
help_texts = {f: None for f in fields}
widgets = {"password": PasswordInput()}
def clean(self):
"""Check if the username is taken"""
cleaned_data = super().clean()
localname = cleaned_data.get("localname").strip()
if models.User.objects.filter(localname=localname).first():
self.add_error("localname", _("User with this username already exists"))
class RatingForm(CustomForm):
class Meta:
model = models.ReviewRating
fields = ["user", "book", "rating", "privacy"]
class ReviewForm(CustomForm):
class Meta:
model = models.Review
fields = [
"user",
"book",
"name",
"content",
"rating",
"content_warning",
"sensitive",
"privacy",
]
class CommentForm(CustomForm):
class Meta:
model = models.Comment
fields = [
"user",
"book",
"content",
"content_warning",
"sensitive",
"privacy",
"progress",
"progress_mode",
"reading_status",
]
class QuotationForm(CustomForm):
class Meta:
model = models.Quotation
fields = [
"user",
"book",
"quote",
"content",
"content_warning",
"sensitive",
"privacy",
"position",
"position_mode",
]
class ReplyForm(CustomForm):
class Meta:
model = models.Status
fields = [
"user",
"content",
"content_warning",
"sensitive",
"reply_parent",
"privacy",
]
class StatusForm(CustomForm):
class Meta:
model = models.Status
fields = ["user", "content", "content_warning", "sensitive", "privacy"]
class DirectForm(CustomForm):
class Meta:
model = models.Status
fields = ["user", "content", "content_warning", "sensitive", "privacy"]
class EditUserForm(CustomForm):
class Meta:
model = models.User
fields = [
"avatar",
"name",
"email",
"summary",
"show_goal",
"show_suggested_users",
"manually_approves_followers",
"default_post_privacy",
"discoverable",
"hide_follows",
"preferred_timezone",
"preferred_language",
"theme",
]
help_texts = {f: None for f in fields}
widgets = {
"avatar": ClearableFileInputWithWarning(
attrs={"aria-describedby": "desc_avatar"}
),
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
"summary": forms.Textarea(attrs={"aria-describedby": "desc_summary"}),
"email": forms.EmailInput(attrs={"aria-describedby": "desc_email"}),
"discoverable": forms.CheckboxInput(
attrs={"aria-describedby": "desc_discoverable"}
),
}
class LimitedEditUserForm(CustomForm):
class Meta:
model = models.User
fields = [
"avatar",
"name",
"summary",
"manually_approves_followers",
"discoverable",
]
help_texts = {f: None for f in fields}
widgets = {
"avatar": ClearableFileInputWithWarning(
attrs={"aria-describedby": "desc_avatar"}
),
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
"summary": forms.Textarea(attrs={"aria-describedby": "desc_summary"}),
"discoverable": forms.CheckboxInput(
attrs={"aria-describedby": "desc_discoverable"}
),
}
class DeleteUserForm(CustomForm):
class Meta:
model = models.User
fields = ["password"]
class UserGroupForm(CustomForm):
class Meta:
model = models.User
fields = ["groups"]
class FeedStatusTypesForm(CustomForm):
class Meta:
model = models.User
fields = ["feed_status_types"]
help_texts = {f: None for f in fields}
widgets = {
"feed_status_types": widgets.CheckboxSelectMultiple(
choices=FeedFilterChoices,
),
}
class CoverForm(CustomForm):
class Meta:
model = models.Book
fields = ["cover"]
help_texts = {f: None for f in fields}
class LinkDomainForm(CustomForm):
class Meta:
model = models.LinkDomain
fields = ["name"]
class FileLinkForm(CustomForm):
class Meta:
model = models.FileLink
fields = ["url", "filetype", "availability", "book", "added_by"]
def clean(self):
"""make sure the domain isn't blocked or pending"""
cleaned_data = super().clean()
url = cleaned_data.get("url")
filetype = cleaned_data.get("filetype")
book = cleaned_data.get("book")
domain = urlparse(url).netloc
if models.LinkDomain.objects.filter(domain=domain).exists():
status = models.LinkDomain.objects.get(domain=domain).status
if status == "blocked":
# pylint: disable=line-too-long
self.add_error(
"url",
_(
"This domain is blocked. Please contact your administrator if you think this is an error."
),
)
elif models.FileLink.objects.filter(
url=url, book=book, filetype=filetype
).exists():
# pylint: disable=line-too-long
self.add_error(
"url",
_(
"This link with file type has already been added for this book. If it is not visible, the domain is still pending."
),
)
class EditionForm(CustomForm):
class Meta:
model = models.Edition
exclude = [
"remote_id",
"origin_id",
"created_date",
"updated_date",
"edition_rank",
"authors",
"parent_work",
"shelves",
"connector",
"search_vector",
"links",
"file_links",
]
widgets = {
"title": forms.TextInput(attrs={"aria-describedby": "desc_title"}),
"subtitle": forms.TextInput(attrs={"aria-describedby": "desc_subtitle"}),
"description": forms.Textarea(
attrs={"aria-describedby": "desc_description"}
),
"series": forms.TextInput(attrs={"aria-describedby": "desc_series"}),
"series_number": forms.TextInput(
attrs={"aria-describedby": "desc_series_number"}
),
"languages": forms.TextInput(
attrs={"aria-describedby": "desc_languages_help desc_languages"}
),
"publishers": forms.TextInput(
attrs={"aria-describedby": "desc_publishers_help desc_publishers"}
),
"first_published_date": forms.SelectDateWidget(
attrs={"aria-describedby": "desc_first_published_date"}
),
"published_date": forms.SelectDateWidget(
attrs={"aria-describedby": "desc_published_date"}
),
"cover": ClearableFileInputWithWarning(
attrs={"aria-describedby": "desc_cover"}
),
"physical_format": forms.Select(
attrs={"aria-describedby": "desc_physical_format"}
),
"physical_format_detail": forms.TextInput(
attrs={"aria-describedby": "desc_physical_format_detail"}
),
"pages": forms.NumberInput(attrs={"aria-describedby": "desc_pages"}),
"isbn_13": forms.TextInput(attrs={"aria-describedby": "desc_isbn_13"}),
"isbn_10": forms.TextInput(attrs={"aria-describedby": "desc_isbn_10"}),
"openlibrary_key": forms.TextInput(
attrs={"aria-describedby": "desc_openlibrary_key"}
),
"inventaire_id": forms.TextInput(
attrs={"aria-describedby": "desc_inventaire_id"}
),
"oclc_number": forms.TextInput(
attrs={"aria-describedby": "desc_oclc_number"}
),
"ASIN": forms.TextInput(attrs={"aria-describedby": "desc_ASIN"}),
}
class AuthorForm(CustomForm):
class Meta:
model = models.Author
fields = [
"last_edited_by",
"name",
"aliases",
"bio",
"wikipedia_link",
"born",
"died",
"openlibrary_key",
"inventaire_id",
"librarything_key",
"goodreads_key",
"isni",
]
widgets = {
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
"aliases": forms.TextInput(attrs={"aria-describedby": "desc_aliases"}),
"bio": forms.Textarea(attrs={"aria-describedby": "desc_bio"}),
"wikipedia_link": forms.TextInput(
attrs={"aria-describedby": "desc_wikipedia_link"}
),
"born": forms.SelectDateWidget(attrs={"aria-describedby": "desc_born"}),
"died": forms.SelectDateWidget(attrs={"aria-describedby": "desc_died"}),
"oepnlibrary_key": forms.TextInput(
attrs={"aria-describedby": "desc_oepnlibrary_key"}
),
"inventaire_id": forms.TextInput(
attrs={"aria-describedby": "desc_inventaire_id"}
),
"librarything_key": forms.TextInput(
attrs={"aria-describedby": "desc_librarything_key"}
),
"goodreads_key": forms.TextInput(
attrs={"aria-describedby": "desc_goodreads_key"}
),
}
class ImportForm(forms.Form):
csv_file = forms.FileField()
class ExpiryWidget(widgets.Select):
def value_from_datadict(self, data, files, name):
"""human-readable exiration time buckets"""
selected_string = super().value_from_datadict(data, files, name)
if selected_string == "day":
interval = datetime.timedelta(days=1)
elif selected_string == "week":
interval = datetime.timedelta(days=7)
elif selected_string == "month":
interval = datetime.timedelta(days=31) # Close enough?
elif selected_string == "forever":
return None
else:
return selected_string # This will raise
return timezone.now() + interval
class InviteRequestForm(CustomForm):
def clean(self):
"""make sure the email isn't in use by a registered user"""
cleaned_data = super().clean()
email = cleaned_data.get("email")
if email and models.User.objects.filter(email=email).exists():
self.add_error("email", _("A user with this email already exists."))
class Meta:
model = models.InviteRequest
fields = ["email"]
class CreateInviteForm(CustomForm):
class Meta:
model = models.SiteInvite
exclude = ["code", "user", "times_used", "invitees"]
widgets = {
"expiry": ExpiryWidget(
choices=[
("day", _("One Day")),
("week", _("One Week")),
("month", _("One Month")),
("forever", _("Does Not Expire")),
]
),
"use_limit": widgets.Select(
choices=[(i, _(f"{i} uses")) for i in [1, 5, 10, 25, 50, 100]]
+ [(None, _("Unlimited"))]
),
}
class ShelfForm(CustomForm):
class Meta:
model = models.Shelf
fields = ["user", "name", "privacy", "description"]
class GoalForm(CustomForm):
class Meta:
model = models.AnnualGoal
fields = ["user", "year", "goal", "privacy"]
class SiteForm(CustomForm):
class Meta:
model = models.SiteSettings
exclude = ["admin_code", "install_mode"]
widgets = {
"instance_short_description": forms.TextInput(
attrs={"aria-describedby": "desc_instance_short_description"}
),
"require_confirm_email": forms.CheckboxInput(
attrs={"aria-describedby": "desc_require_confirm_email"}
),
"invite_request_text": forms.Textarea(
attrs={"aria-describedby": "desc_invite_request_text"}
),
}
class SiteThemeForm(CustomForm):
class Meta:
model = models.SiteSettings
fields = ["default_theme"]
class ThemeForm(CustomForm):
class Meta:
model = models.Theme
fields = ["name", "path"]
widgets = {
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
"path": forms.Select(attrs={"aria-describedby": "desc_path"}),
}
class AnnouncementForm(CustomForm):
class Meta:
model = models.Announcement
exclude = ["remote_id"]
widgets = {
"preview": forms.TextInput(attrs={"aria-describedby": "desc_preview"}),
"content": forms.Textarea(attrs={"aria-describedby": "desc_content"}),
"event_date": forms.SelectDateWidget(
attrs={"aria-describedby": "desc_event_date"}
),
"start_date": forms.SelectDateWidget(
attrs={"aria-describedby": "desc_start_date"}
),
"end_date": forms.SelectDateWidget(
attrs={"aria-describedby": "desc_end_date"}
),
"active": forms.CheckboxInput(attrs={"aria-describedby": "desc_active"}),
}
class ListForm(CustomForm):
class Meta:
model = models.List
fields = ["user", "name", "description", "curation", "privacy", "group"]
class ListItemForm(CustomForm):
class Meta:
model = models.ListItem
fields = ["user", "book", "book_list", "notes"]
class GroupForm(CustomForm):
class Meta:
model = models.Group
fields = ["user", "privacy", "name", "description"]
class ReportForm(CustomForm):
class Meta:
model = models.Report
fields = ["user", "reporter", "status", "links", "note"]
class EmailBlocklistForm(CustomForm):
class Meta:
model = models.EmailBlocklist
fields = ["domain"]
widgets = {
"avatar": forms.TextInput(attrs={"aria-describedby": "desc_domain"}),
}
class IPBlocklistForm(CustomForm):
class Meta:
model = models.IPBlocklist
fields = ["address"]
class ServerForm(CustomForm):
class Meta:
model = models.FederatedServer
exclude = ["remote_id"]
class SortListForm(forms.Form):
sort_by = ChoiceField(
choices=(
("order", _("List Order")),
("title", _("Book Title")),
("rating", _("Rating")),
),
label=_("Sort By"),
)
direction = ChoiceField(
choices=(
("ascending", _("Ascending")),
("descending", _("Descending")),
),
)
class ReadThroughForm(CustomForm):
def clean(self):
"""make sure the email isn't in use by a registered user"""
cleaned_data = super().clean()
start_date = cleaned_data.get("start_date")
finish_date = cleaned_data.get("finish_date")
if start_date and finish_date and start_date > finish_date:
self.add_error(
"finish_date", _("Reading finish date cannot be before start date.")
)
class Meta:
model = models.ReadThrough
fields = ["user", "book", "start_date", "finish_date"]
class AutoModRuleForm(CustomForm):
class Meta:
model = models.AutoMod
fields = ["string_match", "flag_users", "flag_statuses", "created_by"]

View file

@ -0,0 +1,12 @@
""" make forms available to the app """
# site admin
from .admin import *
from .author import *
from .books import *
from .edit_user import *
from .forms import *
from .groups import *
from .landing import *
from .links import *
from .lists import *
from .status import *

141
bookwyrm/forms/admin.py Normal file
View file

@ -0,0 +1,141 @@
""" using django model forms """
import datetime
from django import forms
from django.forms import widgets
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django_celery_beat.models import IntervalSchedule
from bookwyrm import models
from .custom_form import CustomForm
# pylint: disable=missing-class-docstring
class ExpiryWidget(widgets.Select):
def value_from_datadict(self, data, files, name):
"""human-readable exiration time buckets"""
selected_string = super().value_from_datadict(data, files, name)
if selected_string == "day":
interval = datetime.timedelta(days=1)
elif selected_string == "week":
interval = datetime.timedelta(days=7)
elif selected_string == "month":
interval = datetime.timedelta(days=31) # Close enough?
elif selected_string == "forever":
return None
else:
return selected_string # This will raise
return timezone.now() + interval
class CreateInviteForm(CustomForm):
class Meta:
model = models.SiteInvite
exclude = ["code", "user", "times_used", "invitees"]
widgets = {
"expiry": ExpiryWidget(
choices=[
("day", _("One Day")),
("week", _("One Week")),
("month", _("One Month")),
("forever", _("Does Not Expire")),
]
),
"use_limit": widgets.Select(
choices=[(i, _(f"{i} uses")) for i in [1, 5, 10, 25, 50, 100]]
+ [(None, _("Unlimited"))]
),
}
class SiteForm(CustomForm):
class Meta:
model = models.SiteSettings
exclude = ["admin_code", "install_mode"]
widgets = {
"instance_short_description": forms.TextInput(
attrs={"aria-describedby": "desc_instance_short_description"}
),
"require_confirm_email": forms.CheckboxInput(
attrs={"aria-describedby": "desc_require_confirm_email"}
),
"invite_request_text": forms.Textarea(
attrs={"aria-describedby": "desc_invite_request_text"}
),
}
class ThemeForm(CustomForm):
class Meta:
model = models.Theme
fields = ["name", "path"]
widgets = {
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
"path": forms.TextInput(
attrs={
"aria-describedby": "desc_path",
"placeholder": "css/themes/theme-name.scss",
}
),
}
class AnnouncementForm(CustomForm):
class Meta:
model = models.Announcement
exclude = ["remote_id"]
widgets = {
"preview": forms.TextInput(attrs={"aria-describedby": "desc_preview"}),
"content": forms.Textarea(attrs={"aria-describedby": "desc_content"}),
"event_date": forms.SelectDateWidget(
attrs={"aria-describedby": "desc_event_date"}
),
"start_date": forms.SelectDateWidget(
attrs={"aria-describedby": "desc_start_date"}
),
"end_date": forms.SelectDateWidget(
attrs={"aria-describedby": "desc_end_date"}
),
"active": forms.CheckboxInput(attrs={"aria-describedby": "desc_active"}),
}
class EmailBlocklistForm(CustomForm):
class Meta:
model = models.EmailBlocklist
fields = ["domain"]
widgets = {
"avatar": forms.TextInput(attrs={"aria-describedby": "desc_domain"}),
}
class IPBlocklistForm(CustomForm):
class Meta:
model = models.IPBlocklist
fields = ["address"]
class ServerForm(CustomForm):
class Meta:
model = models.FederatedServer
exclude = ["remote_id"]
class AutoModRuleForm(CustomForm):
class Meta:
model = models.AutoMod
fields = ["string_match", "flag_users", "flag_statuses", "created_by"]
class IntervalScheduleForm(CustomForm):
class Meta:
model = IntervalSchedule
fields = ["every", "period"]
widgets = {
"every": forms.NumberInput(attrs={"aria-describedby": "desc_every"}),
"period": forms.Select(attrs={"aria-describedby": "desc_period"}),
}

47
bookwyrm/forms/author.py Normal file
View file

@ -0,0 +1,47 @@
""" using django model forms """
from django import forms
from bookwyrm import models
from .custom_form import CustomForm
# pylint: disable=missing-class-docstring
class AuthorForm(CustomForm):
class Meta:
model = models.Author
fields = [
"last_edited_by",
"name",
"aliases",
"bio",
"wikipedia_link",
"born",
"died",
"openlibrary_key",
"inventaire_id",
"librarything_key",
"goodreads_key",
"isni",
]
widgets = {
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
"aliases": forms.TextInput(attrs={"aria-describedby": "desc_aliases"}),
"bio": forms.Textarea(attrs={"aria-describedby": "desc_bio"}),
"wikipedia_link": forms.TextInput(
attrs={"aria-describedby": "desc_wikipedia_link"}
),
"born": forms.SelectDateWidget(attrs={"aria-describedby": "desc_born"}),
"died": forms.SelectDateWidget(attrs={"aria-describedby": "desc_died"}),
"oepnlibrary_key": forms.TextInput(
attrs={"aria-describedby": "desc_oepnlibrary_key"}
),
"inventaire_id": forms.TextInput(
attrs={"aria-describedby": "desc_inventaire_id"}
),
"librarything_key": forms.TextInput(
attrs={"aria-describedby": "desc_librarything_key"}
),
"goodreads_key": forms.TextInput(
attrs={"aria-describedby": "desc_goodreads_key"}
),
}

87
bookwyrm/forms/books.py Normal file
View file

@ -0,0 +1,87 @@
""" using django model forms """
from django import forms
from bookwyrm import models
from bookwyrm.models.fields import ClearableFileInputWithWarning
from .custom_form import CustomForm
# pylint: disable=missing-class-docstring
class CoverForm(CustomForm):
class Meta:
model = models.Book
fields = ["cover"]
help_texts = {f: None for f in fields}
class ArrayWidget(forms.widgets.TextInput):
# pylint: disable=unused-argument
# pylint: disable=no-self-use
def value_from_datadict(self, data, files, name):
"""get all values for this name"""
return [i for i in data.getlist(name) if i]
class EditionForm(CustomForm):
class Meta:
model = models.Edition
exclude = [
"remote_id",
"origin_id",
"created_date",
"updated_date",
"edition_rank",
"authors",
"parent_work",
"shelves",
"connector",
"search_vector",
"links",
"file_links",
]
widgets = {
"title": forms.TextInput(attrs={"aria-describedby": "desc_title"}),
"subtitle": forms.TextInput(attrs={"aria-describedby": "desc_subtitle"}),
"description": forms.Textarea(
attrs={"aria-describedby": "desc_description"}
),
"series": forms.TextInput(attrs={"aria-describedby": "desc_series"}),
"series_number": forms.TextInput(
attrs={"aria-describedby": "desc_series_number"}
),
"subjects": ArrayWidget(),
"languages": forms.TextInput(
attrs={"aria-describedby": "desc_languages_help desc_languages"}
),
"publishers": forms.TextInput(
attrs={"aria-describedby": "desc_publishers_help desc_publishers"}
),
"first_published_date": forms.SelectDateWidget(
attrs={"aria-describedby": "desc_first_published_date"}
),
"published_date": forms.SelectDateWidget(
attrs={"aria-describedby": "desc_published_date"}
),
"cover": ClearableFileInputWithWarning(
attrs={"aria-describedby": "desc_cover"}
),
"physical_format": forms.Select(
attrs={"aria-describedby": "desc_physical_format"}
),
"physical_format_detail": forms.TextInput(
attrs={"aria-describedby": "desc_physical_format_detail"}
),
"pages": forms.NumberInput(attrs={"aria-describedby": "desc_pages"}),
"isbn_13": forms.TextInput(attrs={"aria-describedby": "desc_isbn_13"}),
"isbn_10": forms.TextInput(attrs={"aria-describedby": "desc_isbn_10"}),
"openlibrary_key": forms.TextInput(
attrs={"aria-describedby": "desc_openlibrary_key"}
),
"inventaire_id": forms.TextInput(
attrs={"aria-describedby": "desc_inventaire_id"}
),
"oclc_number": forms.TextInput(
attrs={"aria-describedby": "desc_oclc_number"}
),
"ASIN": forms.TextInput(attrs={"aria-describedby": "desc_ASIN"}),
}

View file

@ -0,0 +1,26 @@
""" Overrides django's default form class """
from collections import defaultdict
from django.forms import ModelForm
from django.forms.widgets import Textarea
class CustomForm(ModelForm):
"""add css classes to the forms"""
def __init__(self, *args, **kwargs):
css_classes = defaultdict(lambda: "")
css_classes["text"] = "input"
css_classes["password"] = "input"
css_classes["email"] = "input"
css_classes["number"] = "input"
css_classes["checkbox"] = "checkbox"
css_classes["textarea"] = "textarea"
# pylint: disable=super-with-arguments
super(CustomForm, self).__init__(*args, **kwargs)
for visible in self.visible_fields():
if hasattr(visible.field.widget, "input_type"):
input_type = visible.field.widget.input_type
if isinstance(visible.field.widget, Textarea):
input_type = "textarea"
visible.field.widget.attrs["rows"] = 5
visible.field.widget.attrs["class"] = css_classes[input_type]

View file

@ -0,0 +1,68 @@
""" using django model forms """
from django import forms
from bookwyrm import models
from bookwyrm.models.fields import ClearableFileInputWithWarning
from .custom_form import CustomForm
# pylint: disable=missing-class-docstring
class EditUserForm(CustomForm):
class Meta:
model = models.User
fields = [
"avatar",
"name",
"email",
"summary",
"show_goal",
"show_suggested_users",
"manually_approves_followers",
"default_post_privacy",
"discoverable",
"hide_follows",
"preferred_timezone",
"preferred_language",
"theme",
]
help_texts = {f: None for f in fields}
widgets = {
"avatar": ClearableFileInputWithWarning(
attrs={"aria-describedby": "desc_avatar"}
),
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
"summary": forms.Textarea(attrs={"aria-describedby": "desc_summary"}),
"email": forms.EmailInput(attrs={"aria-describedby": "desc_email"}),
"discoverable": forms.CheckboxInput(
attrs={"aria-describedby": "desc_discoverable"}
),
}
class LimitedEditUserForm(CustomForm):
class Meta:
model = models.User
fields = [
"avatar",
"name",
"summary",
"manually_approves_followers",
"discoverable",
]
help_texts = {f: None for f in fields}
widgets = {
"avatar": ClearableFileInputWithWarning(
attrs={"aria-describedby": "desc_avatar"}
),
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
"summary": forms.Textarea(attrs={"aria-describedby": "desc_summary"}),
"discoverable": forms.CheckboxInput(
attrs={"aria-describedby": "desc_discoverable"}
),
}
class DeleteUserForm(CustomForm):
class Meta:
model = models.User
fields = ["password"]

59
bookwyrm/forms/forms.py Normal file
View file

@ -0,0 +1,59 @@
""" using django model forms """
from django import forms
from django.forms import widgets
from django.utils.translation import gettext_lazy as _
from bookwyrm import models
from bookwyrm.models.user import FeedFilterChoices
from .custom_form import CustomForm
# pylint: disable=missing-class-docstring
class FeedStatusTypesForm(CustomForm):
class Meta:
model = models.User
fields = ["feed_status_types"]
help_texts = {f: None for f in fields}
widgets = {
"feed_status_types": widgets.CheckboxSelectMultiple(
choices=FeedFilterChoices,
),
}
class ImportForm(forms.Form):
csv_file = forms.FileField()
class ShelfForm(CustomForm):
class Meta:
model = models.Shelf
fields = ["user", "name", "privacy", "description"]
class GoalForm(CustomForm):
class Meta:
model = models.AnnualGoal
fields = ["user", "year", "goal", "privacy"]
class ReportForm(CustomForm):
class Meta:
model = models.Report
fields = ["user", "reporter", "status", "links", "note"]
class ReadThroughForm(CustomForm):
def clean(self):
"""make sure the email isn't in use by a registered user"""
cleaned_data = super().clean()
start_date = cleaned_data.get("start_date")
finish_date = cleaned_data.get("finish_date")
if start_date and finish_date and start_date > finish_date:
self.add_error(
"finish_date", _("Reading finish date cannot be before start date.")
)
class Meta:
model = models.ReadThrough
fields = ["user", "book", "start_date", "finish_date"]

16
bookwyrm/forms/groups.py Normal file
View file

@ -0,0 +1,16 @@
""" using django model forms """
from bookwyrm import models
from .custom_form import CustomForm
# pylint: disable=missing-class-docstring
class UserGroupForm(CustomForm):
class Meta:
model = models.User
fields = ["groups"]
class GroupForm(CustomForm):
class Meta:
model = models.Group
fields = ["user", "privacy", "name", "description"]

45
bookwyrm/forms/landing.py Normal file
View file

@ -0,0 +1,45 @@
""" Forms for the landing pages """
from django.forms import PasswordInput
from django.utils.translation import gettext_lazy as _
from bookwyrm import models
from .custom_form import CustomForm
# pylint: disable=missing-class-docstring
class LoginForm(CustomForm):
class Meta:
model = models.User
fields = ["localname", "password"]
help_texts = {f: None for f in fields}
widgets = {
"password": PasswordInput(),
}
class RegisterForm(CustomForm):
class Meta:
model = models.User
fields = ["localname", "email", "password"]
help_texts = {f: None for f in fields}
widgets = {"password": PasswordInput()}
def clean(self):
"""Check if the username is taken"""
cleaned_data = super().clean()
localname = cleaned_data.get("localname").strip()
if models.User.objects.filter(localname=localname).first():
self.add_error("localname", _("User with this username already exists"))
class InviteRequestForm(CustomForm):
def clean(self):
"""make sure the email isn't in use by a registered user"""
cleaned_data = super().clean()
email = cleaned_data.get("email")
if email and models.User.objects.filter(email=email).exists():
self.add_error("email", _("A user with this email already exists."))
class Meta:
model = models.InviteRequest
fields = ["email"]

48
bookwyrm/forms/links.py Normal file
View file

@ -0,0 +1,48 @@
""" using django model forms """
from urllib.parse import urlparse
from django.utils.translation import gettext_lazy as _
from bookwyrm import models
from .custom_form import CustomForm
# pylint: disable=missing-class-docstring
class LinkDomainForm(CustomForm):
class Meta:
model = models.LinkDomain
fields = ["name"]
class FileLinkForm(CustomForm):
class Meta:
model = models.FileLink
fields = ["url", "filetype", "availability", "book", "added_by"]
def clean(self):
"""make sure the domain isn't blocked or pending"""
cleaned_data = super().clean()
url = cleaned_data.get("url")
filetype = cleaned_data.get("filetype")
book = cleaned_data.get("book")
domain = urlparse(url).netloc
if models.LinkDomain.objects.filter(domain=domain).exists():
status = models.LinkDomain.objects.get(domain=domain).status
if status == "blocked":
# pylint: disable=line-too-long
self.add_error(
"url",
_(
"This domain is blocked. Please contact your administrator if you think this is an error."
),
)
elif models.FileLink.objects.filter(
url=url, book=book, filetype=filetype
).exists():
# pylint: disable=line-too-long
self.add_error(
"url",
_(
"This link with file type has already been added for this book. If it is not visible, the domain is still pending."
),
)

37
bookwyrm/forms/lists.py Normal file
View file

@ -0,0 +1,37 @@
""" using django model forms """
from django import forms
from django.forms import ChoiceField
from django.utils.translation import gettext_lazy as _
from bookwyrm import models
from .custom_form import CustomForm
# pylint: disable=missing-class-docstring
class ListForm(CustomForm):
class Meta:
model = models.List
fields = ["user", "name", "description", "curation", "privacy", "group"]
class ListItemForm(CustomForm):
class Meta:
model = models.ListItem
fields = ["user", "book", "book_list", "notes"]
class SortListForm(forms.Form):
sort_by = ChoiceField(
choices=(
("order", _("List Order")),
("title", _("Book Title")),
("rating", _("Rating")),
),
label=_("Sort By"),
)
direction = ChoiceField(
choices=(
("ascending", _("Ascending")),
("descending", _("Descending")),
),
)

82
bookwyrm/forms/status.py Normal file
View file

@ -0,0 +1,82 @@
""" using django model forms """
from bookwyrm import models
from .custom_form import CustomForm
# pylint: disable=missing-class-docstring
class RatingForm(CustomForm):
class Meta:
model = models.ReviewRating
fields = ["user", "book", "rating", "privacy"]
class ReviewForm(CustomForm):
class Meta:
model = models.Review
fields = [
"user",
"book",
"name",
"content",
"rating",
"content_warning",
"sensitive",
"privacy",
]
class CommentForm(CustomForm):
class Meta:
model = models.Comment
fields = [
"user",
"book",
"content",
"content_warning",
"sensitive",
"privacy",
"progress",
"progress_mode",
"reading_status",
]
class QuotationForm(CustomForm):
class Meta:
model = models.Quotation
fields = [
"user",
"book",
"quote",
"content",
"content_warning",
"sensitive",
"privacy",
"position",
"position_mode",
]
class ReplyForm(CustomForm):
class Meta:
model = models.Status
fields = [
"user",
"content",
"content_warning",
"sensitive",
"reply_parent",
"privacy",
]
class StatusForm(CustomForm):
class Meta:
model = models.Status
fields = ["user", "content", "content_warning", "sensitive", "privacy"]
class DirectForm(CustomForm):
class Meta:
model = models.Status
fields = ["user", "content", "content_warning", "sensitive", "privacy"]

View file

@ -1,32 +0,0 @@
""" Compile themes """
import os
from django.contrib.staticfiles.utils import get_files
from django.contrib.staticfiles.storage import StaticFilesStorage
from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand
import sass
from sass_processor.processor import SassProcessor
# pylint: disable=line-too-long
class Command(BaseCommand):
"""Compile themes"""
help = "Compile theme scss files"
# pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options):
"""compile themes"""
storage = StaticFilesStorage()
theme_files = list(get_files(storage, location="css/themes"))
theme_files = [t for t in theme_files if t[-5:] == ".scss"]
for filename in theme_files:
path = storage.path(filename)
content = sass.compile(
filename=path,
include_paths=SassProcessor.include_paths,
)
basename, _ = os.path.splitext(path)
destination_filename = basename + ".css"
print(f"saving f{destination_filename}")
storage.save(destination_filename, ContentFile(content))

View file

@ -0,0 +1,54 @@
""" Get your admin code to allow install """
from django.core.management.base import BaseCommand
from bookwyrm import models
from bookwyrm.settings import VERSION
# pylint: disable=no-self-use
class Command(BaseCommand):
"""command-line options"""
help = "What version is this?"
def add_arguments(self, parser):
"""specify which function to run"""
parser.add_argument(
"--current",
action="store_true",
help="Version stored in database",
)
parser.add_argument(
"--target",
action="store_true",
help="Version stored in settings",
)
parser.add_argument(
"--update",
action="store_true",
help="Update database version",
)
# pylint: disable=unused-argument
def handle(self, *args, **options):
"""execute init"""
site = models.SiteSettings.objects.get()
current = site.version or "0.0.1"
target = VERSION
if options.get("current"):
print(current)
return
if options.get("target"):
print(target)
return
if options.get("update"):
site.version = target
site.save()
return
if current != target:
print(f"{current}/{target}")
else:
print(current)

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.12 on 2022-03-16 18:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0144_alter_announcement_display_type"),
]
operations = [
migrations.AddField(
model_name="sitesettings",
name="version",
field=models.CharField(blank=True, max_length=10, null=True),
),
]

View file

@ -27,6 +27,7 @@ class SiteSettings(models.Model):
default_theme = models.ForeignKey(
"Theme", null=True, blank=True, on_delete=models.SET_NULL
)
version = models.CharField(null=True, blank=True, max_length=10)
# admin setup options
install_mode = models.BooleanField(default=False)

View file

@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _
env = Env()
env.read_env()
DOMAIN = env("DOMAIN")
VERSION = "0.3.2"
VERSION = "0.3.4"
RELEASE_API = env(
"RELEASE_API",
@ -21,7 +21,7 @@ RELEASE_API = env(
PAGE_LENGTH = env("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
JS_CACHE = "c7154efb"
JS_CACHE = "bc93172a"
# email
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
@ -90,6 +90,7 @@ INSTALLED_APPS = [
"sass_processor",
"bookwyrm",
"celery",
"django_celery_beat",
"imagekit",
"storages",
]
@ -188,6 +189,7 @@ STATICFILES_FINDERS = [
]
SASS_PROCESSOR_INCLUDE_FILE_PATTERN = r"^.+\.[s]{0,1}(?:a|c)ss$"
SASS_PROCESSOR_ENABLED = True
# minify css is production but not dev
if not DEBUG:

View file

@ -1,6 +1,7 @@
/** Imports
******************************************************************************/
@import "components/avatar";
@import "components/barcode";
@import "components/book_cover";
@import "components/book_grid";
@import "components/book_list";

View file

@ -0,0 +1,26 @@
/* Barcode scanner CSS */
#barcode-scanner {
position: relative;
max-width: 100%;
text-align: center;
height: calc(70vh - 200px);
video {
height: calc(70vh - 200px);
max-width: 100%;
}
canvas {
position: absolute;
top: 0;
left: 0;
right: 0;
margin: auto;
height: calc(70vh - 200px);
max-width: 100%;
}
}
#barcode-camera-list {
float: right;
}

View file

@ -39,6 +39,7 @@
<glyph unicode="&#xe91e;" glyph-name="graphic-heart" d="M934.176 791.52c-116.128 115.072-301.824 117.472-422.112 9.216-120.32 108.256-305.952 105.856-422.144-9.216-119.712-118.528-119.712-310.688 0-429.28 34.208-33.888 353.696-350.112 353.696-350.112 37.856-37.504 99.072-37.504 136.896 0 0 0 349.824 346.304 353.696 350.112 119.744 118.592 119.744 310.752-0.032 429.28zM888.576 407.424l-353.696-350.112c-12.576-12.512-33.088-12.512-45.6 0l-353.696 350.112c-94.4 93.44-94.4 245.472 0 338.912 91.008 90.080 237.312 93.248 333.088 7.104l43.392-39.040 43.36 39.040c95.808 86.144 242.112 83.008 333.12-7.104 94.4-93.408 94.4-245.44 0.032-338.912zM296.096 719.968c8.864 0 16-7.168 16-16s-7.168-16-16-16h-0.032c-57.408 0-103.968-46.56-103.968-103.968v-0.032c0-8.832-7.168-16-16-16s-16 7.168-16 16v0c0 75.072 60.832 135.904 135.872 135.968 0.064 0 0.064 0.032 0.128 0.032z" />
<glyph unicode="&#xe91f;" glyph-name="graphic-paperplane" d="M1009.376 954.88c-5.312 3.424-11.36 5.12-17.376 5.12-6.176 0-12.384-1.76-17.76-5.376l-960-640c-9.888-6.56-15.328-18.112-14.048-29.952 1.216-11.808 8.896-22.016 19.936-26.368l250.368-100.192 117.728-206.016c5.632-9.888 16.096-16 27.424-16.128 0.128 0 0.224 0 0.352 0 11.232 0 21.664 5.952 27.424 15.552l66.464 110.816 310.24-124.064c3.808-1.536 7.808-2.272 11.872-2.272 5.44 0 10.816 1.376 15.68 4.128 8.448 4.736 14.24 13.056 15.872 22.624l160 960c2.080 12.576-3.488 25.184-14.176 32.128zM100.352 295.136l741.6 494.432-539.2-577.184c-2.848 1.696-5.376 3.936-8.512 5.184l-193.888 77.568zM326.048 189.888c-0.064 0.128-0.16 0.192-0.224 0.32l606.176 648.8-516.768-805.184-89.184 156.064zM806.944 12.512l-273.312 109.312c-6.496 2.56-13.248 3.424-19.936 3.808l420.864 652.416-127.616-765.536z" />
<glyph unicode="&#xe920;" glyph-name="graphic-banknote" d="M1005.28 621.248l-320 320c-15.872 15.872-38.88 22.24-60.672 16.864-11.488-2.816-21.76-8.736-29.888-16.864-7.264-7.264-12.736-16.256-15.872-26.304-14.496-47.008-39.552-87.872-76.64-124.928-49.536-49.504-114.048-87.008-182.304-126.656-72.448-41.984-147.296-85.504-208.64-146.816-52.128-52.192-87.616-110.24-108.416-177.632-7.008-22.752-0.896-47.36 15.872-64.192l320-320c15.872-15.872 38.88-22.24 60.672-16.864 11.488 2.88 21.76 8.736 29.888 16.864 7.264 7.264 12.736 16.256 15.872 26.368 14.528 47.008 39.584 87.872 76.704 124.928 49.504 49.504 113.984 86.944 182.304 126.56 72.384 42.048 147.264 85.568 208.576 146.88 52.128 52.128 87.616 110.24 108.448 177.632 6.976 22.72 0.832 47.424-15.904 64.16zM384 0c-105.984 105.984-214.016 214.048-320 320 90.944 294.432 485.12 281.568 576 576 105.984-105.952 214.048-214.016 320.064-320-90.976-294.368-485.152-281.568-576.064-576zM625.984 483.2c-10.432 8.736-20.928 14.688-31.488 17.632-10.496 2.944-20.992 4.128-31.616 3.36-10.496-0.8-21.248-3.2-32-7.328-10.752-4.192-21.568-8.736-32.448-14.016-17.184 19.744-34.368 39.264-51.552 57.376 7.744 7.008 15.264 10.56 22.496 10.816 7.264 0.32 14.24-0.448 20.864-2.112 6.752-1.696 12.928-3.136 18.624-4.256 5.76-1.12 10.752 0.128 15.136 3.808 4.64 4 7.2 9.184 7.552 15.424 0.32 6.304-2.048 12.448-7.328 18.432-6.752 7.744-14.88 12.448-24.64 14.176-9.632 1.696-19.488 1.568-29.76-0.672-10.112-2.304-19.744-6.112-28.864-11.488s-16.448-10.88-21.888-16.256c-2.080 1.984-4.16 3.936-6.24 5.888-2.304 2.112-5.184 3.264-8.64 3.2-3.488 0-6.368-1.504-8.736-4.256-2.304-2.688-3.36-5.824-2.944-9.12 0.32-3.424 1.696-6.048 4.064-8.064 2.080-1.76 4.16-3.488 6.24-5.312-8.192-9.888-14.944-20.8-20.256-32.32-5.376-11.552-8.576-23.008-9.76-34.112-1.248-11.2-0.064-21.44 3.36-30.944 3.424-9.568 9.76-17.696 19.008-25.376 15.072-12.512 32.8-17.824 53.376-16.64 20.512 1.248 42.624 7.36 66.4 20.128 18.88-21.824 37.824-43.488 56.736-63.616-8-6.752-15.008-10.624-21.184-11.872-6.176-1.312-11.68-1.184-16.672 0.32-4.992 1.568-9.632 3.808-13.888 6.688-4.256 2.944-8.448 5.44-12.64 7.488-4.128 2.048-8.384 3.2-12.736 3.264s-8.992-2.048-14.112-6.432c-5.248-4.576-7.872-9.888-7.872-15.872 0-5.952 2.752-12 8.128-18.112 5.44-6.112 12.512-11.264 21.056-15.328s18.208-6.624 28.832-7.328c10.624-0.736 21.824 0.864 33.632 5.248 11.872 4.32 23.616 12.128 35.2 23.744 5.568-5.44 11.2-10.624 16.8-15.616 2.368-2.048 5.248-3.072 8.736-2.816 3.36 0.128 6.304 1.696 8.64 4.512 2.368 2.88 3.36 6.048 3.008 9.376-0.32 3.36-1.696 5.952-4 7.808-5.632 4.512-11.264 9.248-16.864 14.24 9.568 11.744 17.248 24.128 22.944 36.384 5.696 12.32 9.056 24.192 10.176 35.2 1.12 11.072-0.192 21.056-3.808 30.112-3.584 9.184-9.952 17.056-19.072 24.64zM447.072 461.504c-9.056-0.384-16.96 2.624-23.872 9.312-2.944 2.816-4.992 6.24-6.24 10.304-1.312 4.064-1.76 8.512-1.248 13.376 0.448 4.8 1.888 9.824 4.384 14.88 2.368 5.056 5.888 10.112 10.368 15.008 16.224-16.128 32.416-33.824 48.64-52.128-12.288-6.752-22.976-10.368-32.032-10.752zM598.016 397.44c-2.88-5.312-6.176-10.048-10.048-14.176-17.952 18.112-35.872 38.016-53.76 58.432 4.576 2.048 9.376 4.192 14.56 6.368s10.368 3.616 15.552 4.512c5.312 0.8 10.56 0.576 15.808-0.672 5.184-1.312 10.112-4.128 14.688-8.576 4.512-4.512 7.36-9.184 8.512-14.24 1.248-5.12 1.312-10.304 0.448-15.616-0.928-5.344-2.816-10.656-5.76-16.032zM470.944 250.24c6.304 5.088 15.584 4.832 21.376-1.056 6.272-6.24 6.272-16.448 0-22.688-0.512-0.512-1.056-0.864-1.632-1.312l0.064-0.064c-20.256-15.392-36.896-29.248-54.848-47.2-16.224-16.192-30.88-33.248-43.552-50.56l-20.448-28c-0.64-1.152-1.408-2.208-2.368-3.2-6.272-6.24-16.48-6.24-22.72 0-5.44 5.44-6.112 13.824-2.112 20.064l-0.064 0.064 21.888 29.888c13.664 18.688 29.376 36.992 46.752 54.368 18.080 18.144 37.6 34.336 57.6 49.696h0.064zM588.096 713.12c16.192 16.192 30.816 33.184 43.52 50.592l21.248 29.12c0.768 1.376 1.632 2.752 2.816 3.936 6.304 6.304 16.512 6.304 22.816 0 5.984-6.016 6.24-15.52 0.8-21.888l0.064-0.064-21.888-30.016c-13.696-18.688-29.376-36.928-46.752-54.304-18.080-18.080-37.568-34.336-57.568-49.696l-0.128 0.064c-6.368-5.856-16.256-5.728-22.368 0.448-6.304 6.304-6.304 16.576 0 22.88 1.12 1.184 2.432 2.016 3.744 2.752 18.816 14.368 36.96 29.44 53.696 46.176z" />
<glyph unicode="&#xe937;" glyph-name="barcode" d="M0 832h128v-640h-128zM192 832h64v-640h-64zM320 832h64v-640h-64zM512 832h64v-640h-64zM768 832h64v-640h-64zM960 832h64v-640h-64zM640 832h32v-640h-32zM448 832h32v-640h-32zM864 832h32v-640h-32zM0 128h64v-64h-64zM192 128h64v-64h-64zM320 128h64v-64h-64zM640 128h64v-64h-64zM960 128h64v-64h-64zM768 128h128v-64h-128zM448 128h128v-64h-128z" />
<glyph unicode="&#xe97a;" glyph-name="spinner" d="M384 832c0 70.692 57.308 128 128 128s128-57.308 128-128c0-70.692-57.308-128-128-128s-128 57.308-128 128zM655.53 719.53c0 70.692 57.308 128 128 128s128-57.308 128-128c0-70.692-57.308-128-128-128s-128 57.308-128 128zM832 448c0 35.346 28.654 64 64 64s64-28.654 64-64c0-35.346-28.654-64-64-64s-64 28.654-64 64zM719.53 176.47c0 35.346 28.654 64 64 64s64-28.654 64-64c0-35.346-28.654-64-64-64s-64 28.654-64 64zM448.002 64c0 0 0 0 0 0 0 35.346 28.654 64 64 64s64-28.654 64-64c0 0 0 0 0 0 0-35.346-28.654-64-64-64s-64 28.654-64 64zM176.472 176.47c0 0 0 0 0 0 0 35.346 28.654 64 64 64s64-28.654 64-64c0 0 0 0 0 0 0-35.346-28.654-64-64-64s-64 28.654-64 64zM144.472 719.53c0 0 0 0 0 0 0 53.019 42.981 96 96 96s96-42.981 96-96c0 0 0 0 0 0 0-53.019-42.981-96-96-96s-96 42.981-96 96zM56 448c0 39.765 32.235 72 72 72s72-32.235 72-72c0-39.765-32.235-72-72-72s-72 32.235-72 72z" />
<glyph unicode="&#xe986;" glyph-name="search" d="M992.262 88.604l-242.552 206.294c-25.074 22.566-51.89 32.926-73.552 31.926 57.256 67.068 91.842 154.078 91.842 249.176 0 212.078-171.922 384-384 384-212.076 0-384-171.922-384-384s171.922-384 384-384c95.098 0 182.108 34.586 249.176 91.844-1-21.662 9.36-48.478 31.926-73.552l206.294-242.552c35.322-39.246 93.022-42.554 128.22-7.356s31.892 92.898-7.354 128.22zM384 320c-141.384 0-256 114.616-256 256s114.616 256 256 256 256-114.616 256-256-114.614-256-256-256z" />
<glyph unicode="&#xe9d7;" glyph-name="star-empty" d="M1024 562.95l-353.78 51.408-158.22 320.582-158.216-320.582-353.784-51.408 256-249.538-60.432-352.352 316.432 166.358 316.432-166.358-60.434 352.352 256.002 249.538zM512 206.502l-223.462-117.48 42.676 248.83-180.786 176.222 249.84 36.304 111.732 226.396 111.736-226.396 249.836-36.304-180.788-176.222 42.678-248.83-223.462 117.48z" />

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View file

@ -149,3 +149,6 @@
.icon-download:before {
content: "\ea36";
}
.icon-barcode:before {
content: "\e937";
}

View file

@ -1,5 +1,5 @@
/* exported BookWyrm */
/* globals TabGroup */
/* globals TabGroup, Quagga */
let BookWyrm = new (class {
constructor() {
@ -38,15 +38,15 @@ let BookWyrm = new (class {
.querySelectorAll("[data-modal-open]")
.forEach((node) => node.addEventListener("click", this.handleModalButton.bind(this)));
document
.querySelectorAll("[data-duplicate]")
.forEach((node) => node.addEventListener("click", this.duplicateInput.bind(this)));
document
.querySelectorAll("details.dropdown")
.forEach((node) =>
node.addEventListener("toggle", this.handleDetailsDropdown.bind(this))
);
document
.querySelector("#barcode-scanner-modal")
.addEventListener("open", this.openBarcodeScanner.bind(this));
}
/**
@ -427,9 +427,11 @@ let BookWyrm = new (class {
});
modalElement.addEventListener("keydown", handleFocusTrap);
modalElement.dispatchEvent(new Event("open"));
}
function handleModalClose(modalElement) {
modalElement.dispatchEvent(new Event("close"));
modalElement.removeEventListener("keydown", handleFocusTrap);
htmlElement.classList.remove("is-clipped");
modalElement.classList.remove("is-active");
@ -489,26 +491,6 @@ let BookWyrm = new (class {
window.open(url, windowName, "left=100,top=100,width=430,height=600");
}
duplicateInput(event) {
const trigger = event.currentTarget;
const input_id = trigger.dataset.duplicate;
const orig = document.getElementById(input_id);
const parent = orig.parentNode;
const new_count = parent.querySelectorAll("input").length + 1;
let input = orig.cloneNode();
input.id += "-" + new_count;
input.value = "";
let label = parent.querySelector("label").cloneNode();
label.setAttribute("for", input.id);
parent.appendChild(label);
parent.appendChild(input);
}
/**
* Set up a "click-to-copy" component from a textarea element
* with `data-copytext`, `data-copytext-label`, `data-copytext-success`
@ -632,4 +614,174 @@ let BookWyrm = new (class {
}
}
}
openBarcodeScanner(event) {
const scannerNode = document.getElementById("barcode-scanner");
const statusNode = document.getElementById("barcode-status");
const cameraListNode = document.querySelector("#barcode-camera-list > select");
cameraListNode.addEventListener("change", onChangeCamera);
function onChangeCamera(event) {
initBarcodes(event.target.value);
}
function toggleStatus(status) {
for (const child of statusNode.children) {
BookWyrm.toggleContainer(child, !child.classList.contains(status));
}
}
function initBarcodes(cameraId = null) {
toggleStatus("grant-access");
if (!cameraId) {
cameraId = sessionStorage.getItem("preferredCam");
} else {
sessionStorage.setItem("preferredCam", cameraId);
}
scannerNode.replaceChildren();
Quagga.stop();
Quagga.init(
{
inputStream: {
name: "Live",
type: "LiveStream",
target: scannerNode,
constraints: {
facingMode: "environment",
deviceId: cameraId,
},
},
decoder: {
readers: [
"ean_reader",
{
format: "ean_reader",
config: {
supplements: ["ean_2_reader", "ean_5_reader"],
},
},
],
multiple: false,
},
},
(err) => {
if (err) {
scannerNode.replaceChildren();
console.log(err);
toggleStatus("access-denied");
return;
}
let activeId = null;
const track = Quagga.CameraAccess.getActiveTrack();
if (track) {
activeId = track.getSettings().deviceId;
}
Quagga.CameraAccess.enumerateVideoDevices().then((devices) => {
cameraListNode.replaceChildren();
for (const device of devices) {
const child = document.createElement("option");
child.value = device.deviceId;
child.innerText = device.label.slice(0, 30);
if (activeId === child.value) {
child.selected = true;
}
cameraListNode.appendChild(child);
}
});
toggleStatus("scanning");
Quagga.start();
}
);
}
function cleanup(clearDrawing = true) {
Quagga.stop();
cameraListNode.removeEventListener("change", onChangeCamera);
if (clearDrawing) {
scannerNode.replaceChildren();
}
}
Quagga.onProcessed((result) => {
const drawingCtx = Quagga.canvas.ctx.overlay;
const drawingCanvas = Quagga.canvas.dom.overlay;
if (result) {
if (result.boxes) {
drawingCtx.clearRect(
0,
0,
parseInt(drawingCanvas.getAttribute("width")),
parseInt(drawingCanvas.getAttribute("height"))
);
result.boxes
.filter((box) => box !== result.box)
.forEach((box) => {
Quagga.ImageDebug.drawPath(box, { x: 0, y: 1 }, drawingCtx, {
color: "green",
lineWidth: 2,
});
});
}
if (result.box) {
Quagga.ImageDebug.drawPath(result.box, { x: 0, y: 1 }, drawingCtx, {
color: "#00F",
lineWidth: 2,
});
}
if (result.codeResult && result.codeResult.code) {
Quagga.ImageDebug.drawPath(result.line, { x: "x", y: "y" }, drawingCtx, {
color: "red",
lineWidth: 3,
});
}
}
});
let lastDetection = null;
let numDetected = 0;
Quagga.onDetected((result) => {
// Detect the same code 3 times as an extra check to avoid bogus scans.
if (lastDetection === null || lastDetection !== result.codeResult.code) {
numDetected = 1;
lastDetection = result.codeResult.code;
return;
} else if (numDetected++ < 3) {
return;
}
const code = result.codeResult.code;
statusNode.querySelector(".isbn").innerText = code;
toggleStatus("found");
const search = new URL("/search", document.location);
search.searchParams.set("q", code);
cleanup(false);
location.assign(search);
});
event.target.addEventListener("close", cleanup, { once: true });
initBarcodes();
}
})();

View file

@ -0,0 +1,49 @@
(function () {
"use strict";
/**
* Remoev input field
*
* @param {event} the button click event
*/
function removeInput(event) {
const trigger = event.currentTarget;
const input_id = trigger.dataset.remove;
const input = document.getElementById(input_id);
input.remove();
}
/**
* Duplicate an input field
*
* @param {event} the click even on the associated button
*/
function duplicateInput(event) {
const trigger = event.currentTarget;
const input_id = trigger.dataset.duplicate;
const orig = document.getElementById(input_id);
const parent = orig.parentNode;
const new_count = parent.querySelectorAll("input").length + 1;
let input = orig.cloneNode();
input.id += "-" + new_count;
input.value = "";
let label = parent.querySelector("label").cloneNode();
label.setAttribute("for", input.id);
parent.appendChild(label);
parent.appendChild(input);
}
document
.querySelectorAll("[data-duplicate]")
.forEach((node) => node.addEventListener("click", duplicateInput));
document
.querySelectorAll("[data-remove]")
.forEach((node) => node.addEventListener("click", removeInput));
})();

File diff suppressed because one or more lines are too long

View file

@ -12,6 +12,15 @@
{% endblock %}
{% block content %}
{% if update_error %}
<div class="notification is-danger is-light">
<span class="icon icon-x" aria-hidden="true"></span>
<span>
{% trans "Unable to connect to remote source." %}
</span>
</div>
{% endif %}
{% with user_authenticated=request.user.is_authenticated can_edit_book=perms.bookwyrm.edit_book %}
<div class="block" itemscope itemtype="https://schema.org/Book">
<div class="columns is-mobile">

View file

@ -1,4 +1,5 @@
{% load i18n %}
{% load static %}
{% if form.non_field_errors %}
<div class="block">
@ -21,7 +22,7 @@
{% trans "Title:" %}
</label>
<input type="text" name="title" value="{{ form.title.value|default:'' }}" maxlength="255" class="input" required="" id="id_title" aria-describedby="desc_title">
{% include 'snippets/form_errors.html' with errors_list=form.title.errors id="desc_title" %}
</div>
@ -30,7 +31,7 @@
{% trans "Subtitle:" %}
</label>
<input type="text" name="subtitle" value="{{ form.subtitle.value|default:'' }}" maxlength="255" class="input" id="id_subtitle" aria-describedby="desc_subtitle">
{% include 'snippets/form_errors.html' with errors_list=form.subtitle.errors id="desc_subtitle" %}
</div>
@ -39,7 +40,7 @@
{% trans "Description:" %}
</label>
{{ form.description }}
{% include 'snippets/form_errors.html' with errors_list=form.description.errors id="desc_description" %}
</div>
@ -50,7 +51,7 @@
{% trans "Series:" %}
</label>
<input type="text" class="input" name="series" id="id_series" value="{{ form.series.value|default:'' }}" aria-describedby="desc_series">
{% include 'snippets/form_errors.html' with errors_list=form.series.errors id="desc_series" %}
</div>
</div>
@ -60,7 +61,7 @@
{% trans "Series number:" %}
</label>
{{ form.series_number }}
{% include 'snippets/form_errors.html' with errors_list=form.series_number.errors id="desc_series_number" %}
</div>
</div>
@ -74,9 +75,60 @@
<span class="help" id="desc_languages_help">
{% trans "Separate multiple values with commas." %}
</span>
{% include 'snippets/form_errors.html' with errors_list=form.languages.errors id="desc_languages" %}
</div>
<div>
<label class="label" for="id_add_subjects">
{% trans "Subjects:" %}
</label>
{% for subject in book.subjects %}
<label class="label is-sr-only" for="id_add_subject={% if not forloop.first %}-{{forloop.counter}}{% endif %}">
{% trans "Add subject" %}
</label>
<div class="field has-addons" id="subject_field_wrapper_{{ forloop.counter }}">
<div class="control is-expanded">
<input
id="id_add_subject-{{ forloop.counter }}"
type="text"
name="subjects"
value="{{ subject }}"
class="input"
>
</div>
<div class="control">
<button
class="button is-danger is-light"
type="button"
data-remove="subject_field_wrapper_{{ forloop.counter }}"
>
{% trans "Remove subject" as text %}
<span class="icon icon-x" title="{{ text }}">
<span class="is-sr-only">{{ text }}</span>
</span>
</button>
</div>
</div>
{% endfor %}
<input
class="input"
type="text"
name="subjects"
id="id_add_subject"
value="{{ subject }}"
{% if confirm_mode %}readonly{% endif %}
>
{% include 'snippets/form_errors.html' with errors_list=form.subjects.errors id="desc_subjects" %}
</div>
<span class="help">
<button class="button is-small" type="button" data-duplicate="id_add_subject" id="another_subject_field">
<span class="icon icon-plus" aria-hidden="true"></span>
<span>{% trans "Add Another Subject" %}</span>
</button>
</span>
</div>
</section>
@ -93,7 +145,7 @@
<span class="help" id="desc_publishers_help">
{% trans "Separate multiple values with commas." %}
</span>
{% include 'snippets/form_errors.html' with errors_list=form.publishers.errors id="desc_publishers" %}
</div>
@ -102,7 +154,7 @@
{% trans "First published date:" %}
</label>
<input type="date" name="first_published_date" class="input" id="id_first_published_date"{% if form.first_published_date.value %} value="{{ form.first_published_date.value|date:'Y-m-d' }}"{% endif %} aria-describedby="desc_first_published_date">
{% include 'snippets/form_errors.html' with errors_list=form.first_published_date.errors id="desc_first_published_date" %}
</div>
@ -111,7 +163,7 @@
{% trans "Published date:" %}
</label>
<input type="date" name="published_date" class="input" id="id_published_date"{% if form.published_date.value %} value="{{ form.published_date.value|date:'Y-m-d'}}"{% endif %} aria-describedby="desc_published_date">
{% include 'snippets/form_errors.html' with errors_list=form.published_date.errors id="desc_published_date" %}
</div>
</div>
@ -149,7 +201,12 @@
<input class="input" type="text" name="add_author" id="id_add_author" placeholder="{% trans 'Jane Doe' %}" value="{{ author }}" {% if confirm_mode %}readonly{% endif %}>
{% endfor %}
</div>
<span class="help"><button class="button is-small" type="button" data-duplicate="id_add_author" id="another_author_field">{% trans "Add Another Author" %}</button></span>
<span class="help">
<button class="button is-small" type="button" data-duplicate="id_add_author" id="another_author_field">
<span class="icon icon-plus" aria-hidden="true"></span>
<span>{% trans "Add Another Author" %}</span>
</button>
</span>
</div>
</section>
</div>
@ -180,7 +237,7 @@
</label>
<input class="input" name="cover-url" id="id_cover_url" type="url" value="{{ cover_url|default:'' }}" aria-describedby="desc_cover">
</div>
{% include 'snippets/form_errors.html' with errors_list=form.cover.errors id="desc_cover" %}
</div>
</div>
@ -201,7 +258,7 @@
<div class="select">
{{ form.physical_format }}
</div>
{% include 'snippets/form_errors.html' with errors_list=form.physical_format.errors id="desc_physical_format" %}
</div>
</div>
@ -211,7 +268,7 @@
{% trans "Format details:" %}
</label>
{{ form.physical_format_detail }}
{% include 'snippets/form_errors.html' with errors_list=form.physical_format_detail.errors id="desc_physical_format_detail" %}
</div>
</div>
@ -222,7 +279,7 @@
{% trans "Pages:" %}
</label>
{{ form.pages }}
{% include 'snippets/form_errors.html' with errors_list=form.pages.errors id="desc_pages" %}
</div>
</div>
@ -238,7 +295,7 @@
{% trans "ISBN 13:" %}
</label>
{{ form.isbn_13 }}
{% include 'snippets/form_errors.html' with errors_list=form.isbn_13.errors id="desc_isbn_13" %}
</div>
@ -247,7 +304,7 @@
{% trans "ISBN 10:" %}
</label>
{{ form.isbn_10 }}
{% include 'snippets/form_errors.html' with errors_list=form.isbn_10.errors id="desc_isbn_10" %}
</div>
@ -256,7 +313,7 @@
{% trans "Openlibrary ID:" %}
</label>
{{ form.openlibrary_key }}
{% include 'snippets/form_errors.html' with errors_list=form.openlibrary_key.errors id="desc_openlibrary_key" %}
</div>
@ -265,7 +322,7 @@
{% trans "Inventaire ID:" %}
</label>
{{ form.inventaire_id }}
{% include 'snippets/form_errors.html' with errors_list=form.inventaire_id.errors id="desc_inventaire_id" %}
</div>
@ -274,7 +331,7 @@
{% trans "OCLC Number:" %}
</label>
{{ form.oclc_number }}
{% include 'snippets/form_errors.html' with errors_list=form.oclc_number.errors id="desc_oclc_number" %}
</div>
@ -283,10 +340,14 @@
{% trans "ASIN:" %}
</label>
{{ form.asin }}
{% include 'snippets/form_errors.html' with errors_list=form.ASIN.errors id="desc_ASIN" %}
</div>
</div>
</section>
</div>
</div>
{% block scripts %}
<script src="{% static "js/forms.js" %}"></script>
{% endblock %}

View file

@ -8,7 +8,7 @@
<header class="block">
<h1 class="title">
{% blocktrans with title=book|book_title %}
{% blocktrans trimmed with title=book|book_title %}
Links for "<em>{{ title }}</em>"
{% endblocktrans %}
</h1>

View file

@ -56,8 +56,16 @@
</span>
</button>
</div>
<div class="control">
<button class="button" type="button" data-modal-open="barcode-scanner-modal">
<span class="icon icon-barcode" title="{% trans 'Scan Barcode' %}">
<span class="is-sr-only">{% trans "Scan Barcode" %}</span>
</span>
</button>
</div>
</div>
</form>
{% include "search/barcode_modal.html" with id="barcode-scanner-modal" %}
<button type="button" tabindex="0" class="navbar-burger pulldown-menu my-4" data-controls="main_nav" aria-expanded="false">
<i class="icon icon-dots-three-vertical" aria-hidden="true"></i>
@ -266,6 +274,7 @@
<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>
<script src="{% static "js/vendor/quagga.min.js" %}?v={{ js_cache }}"></script>
{% block scripts %}{% endblock %}

View file

@ -0,0 +1,48 @@
{% extends 'components/modal.html' %}
{% load i18n %}
{% block modal-title %}
{% blocktrans %}
Scan Barcode
{% endblocktrans %}
{% endblock %}
{% block modal-body %}
<div class="block">
<div id="barcode-scanner"></div>
</div>
<div id="barcode-camera-list" class="select is-small">
<select>
</select>
</div>
<div id="barcode-status" class="block">
<div class="grant-access is-hidden">
<span class="icon icon-lock"></span>
<span class="is-size-5">{% trans "Requesting camera..." %}</span></br>
<span>{% trans "Grant access to the camera to scan a book's barcode." %}</span>
</div>
<div class="access-denied is-hidden">
<span class="icon icon-warning"></span>
<span class="is-size-5">Access denied</span><br/>
<span>{% trans "Could not access camera" %}</span>
</div>
<div class="scanning is-hidden">
<span class="icon icon-barcode"></span>
<span class="is-size-5">{% trans "Scanning..." context "barcode scanner" %}</span><br/>
<span>{% trans "Align your book's barcode with the camera." %}</span>
</div>
<div class="found is-hidden">
<span class="icon icon-check"></span>
<span class="is-size-5">{% trans "ISBN scanned" context "barcode scanner" %}</span><br/>
{% trans "Searching for book:" context "followed by ISBN" %} <span class="isbn"></span>...
</div>
</div>
{% endblock %}
{% block modal-footer %}
<button class="button" type="button" data-modal-close>{% trans "Cancel" %}</button>
{% endblock %}

View file

@ -1,5 +1,6 @@
{% extends 'settings/layout.html' %}
{% load i18n %}
{% load humanize %}
{% load utilities %}
{% block title %}
@ -16,12 +17,81 @@
<p>
{% trans "Auto-moderation rules will create reports for any local user or status with fields matching the provided string." %}
{% trans "Users or statuses that have already been reported (regardless of whether the report was resolved) will not be flagged." %}
{% trans "At this time, reports are <em>not</em> being generated automatically, and you must manually trigger a scan." %}
</p>
<form name="run-scan" method="POST" action="{% url 'settings-automod-run' %}">
</div>
<div class="box block">
{% if task %}
<dl class="block">
<dt class="is-pulled-left mr-5 has-text-weight-bold">
{% trans "Schedule:" %}
</dt>
<dd>
{{ task.schedule }}
</dd>
<dt class="is-pulled-left mr-5 has-text-weight-bold">
{% trans "Last run:" %}
</dt>
<dd>
{{ task.last_run_at|naturaltime }}
</dd>
<dt class="is-pulled-left mr-5 has-text-weight-bold">
{% trans "Total run count:" %}
</dt>
<dd>
{{ task.total_run_count }}
</dd>
<dt class="is-pulled-left mr-5 has-text-weight-bold">
{% trans "Enabled:" %}
</dt>
<dd>
<span class="tag {% if task.enabled %}is-success{% else %}is-danger{% endif %}">
{{ task.enabled|yesno }}
</span>
</dd>
</dl>
<div class="is-flex is-justify-content-space-between block">
<form name="unschedule-scan" method="POST" action="{% url 'settings-automod-unschedule' task.id %}">
{% csrf_token %}
<button class="button is-danger">{% trans "Delete schedule" %}</button>
</form>
<form name="run-scan" method="POST" action="{% url 'settings-automod-run' %}">
{% csrf_token %}
<button class="button">{% trans "Run now" %}</button>
<p class="help">{% trans "Last run date will not be updated" %}</p>
</form>
</div>
{% else %}
<h2 class="title is-4">{% trans "Schedule scan" %}</h2>
<form name="schedule-scan" method="POST" action="{% url 'settings-automod-schedule' %}">
{% csrf_token %}
<button class="button is-warning">{% trans "Run scan" %}</button>
<div class="field">
<label class="label" for="id_every">
{{ task_form.every.label }}
</label>
{{ task_form.every }}
<p class="help" id="desc_every">
{{ task_form.every.help_text }}
</p>
</div>
<div class="field">
<label class="label" for="id_period">
{{ task_form.period.label }}
</label>
<div class="select">
{{ task_form.period }}
</div>
<p class="help" id="desc_period">
{{ task_form.period.help_text }}
</p>
</div>
<button class="button is-warning">{% trans "Schedule scan" %}</button>
</form>
{% endif %}
</div>
{% if success %}

View file

@ -0,0 +1,7 @@
{% extends 'snippets/filters_panel/filters_panel.html' %}
{% block filter_fields %}
{% include 'settings/users/username_filter.html' %}
{% include 'directory/community_filter.html' %}
{% include 'settings/users/server_filter.html' %}
{% endblock %}

View file

@ -30,7 +30,7 @@
</ul>
</div>
{% include 'settings/users/user_admin_filters.html' %}
{% include 'settings/reports/report_filters.html' %}
<div class="block">
{% if not reports %}

View file

@ -29,7 +29,7 @@
{% trans "Copy the theme file into the <code>bookwyrm/static/css/themes</code> directory on your server from the command line." %}
</li>
<li>
{% trans "Run <code>./bw-dev compilescss</code>." %}
{% trans "Run <code>./bw-dev collectstatic</code>." %}
</li>
<li>
{% trans "Add the file name using the form below to make it available in the application interface." %}
@ -56,12 +56,7 @@
class="box"
enctype="multipart/form-data"
>
{% if not choices %}
<div class="notification is-warning">
{% trans "No available theme files detected" %}
</div>
{% endif %}
<fieldset {% if not choices %}disabled{% endif %}>
<fieldset>
{% csrf_token %}
<div class="columns">
<div class="column is-half">
@ -79,20 +74,7 @@
{% trans "Theme filename" %}
</label>
<div class="control">
<div class="select">
<select
name="path"
aria-describedby="desc_path"
class=""
id="id_path"
>
{% for choice in choices %}
<option value="{{ choice }}">
{{ choice }}
</option>
{% endfor %}
</select>
</div>
{{ theme_form.path }}
{% include 'snippets/form_errors.html' with errors_list=theme_form.path.errors id="desc_path" %}
</div>
</div>

View file

@ -0,0 +1,16 @@
{% extends 'snippets/filters_panel/filter_field.html' %}
{% load i18n %}
{% block filter %}
<label class="label" for="id_email">{% trans "Email" %}</label>
<div class="control">
<input
type="text"
class="input"
name="email"
value="{{ request.GET.email|default:'' }}"
id="id_email" placeholder="user@email.com"
>
</div>
{% endblock %}

View file

@ -1,10 +1,23 @@
{% extends 'settings/layout.html' %}
{% load i18n %}
{% load utilities %}
{% block title %}{{ user.username }}{% endblock %}
{% block header %}
{{ user.username }}
<a class="help has-text-weight-normal" href="{% url 'settings-users' %}">{% trans "Back to users" %}</a>
{% endblock %}
{% block breadcrumbs %}
<nav class="breadcrumb subtitle" aria-label="breadcrumbs">
<ul>
<li><a href="{% url 'settings-users' %}">{% trans "Users" %}</a></li>
<li class="is-active">
<a href="#" aria-current="page">
{{ user|username }}
</a>
</li>
</ul>
</nav>
{% endblock %}
{% block panel %}

View file

@ -1,5 +1,7 @@
{% extends 'settings/layout.html' %}
{% load i18n %}
{% load utilities %}
{% block title %}{% trans "Users" %}{% endblock %}
{% block header %}
@ -15,46 +17,67 @@
{% include 'settings/users/user_admin_filters.html' %}
<table class="table is-striped">
<tr>
{% url 'settings-users' as url %}
<th>
{% trans "Username" as text %}
{% include 'snippets/table-sort-header.html' with field="username" sort=sort text=text %}
</th>
<th>
{% trans "Date Added" as text %}
{% include 'snippets/table-sort-header.html' with field="created_date" sort=sort text=text %}
</th>
<th>
{% trans "Last Active" as text %}
{% include 'snippets/table-sort-header.html' with field="last_active_date" sort=sort text=text %}
</th>
<th>
{% trans "Status" as text %}
{% include 'snippets/table-sort-header.html' with field="is_active" sort=sort text=text %}
</th>
<th>
{% trans "Remote instance" as text %}
{% include 'snippets/table-sort-header.html' with field="federated_server__server_name" sort=sort text=text %}
</th>
</tr>
{% for user in users %}
<tr>
<td><a href="{% url 'settings-user' user.id %}">{{ user.username }}</a></td>
<td>{{ user.created_date }}</td>
<td>{{ user.last_active_date }}</td>
<td>{% if user.is_active %}{% trans "Active" %}{% else %}{% trans "Inactive" %}{% endif %}</td>
<td>
{% if user.federated_server %}
<a href="{% url 'settings-federated-server' user.federated_server.id %}">{{ user.federated_server.server_name }}</a>
{% elif not user.local %}
<em>{% trans "Not set" %}</em>
<div class="block">
<div class="tabs">
<ul>
{% url 'settings-users' as url %}
<li {% if request.path in url %}class="is-active" aria-current="page"{% endif %}>
<a href="{{ url }}">{% trans "Local users" %}</a>
</li>
{% url 'settings-users' status="federated" as url %}
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
<a href="{{ url }}">{% trans "Federated community" %}</a>
</li>
</ul>
</div>
</div>
<div class="table-container block">
<table class="table is-striped">
<tr>
{% url 'settings-users' as url %}
<th>
{% trans "Username" as text %}
{% include 'snippets/table-sort-header.html' with field="username" sort=sort text=text %}
</th>
<th>
{% trans "Date Added" as text %}
{% include 'snippets/table-sort-header.html' with field="created_date" sort=sort text=text %}
</th>
<th>
{% trans "Last Active" as text %}
{% include 'snippets/table-sort-header.html' with field="last_active_date" sort=sort text=text %}
</th>
<th>
{% trans "Status" as text %}
{% include 'snippets/table-sort-header.html' with field="is_active" sort=sort text=text %}
</th>
{% if status != "local" %}
<th>
{% trans "Remote instance" as text %}
{% include 'snippets/table-sort-header.html' with field="federated_server__server_name" sort=sort text=text %}
</th>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</tr>
{% for user in users %}
<tr>
<td><a href="{% url 'settings-user' user.id %}">{{ user|username }}</a></td>
<td>{{ user.created_date }}</td>
<td>{{ user.last_active_date }}</td>
<td>{% if user.is_active %}{% trans "Active" %}{% else %}{% trans "Inactive" %}{% endif %}</td>
{% if status != "local" %}
<td>
{% if user.federated_server %}
<a href="{% url 'settings-federated-server' user.federated_server.id %}">{{ user.federated_server.server_name }}</a>
{% else %}
<em>{% trans "Not set" %}</em>
{% endif %}
</td>
{% endif %}
</tr>
{% endfor %}
</table>
</div>
{% include 'snippets/pagination.html' with page=users path=request.path %}
{% endblock %}

View file

@ -2,6 +2,11 @@
{% block filter_fields %}
{% include 'settings/users/username_filter.html' %}
{% include 'directory/community_filter.html' %}
{% if status != "local" %}
{% include 'settings/users/server_filter.html' %}
{% else %}
{% include 'settings/users/email_filter.html' %}
{% endif %}
{% endblock %}

View file

@ -71,14 +71,14 @@
<dd>{{ user.last_active_date }}</dd>
<dt class="is-pulled-left mr-5">{% trans "Manually approved followers:" %}</dt>
<dd>{{ user.manually_approves_followers }}</dd>
<dd>{{ user.manually_approves_followers|yesno }}</dd>
<dt class="is-pulled-left mr-5">{% trans "Discoverable:" %}</dt>
<dd>{{ user.discoverable }}</dd>
<dd>{{ user.discoverable|yesno }}</dd>
{% if not user.is_active %}
<dt class="is-pulled-left mr-5">{% trans "Deactivation reason:" %}</dt>
<dd>{{ user.deactivation_reason }}</dd>
<dd>{{ user.get_deactivation_reason_display }}</dd>
{% endif %}
{% if not user.is_active and user.deactivation_reason == "pending" %}
@ -104,7 +104,7 @@
<dd>{{ server.application_version }}</dd>
<dt class="is-pulled-left mr-5">{% trans "Status:" %}</dt>
<dd>{{ server.status }}</dd>
<dd>{{ server.get_status_display }}</dd>
</dl>
{% if server.notes %}
<h5>{% trans "Notes" %}</h5>

View file

@ -4,7 +4,7 @@
{% with goal.progress as progress %}
<p>
{% if progress.percent >= 100 %}
{% trans "Success!" %}
{% trans "Success!" context "Goal successfully completed" %}
{% elif progress.percent %}
{% blocktrans with percent=progress.percent %}{{ percent }}% complete!{% endblocktrans %}
{% endif %}

View file

@ -125,6 +125,11 @@ urlpatterns = [
re_path(
r"^settings/users/?$", views.UserAdminList.as_view(), name="settings-users"
),
re_path(
r"^settings/users/(?P<status>(local|federated))\/?$",
views.UserAdminList.as_view(),
name="settings-users",
),
re_path(
r"^settings/users/(?P<user>\d+)/?$",
views.UserAdmin.as_view(),
@ -228,11 +233,23 @@ urlpatterns = [
# auto-moderation rules
re_path(r"^settings/automod/?$", views.AutoMod.as_view(), name="settings-automod"),
re_path(
r"^settings/automod/(?P<rule_id>\d+)/delete?$",
r"^settings/automod/(?P<rule_id>\d+)/delete/?$",
views.automod_delete,
name="settings-automod-delete",
),
re_path(r"^settings/automod/run?$", views.run_automod, name="settings-automod-run"),
re_path(
r"^settings/automod/schedule/?$",
views.schedule_automod_task,
name="settings-automod-schedule",
),
re_path(
r"^settings/automod/unschedule/(?P<task_id>\d+)/?$",
views.unschedule_automod_task,
name="settings-automod-unschedule",
),
re_path(
r"^settings/automod/run/?$", views.run_automod, name="settings-automod-run"
),
# moderation
re_path(
r"^settings/reports/?$", views.ReportsAdmin.as_view(), name="settings-reports"

View file

@ -3,6 +3,7 @@
from .admin.announcements import Announcements, Announcement
from .admin.announcements import EditAnnouncement, delete_announcement
from .admin.automod import AutoMod, automod_delete, run_automod
from .admin.automod import schedule_automod_task, unschedule_automod_task
from .admin.dashboard import Dashboard
from .admin.federation import Federation, FederatedServer
from .admin.federation import AddFederatedServer, ImportServerBlocklist

View file

@ -1,10 +1,12 @@
""" moderation via flagged posts and users """
from django.contrib.auth.decorators import login_required, permission_required
from django.db import transaction
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.http import require_POST
from django_celery_beat.models import PeriodicTask
from bookwyrm import forms, models
@ -24,8 +26,9 @@ class AutoMod(View):
def get(self, request):
"""view rules"""
data = {"rules": models.AutoMod.objects.all(), "form": forms.AutoModRuleForm()}
return TemplateResponse(request, "settings/automod/rules.html", data)
return TemplateResponse(
request, "settings/automod/rules.html", automod_view_data()
)
def post(self, request):
"""add rule"""
@ -35,22 +38,49 @@ class AutoMod(View):
form.save()
form = forms.AutoModRuleForm()
data = {
"rules": models.AutoMod.objects.all(),
"form": form,
"success": success,
}
data = automod_view_data()
data["form"] = form
return TemplateResponse(request, "settings/automod/rules.html", data)
@require_POST
@permission_required("bookwyrm.moderate_user", raise_exception=True)
@permission_required("bookwyrm.moderate_post", raise_exception=True)
def schedule_automod_task(request):
"""scheduler"""
form = forms.IntervalScheduleForm(request.POST)
if not form.is_valid():
data = automod_view_data()
data["task_form"] = form
return TemplateResponse(request, "settings/automod/rules.html", data)
with transaction.atomic():
schedule = form.save()
PeriodicTask.objects.get_or_create(
interval=schedule,
name="automod-task",
task="bookwyrm.models.antispam.automod_task",
)
return redirect("settings-automod")
@require_POST
@permission_required("bookwyrm.moderate_user", raise_exception=True)
@permission_required("bookwyrm.moderate_post", raise_exception=True)
# pylint: disable=unused-argument
def unschedule_automod_task(request, task_id):
"""unscheduler"""
get_object_or_404(PeriodicTask, id=task_id).delete()
return redirect("settings-automod")
@require_POST
@permission_required("bookwyrm.moderate_user", raise_exception=True)
@permission_required("bookwyrm.moderate_post", raise_exception=True)
# pylint: disable=unused-argument
def automod_delete(request, rule_id):
"""Remove a rule"""
rule = get_object_or_404(models.AutoMod, id=rule_id)
rule.delete()
get_object_or_404(models.AutoMod, id=rule_id).delete()
return redirect("settings-automod")
@ -62,3 +92,18 @@ def run_automod(request):
"""run scan"""
models.automod_task.delay()
return redirect("settings-automod")
def automod_view_data():
"""helper to get data used in the template"""
try:
task = PeriodicTask.objects.get(name="automod-task")
except PeriodicTask.DoesNotExist:
task = None
return {
"task": task,
"task_form": forms.IntervalScheduleForm(),
"rules": models.AutoMod.objects.all(),
"form": forms.AutoModRuleForm(),
}

View file

@ -1,7 +1,5 @@
""" manage themes """
from django.contrib.auth.decorators import login_required, permission_required
from django.contrib.staticfiles.utils import get_files
from django.contrib.staticfiles.storage import StaticFilesStorage
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
@ -41,11 +39,8 @@ class Themes(View):
def get_view_data():
"""data for view"""
choices = list(get_files(StaticFilesStorage(), location="css/themes"))
current = models.Theme.objects.values_list("path", flat=True)
return {
"themes": models.Theme.objects.all(),
"choices": [c for c in choices if c not in current and c[-5:] == ".scss"],
"theme_form": forms.ThemeForm(),
}

View file

@ -19,7 +19,7 @@ from bookwyrm.settings import PAGE_LENGTH
class UserAdminList(View):
"""admin view of users on this server"""
def get(self, request):
def get(self, request, status="local"):
"""list of users"""
filters = {}
server = request.GET.get("server")
@ -37,6 +37,8 @@ class UserAdminList(View):
if email:
filters["email__endswith"] = email
filters["local"] = status == "local"
users = models.User.objects.filter(**filters)
sort = request.GET.get("sort", "-created_date")
@ -56,6 +58,7 @@ class UserAdminList(View):
"users": paginated.get_page(request.GET.get("page")),
"sort": sort,
"server": server,
"status": status,
}
return TemplateResponse(request, "settings/users/user_admin.html", data)

View file

@ -12,7 +12,7 @@ from django.views.decorators.http import require_POST
from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.connectors import connector_manager
from bookwyrm.connectors import connector_manager, ConnectorException
from bookwyrm.connectors.abstract_connector import get_image
from bookwyrm.settings import PAGE_LENGTH
from bookwyrm.views.helpers import is_api_request, maybe_redirect_local_path
@ -89,6 +89,7 @@ class Book(View):
else None,
"rating": reviews.aggregate(Avg("rating"))["rating__avg"],
"lists": lists,
"update_error": kwargs.get("update_error", False),
}
if request.user.is_authenticated:
@ -200,6 +201,10 @@ def update_book_from_remote(request, book_id, connector_identifier):
)
book = get_object_or_404(models.Book.objects.select_subclasses(), id=book_id)
connector.update_book_from_remote(book)
try:
connector.update_book_from_remote(book)
except ConnectorException:
# the remote source isn't available or doesn't know this book
return Book().get(request, book_id, update_error=True)
return redirect("book", book.id)

5
bw-dev
View file

@ -154,7 +154,7 @@ case "$CMD" in
--config dev-tools/.stylelintrc.js
;;
compilescss)
runweb python manage.py compilethemes
runweb python manage.py compilescss
runweb python manage.py collectstatic --no-input
;;
collectstatic_watch)
@ -163,6 +163,7 @@ case "$CMD" in
update)
git pull
docker-compose build
./update.sh
runweb python manage.py migrate
runweb python manage.py collectstatic --no-input
docker-compose up -d
@ -215,6 +216,7 @@ case "$CMD" in
;;
setup)
migrate
migrate django_celery_beat
initdb
runweb python manage.py collectstatic --no-input
admin_code
@ -225,6 +227,7 @@ case "$CMD" in
*)
set +x # No need to echo echo
echo "Unrecognised command. Try:"
echo " setup"
echo " up [container]"
echo " service_ports_web"
echo " initdb"

View file

@ -3,6 +3,7 @@
# pylint: disable=unused-wildcard-import
from bookwyrm.settings import *
# pylint: disable=line-too-long
REDIS_BROKER_PASSWORD = requests.utils.quote(env("REDIS_BROKER_PASSWORD", None))
REDIS_BROKER_HOST = env("REDIS_BROKER_HOST", "redis_broker")
REDIS_BROKER_PORT = env("REDIS_BROKER_PORT", 6379)
@ -16,6 +17,10 @@ CELERY_DEFAULT_QUEUE = "low_priority"
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
CELERY_TIMEZONE = env("TIME_ZONE", "UTC")
FLOWER_PORT = env("FLOWER_PORT")
INSTALLED_APPS = INSTALLED_APPS + [

89
complete_bwdev.fish Normal file
View file

@ -0,0 +1,89 @@
# bw-dev auto-completions for fish-shell.
# copy this to ~./.config/fish/completions/ with the name `bw-dev.fish`
# this will only work if renamed to `bw-dev.fish`.
set -l commands up \
service_ports_web \
initdb \
resetdb \
makemigrations \
migrate \
bash \
shell \
dbshell \
restart_celery \
pytest \
collectstatic \
makemessages \
compilemessages \
update_locales \
build \
clean \
black \
prettier \
stylelint \
formatters \
compilescss \
collectstatic_watch \
populate_streams \
populate_lists_streams \
populate_suggestions \
generate_thumbnails \
generate_preview_images \
copy_media_to_s3 \
set_cors_to_s3 \
setup \
admin_code \
runweb
function __bw_complete -a cmds cmd desc
complete -f -c bw-dev -n "not __fish_seen_subcommand_from $cmds" -a $cmd -d $desc
end
__bw_complete "$commands" "up" "bring one or all service(s) up"
__bw_complete "$commands" "service_ports_web" "run command on the web container with its portsenabled and mapped"
__bw_complete "$commands" "initdb" "initialize database"
__bw_complete "$commands" "resetdb" "!! WARNING !! reset database"
__bw_complete "$commands" "makemigrations" "create new migrations"
__bw_complete "$commands" "migrate" "perform all migrations"
__bw_complete "$commands" "bash" "open up bash within the web container"
__bw_complete "$commands" "shell" "open the Python shell within the web container"
__bw_complete "$commands" "dbshell" "open the database shell within the web container"
__bw_complete "$commands" "restart_celery" "restart the celery container"
__bw_complete "$commands" "pytest" "run unit tests"
__bw_complete "$commands" "collectstatic" "copy changed static files into the installation"
__bw_complete "$commands" "makemessages" "extract all localizable messages from the code"
__bw_complete "$commands" "compilemessages" "compile .po localization files to .mo"
__bw_complete "$commands" "update_locales" "run makemessages and compilemessages for the en_US and additional locales"
__bw_complete "$commands" "build" "build the containers"
__bw_complete "$commands" "clean" "bring the cluster down and remove all containers"
__bw_complete "$commands" "black" "run Python code formatting tool"
__bw_complete "$commands" "prettier" "run JavaScript code formatting tool"
__bw_complete "$commands" "stylelint" "run SCSS linting tool"
__bw_complete "$commands" "formatters" "run multiple formatter tools"
__bw_complete "$commands" "compilescss" "compile the SCSS layouts to CSS"
__bw_complete "$commands" "populate_streams" "populate the main streams"
__bw_complete "$commands" "populate_lists_streams" "populate streams for book lists"
__bw_complete "$commands" "populate_suggestions" "populate book suggestions"
__bw_complete "$commands" "generate_thumbnails" "generate book thumbnails"
__bw_complete "$commands" "generate_preview_images" "generate book preview images"
__bw_complete "$commands" "collectstatic_watch" "watch filesystem and copy changed static files"
__bw_complete "$commands" "copy_media_to_s3" "run the `s3 cp` command to copy media to a bucket on S3"
__bw_complete "$commands" "sync_media_to_s3" "run the `s3 sync` command to sync media with a bucket on S3"
__bw_complete "$commands" "set_cors_to_s3" "push a CORS configuration defined in .json to s3"
__bw_complete "$commands" "setup" "perform first-time setup"
__bw_complete "$commands" "admin_code" "get the admin code"
__bw_complete "$commands" "runweb" "run a command on the web container"
function __bw_complete_subcommand -a cmd
complete -f -c bw-dev -n "__fish_seen_subcommand_from $cmd" $argv[2..-1]
end
__bw_complete_subcommand "up" -a "(docker-compose config --service)"
__bw_complete_subcommand "pytest" -a "bookwyrm/tests/**.py"
__bw_complete_subcommand "populate_streams" -a "--stream=" -d "pick a single stream to populate"
__bw_complete_subcommand "populate_streams" -l stream -a "home local books"
__bw_complete_subcommand "generate_preview_images" -a "--all"\
-d "Generates images for ALL types: site, users and books. Can use a lot of computing power."
__bw_complete_subcommand "set_cors_to_s3" -a "**.json"

View file

@ -70,6 +70,19 @@ services:
- db
- redis_broker
restart: on-failure
celery_beat:
env_file: .env
build: .
networks:
- main
command: celery -A celerywyrm beat -l INFO --scheduler django_celery_beat.schedulers:DatabaseScheduler
volumes:
- .:/app
- static_volume:/app/static
- media_volume:/app/images
depends_on:
- celery_worker
restart: on-failure
flower:
build: .
command: celery -A celerywyrm flower --basic_auth=${FLOWER_USER}:${FLOWER_PASSWORD}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,7 @@
celery==5.2.2
colorthief==0.2.1
Django==3.2.12
django-celery-beat==2.2.1
django-compressor==2.4.1
django-imagekit==4.1.0
django-model-utils==4.0.0

37
update.sh Executable file
View file

@ -0,0 +1,37 @@
#!/bin/bash
set -e
# determine inital and target versions
initial_version="`./bw-dev runweb python manage.py instance_version --current`"
target_version="`./bw-dev runweb python manage.py instance_version --target`"
initial_version="`echo $initial_version | tail -n 1 | xargs`"
target_version="`echo $target_version | tail -n 1 | xargs`"
if [[ "$initial_version" = "$target_version" ]]; then
echo "Already up to date; version $initial_version"
exit
fi
echo "---------------------------------------"
echo "Updating from version: $initial_version"
echo ".......... to version: $target_version"
echo "---------------------------------------"
function version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; }
# execute scripts between initial and target
for version in `ls -A updates/ | sort -V `; do
if version_gt $initial_version $version; then
# too early
continue
fi
if version_gt $version $target_version; then
# too late
continue
fi
echo "Running tasks for version $version"
./updates/$version
done
./bw-dev runweb python manage.py instance_version --update
echo "✨ ----------- Done! --------------- ✨"

1
updates/0.3.4.sh Executable file
View file

@ -0,0 +1 @@
./bw-dev migrate django_celery_beat