Merge branch 'main' into stopped-shelf

This commit is contained in:
Mouse Reeve 2022-03-26 13:06:06 -07:00
commit ec21d20b90
94 changed files with 10585 additions and 3417 deletions

View file

@ -4,6 +4,7 @@ from django import forms
from bookwyrm import models from bookwyrm import models
from bookwyrm.models.fields import ClearableFileInputWithWarning from bookwyrm.models.fields import ClearableFileInputWithWarning
from .custom_form import CustomForm from .custom_form import CustomForm
from .widgets import ArrayWidget, SelectDateWidget, Select
# pylint: disable=missing-class-docstring # pylint: disable=missing-class-docstring
@ -14,14 +15,6 @@ class CoverForm(CustomForm):
help_texts = {f: None for f in fields} 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 EditionForm(CustomForm):
class Meta: class Meta:
model = models.Edition model = models.Edition
@ -56,16 +49,16 @@ class EditionForm(CustomForm):
"publishers": forms.TextInput( "publishers": forms.TextInput(
attrs={"aria-describedby": "desc_publishers_help desc_publishers"} attrs={"aria-describedby": "desc_publishers_help desc_publishers"}
), ),
"first_published_date": forms.SelectDateWidget( "first_published_date": SelectDateWidget(
attrs={"aria-describedby": "desc_first_published_date"} attrs={"aria-describedby": "desc_first_published_date"}
), ),
"published_date": forms.SelectDateWidget( "published_date": SelectDateWidget(
attrs={"aria-describedby": "desc_published_date"} attrs={"aria-describedby": "desc_published_date"}
), ),
"cover": ClearableFileInputWithWarning( "cover": ClearableFileInputWithWarning(
attrs={"aria-describedby": "desc_cover"} attrs={"aria-describedby": "desc_cover"}
), ),
"physical_format": forms.Select( "physical_format": Select(
attrs={"aria-describedby": "desc_physical_format"} attrs={"aria-describedby": "desc_physical_format"}
), ),
"physical_format_detail": forms.TextInput( "physical_format_detail": forms.TextInput(
@ -85,3 +78,27 @@ class EditionForm(CustomForm):
), ),
"ASIN": forms.TextInput(attrs={"aria-describedby": "desc_ASIN"}), "ASIN": forms.TextInput(attrs={"aria-describedby": "desc_ASIN"}),
} }
class EditionFromWorkForm(CustomForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# make all fields hidden
for visible in self.visible_fields():
visible.field.widget = forms.HiddenInput()
class Meta:
model = models.Work
fields = [
"title",
"subtitle",
"authors",
"description",
"languages",
"series",
"series_number",
"subjects",
"subject_places",
"cover",
"first_published_date",
]

View file

@ -45,7 +45,7 @@ class ReportForm(CustomForm):
class ReadThroughForm(CustomForm): class ReadThroughForm(CustomForm):
def clean(self): def clean(self):
"""make sure the email isn't in use by a registered user""" """don't let readthroughs end before they start"""
cleaned_data = super().clean() cleaned_data = super().clean()
start_date = cleaned_data.get("start_date") start_date = cleaned_data.get("start_date")
finish_date = cleaned_data.get("finish_date") finish_date = cleaned_data.get("finish_date")

View file

@ -42,4 +42,4 @@ class InviteRequestForm(CustomForm):
class Meta: class Meta:
model = models.InviteRequest model = models.InviteRequest
fields = ["email"] fields = ["email", "answer"]

70
bookwyrm/forms/widgets.py Normal file
View file

@ -0,0 +1,70 @@
""" using django model forms """
from django import forms
class ArrayWidget(forms.widgets.TextInput):
"""Inputs for postgres array fields"""
# 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 Select(forms.Select):
"""custom template for select widget"""
template_name = "widgets/select.html"
class SelectDateWidget(forms.SelectDateWidget):
"""
A widget that splits date input into two <select> boxes and a numerical year.
"""
template_name = "widgets/addon_multiwidget.html"
select_widget = Select
def get_context(self, name, value, attrs):
"""sets individual widgets"""
context = super().get_context(name, value, attrs)
date_context = {}
year_name = self.year_field % name
date_context["year"] = forms.NumberInput().get_context(
name=year_name,
value=context["widget"]["value"]["year"],
attrs={
**context["widget"]["attrs"],
"id": f"id_{year_name}",
"class": "input",
},
)
month_choices = list(self.months.items())
if not self.is_required:
month_choices.insert(0, self.month_none_value)
month_name = self.month_field % name
date_context["month"] = self.select_widget(
attrs, choices=month_choices
).get_context(
name=month_name,
value=context["widget"]["value"]["month"],
attrs={**context["widget"]["attrs"], "id": f"id_{month_name}"},
)
day_choices = [(i, i) for i in range(1, 32)]
if not self.is_required:
day_choices.insert(0, self.day_none_value)
day_name = self.day_field % name
date_context["day"] = self.select_widget(
attrs,
choices=day_choices,
).get_context(
name=day_name,
value=context["widget"]["value"]["day"],
attrs={**context["widget"]["attrs"], "id": f"id_{day_name}"},
)
subwidgets = []
for field in self._parse_date_fmt():
subwidgets.append(date_context[field]["widget"])
context["widget"]["subwidgets"] = subwidgets
return context

View file

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

View file

@ -0,0 +1,38 @@
# Generated by Django 3.2.12 on 2022-03-26 16:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0146_auto_20220316_2352"),
]
operations = [
migrations.AlterField(
model_name="user",
name="preferred_language",
field=models.CharField(
blank=True,
choices=[
("en-us", "English"),
("de-de", "Deutsch (German)"),
("es-es", "Español (Spanish)"),
("gl-es", "Galego (Galician)"),
("it-it", "Italiano (Italian)"),
("fr-fr", "Français (French)"),
("lt-lt", "Lietuvių (Lithuanian)"),
("no-no", "Norsk (Norwegian)"),
("pt-br", "Português do Brasil (Brazilian Portuguese)"),
("pt-pt", "Português Europeu (European Portuguese)"),
("ro-ro", "Română (Romanian)"),
("sv-se", "Svenska (Swedish)"),
("zh-hans", "简体中文 (Simplified Chinese)"),
("zh-hant", "繁體中文 (Traditional Chinese)"),
],
max_length=255,
null=True,
),
),
]

View file

@ -389,7 +389,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
self.alt_field = alt_field self.alt_field = alt_field
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# pylint: disable=arguments-differ # pylint: disable=arguments-differ,arguments-renamed
def set_field_from_activity(self, instance, data, save=True, overwrite=True): def set_field_from_activity(self, instance, data, save=True, overwrite=True):
"""helper function for assinging a value to the field""" """helper function for assinging a value to the field"""
value = getattr(data, self.get_activitypub_field()) value = getattr(data, self.get_activitypub_field())

View file

