diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py new file mode 100644 index 000000000..a20fadcb3 --- /dev/null +++ b/bookwyrm/forms.py @@ -0,0 +1,584 @@ +""" 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"} + ), + "subjects": forms.TextInput( + attrs={"aria-describedby": "desc_subjects_help desc_subjects"} + ), + "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", "answer"] + + +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.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 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"] diff --git a/bookwyrm/forms/landing.py b/bookwyrm/forms/landing.py index 61b92ee83..b01c2cc98 100644 --- a/bookwyrm/forms/landing.py +++ b/bookwyrm/forms/landing.py @@ -42,4 +42,4 @@ class InviteRequestForm(CustomForm): class Meta: model = models.InviteRequest - fields = ["email"] + fields = ["email", "answer"] diff --git a/bookwyrm/migrations/0146_auto_20220316_2352.py b/bookwyrm/migrations/0146_auto_20220316_2352.py new file mode 100644 index 000000000..2eab3b562 --- /dev/null +++ b/bookwyrm/migrations/0146_auto_20220316_2352.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.12 on 2022-03-16 23:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0145_sitesettings_version"), + ] + + operations = [ + migrations.AddField( + model_name="inviterequest", + name="answer", + field=models.TextField(blank=True, max_length=50, null=True), + ), + migrations.AddField( + model_name="sitesettings", + name="invite_question_text", + field=models.CharField( + blank=True, default="What is your favourite book?", max_length=255 + ), + ), + migrations.AddField( + model_name="sitesettings", + name="invite_request_question", + field=models.BooleanField(default=False), + ), + ] diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py index cbad6c4b7..7730391f1 100644 --- a/bookwyrm/models/site.py +++ b/bookwyrm/models/site.py @@ -49,8 +49,12 @@ class SiteSettings(models.Model): # registration allow_registration = models.BooleanField(default=False) allow_invite_requests = models.BooleanField(default=True) + invite_request_question = models.BooleanField(default=False) require_confirm_email = models.BooleanField(default=True) + invite_question_text = models.CharField( + max_length=255, blank=True, default="What is your favourite book?" + ) # images logo = models.ImageField(upload_to="logos/", null=True, blank=True) logo_small = models.ImageField(upload_to="logos/", null=True, blank=True) @@ -100,11 +104,14 @@ class SiteSettings(models.Model): return urljoin(STATIC_FULL_URL, default_path) def save(self, *args, **kwargs): - """if require_confirm_email is disabled, make sure no users are pending""" + """if require_confirm_email is disabled, make sure no users are pending, + if enabled, make sure invite_question_text is not empty""" if not self.require_confirm_email: User.objects.filter(is_active=False, deactivation_reason="pending").update( is_active=True, deactivation_reason=None ) + if not self.invite_question_text: + self.invite_question_text = "What is your favourite book?" super().save(*args, **kwargs) @@ -150,6 +157,7 @@ class InviteRequest(BookWyrmModel): invite = models.ForeignKey( SiteInvite, on_delete=models.SET_NULL, null=True, blank=True ) + answer = models.TextField(max_length=50, unique=False, null=True, blank=True) invite_sent = models.BooleanField(default=False) ignored = models.BooleanField(default=False) diff --git a/bookwyrm/templates/landing/layout.html b/bookwyrm/templates/landing/layout.html index 6d69cafc2..bf0a6b2a1 100644 --- a/bookwyrm/templates/landing/layout.html +++ b/bookwyrm/templates/landing/layout.html @@ -70,6 +70,14 @@ {% include 'snippets/form_errors.html' with errors_list=request_form.email.errors id="desc_request_email" %} + {% if site.invite_request_question %} +
+ + + {% include 'snippets/form_errors.html' with errors_list=request_form.answer.errors id="desc_answer_register" %} +
+ {% endif %} + {% endif %} diff --git a/bookwyrm/templates/settings/invites/manage_invite_requests.html b/bookwyrm/templates/settings/invites/manage_invite_requests.html index fb7c0b1fb..bdd60099a 100644 --- a/bookwyrm/templates/settings/invites/manage_invite_requests.html +++ b/bookwyrm/templates/settings/invites/manage_invite_requests.html @@ -40,6 +40,9 @@ {% include 'snippets/table-sort-header.html' with field="invite__invitees__created_date" sort=sort text=text %} {% trans "Email" %} + {% if site.invite_request_question %} + {% trans "Answer" %} + {% endif %} {% trans "Status" as text %} {% include 'snippets/table-sort-header.html' with field="invite__times_used" sort=sort text=text %} @@ -54,6 +57,9 @@ {{ req.created_date | naturaltime }} {{ req.invite.invitees.first.created_date | naturaltime }} {{ req.email }} + {% if site.invite_request_question %} + {{ req.answer }} + {% endif %} {% if req.invite.times_used %} {% trans "Accepted" %} diff --git a/bookwyrm/templates/settings/site.html b/bookwyrm/templates/settings/site.html index 4d9dbe400..d55514b55 100644 --- a/bookwyrm/templates/settings/site.html +++ b/bookwyrm/templates/settings/site.html @@ -145,6 +145,18 @@ {% trans "Allow invite requests" %} +
+ +
+
+ +