@ -39,15 +39,14 @@ class UserRelationship(BookWyrmModel):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""clear the template cache""" """clear the template cache"""
# invalidate the template cache clear_cache(self.user_subject, self.user_object)
cache.delete_many(
[
f"relationship-{self.user_subject.id}-{self.user_object.id}",
f"relationship-{self.user_object.id}-{self.user_subject.id}",
]
)
super().save(*args, **kwargs) super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
"""clear the template cache"""
clear_cache(self.user_subject, self.user_object)
super().delete(*args, **kwargs)
class Meta: class Meta:
"""relationships should be unique""" """relationships should be unique"""
@ -90,7 +89,9 @@ class UserFollows(ActivityMixin, UserRelationship):
user_object=self.user_subject, user_object=self.user_subject,
) )
).exists(): ).exists():
raise IntegrityError() raise IntegrityError(
"Attempting to follow blocked user", self.user_subject, self.user_object
)
# don't broadcast this type of relationship -- accepts and requests # don't broadcast this type of relationship -- accepts and requests
# are handled by the UserFollowRequest model # are handled by the UserFollowRequest model
super().save(*args, broadcast=False, **kwargs) super().save(*args, broadcast=False, **kwargs)
@ -98,11 +99,12 @@ class UserFollows(ActivityMixin, UserRelationship):
@classmethod @classmethod
def from_request(cls, follow_request): def from_request(cls, follow_request):
"""converts a follow request into a follow relationship""" """converts a follow request into a follow relationship"""
return cls.objects.create( obj, _ = cls.objects.get_or_create(
user_subject=follow_request.user_subject, user_subject=follow_request.user_subject,
user_object=follow_request.user_object, user_object=follow_request.user_object,
remote_id=follow_request.remote_id, remote_id=follow_request.remote_id,
) )
return obj
class UserFollowRequest(ActivitypubMixin, UserRelationship): class UserFollowRequest(ActivitypubMixin, UserRelationship):
@ -133,7 +135,9 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
user_object=self.user_subject, user_object=self.user_subject,
) )
).exists(): ).exists():
raise IntegrityError() raise IntegrityError(
"Attempting to follow blocked user", self.user_subject, self.user_object
)
super().save(*args, **kwargs) super().save(*args, **kwargs)
if broadcast and self.user_subject.local and not self.user_object.local: if broadcast and self.user_subject.local and not self.user_object.local:
@ -174,7 +178,8 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
with transaction.atomic(): with transaction.atomic():
UserFollows.from_request(self) UserFollows.from_request(self)
self.delete() if self.id:
self.delete()
def reject(self): def reject(self):
"""generate a Reject for this follow request""" """generate a Reject for this follow request"""
@ -207,3 +212,13 @@ class UserBlocks(ActivityMixin, UserRelationship):
Q(user_subject=self.user_subject, user_object=self.user_object) Q(user_subject=self.user_subject, user_object=self.user_object)
| Q(user_subject=self.user_object, user_object=self.user_subject) | Q(user_subject=self.user_object, user_object=self.user_subject)
).delete() ).delete()
def clear_cache(user_subject, user_object):
"""clear relationship cache"""
cache.delete_many(
[
f"relationship-{user_subject.id}-{user_object.id}",
f"relationship-{user_object.id}-{user_subject.id}",
]
)

View file

@ -49,8 +49,12 @@ class SiteSettings(models.Model):
# registration # registration
allow_registration = models.BooleanField(default=False) allow_registration = models.BooleanField(default=False)
allow_invite_requests = models.BooleanField(default=True) allow_invite_requests = models.BooleanField(default=True)
invite_request_question = models.BooleanField(default=False)
require_confirm_email = models.BooleanField(default=True) require_confirm_email = models.BooleanField(default=True)
invite_question_text = models.CharField(
max_length=255, blank=True, default="What is your favourite book?"
)
# images # images
logo = models.ImageField(upload_to="logos/", null=True, blank=True) logo = models.ImageField(upload_to="logos/", null=True, blank=True)
logo_small = 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) return urljoin(STATIC_FULL_URL, default_path)
def save(self, *args, **kwargs): 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: if not self.require_confirm_email:
User.objects.filter(is_active=False, deactivation_reason="pending").update( User.objects.filter(is_active=False, deactivation_reason="pending").update(
is_active=True, deactivation_reason=None 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) super().save(*args, **kwargs)
@ -150,6 +157,7 @@ class InviteRequest(BookWyrmModel):
invite = models.ForeignKey( invite = models.ForeignKey(
SiteInvite, on_delete=models.SET_NULL, null=True, blank=True 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) invite_sent = models.BooleanField(default=False)
ignored = models.BooleanField(default=False) ignored = models.BooleanField(default=False)

View file

@ -289,6 +289,7 @@ LANGUAGES = [
("no-no", _("Norsk (Norwegian)")), ("no-no", _("Norsk (Norwegian)")),
("pt-br", _("Português do Brasil (Brazilian Portuguese)")), ("pt-br", _("Português do Brasil (Brazilian Portuguese)")),
("pt-pt", _("Português Europeu (European Portuguese)")), ("pt-pt", _("Português Europeu (European Portuguese)")),
("ro-ro", _("Română (Romanian)")),
("sv-se", _("Svenska (Swedish)")), ("sv-se", _("Svenska (Swedish)")),
("zh-hans", _("简体中文 (Simplified Chinese)")), ("zh-hans", _("简体中文 (Simplified Chinese)")),
("zh-hant", _("繁體中文 (Traditional Chinese)")), ("zh-hant", _("繁體中文 (Traditional Chinese)")),

View file

@ -36,6 +36,18 @@ body {
flex-direction: column; flex-direction: column;
} }
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-thumb {
background: $scrollbar-thumb;
border-radius: 0.5em;
}
::-webkit-scrollbar-track {
background: $scrollbar-track;
}
button { button {
border: none; border: none;
margin: 0; margin: 0;
@ -129,14 +141,6 @@ button:focus-visible .button-invisible-overlay {
} }
/** Tooltips
******************************************************************************/
.tooltip {
width: 100%;
}
/** States /** States
******************************************************************************/ ******************************************************************************/

View file

@ -8,7 +8,9 @@ $primary: #005e50;
$primary-light: #1d2b28; $primary-light: #1d2b28;
$info: #1f4666; $info: #1f4666;
$success: #246447; $success: #246447;
$success-light: #0d2f1e;
$warning: #8b6c15; $warning: #8b6c15;
$warning-light: #372e13;
$danger: #872538; $danger: #872538;
$danger-light: #481922; $danger-light: #481922;
$light: #393939; $light: #393939;
@ -26,6 +28,8 @@ $background-body: rgb(24, 27, 28);
$background-secondary: rgb(28, 30, 32); $background-secondary: rgb(28, 30, 32);
$background-tertiary: rgb(32, 34, 36); $background-tertiary: rgb(32, 34, 36);
$modal-background-background-color: rgba($black, 0.8); $modal-background-background-color: rgba($black, 0.8);
$scrollbar-track: $background-secondary;
$scrollbar-thumb: $light;
/* highlight colors */ /* highlight colors */
$primary-highlight: $primary; $primary-highlight: $primary;

View file

@ -19,6 +19,8 @@ $scheme-main: $white-bis;
$background-body: $white; $background-body: $white;
$background-secondary: $white-ter; $background-secondary: $white-ter;
$background-tertiary: $white-bis; $background-tertiary: $white-bis;
$scrollbar-track: $background-secondary;
$scrollbar-thumb: $grey-lighter;
/* highlight colors */ /* highlight colors */
$primary-highlight: $primary-light; $primary-highlight: $primary-light;

View file

@ -99,7 +99,7 @@
<p> <p>
{% url "conduct" as coc_path %} {% url "conduct" as coc_path %}
{% blocktrans trimmed with site_name=site.name %} {% blocktrans trimmed with site_name=site.name %}
{{ site_name }}'s moderators and administrators keep the site up and running, enforce the <a href="coc_path">code of conduct</a>, and respond when users report spam and bad behavior. {{ site_name }}'s moderators and administrators keep the site up and running, enforce the <a href="{{ coc_path }}">code of conduct</a>, and respond when users report spam and bad behavior.
{% endblocktrans %} {% endblocktrans %}
</p> </p>
</header> </header>

View file

@ -208,9 +208,17 @@
{% endif %} {% endif %}
{% if book.parent_work.editions.count > 1 %} {% with work=book.parent_work %}
<p>{% blocktrans with path=book.parent_work.local_path count=book.parent_work.editions.count %}<a href="{{ path }}/editions">{{ count }} editions</a>{% endblocktrans %}</p> <p>
{% endif %} <a href="{{ work.local_path }}/editions">
{% blocktrans trimmed count counter=work.editions.count with count=work.editions.count|intcomma %}
{{ count }} edition
{% plural %}
{{ count }} editions
{% endblocktrans %}
</a>
</p>
{% endwith %}
</div> </div>
{# user's relationship to the book #} {# user's relationship to the book #}

View file

@ -3,18 +3,24 @@
{% load humanize %} {% load humanize %}
{% load utilities %} {% load utilities %}
{% block title %}{% if book %}{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}{% else %}{% trans "Add Book" %}{% endif %}{% endblock %} {% block title %}
{% if book.title %}
{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}
{% else %}
{% trans "Add Book" %}
{% endif %}
{% endblock %}
{% block content %} {% block content %}
<header class="block"> <header class="block">
<h1 class="title level-left"> <h1 class="title level-left">
{% if book %} {% if book.title %}
{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %} {% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}
{% else %} {% else %}
{% trans "Add Book" %} {% trans "Add Book" %}
{% endif %} {% endif %}
</h1> </h1>
{% if book %} {% if book.created_date %}
<dl> <dl>
<dt class="is-pulled-left mr-5 has-text-weight-semibold">{% trans "Added:" %}</dt> <dt class="is-pulled-left mr-5 has-text-weight-semibold">{% trans "Added:" %}</dt>
<dd class="ml-2">{{ book.created_date | naturaltime }}</dd> <dd class="ml-2">{{ book.created_date | naturaltime }}</dd>
@ -33,7 +39,7 @@
<form <form
class="block" class="block"
{% if book %} {% if book.id %}
name="edit-book" name="edit-book"
action="{{ book.local_path }}/{% if confirm_mode %}confirm{% else %}edit{% endif %}" action="{{ book.local_path }}/{% if confirm_mode %}confirm{% else %}edit{% endif %}"
{% else %} {% else %}
@ -97,7 +103,7 @@
<input type="radio" name="parent_work" value="{{ match.parent_work.id }}"> {{ match.parent_work.title }} <input type="radio" name="parent_work" value="{{ match.parent_work.id }}"> {{ match.parent_work.title }}
</label> </label>
{% endfor %} {% endfor %}
<label> <label class="label mt-2">
<input type="radio" name="parent_work" value="0" required> {% trans "This is a new work" %} <input type="radio" name="parent_work" value="0" required> {% trans "This is a new work" %}
</label> </label>
</fieldset> </fieldset>
@ -119,7 +125,7 @@
{% if not confirm_mode %} {% if not confirm_mode %}
<div class="block"> <div class="block">
<button class="button is-primary" type="submit">{% trans "Save" %}</button> <button class="button is-primary" type="submit">{% trans "Save" %}</button>
{% if book %} {% if book.id %}
<a class="button" href="{{ book.local_path }}">{% trans "Cancel" %}</a> <a class="button" href="{{ book.local_path }}">{% trans "Cancel" %}</a>
{% else %} {% else %}
<a href="/" class="button" data-back> <a href="/" class="button" data-back>

View file

@ -10,6 +10,8 @@
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="last_edited_by" value="{{ request.user.id }}"> <input type="hidden" name="last_edited_by" value="{{ request.user.id }}">
<input type="hidden" name="parent_work" value="{% firstof book.parent_work.id form.parent_work %}">
<div class="columns"> <div class="columns">
<div class="column is-half"> <div class="column is-half">
<section class="block"> <section class="block">
@ -153,8 +155,7 @@
<label class="label" for="id_first_published_date"> <label class="label" for="id_first_published_date">
{% trans "First published date:" %} {% trans "First published date:" %}
</label> </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"> {{ form.first_published_date }}
{% include 'snippets/form_errors.html' with errors_list=form.first_published_date.errors id="desc_first_published_date" %} {% include 'snippets/form_errors.html' with errors_list=form.first_published_date.errors id="desc_first_published_date" %}
</div> </div>
@ -162,7 +163,7 @@
<label class="label" for="id_published_date"> <label class="label" for="id_published_date">
{% trans "Published date:" %} {% trans "Published date:" %}
</label> </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"> {{ form.published_date }}
{% include 'snippets/form_errors.html' with errors_list=form.published_date.errors id="desc_published_date" %} {% include 'snippets/form_errors.html' with errors_list=form.published_date.errors id="desc_published_date" %}
</div> </div>
@ -175,6 +176,8 @@
</h2> </h2>
<div class="box"> <div class="box">
{% if book.authors.exists %} {% if book.authors.exists %}
{# preserve authors if the book is unsaved #}
<input type="hidden" name="authors" value="{% for author in book.authors.all %}{{ author.id }},{% endfor %}">
<fieldset> <fieldset>
{% for author in book.authors.all %} {% for author in book.authors.all %}
<div class="is-flex is-justify-content-space-between"> <div class="is-flex is-justify-content-space-between">
@ -255,9 +258,7 @@
<label class="label" for="id_physical_format"> <label class="label" for="id_physical_format">
{% trans "Format:" %} {% trans "Format:" %}
</label> </label>
<div class="select"> {{ form.physical_format }}
{{ form.physical_format }}
</div>
{% include 'snippets/form_errors.html' with errors_list=form.physical_format.errors id="desc_physical_format" %} {% include 'snippets/form_errors.html' with errors_list=form.physical_format.errors id="desc_physical_format" %}
</div> </div>

View file

@ -46,7 +46,36 @@
{% endfor %} {% endfor %}
</div> </div>
<div> <div class="block">
{% include 'snippets/pagination.html' with page=editions path=request.path %} {% include 'snippets/pagination.html' with page=editions path=request.path %}
</div> </div>
<div class="block has-text-centered help">
<p>
{% trans "Can't find the edition you're looking for?" %}
</p>
<form action="{% url 'create-book-data' %}" method="POST" name="add-edition-form">
{% csrf_token %}
{{ work_form.title }}
{{ work_form.subtitle }}
{{ work_form.authors }}
{{ work_form.description }}
{{ work_form.languages }}
{{ work_form.series }}
{{ work_form.cover }}
{{ work_form.first_published_date }}
{% for subject in work.subjects %}
<input type="hidden" name="subjects" value="{{ subject }}">
{% endfor %}
<input type="hidden" name="parent_work" value="{{ work.id }}">
<div>
<button class="button is-small" type="submit">
{% trans "Add another edition" %}
</button>
</div>
</form>
</div>
{% endblock %} {% endblock %}

View file

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

View file

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

View file

@ -29,9 +29,16 @@
</section> </section>
<section class="block"> <section class="block">
{% trans "Can't find your code?" as button_text %} <form name="fallback" method="GET" action="{% url 'resend-link' %}" autocomplete="off">
{% include "snippets/toggle/open_button.html" with text=button_text controls_text="resend_form" focus="resend_form_header" %} <button
{% include "confirm_email/resend_form.html" with controls_text="resend_form" %} type="submit"
class="button"
data-modal-open="resend_form"
>
{% trans "Can't find your code?" %}
</button>
</form>
{% include "confirm_email/resend_modal.html" with id="resend_form" %}
</section> </section>
</div> </div>
</div> </div>

View file

@ -0,0 +1,10 @@
{% extends 'landing/layout.html' %}
{% load i18n %}
{% block title %}
{% trans "Resend confirmation link" %}
{% endblock %}
{% block content %}
{% include "confirm_email/resend_modal.html" with active=True static=True id="resend-modal" %}
{% endblock %}

View file

@ -1,20 +0,0 @@
{% extends "components/inline_form.html" %}
{% load i18n %}
{% block header %}
{% trans "Resend confirmation link" %}
{% endblock %}
{% block form %}
<form name="resend" method="post" action="{% url 'resend-link' %}">
{% csrf_token %}
<div class="field">
<label class="label" for="email">{% trans "Email address:" %}</label>
<div class="control">
<input type="text" name="email" class="input" required id="email">
</div>
</div>
<div class="control">
<button class="button is-link">{% trans "Resend link" %}</button>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,44 @@
{% extends "components/modal.html" %}
{% load i18n %}
{% block modal-title %}
{% trans "Resend confirmation link" %}
{% endblock %}
{% block modal-form-open %}
<form name="resend" method="post" action="{% url 'resend-link' %}">
{% endblock %}
{% block modal-body %}
{% csrf_token %}
<div class="field">
<label class="label" for="email">{% trans "Email address:" %}</label>
<div class="control">
<input
type="email"
name="email"
class="input"
id="email"
aria-described-by="id_email_errors"
required
>
{% if error %}
<div id="id_email_errors">
<p class="help is-danger">
{% trans "No user matching this email address found." %}
</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block modal-footer %}
<div class="control">
<button class="button is-link">{% trans "Resend link" %}</button>
</div>
{% endblock %}
{% block modal-form-close %}
</form>
{% endblock %}

View file

@ -5,7 +5,19 @@
<section class="block"> <section class="block">
<h2 class="title is-4">{% trans "Your Books" %}</h2> <h2 class="title is-4">{% trans "Your Books" %}</h2>
{% if not suggested_books %} {% if not suggested_books %}
<p>{% trans "There are no books here right now! Try searching for a book to get started" %}</p>
<div class="content">
<p>{% trans "There are no books here right now! Try searching for a book to get started" %}</p>
<div class="box has-background-link-light">
<p>{% trans "Do you have book data from another service like GoodReads?" %}</p>
<a href="{% url 'import' %}">
<span class="icon icon-list" aria-hidden="true"></span>
{% trans "Import your reading history" %}
</a>
</div>
</div>
{% else %} {% else %}
{% with active_book=request.GET.book %} {% with active_book=request.GET.book %}
<div class="tab-group"> <div class="tab-group">

View file

@ -14,28 +14,32 @@
<div class="column is-half"> <div class="column is-half">
<div class="field"> <div class="field">
<label class="label is-pulled-left" for="source"> <label class="label" for="source">
{% trans "Data source:" %} {% trans "Data source:" %}
</label> </label>
{% include 'import/tooltip.html' with controls_text="goodreads-tooltip" %}
<div class="select">
<select name="source" id="source" aria-describedby="desc_source">
<option value="Goodreads" {% if current == 'Goodreads' %}selected{% endif %}>
Goodreads (CSV)
</option>
<option value="Storygraph" {% if current == 'Storygraph' %}selected{% endif %}>
Storygraph (CSV)
</option>
<option value="LibraryThing" {% if current == 'LibraryThing' %}selected{% endif %}>
LibraryThing (TSV)
</option>
<option value="OpenLibrary" {% if current == 'OpenLibrary' %}selected{% endif %}>
OpenLibrary (CSV)
</option>
</select>
</div>
<p class="help" id="desc_source">
{% trans 'You can download your Goodreads data from the <a href="https://www.goodreads.com/review/import" target="_blank" rel="noopener noreferrer">Import/Export page</a> of your Goodreads account.' %}
</p>
</div> </div>
<div class="select block">
<select name="source" id="source">
<option value="Goodreads" {% if current == 'Goodreads' %}selected{% endif %}>
Goodreads (CSV)
</option>
<option value="Storygraph" {% if current == 'Storygraph' %}selected{% endif %}>
Storygraph (CSV)
</option>
<option value="LibraryThing" {% if current == 'LibraryThing' %}selected{% endif %}>
LibraryThing (TSV)
</option>
<option value="OpenLibrary" {% if current == 'OpenLibrary' %}selected{% endif %}>
OpenLibrary (CSV)
</option>
</select>
</div>
<div class="field"> <div class="field">
<label class="label" for="id_csv_file">{% trans "Data file:" %}</label> <label class="label" for="id_csv_file">{% trans "Data file:" %}</label>
{{ import_form.csv_file }} {{ import_form.csv_file }}
@ -63,7 +67,7 @@
<div class="content block"> <div class="content block">
<h2 class="title">{% trans "Recent Imports" %}</h2> <h2 class="title">{% trans "Recent Imports" %}</h2>
{% if not jobs %} {% if not jobs %}
<p>{% trans "No recent imports" %}</p> <p><em>{% trans "No recent imports" %}</em></p>
{% endif %} {% endif %}
<ul> <ul>
{% for job in jobs %} {% for job in jobs %}

View file

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

View file

@ -70,6 +70,14 @@
{% include 'snippets/form_errors.html' with errors_list=request_form.email.errors id="desc_request_email" %} {% include 'snippets/form_errors.html' with errors_list=request_form.email.errors id="desc_request_email" %}
</div> </div>
{% if site.invite_request_question %}
<div class="block">
<label for="id_answer_register" class="label">{{ site.invite_question_text }}</label>
<input type="answer" name="answer" maxlength="50" class="input" required="true" id="id_answer_register" aria-describedby="desc_answer_register">
{% include 'snippets/form_errors.html' with errors_list=request_form.answer.errors id="desc_answer_register" %}
</div>
{% endif %}
<button type="submit" class="button is-link">{% trans "Submit" %}</button> <button type="submit" class="button is-link">{% trans "Submit" %}</button>
</form> </form>
{% endif %} {% endif %}

View file

@ -0,0 +1,22 @@
{% extends 'preferences/layout.html' %}
{% load i18n %}
{% block title %}{% trans "CSV Export" %}{% endblock %}
{% block header %}
{% trans "CSV Export" %}
{% endblock %}
{% block panel %}
<div class="block content">
<p class="notification">
{% trans "Your export will include all the books on your shelves, books you have reviewed, and books with reading activity." %}
</p>
<p>
<a href="{% url 'prefs-export-file' %}" class="button">
<span class="icon icon-download" aria-hidden="true"></span>
<span>Download file</span>
</a>
</p>
</div>
{% endblock %}

View file

@ -24,6 +24,17 @@
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Delete Account" %}</a> <a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Delete Account" %}</a>
</li> </li>
</ul> </ul>
<h2 class="menu-label">{% trans "Data" %}</h2>
<ul class="menu-list">
<li>
{% url 'import' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Import" %}</a>
</li>
<li>
{% url 'prefs-export' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "CSV export" %}</a>
</li>
</ul>
<h2 class="menu-label">{% trans "Relationships" %}</h2> <h2 class="menu-label">{% trans "Relationships" %}</h2>
<ul class="menu-list"> <ul class="menu-list">
<li> <li>

View file

@ -17,7 +17,14 @@
{% endblock %} {% endblock %}
{% block modal-form-open %} {% block modal-form-open %}
<form name="add-readthrough-{{ readthrough.id }}" action="/create-readthrough" method="post"> <form
name="add-readthrough-{{ readthrough.id }}"
{% if readthrough.id %}
action="{% url 'edit-readthrough' %}"
{% else %}
action="{% url 'create-readthrough' %}"
{% endif %}
method="POST">
{% endblock %} {% endblock %}
{% block modal-body %} {% block modal-body %}

View file

@ -14,7 +14,7 @@
{% block panel %} {% block panel %}
<div class="block table-container"> <div class="block table-container">
<table class="table is-striped"> <table class="table is-striped is-fullwidth">
<tr> <tr>
<th> <th>
{% url 'settings-announcements' as url %} {% url 'settings-announcements' as url %}

View file

@ -154,7 +154,7 @@
</summary> </summary>
<div class="table-container"> <div class="table-container">
<table class="table is-striped"> <table class="table is-striped is-fullwidth">
<tr> <tr>
<th> <th>
<label for="id_string_match">{% trans "String match" %}</label> <label for="id_string_match">{% trans "String match" %}</label>

View file

@ -10,26 +10,26 @@
{% block panel %} {% block panel %}
<div class="columns block has-text-centered is-mobile is-multiline"> <div class="columns block has-text-centered is-mobile is-multiline">
<div class="column is-3-desktop is-6-mobile"> <div class="column is-3-desktop is-6-mobile is-flex">
<div class="notification"> <div class="notification is-flex-grow-1">
<h3>{% trans "Total users" %}</h3> <h3>{% trans "Total users" %}</h3>
<p class="title is-5">{{ users|intcomma }}</p> <p class="title is-5">{{ users|intcomma }}</p>
</div> </div>
</div> </div>
<div class="column is-3-desktop is-6-mobile"> <div class="column is-3-desktop is-6-mobil is-flexe">
<div class="notification"> <div class="notification is-flex-grow-1">
<h3>{% trans "Active this month" %}</h3> <h3>{% trans "Active this month" %}</h3>
<p class="title is-5">{{ active_users|intcomma }}</p> <p class="title is-5">{{ active_users|intcomma }}</p>
</div> </div>
</div> </div>
<div class="column is-3-desktop is-6-mobile"> <div class="column is-3-desktop is-6-mobile is-flex">
<div class="notification"> <div class="notification is-flex-grow-1">
<h3>{% trans "Statuses" %}</h3> <h3>{% trans "Statuses" %}</h3>
<p class="title is-5">{{ statuses|intcomma }}</p> <p class="title is-5">{{ statuses|intcomma }}</p>
</div> </div>
</div> </div>
<div class="column is-3-desktop is-6-mobile"> <div class="column is-3-desktop is-6-mobile is-flex">
<div class="notification"> <div class="notification is-flex-grow-1">
<h3>{% trans "Works" %}</h3> <h3>{% trans "Works" %}</h3>
<p class="title is-5">{{ works|intcomma }}</p> <p class="title is-5">{{ works|intcomma }}</p>
</div> </div>
@ -38,8 +38,8 @@
<div class="columns block is-multiline"> <div class="columns block is-multiline">
{% if reports %} {% if reports %}
<div class="column"> <div class="column is-flex">
<a href="{% url 'settings-reports' %}" class="notification is-warning is-block"> <a href="{% url 'settings-reports' %}" class="notification is-warning is-block is-flex-grow-1">
{% blocktrans trimmed count counter=reports with display_count=reports|intcomma %} {% blocktrans trimmed count counter=reports with display_count=reports|intcomma %}
{{ display_count }} open report {{ display_count }} open report
{% plural %} {% plural %}
@ -50,8 +50,8 @@
{% endif %} {% endif %}
{% if pending_domains %} {% if pending_domains %}
<div class="column"> <div class="column is-flex">
<a href="{% url 'settings-link-domain' %}" class="notification is-primary is-block"> <a href="{% url 'settings-link-domain' %}" class="notification is-primary is-block is-flex-grow-1">
{% blocktrans trimmed count counter=pending_domains with display_count=pending_domains|intcomma %} {% blocktrans trimmed count counter=pending_domains with display_count=pending_domains|intcomma %}
{{ display_count }} domain needs review {{ display_count }} domain needs review
{% plural %} {% plural %}
@ -62,8 +62,8 @@
{% endif %} {% endif %}
{% if not site.allow_registration and site.allow_invite_requests and invite_requests %} {% if not site.allow_registration and site.allow_invite_requests and invite_requests %}
<div class="column"> <div class="column is-flex">
<a href="{% url 'settings-invite-requests' %}" class="notification is-block is-success"> <a href="{% url 'settings-invite-requests' %}" class="notification is-block is-success is-flex-grow-1">
{% blocktrans trimmed count counter=invite_requests with display_count=invite_requests|intcomma %} {% blocktrans trimmed count counter=invite_requests with display_count=invite_requests|intcomma %}
{{ display_count }} invite request {{ display_count }} invite request
{% plural %} {% plural %}
@ -74,8 +74,8 @@
{% endif %} {% endif %}
{% if current_version %} {% if current_version %}
<div class="column"> <div class="column is-flex">
<a href="https://docs.joinbookwyrm.com/updating-your-instance.html" class="notification is-block is-warning" target="_blank"> <a href="https://docs.joinbookwyrm.com/updating-your-instance.html" class="notification is-block is-warning is-flex-grow-1" target="_blank">
{% blocktrans trimmed with current=current_version available=available_version %} {% blocktrans trimmed with current=current_version available=available_version %}
An update is available! You're running v{{ current }} and the latest release is {{ available }}. An update is available! You're running v{{ current }} and the latest release is {{ available }}.
{% endblocktrans %} {% endblocktrans %}

View file

@ -25,7 +25,7 @@
</ul> </ul>
</div> </div>
<table class="table is-striped"> <table class="table is-striped is-fullwidth">
<tr> <tr>
{% url 'settings-federation' as url %} {% url 'settings-federation' as url %}
<th> <th>

View file

@ -40,6 +40,9 @@
{% include 'snippets/table-sort-header.html' with field="invite__invitees__created_date" sort=sort text=text %} {% include 'snippets/table-sort-header.html' with field="invite__invitees__created_date" sort=sort text=text %}
</th> </th>
<th>{% trans "Email" %}</th> <th>{% trans "Email" %}</th>
{% if site.invite_request_question %}
<th>{% trans "Answer" %}</th>
{% endif %}
<th> <th>
{% trans "Status" as text %} {% trans "Status" as text %}
{% include 'snippets/table-sort-header.html' with field="invite__times_used" sort=sort text=text %} {% include 'snippets/table-sort-header.html' with field="invite__times_used" sort=sort text=text %}
@ -54,6 +57,9 @@
<td>{{ req.created_date | naturaltime }}</td> <td>{{ req.created_date | naturaltime }}</td>
<td>{{ req.invite.invitees.first.created_date | naturaltime }}</td> <td>{{ req.invite.invitees.first.created_date | naturaltime }}</td>
<td>{{ req.email }}</td> <td>{{ req.email }}</td>
{% if site.invite_request_question %}
<td>{{ req.answer }}</td>
{% endif %}
<td> <td>
{% if req.invite.times_used %} {% if req.invite.times_used %}
{% trans "Accepted" %} {% trans "Accepted" %}

View file

@ -21,6 +21,7 @@
<div class="field"> <div class="field">
<input type="text" name="address" maxlength="255" class="input" required="" id="id_address" placeholder="190.0.2.0/24" aria-describedby="desc_address"> <input type="text" name="address" maxlength="255" class="input" required="" id="id_address" placeholder="190.0.2.0/24" aria-describedby="desc_address">
<p class="help">{% trans "You can block IP ranges using CIDR syntax." %}</p>
</div> </div>
{% include 'snippets/form_errors.html' with errors_list=form.address.errors id="desc_address" %} {% include 'snippets/form_errors.html' with errors_list=form.address.errors id="desc_address" %}

View file

@ -1,8 +0,0 @@
{% extends 'components/tooltip.html' %}
{% load i18n %}
{% block tooltip_content %}
{% trans "You can block IP ranges using CIDR syntax." %}
{% endblock %}

View file

@ -93,7 +93,7 @@
</ul> </ul>
{% endif %} {% endif %}
</nav> </nav>
<div class="column"> <div class="column is-clipped">
{% block panel %}{% endblock %} {% block panel %}{% endblock %}
</div> </div>
</div> </div>

View file

@ -44,5 +44,6 @@
{% endfor %} {% endfor %}
</div> </div>
{% include 'snippets/pagination.html' with page=reports path=request.path %}
{% endblock %} {% endblock %}

View file

@ -139,12 +139,6 @@
{% trans "Allow registration" %} {% trans "Allow registration" %}
</label> </label>
</div> </div>
<div class="field">
<label class="label" for="id_allow_invite_requests">
{{ site_form.allow_invite_requests }}
{% trans "Allow invite requests" %}
</label>
</div>
<div class="field"> <div class="field">
<label class="label mb-0" for="id_require_confirm_email"> <label class="label mb-0" for="id_require_confirm_email">
{{ site_form.require_confirm_email }} {{ site_form.require_confirm_email }}
@ -152,6 +146,24 @@
</label> </label>
<p class="help" id="desc_require_confirm_email">{% trans "(Recommended if registration is open)" %}</p> <p class="help" id="desc_require_confirm_email">{% trans "(Recommended if registration is open)" %}</p>
</div> </div>
<div class="field">
<label class="label" for="id_allow_invite_requests">
{{ site_form.allow_invite_requests }}
{% trans "Allow invite requests" %}
</label>
</div>
<div class="field">
<label class="label" for="id_invite_requests_question">
{{ site_form.invite_request_question }}
{% trans "Set a question for invite requests" %}
</label>
</div>
<div class="field">
<label class="label" for="id_invite_question_text">
{% trans "Question:" %}
{{ site_form.invite_question_text }}
</label>
</div>
<div class="field"> <div class="field">
<label class="label" for="id_registration_closed_text">{% trans "Registration closed text:" %}</label> <label class="label" for="id_registration_closed_text">{% trans "Registration closed text:" %}</label>
{{ site_form.registration_closed_text }} {{ site_form.registration_closed_text }}

View file

@ -88,7 +88,7 @@
<section class="block content"> <section class="block content">
<h2 class="title is-4">{% trans "Available Themes" %}</h2> <h2 class="title is-4">{% trans "Available Themes" %}</h2>
<div class="table-container"> <div class="table-container">
<table class="table is-striped"> <table class="table is-striped is-fullwidth">
<tr> <tr>
<th> <th>
{% trans "Theme name" %} {% trans "Theme name" %}

View file

@ -33,7 +33,7 @@
</div> </div>
<div class="table-container block"> <div class="table-container block">
<table class="table is-striped"> <table class="table is-striped is-fullwidth">
<tr> <tr>
{% url 'settings-users' as url %} {% url 'settings-users' as url %}
<th> <th>
@ -61,10 +61,25 @@
</tr> </tr>
{% for user in users %} {% for user in users %}
<tr> <tr>
<td><a href="{% url 'settings-user' user.id %}">{{ user|username }}</a></td> <td class="overflow-wrap-anywhere">
<a href="{% url 'settings-user' user.id %}">{{ user|username }}</a>
</td>
<td>{{ user.created_date }}</td> <td>{{ user.created_date }}</td>
<td>{{ user.last_active_date }}</td> <td>{{ user.last_active_date }}</td>
<td>{% if user.is_active %}{% trans "Active" %}{% else %}{% trans "Inactive" %}{% endif %}</td> <td>
{% if user.is_active %}
<span class="tag is-success" aria-hidden="true">
<span class="icon icon-check"></span>
</span>
{% trans "Active" %}
{% else %}
<span class="tag is-warning" aria-hidden="true">
<span class="icon icon-x"></span>
</span>
{% trans "Inactive" %}
<span class="help">({{ user.get_deactivation_reason_display }})</span>
{% endif %}
</td>
{% if status != "local" %} {% if status != "local" %}
<td> <td>
{% if user.federated_server %} {% if user.federated_server %}

View file

@ -6,7 +6,7 @@
<div class="column is-flex is-flex-direction-column"> <div class="column is-flex is-flex-direction-column">
<h4 class="title is-4">{% trans "Profile" %}</h4> <h4 class="title is-4">{% trans "Profile" %}</h4>
<div class="box is-flex-grow-1"> <div class="box is-flex-grow-1">
{% include 'user/user_preview.html' with user=user %} {% include 'user/user_preview.html' with user=user admin_mode=True %}
{% if user.summary %} {% if user.summary %}
<div class="box content has-background-secondary is-shadowless"> <div class="box content has-background-secondary is-shadowless">
{{ user.summary|to_markdown|safe }} {{ user.summary|to_markdown|safe }}
@ -14,6 +14,10 @@
{% endif %} {% endif %}
<p class="mt-2"><a href="{{ user.local_path }}">{% trans "View user profile" %}</a></p> <p class="mt-2"><a href="{{ user.local_path }}">{% trans "View user profile" %}</a></p>
{% url 'settings-user' user.id as url %}
{% if not request.path == url %}
<p class="mt-2"><a href="{{ url }}">{% trans "Go to user admin" %}</a></p>
{% endif %}
</div> </div>
</div> </div>
<div class="column is-flex is-flex-direction-column is-4"> <div class="column is-flex is-flex-direction-column is-4">
@ -67,6 +71,9 @@
<dt class="is-pulled-left mr-5">{% trans "Blocked by count:" %}</dt> <dt class="is-pulled-left mr-5">{% trans "Blocked by count:" %}</dt>
<dd>{{ user.blocked_by.count }}</dd> <dd>{{ user.blocked_by.count }}</dd>
<dt class="is-pulled-left mr-5">{% trans "Date added:" %}</dt>
<dd>{{ user.created_date }}</dd>
<dt class="is-pulled-left mr-5">{% trans "Last active date:" %}</dt> <dt class="is-pulled-left mr-5">{% trans "Last active date:" %}</dt>
<dd>{{ user.last_active_date }}</dd> <dd>{{ user.last_active_date }}</dd>

View file

@ -4,7 +4,7 @@
{% with goal.progress as progress %} {% with goal.progress as progress %}
<p> <p>
{% if progress.percent >= 100 %} {% if progress.percent >= 100 %}
{% trans "Success!" %} {% trans "Success!" context "Goal successfully completed" %}
{% elif progress.percent %} {% elif progress.percent %}
{% blocktrans with percent=progress.percent %}{{ percent }}% complete!{% endblocktrans %} {% blocktrans with percent=progress.percent %}{{ percent }}% complete!{% endblocktrans %}
{% endif %} {% endif %}
@ -14,6 +14,11 @@
{% blocktrans with username=goal.user.display_name read_count=progress.count|intcomma goal_count=goal.goal|intcomma path=goal.local_path %}{{ username }} has read <a href="{{ path }}">{{ read_count }} of {{ goal_count}} books</a>.{% endblocktrans %} {% blocktrans with username=goal.user.display_name read_count=progress.count|intcomma goal_count=goal.goal|intcomma path=goal.local_path %}{{ username }} has read <a href="{{ path }}">{{ read_count }} of {{ goal_count}} books</a>.{% endblocktrans %}
{% endif %} {% endif %}
</p> </p>
<progress class="progress is-large" value="{{ progress.count }}" max="{{ goal.goal }}" aria-hidden="true">{{ progress.percent }}%</progress> <progress
class="progress is-large is-primary"
value="{{ progress.count }}"
max="{{ goal.goal }}"
aria-hidden="true"
>{{ progress.percent }}%</progress>
{% endwith %} {% endwith %}

View file

@ -10,6 +10,7 @@
<form name="reading-progress-{{ uuid }}" action="{% url 'reading-status-update' book.id %}" method="POST" class="submit-status"> <form name="reading-progress-{{ uuid }}" action="{% url 'reading-status-update' book.id %}" method="POST" class="submit-status">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="id" value="{{ readthrough.id }}"> <input type="hidden" name="id" value="{{ readthrough.id }}">
<input type="hidden" name="start_date" value="{{ readthrough.start_date|date:'Y-m-d' }}">
{% endblock %} {% endblock %}
{% block reading-dates %} {% block reading-dates %}

View file

@ -37,7 +37,7 @@
{% endwith %} {% endwith %}
{% endif %} {% endif %}
<article class="column ml-3-tablet my-3-mobile"> <article class="column ml-3-tablet my-3-mobile is-clipped">
{% if status_type == 'Review' %} {% if status_type == 'Review' %}
<header class="mb-2"> <header class="mb-2">
<h3 <h3

View file

@ -32,7 +32,7 @@
<div class="card-footer-item"> <div class="card-footer-item">
{% trans "Reply" as button_text %} {% trans "Reply" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with controls_text="show_comment" controls_uid=status.id text=button_text icon_with_text="comment" class="is-small is-light is-transparent toggle-button" focus="id_content_reply" %} {% include 'snippets/toggle/toggle_button.html' with controls_text="show_comment" controls_uid=status.id text=button_text icon_with_text="comment" class="is-small is-light toggle-button" focus="id_content_reply" %}
</div> </div>
<div class="card-footer-item"> <div class="card-footer-item">
{% include 'snippets/boost_button.html' with status=status %} {% include 'snippets/boost_button.html' with status=status %}
@ -42,7 +42,7 @@
</div> </div>
{% if not moderation_mode %} {% if not moderation_mode %}
<div class="card-footer-item"> <div class="card-footer-item">
{% include 'snippets/status/status_options.html' with class="is-small is-light is-transparent" right=True %} {% include 'snippets/status/status_options.html' with class="is-small is-light" right=True %}
</div> </div>
{% endif %} {% endif %}

View file

@ -21,7 +21,7 @@
<p><a href="{{ user.remote_id }}">{{ user.username }}</a></p> <p><a href="{{ user.remote_id }}">{{ user.username }}</a></p>
<p>{% blocktrans with date=user.created_date|naturaltime %}Joined {{ date }}{% endblocktrans %}</p> <p>{% blocktrans with date=user.created_date|naturaltime %}Joined {{ date }}{% endblocktrans %}</p>
<p> <p>
{% if request.user.id == user.id %} {% if request.user.id == user.id or admin_mode %}
<a href="{% url 'user-followers' user|username %}">{% blocktrans count counter=user.followers.count %}{{ counter }} follower{% plural %}{{ counter }} followers{% endblocktrans %}</a>, <a href="{% url 'user-followers' user|username %}">{% blocktrans count counter=user.followers.count %}{{ counter }} follower{% plural %}{{ counter }} followers{% endblocktrans %}</a>,
<a href="{% url 'user-following' user|username %}">{% blocktrans with counter=user.following.count %}{{ counter }} following{% endblocktrans %}</a> <a href="{% url 'user-following' user|username %}">{% blocktrans with counter=user.following.count %}{{ counter }} following{% endblocktrans %}</a>

View file

@ -0,0 +1,9 @@
{% spaceless %}
<div class="field has-addons">
{% for widget in widget.subwidgets %}
<div class="control{% if forloop.last %} is-expanded{% endif %}">
{% include widget.template_name %}
</div>
{% endfor %}
</div>
{% endspaceless %}

View file

@ -0,0 +1,10 @@
<div class="select">
<select
name="{{ widget.name }}"
{% include "django/forms/widgets/attrs.html" %}
>{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
<optgroup label="{{ group_name }}">{% endif %}{% for option in group_choices %}
{% include option.template_name with widget=option %}{% endfor %}{% if group_name %}
</optgroup>{% endif %}{% endfor %}
</select>
</div>

View file

@ -60,7 +60,7 @@ class EditBookViews(TestCase):
def test_edit_book_create_page(self): def test_edit_book_create_page(self):
"""there are so many views, this just makes sure it LOADS""" """there are so many views, this just makes sure it LOADS"""
view = views.EditBook.as_view() view = views.CreateBook.as_view()
request = self.factory.get("") request = self.factory.get("")
request.user = self.local_user request.user = self.local_user
request.user.is_superuser = True request.user.is_superuser = True

View file

@ -360,10 +360,17 @@ class RegisterViews(TestCase):
result = view(request) result = view(request)
validate_html(result.render()) validate_html(result.render())
def test_resend_link(self, *_): def test_resend_link_get(self, *_):
"""try again"""
request = self.factory.get("")
request.user = self.anonymous_user
result = views.ResendConfirmEmail.as_view()(request)
validate_html(result.render())
def test_resend_link_post(self, *_):
"""try again""" """try again"""
request = self.factory.post("", {"email": "mouse@mouse.com"}) request = self.factory.post("", {"email": "mouse@mouse.com"})
request.user = self.anonymous_user request.user = self.anonymous_user
with patch("bookwyrm.emailing.send_email.delay") as mock: with patch("bookwyrm.emailing.send_email.delay") as mock:
views.resend_link(request) views.ResendConfirmEmail.as_view()(request)
self.assertEqual(mock.call_count, 1) self.assertEqual(mock.call_count, 1)

View file

@ -0,0 +1,69 @@
""" test for app action functionality """
from unittest.mock import patch
from django.http import StreamingHttpResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import models, views
from bookwyrm.tests.validate_html import validate_html
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
@patch("bookwyrm.activitystreams.add_status_task.delay")
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
class ExportViews(TestCase):
"""viewing and creating statuses"""
def setUp(self):
"""we need basic test data and mocks"""
self.factory = RequestFactory()
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
"bookwyrm.activitystreams.populate_stream_task.delay"
):
self.local_user = models.User.objects.create_user(
"mouse@local.com",
"mouse@mouse.com",
"mouseword",
local=True,
localname="mouse",
remote_id="https://example.com/users/mouse",
)
self.work = models.Work.objects.create(title="Test Work")
self.book = models.Edition.objects.create(
title="Test Book",
remote_id="https://example.com/book/1",
parent_work=self.work,
isbn_13="9781234567890",
bnf_id="beep",
)
def tst_export_get(self, *_):
"""request export"""
request = self.factory.get("")
request.user = self.local_user
result = views.Export.as_view()(request)
validate_html(result.render())
def test_export_file(self, *_):
"""simple export"""
models.ShelfBook.objects.create(
shelf=self.local_user.shelf_set.first(),
user=self.local_user,
book=self.book,
)
request = self.factory.get("")
request.user = self.local_user
export = views.export_user_book_data(request)
self.assertIsInstance(export, StreamingHttpResponse)
self.assertEqual(export.status_code, 200)
result = list(export.streaming_content)
# pylint: disable=line-too-long
self.assertEqual(
result[0],
b"title,author_text,remote_id,openlibrary_key,inventaire_id,librarything_key,goodreads_key,bnf_id,viaf,wikidata,asin,isbn_10,isbn_13,oclc_number,rating,review_name,review_cw,review_content\r\n",
)
expected = f"Test Book,,{self.book.remote_id},,,,,beep,,,,123456789X,9781234567890,,,,,\r\n"
self.assertEqual(result[1].decode("utf-8"), expected)

View file

@ -71,7 +71,7 @@ urlpatterns = [
views.ConfirmEmailCode.as_view(), views.ConfirmEmailCode.as_view(),
name="confirm-email-code", name="confirm-email-code",
), ),
re_path(r"^resend-link/?$", views.resend_link, name="resend-link"), re_path(r"^resend-link/?$", views.ResendConfirmEmail.as_view(), name="resend-link"),
re_path(r"^logout/?$", views.Logout.as_view(), name="logout"), re_path(r"^logout/?$", views.Logout.as_view(), name="logout"),
re_path( re_path(
r"^password-reset/?$", r"^password-reset/?$",
@ -475,6 +475,12 @@ urlpatterns = [
views.ChangePassword.as_view(), views.ChangePassword.as_view(),
name="prefs-password", name="prefs-password",
), ),
re_path(r"^preferences/export/?$", views.Export.as_view(), name="prefs-export"),
re_path(
r"^preferences/export/file/?$",
views.export_user_book_data,
name="prefs-export-file",
),
re_path(r"^preferences/delete/?$", views.DeleteUser.as_view(), name="prefs-delete"), re_path(r"^preferences/delete/?$", views.DeleteUser.as_view(), name="prefs-delete"),
re_path(r"^preferences/block/?$", views.Block.as_view(), name="prefs-block"), re_path(r"^preferences/block/?$", views.Block.as_view(), name="prefs-block"),
re_path(r"^block/(?P<user_id>\d+)/?$", views.Block.as_view()), re_path(r"^block/(?P<user_id>\d+)/?$", views.Block.as_view()),
@ -524,7 +530,10 @@ urlpatterns = [
), ),
re_path(rf"{BOOK_PATH}/edit/?$", views.EditBook.as_view(), name="edit-book"), re_path(rf"{BOOK_PATH}/edit/?$", views.EditBook.as_view(), name="edit-book"),
re_path(rf"{BOOK_PATH}/confirm/?$", views.ConfirmEditBook.as_view()), re_path(rf"{BOOK_PATH}/confirm/?$", views.ConfirmEditBook.as_view()),
re_path(r"^create-book/?$", views.EditBook.as_view(), name="create-book"), re_path(
r"^create-book/data/?$", views.create_book_from_data, name="create-book-data"
),
re_path(r"^create-book/?$", views.CreateBook.as_view(), name="create-book"),
re_path(r"^create-book/confirm/?$", views.ConfirmEditBook.as_view()), re_path(r"^create-book/confirm/?$", views.ConfirmEditBook.as_view()),
re_path(rf"{BOOK_PATH}/editions(.json)?/?$", views.Editions.as_view()), re_path(rf"{BOOK_PATH}/editions(.json)?/?$", views.Editions.as_view()),
re_path( re_path(

View file

@ -28,6 +28,7 @@ from .admin.user_admin import UserAdmin, UserAdminList
# user preferences # user preferences
from .preferences.change_password import ChangePassword from .preferences.change_password import ChangePassword
from .preferences.edit_user import EditUser from .preferences.edit_user import EditUser
from .preferences.export import Export, export_user_book_data
from .preferences.delete_user import DeleteUser from .preferences.delete_user import DeleteUser
from .preferences.block import Block, unblock from .preferences.block import Block, unblock
@ -39,7 +40,12 @@ from .books.books import (
resolve_book, resolve_book,
) )
from .books.books import update_book_from_remote from .books.books import update_book_from_remote
from .books.edit_book import EditBook, ConfirmEditBook from .books.edit_book import (
EditBook,
ConfirmEditBook,
CreateBook,
create_book_from_data,
)
from .books.editions import Editions, switch_edition from .books.editions import Editions, switch_edition
from .books.links import BookFileLinks, AddFileLink, delete_link from .books.links import BookFileLinks, AddFileLink, delete_link
@ -47,7 +53,8 @@ from .books.links import BookFileLinks, AddFileLink, delete_link
from .landing.about import about, privacy, conduct from .landing.about import about, privacy, conduct
from .landing.landing import Home, Landing from .landing.landing import Home, Landing
from .landing.login import Login, Logout from .landing.login import Login, Logout
from .landing.register import Register, ConfirmEmail, ConfirmEmailCode, resend_link from .landing.register import Register
from .landing.register import ConfirmEmail, ConfirmEmailCode, ResendConfirmEmail
from .landing.password import PasswordResetRequest, PasswordReset from .landing.password import PasswordResetRequest, PasswordReset
# shelves # shelves

View file

@ -96,6 +96,7 @@ class ManageInviteRequests(View):
"created_date", "created_date",
"invite__times_used", "invite__times_used",
"invite__invitees__created_date", "invite__invitees__created_date",
"answer",
] ]
# pylint: disable=consider-using-f-string # pylint: disable=consider-using-f-string
if not sort in sort_fields + ["-{:s}".format(f) for f in sort_fields]: if not sort in sort_fields + ["-{:s}".format(f) for f in sort_fields]:
@ -143,6 +144,7 @@ class ManageInviteRequests(View):
invite_request = get_object_or_404( invite_request = get_object_or_404(
models.InviteRequest, id=request.POST.get("invite-request") models.InviteRequest, id=request.POST.get("invite-request")
) )
# only create a new invite if one doesn't exist already (resending) # only create a new invite if one doesn't exist already (resending)
if not invite_request.invite: if not invite_request.invite:
invite_request.invite = models.SiteInvite.objects.create( invite_request.invite = models.SiteInvite.objects.create(
@ -170,10 +172,7 @@ class InviteRequest(View):
received = True received = True
form.save() form.save()
data = { data = {"request_form": form, "request_received": received}
"request_form": form,
"request_received": received,
}
return TemplateResponse(request, "landing/landing.html", data) return TemplateResponse(request, "landing/landing.html", data)

View file

@ -1,5 +1,6 @@
""" moderation via flagged posts and users """ """ moderation via flagged posts and users """
from django.contrib.auth.decorators import login_required, permission_required from django.contrib.auth.decorators import login_required, permission_required
from django.core.paginator import Paginator
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
@ -7,6 +8,7 @@ from django.utils.decorators import method_decorator
from django.views import View from django.views import View
from bookwyrm import forms, models from bookwyrm import forms, models
from bookwyrm.settings import PAGE_LENGTH
# pylint: disable=no-self-use # pylint: disable=no-self-use
@ -34,10 +36,17 @@ class ReportsAdmin(View):
if username: if username:
filters["user__username__icontains"] = username filters["user__username__icontains"] = username
filters["resolved"] = resolved filters["resolved"] = resolved
reports = models.Report.objects.filter(**filters)
paginated = Paginator(reports, PAGE_LENGTH)
page = paginated.get_page(request.GET.get("page"))
data = { data = {
"resolved": resolved, "resolved": resolved,
"server": server, "server": server,
"reports": models.Report.objects.filter(**filters), "reports": page,
"page_range": paginated.get_elided_page_range(
page.number, on_each_side=2, on_ends=1
),
} }
return TemplateResponse(request, "settings/reports/reports.html", data) return TemplateResponse(request, "settings/reports/reports.html", data)

View file

@ -1,7 +1,6 @@
""" the good people stuff! the authors! """ """ the good people stuff! the authors! """
from django.contrib.auth.decorators import login_required, permission_required from django.contrib.auth.decorators import login_required, permission_required
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db.models import Avg, Q
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
@ -27,9 +26,8 @@ class Author(View):
return ActivitypubResponse(author.to_activity()) return ActivitypubResponse(author.to_activity())
books = ( books = (
models.Work.objects.filter(Q(authors=author) | Q(editions__authors=author)) models.Work.objects.filter(editions__authors=author)
.annotate(Avg("editions__review__rating")) .order_by("created_date")
.order_by("editions__review__rating__avg")
.distinct() .distinct()
) )

View file

@ -1,14 +1,13 @@
""" the good stuff! the books! """ """ the good stuff! the books! """
from re import sub from re import sub, findall
from dateutil.parser import parse as dateparse
from django.contrib.auth.decorators import login_required, permission_required from django.contrib.auth.decorators import login_required, permission_required
from django.contrib.postgres.search import SearchRank, SearchVector from django.contrib.postgres.search import SearchRank, SearchVector
from django.db import transaction from django.db import transaction
from django.http import HttpResponseBadRequest from django.http import HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.utils.datastructures import MultiValueDictKeyError
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.http import require_POST
from django.views import View from django.views import View
from bookwyrm import book_search, forms, models from bookwyrm import book_search, forms, models
@ -30,105 +29,27 @@ from .books import set_cover_from_url
class EditBook(View): class EditBook(View):
"""edit a book""" """edit a book"""
def get(self, request, book_id=None): def get(self, request, book_id):
"""info about a book""" """info about a book"""
book = None book = get_edition(book_id)
if book_id: if not book.description:
book = get_edition(book_id) book.description = book.parent_work.description
if not book.description:
book.description = book.parent_work.description
data = {"book": book, "form": forms.EditionForm(instance=book)} data = {"book": book, "form": forms.EditionForm(instance=book)}
return TemplateResponse(request, "book/edit/edit_book.html", data) return TemplateResponse(request, "book/edit/edit_book.html", data)
# pylint: disable=too-many-locals def post(self, request, book_id):
def post(self, request, book_id=None):
"""edit a book cool""" """edit a book cool"""
# returns None if no match is found book = get_object_or_404(models.Edition, id=book_id)
book = models.Edition.objects.filter(id=book_id).first()
form = forms.EditionForm(request.POST, request.FILES, instance=book) form = forms.EditionForm(request.POST, request.FILES, instance=book)
data = {"book": book, "form": form} data = {"book": book, "form": form}
if not form.is_valid(): if not form.is_valid():
return TemplateResponse(request, "book/edit/edit_book.html", data) return TemplateResponse(request, "book/edit/edit_book.html", data)
# filter out empty author fields data = add_authors(request, data)
add_author = [author for author in request.POST.getlist("add_author") if author]
if add_author:
data["add_author"] = add_author
data["author_matches"] = []
data["isni_matches"] = []
for author in add_author:
if not author:
continue
# check for existing authors
vector = SearchVector("name", weight="A") + SearchVector(
"aliases", weight="B"
)
author_matches = (
models.Author.objects.annotate(search=vector)
.annotate(rank=SearchRank(vector, author))
.filter(rank__gt=0.4)
.order_by("-rank")[:5]
)
isni_authors = find_authors_by_name(
author, description=True
) # find matches from ISNI API
# dedupe isni authors we already have in the DB
exists = [
i
for i in isni_authors
for a in author_matches
if sub(r"\D", "", str(i.isni)) == sub(r"\D", "", str(a.isni))
]
# pylint: disable=cell-var-from-loop
matches = list(filter(lambda x: x not in exists, isni_authors))
# combine existing and isni authors
matches.extend(author_matches)
data["author_matches"].append(
{
"name": author.strip(),
"matches": matches,
"existing_isnis": exists,
}
)
# we're creating a new book
if not book:
# check if this is an edition of an existing work
author_text = book.author_text if book else add_author
data["book_matches"] = book_search.search(
f'{form.cleaned_data.get("title")} {author_text}',
min_confidence=0.5,
)[:5]
# either of the above cases requires additional confirmation # either of the above cases requires additional confirmation
if add_author or not book: if data.get("add_author"):
# creting a book or adding an author to a book needs another step
data["confirm_mode"] = True
# this isn't preserved because it isn't part of the form obj
data["remove_authors"] = request.POST.getlist("remove_authors")
data["cover_url"] = request.POST.get("cover-url")
# make sure the dates are passed in as datetime, they're currently a string
# QueryDicts are immutable, we need to copy
formcopy = data["form"].data.copy()
try:
formcopy["first_published_date"] = dateparse(
formcopy["first_published_date"]
)
except (MultiValueDictKeyError, ValueError):
pass
try:
formcopy["published_date"] = dateparse(formcopy["published_date"])
except (MultiValueDictKeyError, ValueError):
pass
data["form"].data = formcopy
return TemplateResponse(request, "book/edit/edit_book.html", data) return TemplateResponse(request, "book/edit/edit_book.html", data)
remove_authors = request.POST.getlist("remove_authors") remove_authors = request.POST.getlist("remove_authors")
@ -136,15 +57,156 @@ class EditBook(View):
book.authors.remove(author_id) book.authors.remove(author_id)
book = form.save(commit=False) book = form.save(commit=False)
url = request.POST.get("cover-url") url = request.POST.get("cover-url")
if url: if url:
image = set_cover_from_url(url) image = set_cover_from_url(url)
if image: if image:
book.cover.save(*image, save=False) book.cover.save(*image, save=False)
book.save() book.save()
return redirect(f"/book/{book.id}") return redirect(f"/book/{book.id}")
@method_decorator(login_required, name="dispatch")
@method_decorator(
permission_required("bookwyrm.edit_book", raise_exception=True), name="dispatch"
)
class CreateBook(View):
"""brand new book"""
def get(self, request):
"""info about a book"""
data = {"form": forms.EditionForm()}
return TemplateResponse(request, "book/edit/edit_book.html", data)
# pylint: disable=too-many-locals
def post(self, request):
"""create a new book"""
# returns None if no match is found
form = forms.EditionForm(request.POST, request.FILES)
data = {"form": form}
# collect data provided by the work or import item
parent_work_id = request.POST.get("parent_work")
authors = None
if request.POST.get("authors"):
author_ids = findall(r"\d+", request.POST["authors"])
authors = models.Author.objects.filter(id__in=author_ids)
# fake book in case we need to keep editing
if parent_work_id:
data["book"] = {
"parent_work": {"id": parent_work_id},
"authors": authors,
}
if not form.is_valid():
return TemplateResponse(request, "book/edit/edit_book.html", data)
data = add_authors(request, data)
# check if this is an edition of an existing work
author_text = ", ".join(data.get("add_author", []))
data["book_matches"] = book_search.search(
f'{form.cleaned_data.get("title")} {author_text}',
min_confidence=0.1,
)[:5]
# go to confirm mode
if not parent_work_id or data.get("add_author"):
return TemplateResponse(request, "book/edit/edit_book.html", data)
with transaction.atomic():
book = form.save()
parent_work = get_object_or_404(models.Work, id=parent_work_id)
book.parent_work = parent_work
if authors:
book.authors.add(*authors)
url = request.POST.get("cover-url")
if url:
image = set_cover_from_url(url)
if image:
book.cover.save(*image, save=False)
book.save()
return redirect(f"/book/{book.id}")
def add_authors(request, data):
"""helper for adding authors"""
add_author = [author for author in request.POST.getlist("add_author") if author]
if not add_author:
return data
data["add_author"] = add_author
data["author_matches"] = []
data["isni_matches"] = []
# creting a book or adding an author to a book needs another step
data["confirm_mode"] = True
# this isn't preserved because it isn't part of the form obj
data["remove_authors"] = request.POST.getlist("remove_authors")
data["cover_url"] = request.POST.get("cover-url")
for author in add_author:
# filter out empty author fields
if not author:
continue
# check for existing authors
vector = SearchVector("name", weight="A") + SearchVector("aliases", weight="B")
author_matches = (
models.Author.objects.annotate(search=vector)
.annotate(rank=SearchRank(vector, author))
.filter(rank__gt=0.4)
.order_by("-rank")[:5]
)
isni_authors = find_authors_by_name(
author, description=True
) # find matches from ISNI API
# dedupe isni authors we already have in the DB
exists = [
i
for i in isni_authors
for a in author_matches
if sub(r"\D", "", str(i.isni)) == sub(r"\D", "", str(a.isni))
]
# pylint: disable=cell-var-from-loop
matches = list(filter(lambda x: x not in exists, isni_authors))
# combine existing and isni authors
matches.extend(author_matches)
data["author_matches"].append(
{
"name": author.strip(),
"matches": matches,
"existing_isnis": exists,
}
)
return data
@require_POST
@permission_required("bookwyrm.edit_book", raise_exception=True)
def create_book_from_data(request):
"""create a book with starter data"""
author_ids = findall(r"\d+", request.POST.get("authors"))
book = {
"parent_work": {"id": request.POST.get("parent_work")},
"authors": models.Author.objects.filter(id__in=author_ids).all(),
"subjects": request.POST.getlist("subjects"),
}
data = {"book": book, "form": forms.EditionForm(request.POST)}
return TemplateResponse(request, "book/edit/edit_book.html", data)
@method_decorator(login_required, name="dispatch") @method_decorator(login_required, name="dispatch")
@method_decorator( @method_decorator(
permission_required("bookwyrm.edit_book", raise_exception=True), name="dispatch" permission_required("bookwyrm.edit_book", raise_exception=True), name="dispatch"
@ -168,6 +230,13 @@ class ConfirmEditBook(View):
# save book # save book
book = form.save() book = form.save()
# add known authors
authors = None
if request.POST.get("authors"):
author_ids = findall(r"\d+", request.POST["authors"])
authors = models.Author.objects.filter(id__in=author_ids)
book.authors.add(*authors)
# get or create author as needed # get or create author as needed
for i in range(int(request.POST.get("author-match-count", 0))): for i in range(int(request.POST.get("author-match-count", 0))):
match = request.POST.get(f"author_match-{i}") match = request.POST.get(f"author_match-{i}")
@ -201,7 +270,7 @@ class ConfirmEditBook(View):
book.authors.add(author) book.authors.add(author)
# create work, if needed # create work, if needed
if not book_id: if not book.parent_work:
work_match = request.POST.get("parent_work") work_match = request.POST.get("parent_work")
if work_match and work_match != "0": if work_match and work_match != "0":
work = get_object_or_404(models.Work, id=work_match) work = get_object_or_404(models.Work, id=work_match)

View file

@ -11,7 +11,7 @@ from django.template.response import TemplateResponse
from django.views import View from django.views import View
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from bookwyrm import models from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import PAGE_LENGTH from bookwyrm.settings import PAGE_LENGTH
from bookwyrm.views.helpers import is_api_request from bookwyrm.views.helpers import is_api_request
@ -65,6 +65,7 @@ class Editions(View):
page.number, on_each_side=2, on_ends=1 page.number, on_each_side=2, on_ends=1
), ),
"work": work, "work": work,
"work_form": forms.EditionFromWorkForm(instance=work),
"languages": languages, "languages": languages,
"formats": set( "formats": set(
e.physical_format.lower() for e in editions if e.physical_format e.physical_format.lower() for e in editions if e.physical_format

View file

@ -2,12 +2,12 @@
import urllib.parse import urllib.parse
import re import re
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db import IntegrityError
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from bookwyrm import models from bookwyrm import models
from bookwyrm.models.relationship import clear_cache
from .helpers import ( from .helpers import (
get_user_from_username, get_user_from_username,
handle_remote_webfinger, handle_remote_webfinger,
@ -22,17 +22,17 @@ def follow(request):
"""follow another user, here or abroad""" """follow another user, here or abroad"""
username = request.POST["user"] username = request.POST["user"]
to_follow = get_user_from_username(request.user, username) to_follow = get_user_from_username(request.user, username)
clear_cache(request.user, to_follow)
try: follow_request, created = models.UserFollowRequest.objects.get_or_create(
models.UserFollowRequest.objects.create( user_subject=request.user,
user_subject=request.user, user_object=to_follow,
user_object=to_follow, )
)
except IntegrityError:
pass
if request.GET.get("next"): if not created:
return redirect(request.GET.get("next", "/")) # this request probably failed to connect with the remote
# that means we should save to trigger a re-broadcast
follow_request.save()
return redirect(to_follow.local_path) return redirect(to_follow.local_path)
@ -49,14 +49,14 @@ def unfollow(request):
user_subject=request.user, user_object=to_unfollow user_subject=request.user, user_object=to_unfollow
).delete() ).delete()
except models.UserFollows.DoesNotExist: except models.UserFollows.DoesNotExist:
pass clear_cache(request.user, to_unfollow)
try: try:
models.UserFollowRequest.objects.get( models.UserFollowRequest.objects.get(
user_subject=request.user, user_object=to_unfollow user_subject=request.user, user_object=to_unfollow
).delete() ).delete()
except models.UserFollowRequest.DoesNotExist: except models.UserFollowRequest.DoesNotExist:
pass clear_cache(request.user, to_unfollow)
# this is handled with ajax so it shouldn't really matter # this is handled with ajax so it shouldn't really matter
return redirect(request.headers.get("Referer", "/")) return redirect(request.headers.get("Referer", "/"))

View file

@ -5,7 +5,6 @@ from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views import View from django.views import View
from django.views.decorators.http import require_POST
from django.views.decorators.debug import sensitive_variables, sensitive_post_parameters from django.views.decorators.debug import sensitive_variables, sensitive_post_parameters
from bookwyrm import emailing, forms, models from bookwyrm import emailing, forms, models
@ -129,12 +128,22 @@ class ConfirmEmail(View):
return ConfirmEmailCode().get(request, code) return ConfirmEmailCode().get(request, code)
@require_POST class ResendConfirmEmail(View):
def resend_link(request): """you probably didn't get the email because celery is slow but you can try this"""
"""resend confirmation link"""
email = request.POST.get("email") def get(self, request, error=False):
user = get_object_or_404(models.User, email=email) """resend link landing page"""
emailing.email_confirmation_email(user) return TemplateResponse(request, "confirm_email/resend.html", {"error": error})
return TemplateResponse(
request, "confirm_email/confirm_email.html", {"valid": True} def post(self, request):
) """resend confirmation link"""
email = request.POST.get("email")
try:
user = models.User.objects.get(email=email)
except models.User.DoesNotExist:
return self.get(request, error=True)
emailing.email_confirmation_email(user)
return TemplateResponse(
request, "confirm_email/confirm_email.html", {"valid": True}
)

View file

@ -0,0 +1,97 @@
""" Let users export their book data """
import csv
from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.http import StreamingHttpResponse
from django.template.response import TemplateResponse
from django.views import View
from django.utils.decorators import method_decorator
from django.views.decorators.http import require_GET
from bookwyrm import models
# pylint: disable=no-self-use
@method_decorator(login_required, name="dispatch")
class Export(View):
"""Let users export data"""
def get(self, request):
"""Request csv file"""
return TemplateResponse(request, "preferences/export.html")
@login_required
@require_GET
def export_user_book_data(request):
"""Streaming the csv file of a user's book data"""
data = (
models.Edition.viewer_aware_objects(request.user)
.filter(
Q(shelves__user=request.user)
| Q(readthrough__user=request.user)
| Q(review__user=request.user)
| Q(comment__user=request.user)
| Q(quotation__user=request.user)
)
.distinct()
)
generator = csv_row_generator(data, request.user)
pseudo_buffer = Echo()
writer = csv.writer(pseudo_buffer)
# for testing, if you want to see the results in the browser:
# from django.http import JsonResponse
# return JsonResponse(list(generator), safe=False)
return StreamingHttpResponse(
(writer.writerow(row) for row in generator),
content_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="bookwyrm-export.csv"'},
)
def csv_row_generator(books, user):
"""generate a csv entry for the user's book"""
deduplication_fields = [
f.name
for f in models.Edition._meta.get_fields() # pylint: disable=protected-access
if getattr(f, "deduplication_field", False)
]
fields = (
["title", "author_text"]
+ deduplication_fields
+ ["rating", "review_name", "review_cw", "review_content"]
)
yield fields
for book in books:
# I think this is more efficient than doing a subquery in the view? but idk
review_rating = (
models.Review.objects.filter(user=user, book=book, rating__isnull=False)
.order_by("-published_date")
.first()
)
book.rating = review_rating.rating if review_rating else None
review = (
models.Review.objects.filter(user=user, book=book, content__isnull=False)
.order_by("-published_date")
.first()
)
if review:
book.review_name = review.name
book.review_cw = review.content_warning
book.review_content = review.raw_content
yield [getattr(book, field, "") or "" for field in fields]
class Echo:
"""An object that implements just the write method of the file-like
interface. (https://docs.djangoproject.com/en/3.2/howto/outputting-csv/)
"""
# pylint: disable=no-self-use
def write(self, value):
"""Write the value by returning it, instead of storing in a buffer."""
return value

3
bw-dev
View file

@ -123,6 +123,7 @@ case "$CMD" in
git checkout l10n_main locale/no_NO git checkout l10n_main locale/no_NO
git checkout l10n_main locale/pt_PT git checkout l10n_main locale/pt_PT
git checkout l10n_main locale/pt_BR git checkout l10n_main locale/pt_BR
git checkout l10n_main locale/ro_RO
git checkout l10n_main locale/sv_SE git checkout l10n_main locale/sv_SE
git checkout l10n_main locale/zh_Hans git checkout l10n_main locale/zh_Hans
git checkout l10n_main locale/zh_Hant git checkout l10n_main locale/zh_Hant
@ -163,7 +164,7 @@ case "$CMD" in
update) update)
git pull git pull
docker-compose build docker-compose build
./update.sh # ./update.sh
runweb python manage.py migrate runweb python manage.py migrate
runweb python manage.py collectstatic --no-input runweb python manage.py collectstatic --no-input
docker-compose up -d docker-compose up -d

View file

@ -13,6 +13,7 @@ CELERY_BROKER_URL = f"redis://:{REDIS_BROKER_PASSWORD}@{REDIS_BROKER_HOST}:{REDI
CELERY_RESULT_BACKEND = f"redis://:{REDIS_BROKER_PASSWORD}@{REDIS_BROKER_HOST}:{REDIS_BROKER_PORT}/{REDIS_BROKER_DB_INDEX}" CELERY_RESULT_BACKEND = f"redis://:{REDIS_BROKER_PASSWORD}@{REDIS_BROKER_HOST}:{REDIS_BROKER_PORT}/{REDIS_BROKER_DB_INDEX}"
CELERY_DEFAULT_QUEUE = "low_priority" CELERY_DEFAULT_QUEUE = "low_priority"
CELERY_CREATE_MISSING_QUEUES = True
CELERY_ACCEPT_CONTENT = ["json"] CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json" CELERY_TASK_SERIALIZER = "json"

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

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

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! --------------- ✨"