forked from mirrors/bookwyrm
Merge branch 'main' into stopped-shelf
This commit is contained in:
commit
ec21d20b90
94 changed files with 10585 additions and 3417 deletions
|
@ -4,6 +4,7 @@ from django import forms
|
|||
from bookwyrm import models
|
||||
from bookwyrm.models.fields import ClearableFileInputWithWarning
|
||||
from .custom_form import CustomForm
|
||||
from .widgets import ArrayWidget, SelectDateWidget, Select
|
||||
|
||||
|
||||
# pylint: disable=missing-class-docstring
|
||||
|
@ -14,14 +15,6 @@ class CoverForm(CustomForm):
|
|||
help_texts = {f: None for f in fields}
|
||||
|
||||
|
||||
class ArrayWidget(forms.widgets.TextInput):
|
||||
# pylint: disable=unused-argument
|
||||
# pylint: disable=no-self-use
|
||||
def value_from_datadict(self, data, files, name):
|
||||
"""get all values for this name"""
|
||||
return [i for i in data.getlist(name) if i]
|
||||
|
||||
|
||||
class EditionForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Edition
|
||||
|
@ -56,16 +49,16 @@ class EditionForm(CustomForm):
|
|||
"publishers": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_publishers_help desc_publishers"}
|
||||
),
|
||||
"first_published_date": forms.SelectDateWidget(
|
||||
"first_published_date": SelectDateWidget(
|
||||
attrs={"aria-describedby": "desc_first_published_date"}
|
||||
),
|
||||
"published_date": forms.SelectDateWidget(
|
||||
"published_date": SelectDateWidget(
|
||||
attrs={"aria-describedby": "desc_published_date"}
|
||||
),
|
||||
"cover": ClearableFileInputWithWarning(
|
||||
attrs={"aria-describedby": "desc_cover"}
|
||||
),
|
||||
"physical_format": forms.Select(
|
||||
"physical_format": Select(
|
||||
attrs={"aria-describedby": "desc_physical_format"}
|
||||
),
|
||||
"physical_format_detail": forms.TextInput(
|
||||
|
@ -85,3 +78,27 @@ class EditionForm(CustomForm):
|
|||
),
|
||||
"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",
|
||||
]
|
||||
|
|
|
@ -45,7 +45,7 @@ class ReportForm(CustomForm):
|
|||
|
||||
class ReadThroughForm(CustomForm):
|
||||
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()
|
||||
start_date = cleaned_data.get("start_date")
|
||||
finish_date = cleaned_data.get("finish_date")
|
||||
|
|
|
@ -42,4 +42,4 @@ class InviteRequestForm(CustomForm):
|
|||
|
||||
class Meta:
|
||||
model = models.InviteRequest
|
||||
fields = ["email"]
|
||||
fields = ["email", "answer"]
|
||||
|
|
70
bookwyrm/forms/widgets.py
Normal file
70
bookwyrm/forms/widgets.py
Normal 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
|
30
bookwyrm/migrations/0146_auto_20220316_2352.py
Normal file
30
bookwyrm/migrations/0146_auto_20220316_2352.py
Normal 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),
|
||||
),
|
||||
]
|
38
bookwyrm/migrations/0147_alter_user_preferred_language.py
Normal file
38
bookwyrm/migrations/0147_alter_user_preferred_language.py
Normal 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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -389,7 +389,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
|
|||
self.alt_field = alt_field
|
||||
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):
|
||||
"""helper function for assinging a value to the field"""
|
||||
value = getattr(data, self.get_activitypub_field())
|
||||
|
|
|
@ -39,15 +39,14 @@ class UserRelationship(BookWyrmModel):
|
|||
|
||||
def save(self, *args, **kwargs):
|
||||
"""clear the template cache"""
|
||||
# invalidate the template cache
|
||||
cache.delete_many(
|
||||
[
|
||||
f"relationship-{self.user_subject.id}-{self.user_object.id}",
|
||||
f"relationship-{self.user_object.id}-{self.user_subject.id}",
|
||||
]
|
||||
)
|
||||
clear_cache(self.user_subject, self.user_object)
|
||||
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:
|
||||
"""relationships should be unique"""
|
||||
|
||||
|
@ -90,7 +89,9 @@ class UserFollows(ActivityMixin, UserRelationship):
|
|||
user_object=self.user_subject,
|
||||
)
|
||||
).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
|
||||
# are handled by the UserFollowRequest model
|
||||
super().save(*args, broadcast=False, **kwargs)
|
||||
|
@ -98,11 +99,12 @@ class UserFollows(ActivityMixin, UserRelationship):
|
|||
@classmethod
|
||||
def from_request(cls, follow_request):
|
||||
"""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_object=follow_request.user_object,
|
||||
remote_id=follow_request.remote_id,
|
||||
)
|
||||
return obj
|
||||
|
||||
|
||||
class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
||||
|
@ -133,7 +135,9 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
|||
user_object=self.user_subject,
|
||||
)
|
||||
).exists():
|
||||
raise IntegrityError()
|
||||
raise IntegrityError(
|
||||
"Attempting to follow blocked user", self.user_subject, self.user_object
|
||||
)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if broadcast and self.user_subject.local and not self.user_object.local:
|
||||
|
@ -174,7 +178,8 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
|||
|
||||
with transaction.atomic():
|
||||
UserFollows.from_request(self)
|
||||
self.delete()
|
||||
if self.id:
|
||||
self.delete()
|
||||
|
||||
def reject(self):
|
||||
"""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_object, user_object=self.user_subject)
|
||||
).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}",
|
||||
]
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -289,6 +289,7 @@ LANGUAGES = [
|
|||
("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)")),
|
||||
|
|
|
@ -36,6 +36,18 @@ body {
|
|||
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 {
|
||||
border: none;
|
||||
margin: 0;
|
||||
|
@ -129,14 +141,6 @@ button:focus-visible .button-invisible-overlay {
|
|||
}
|
||||
|
||||
|
||||
|
||||
/** Tooltips
|
||||
******************************************************************************/
|
||||
|
||||
.tooltip {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/** States
|
||||
******************************************************************************/
|
||||
|
||||
|
|
|
@ -8,7 +8,9 @@ $primary: #005e50;
|
|||
$primary-light: #1d2b28;
|
||||
$info: #1f4666;
|
||||
$success: #246447;
|
||||
$success-light: #0d2f1e;
|
||||
$warning: #8b6c15;
|
||||
$warning-light: #372e13;
|
||||
$danger: #872538;
|
||||
$danger-light: #481922;
|
||||
$light: #393939;
|
||||
|
@ -26,6 +28,8 @@ $background-body: rgb(24, 27, 28);
|
|||
$background-secondary: rgb(28, 30, 32);
|
||||
$background-tertiary: rgb(32, 34, 36);
|
||||
$modal-background-background-color: rgba($black, 0.8);
|
||||
$scrollbar-track: $background-secondary;
|
||||
$scrollbar-thumb: $light;
|
||||
|
||||
/* highlight colors */
|
||||
$primary-highlight: $primary;
|
||||
|
|
|
@ -19,6 +19,8 @@ $scheme-main: $white-bis;
|
|||
$background-body: $white;
|
||||
$background-secondary: $white-ter;
|
||||
$background-tertiary: $white-bis;
|
||||
$scrollbar-track: $background-secondary;
|
||||
$scrollbar-thumb: $grey-lighter;
|
||||
|
||||
/* highlight colors */
|
||||
$primary-highlight: $primary-light;
|
||||
|
|
|
@ -99,7 +99,7 @@
|
|||
<p>
|
||||
{% url "conduct" as coc_path %}
|
||||
{% 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 %}
|
||||
</p>
|
||||
</header>
|
||||
|
|
|
@ -208,9 +208,17 @@
|
|||
{% endif %}
|
||||
|
||||
|
||||
{% if book.parent_work.editions.count > 1 %}
|
||||
<p>{% blocktrans with path=book.parent_work.local_path count=book.parent_work.editions.count %}<a href="{{ path }}/editions">{{ count }} editions</a>{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
{% with work=book.parent_work %}
|
||||
<p>
|
||||
<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>
|
||||
|
||||
{# user's relationship to the book #}
|
||||
|
|
|
@ -3,18 +3,24 @@
|
|||
{% load humanize %}
|
||||
{% 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 %}
|
||||
<header class="block">
|
||||
<h1 class="title level-left">
|
||||
{% if book %}
|
||||
{% if book.title %}
|
||||
{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}
|
||||
{% else %}
|
||||
{% trans "Add Book" %}
|
||||
{% endif %}
|
||||
</h1>
|
||||
{% if book %}
|
||||
{% if book.created_date %}
|
||||
<dl>
|
||||
<dt class="is-pulled-left mr-5 has-text-weight-semibold">{% trans "Added:" %}</dt>
|
||||
<dd class="ml-2">{{ book.created_date | naturaltime }}</dd>
|
||||
|
@ -33,7 +39,7 @@
|
|||
|
||||
<form
|
||||
class="block"
|
||||
{% if book %}
|
||||
{% if book.id %}
|
||||
name="edit-book"
|
||||
action="{{ book.local_path }}/{% if confirm_mode %}confirm{% else %}edit{% endif %}"
|
||||
{% else %}
|
||||
|
@ -97,7 +103,7 @@
|
|||
<input type="radio" name="parent_work" value="{{ match.parent_work.id }}"> {{ match.parent_work.title }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
<label>
|
||||
<label class="label mt-2">
|
||||
<input type="radio" name="parent_work" value="0" required> {% trans "This is a new work" %}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
@ -119,7 +125,7 @@
|
|||
{% if not confirm_mode %}
|
||||
<div class="block">
|
||||
<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>
|
||||
{% else %}
|
||||
<a href="/" class="button" data-back>
|
||||
|
|
|
@ -10,6 +10,8 @@
|
|||
{% csrf_token %}
|
||||
|
||||
<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="column is-half">
|
||||
<section class="block">
|
||||
|
@ -153,8 +155,7 @@
|
|||
<label class="label" for="id_first_published_date">
|
||||
{% trans "First published date:" %}
|
||||
</label>
|
||||
<input type="date" name="first_published_date" class="input" id="id_first_published_date"{% if form.first_published_date.value %} value="{{ form.first_published_date.value|date:'Y-m-d' }}"{% endif %} aria-describedby="desc_first_published_date">
|
||||
|
||||
{{ form.first_published_date }}
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.first_published_date.errors id="desc_first_published_date" %}
|
||||
</div>
|
||||
|
||||
|
@ -162,7 +163,7 @@
|
|||
<label class="label" for="id_published_date">
|
||||
{% trans "Published date:" %}
|
||||
</label>
|
||||
<input type="date" name="published_date" class="input" id="id_published_date"{% if form.published_date.value %} value="{{ form.published_date.value|date:'Y-m-d'}}"{% endif %} aria-describedby="desc_published_date">
|
||||
{{ form.published_date }}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.published_date.errors id="desc_published_date" %}
|
||||
</div>
|
||||
|
@ -175,6 +176,8 @@
|
|||
</h2>
|
||||
<div class="box">
|
||||
{% 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>
|
||||
{% for author in book.authors.all %}
|
||||
<div class="is-flex is-justify-content-space-between">
|
||||
|
@ -255,9 +258,7 @@
|
|||
<label class="label" for="id_physical_format">
|
||||
{% trans "Format:" %}
|
||||
</label>
|
||||
<div class="select">
|
||||
{{ form.physical_format }}
|
||||
</div>
|
||||
{{ form.physical_format }}
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.physical_format.errors id="desc_physical_format" %}
|
||||
</div>
|
||||
|
|
|
@ -46,7 +46,36 @@
|
|||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="block">
|
||||
{% include 'snippets/pagination.html' with page=editions path=request.path %}
|
||||
</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 %}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
<header class="block">
|
||||
<h1 class="title">
|
||||
{% blocktrans with title=book|book_title %}
|
||||
{% blocktrans trimmed with title=book|book_title %}
|
||||
Links for "<em>{{ title }}</em>"
|
||||
{% endblocktrans %}
|
||||
</h1>
|
||||
|
|
|
@ -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>
|
|
@ -29,9 +29,16 @@
|
|||
</section>
|
||||
|
||||
<section class="block">
|
||||
{% trans "Can't find your code?" as button_text %}
|
||||
{% include "snippets/toggle/open_button.html" with text=button_text controls_text="resend_form" focus="resend_form_header" %}
|
||||
{% include "confirm_email/resend_form.html" with controls_text="resend_form" %}
|
||||
<form name="fallback" method="GET" action="{% url 'resend-link' %}" autocomplete="off">
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
10
bookwyrm/templates/confirm_email/resend.html
Normal file
10
bookwyrm/templates/confirm_email/resend.html
Normal 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 %}
|
|
@ -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 %}
|
44
bookwyrm/templates/confirm_email/resend_modal.html
Normal file
44
bookwyrm/templates/confirm_email/resend_modal.html
Normal 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 %}
|
|
@ -5,7 +5,19 @@
|
|||
<section class="block">
|
||||
<h2 class="title is-4">{% trans "Your Books" %}</h2>
|
||||
{% 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 %}
|
||||
{% with active_book=request.GET.book %}
|
||||
<div class="tab-group">
|
||||
|
|
|
@ -14,28 +14,32 @@
|
|||
<div class="column is-half">
|
||||
|
||||
<div class="field">
|
||||
<label class="label is-pulled-left" for="source">
|
||||
<label class="label" for="source">
|
||||
{% trans "Data source:" %}
|
||||
</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 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">
|
||||
<label class="label" for="id_csv_file">{% trans "Data file:" %}</label>
|
||||
{{ import_form.csv_file }}
|
||||
|
@ -63,7 +67,7 @@
|
|||
<div class="content block">
|
||||
<h2 class="title">{% trans "Recent Imports" %}</h2>
|
||||
{% if not jobs %}
|
||||
<p>{% trans "No recent imports" %}</p>
|
||||
<p><em>{% trans "No recent imports" %}</em></p>
|
||||
{% endif %}
|
||||
<ul>
|
||||
{% for job in jobs %}
|
||||
|
|
|
@ -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 %}
|
|
@ -70,6 +70,14 @@
|
|||
|
||||
{% include 'snippets/form_errors.html' with errors_list=request_form.email.errors id="desc_request_email" %}
|
||||
</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>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
|
22
bookwyrm/templates/preferences/export.html
Normal file
22
bookwyrm/templates/preferences/export.html
Normal 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 %}
|
|
@ -24,6 +24,17 @@
|
|||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Delete Account" %}</a>
|
||||
</li>
|
||||
</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>
|
||||
<ul class="menu-list">
|
||||
<li>
|
||||
|
|
|
@ -17,7 +17,14 @@
|
|||
{% endblock %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
{% block modal-body %}
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
{% block panel %}
|
||||
<div class="block table-container">
|
||||
<table class="table is-striped">
|
||||
<table class="table is-striped is-fullwidth">
|
||||
<tr>
|
||||
<th>
|
||||
{% url 'settings-announcements' as url %}
|
||||
|
|
|
@ -154,7 +154,7 @@
|
|||
</summary>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="table is-striped">
|
||||
<table class="table is-striped is-fullwidth">
|
||||
<tr>
|
||||
<th>
|
||||
<label for="id_string_match">{% trans "String match" %}</label>
|
||||
|
|
|
@ -10,26 +10,26 @@
|
|||
{% block panel %}
|
||||
|
||||
<div class="columns block has-text-centered is-mobile is-multiline">
|
||||
<div class="column is-3-desktop is-6-mobile">
|
||||
<div class="notification">
|
||||
<div class="column is-3-desktop is-6-mobile is-flex">
|
||||
<div class="notification is-flex-grow-1">
|
||||
<h3>{% trans "Total users" %}</h3>
|
||||
<p class="title is-5">{{ users|intcomma }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3-desktop is-6-mobile">
|
||||
<div class="notification">
|
||||
<div class="column is-3-desktop is-6-mobil is-flexe">
|
||||
<div class="notification is-flex-grow-1">
|
||||
<h3>{% trans "Active this month" %}</h3>
|
||||
<p class="title is-5">{{ active_users|intcomma }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3-desktop is-6-mobile">
|
||||
<div class="notification">
|
||||
<div class="column is-3-desktop is-6-mobile is-flex">
|
||||
<div class="notification is-flex-grow-1">
|
||||
<h3>{% trans "Statuses" %}</h3>
|
||||
<p class="title is-5">{{ statuses|intcomma }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3-desktop is-6-mobile">
|
||||
<div class="notification">
|
||||
<div class="column is-3-desktop is-6-mobile is-flex">
|
||||
<div class="notification is-flex-grow-1">
|
||||
<h3>{% trans "Works" %}</h3>
|
||||
<p class="title is-5">{{ works|intcomma }}</p>
|
||||
</div>
|
||||
|
@ -38,8 +38,8 @@
|
|||
|
||||
<div class="columns block is-multiline">
|
||||
{% if reports %}
|
||||
<div class="column">
|
||||
<a href="{% url 'settings-reports' %}" class="notification is-warning is-block">
|
||||
<div class="column is-flex">
|
||||
<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 %}
|
||||
{{ display_count }} open report
|
||||
{% plural %}
|
||||
|
@ -50,8 +50,8 @@
|
|||
{% endif %}
|
||||
|
||||
{% if pending_domains %}
|
||||
<div class="column">
|
||||
<a href="{% url 'settings-link-domain' %}" class="notification is-primary is-block">
|
||||
<div class="column is-flex">
|
||||
<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 %}
|
||||
{{ display_count }} domain needs review
|
||||
{% plural %}
|
||||
|
@ -62,8 +62,8 @@
|
|||
{% endif %}
|
||||
|
||||
{% if not site.allow_registration and site.allow_invite_requests and invite_requests %}
|
||||
<div class="column">
|
||||
<a href="{% url 'settings-invite-requests' %}" class="notification is-block is-success">
|
||||
<div class="column is-flex">
|
||||
<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 %}
|
||||
{{ display_count }} invite request
|
||||
{% plural %}
|
||||
|
@ -74,8 +74,8 @@
|
|||
{% endif %}
|
||||
|
||||
{% if current_version %}
|
||||
<div class="column">
|
||||
<a href="https://docs.joinbookwyrm.com/updating-your-instance.html" class="notification is-block is-warning" target="_blank">
|
||||
<div class="column is-flex">
|
||||
<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 %}
|
||||
An update is available! You're running v{{ current }} and the latest release is {{ available }}.
|
||||
{% endblocktrans %}
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
<table class="table is-striped">
|
||||
<table class="table is-striped is-fullwidth">
|
||||
<tr>
|
||||
{% url 'settings-federation' as url %}
|
||||
<th>
|
||||
|
|
|
@ -40,6 +40,9 @@
|
|||
{% include 'snippets/table-sort-header.html' with field="invite__invitees__created_date" sort=sort text=text %}
|
||||
</th>
|
||||
<th>{% trans "Email" %}</th>
|
||||
{% if site.invite_request_question %}
|
||||
<th>{% trans "Answer" %}</th>
|
||||
{% endif %}
|
||||
<th>
|
||||
{% trans "Status" as 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.invite.invitees.first.created_date | naturaltime }}</td>
|
||||
<td>{{ req.email }}</td>
|
||||
{% if site.invite_request_question %}
|
||||
<td>{{ req.answer }}</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
{% if req.invite.times_used %}
|
||||
{% trans "Accepted" %}
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
|
||||
<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">
|
||||
<p class="help">{% trans "You can block IP ranges using CIDR syntax." %}</p>
|
||||
</div>
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.address.errors id="desc_address" %}
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
{% extends 'components/tooltip.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block tooltip_content %}
|
||||
|
||||
{% trans "You can block IP ranges using CIDR syntax." %}
|
||||
|
||||
{% endblock %}
|
|
@ -93,7 +93,7 @@
|
|||
</ul>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<div class="column">
|
||||
<div class="column is-clipped">
|
||||
{% block panel %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -44,5 +44,6 @@
|
|||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% include 'snippets/pagination.html' with page=reports path=request.path %}
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
@ -139,12 +139,6 @@
|
|||
{% trans "Allow registration" %}
|
||||
</label>
|
||||
</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 mb-0" for="id_require_confirm_email">
|
||||
{{ site_form.require_confirm_email }}
|
||||
|
@ -152,6 +146,24 @@
|
|||
</label>
|
||||
<p class="help" id="desc_require_confirm_email">{% trans "(Recommended if registration is open)" %}</p>
|
||||
</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">
|
||||
<label class="label" for="id_registration_closed_text">{% trans "Registration closed text:" %}</label>
|
||||
{{ site_form.registration_closed_text }}
|
||||
|
@ -159,7 +171,7 @@
|
|||
<div class="field">
|
||||
<label class="label" for="id_invite_request_text">{% trans "Invite request text:" %}</label>
|
||||
{{ site_form.invite_request_text }}
|
||||
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=site_form.invite_request_text.errors id="desc_invite_request_text" %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -88,7 +88,7 @@
|
|||
<section class="block content">
|
||||
<h2 class="title is-4">{% trans "Available Themes" %}</h2>
|
||||
<div class="table-container">
|
||||
<table class="table is-striped">
|
||||
<table class="table is-striped is-fullwidth">
|
||||
<tr>
|
||||
<th>
|
||||
{% trans "Theme name" %}
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
</div>
|
||||
|
||||
<div class="table-container block">
|
||||
<table class="table is-striped">
|
||||
<table class="table is-striped is-fullwidth">
|
||||
<tr>
|
||||
{% url 'settings-users' as url %}
|
||||
<th>
|
||||
|
@ -61,10 +61,25 @@
|
|||
</tr>
|
||||
{% for user in users %}
|
||||
<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.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" %}
|
||||
<td>
|
||||
{% if user.federated_server %}
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<div class="column is-flex is-flex-direction-column">
|
||||
<h4 class="title is-4">{% trans "Profile" %}</h4>
|
||||
<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 %}
|
||||
<div class="box content has-background-secondary is-shadowless">
|
||||
{{ user.summary|to_markdown|safe }}
|
||||
|
@ -14,6 +14,10 @@
|
|||
{% endif %}
|
||||
|
||||
<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 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>
|
||||
<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>
|
||||
<dd>{{ user.last_active_date }}</dd>
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
{% with goal.progress as progress %}
|
||||
<p>
|
||||
{% if progress.percent >= 100 %}
|
||||
{% trans "Success!" %}
|
||||
{% trans "Success!" context "Goal successfully completed" %}
|
||||
{% elif progress.percent %}
|
||||
{% blocktrans with percent=progress.percent %}{{ percent }}% complete!{% endblocktrans %}
|
||||
{% endif %}
|
||||
|
@ -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 %}
|
||||
{% endif %}
|
||||
</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 %}
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
<form name="reading-progress-{{ uuid }}" action="{% url 'reading-status-update' book.id %}" method="POST" class="submit-status">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="id" value="{{ readthrough.id }}">
|
||||
<input type="hidden" name="start_date" value="{{ readthrough.start_date|date:'Y-m-d' }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block reading-dates %}
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
{% endwith %}
|
||||
{% 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' %}
|
||||
<header class="mb-2">
|
||||
<h3
|
||||
|
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
<div class="card-footer-item">
|
||||
{% 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 class="card-footer-item">
|
||||
{% include 'snippets/boost_button.html' with status=status %}
|
||||
|
@ -42,7 +42,7 @@
|
|||
</div>
|
||||
{% if not moderation_mode %}
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
<p><a href="{{ user.remote_id }}">{{ user.username }}</a></p>
|
||||
<p>{% blocktrans with date=user.created_date|naturaltime %}Joined {{ date }}{% endblocktrans %}</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-following' user|username %}">{% blocktrans with counter=user.following.count %}{{ counter }} following{% endblocktrans %}</a>
|
||||
|
|
9
bookwyrm/templates/widgets/addon_multiwidget.html
Normal file
9
bookwyrm/templates/widgets/addon_multiwidget.html
Normal 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 %}
|
10
bookwyrm/templates/widgets/select.html
Normal file
10
bookwyrm/templates/widgets/select.html
Normal 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>
|
|
@ -60,7 +60,7 @@ class EditBookViews(TestCase):
|
|||
|
||||
def test_edit_book_create_page(self):
|
||||
"""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.user = self.local_user
|
||||
request.user.is_superuser = True
|
||||
|
|
|
@ -360,10 +360,17 @@ class RegisterViews(TestCase):
|
|||
result = view(request)
|
||||
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"""
|
||||
request = self.factory.post("", {"email": "mouse@mouse.com"})
|
||||
request.user = self.anonymous_user
|
||||
with patch("bookwyrm.emailing.send_email.delay") as mock:
|
||||
views.resend_link(request)
|
||||
views.ResendConfirmEmail.as_view()(request)
|
||||
self.assertEqual(mock.call_count, 1)
|
||||
|
|
69
bookwyrm/tests/views/test_export.py
Normal file
69
bookwyrm/tests/views/test_export.py
Normal 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)
|
|
@ -71,7 +71,7 @@ urlpatterns = [
|
|||
views.ConfirmEmailCode.as_view(),
|
||||
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"^password-reset/?$",
|
||||
|
@ -475,6 +475,12 @@ urlpatterns = [
|
|||
views.ChangePassword.as_view(),
|
||||
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/block/?$", views.Block.as_view(), name="prefs-block"),
|
||||
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}/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(rf"{BOOK_PATH}/editions(.json)?/?$", views.Editions.as_view()),
|
||||
re_path(
|
||||
|
|
|
@ -28,6 +28,7 @@ from .admin.user_admin import UserAdmin, UserAdminList
|
|||
# user preferences
|
||||
from .preferences.change_password import ChangePassword
|
||||
from .preferences.edit_user import EditUser
|
||||
from .preferences.export import Export, export_user_book_data
|
||||
from .preferences.delete_user import DeleteUser
|
||||
from .preferences.block import Block, unblock
|
||||
|
||||
|
@ -39,7 +40,12 @@ from .books.books import (
|
|||
resolve_book,
|
||||
)
|
||||
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.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.landing import Home, Landing
|
||||
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
|
||||
|
||||
# shelves
|
||||
|
|
|
@ -96,6 +96,7 @@ class ManageInviteRequests(View):
|
|||
"created_date",
|
||||
"invite__times_used",
|
||||
"invite__invitees__created_date",
|
||||
"answer",
|
||||
]
|
||||
# pylint: disable=consider-using-f-string
|
||||
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(
|
||||
models.InviteRequest, id=request.POST.get("invite-request")
|
||||
)
|
||||
|
||||
# only create a new invite if one doesn't exist already (resending)
|
||||
if not invite_request.invite:
|
||||
invite_request.invite = models.SiteInvite.objects.create(
|
||||
|
@ -170,10 +172,7 @@ class InviteRequest(View):
|
|||
received = True
|
||||
form.save()
|
||||
|
||||
data = {
|
||||
"request_form": form,
|
||||
"request_received": received,
|
||||
}
|
||||
data = {"request_form": form, "request_received": received}
|
||||
return TemplateResponse(request, "landing/landing.html", data)
|
||||
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
""" moderation via flagged posts and users """
|
||||
from django.contrib.auth.decorators import login_required, permission_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.response import TemplateResponse
|
||||
|
@ -7,6 +8,7 @@ from django.utils.decorators import method_decorator
|
|||
from django.views import View
|
||||
|
||||
from bookwyrm import forms, models
|
||||
from bookwyrm.settings import PAGE_LENGTH
|
||||
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
|
@ -34,10 +36,17 @@ class ReportsAdmin(View):
|
|||
if username:
|
||||
filters["user__username__icontains"] = username
|
||||
filters["resolved"] = resolved
|
||||
|
||||
reports = models.Report.objects.filter(**filters)
|
||||
paginated = Paginator(reports, PAGE_LENGTH)
|
||||
page = paginated.get_page(request.GET.get("page"))
|
||||
data = {
|
||||
"resolved": resolved,
|
||||
"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)
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
""" the good people stuff! the authors! """
|
||||
from django.contrib.auth.decorators import login_required, permission_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Avg, Q
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.response import TemplateResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
|
@ -27,9 +26,8 @@ class Author(View):
|
|||
return ActivitypubResponse(author.to_activity())
|
||||
|
||||
books = (
|
||||
models.Work.objects.filter(Q(authors=author) | Q(editions__authors=author))
|
||||
.annotate(Avg("editions__review__rating"))
|
||||
.order_by("editions__review__rating__avg")
|
||||
models.Work.objects.filter(editions__authors=author)
|
||||
.order_by("created_date")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
|
|
|
@ -1,14 +1,13 @@
|
|||
""" the good stuff! the books! """
|
||||
from re import sub
|
||||
from dateutil.parser import parse as dateparse
|
||||
from re import sub, findall
|
||||
from django.contrib.auth.decorators import login_required, permission_required
|
||||
from django.contrib.postgres.search import SearchRank, SearchVector
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponseBadRequest
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.response import TemplateResponse
|
||||
from django.utils.datastructures import MultiValueDictKeyError
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.views import View
|
||||
|
||||
from bookwyrm import book_search, forms, models
|
||||
|
@ -30,105 +29,27 @@ from .books import set_cover_from_url
|
|||
class EditBook(View):
|
||||
"""edit a book"""
|
||||
|
||||
def get(self, request, book_id=None):
|
||||
def get(self, request, book_id):
|
||||
"""info about a book"""
|
||||
book = None
|
||||
if book_id:
|
||||
book = get_edition(book_id)
|
||||
if not book.description:
|
||||
book.description = book.parent_work.description
|
||||
book = get_edition(book_id)
|
||||
if not book.description:
|
||||
book.description = book.parent_work.description
|
||||
data = {"book": book, "form": forms.EditionForm(instance=book)}
|
||||
return TemplateResponse(request, "book/edit/edit_book.html", data)
|
||||
|
||||
# pylint: disable=too-many-locals
|
||||
def post(self, request, book_id=None):
|
||||
def post(self, request, book_id):
|
||||
"""edit a book cool"""
|
||||
# returns None if no match is found
|
||||
book = models.Edition.objects.filter(id=book_id).first()
|
||||
book = get_object_or_404(models.Edition, id=book_id)
|
||||
form = forms.EditionForm(request.POST, request.FILES, instance=book)
|
||||
|
||||
data = {"book": book, "form": form}
|
||||
if not form.is_valid():
|
||||
return TemplateResponse(request, "book/edit/edit_book.html", data)
|
||||
|
||||
# filter out empty author fields
|
||||
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]
|
||||
data = add_authors(request, data)
|
||||
|
||||
# either of the above cases requires additional confirmation
|
||||
if add_author or not book:
|
||||
# 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
|
||||
if data.get("add_author"):
|
||||
return TemplateResponse(request, "book/edit/edit_book.html", data)
|
||||
|
||||
remove_authors = request.POST.getlist("remove_authors")
|
||||
|
@ -136,15 +57,156 @@ class EditBook(View):
|
|||
book.authors.remove(author_id)
|
||||
|
||||
book = form.save(commit=False)
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
@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(
|
||||
permission_required("bookwyrm.edit_book", raise_exception=True), name="dispatch"
|
||||
|
@ -168,6 +230,13 @@ class ConfirmEditBook(View):
|
|||
# save book
|
||||
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
|
||||
for i in range(int(request.POST.get("author-match-count", 0))):
|
||||
match = request.POST.get(f"author_match-{i}")
|
||||
|
@ -201,7 +270,7 @@ class ConfirmEditBook(View):
|
|||
book.authors.add(author)
|
||||
|
||||
# create work, if needed
|
||||
if not book_id:
|
||||
if not book.parent_work:
|
||||
work_match = request.POST.get("parent_work")
|
||||
if work_match and work_match != "0":
|
||||
work = get_object_or_404(models.Work, id=work_match)
|
||||
|
|
|
@ -11,7 +11,7 @@ from django.template.response import TemplateResponse
|
|||
from django.views import View
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm import forms, models
|
||||
from bookwyrm.activitypub import ActivitypubResponse
|
||||
from bookwyrm.settings import PAGE_LENGTH
|
||||
from bookwyrm.views.helpers import is_api_request
|
||||
|
@ -65,6 +65,7 @@ class Editions(View):
|
|||
page.number, on_each_side=2, on_ends=1
|
||||
),
|
||||
"work": work,
|
||||
"work_form": forms.EditionFromWorkForm(instance=work),
|
||||
"languages": languages,
|
||||
"formats": set(
|
||||
e.physical_format.lower() for e in editions if e.physical_format
|
||||
|
|
|
@ -2,12 +2,12 @@
|
|||
import urllib.parse
|
||||
import re
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db import IntegrityError
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.response import TemplateResponse
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.models.relationship import clear_cache
|
||||
from .helpers import (
|
||||
get_user_from_username,
|
||||
handle_remote_webfinger,
|
||||
|
@ -22,17 +22,17 @@ def follow(request):
|
|||
"""follow another user, here or abroad"""
|
||||
username = request.POST["user"]
|
||||
to_follow = get_user_from_username(request.user, username)
|
||||
clear_cache(request.user, to_follow)
|
||||
|
||||
try:
|
||||
models.UserFollowRequest.objects.create(
|
||||
user_subject=request.user,
|
||||
user_object=to_follow,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
follow_request, created = models.UserFollowRequest.objects.get_or_create(
|
||||
user_subject=request.user,
|
||||
user_object=to_follow,
|
||||
)
|
||||
|
||||
if request.GET.get("next"):
|
||||
return redirect(request.GET.get("next", "/"))
|
||||
if not created:
|
||||
# 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)
|
||||
|
||||
|
@ -49,14 +49,14 @@ def unfollow(request):
|
|||
user_subject=request.user, user_object=to_unfollow
|
||||
).delete()
|
||||
except models.UserFollows.DoesNotExist:
|
||||
pass
|
||||
clear_cache(request.user, to_unfollow)
|
||||
|
||||
try:
|
||||
models.UserFollowRequest.objects.get(
|
||||
user_subject=request.user, user_object=to_unfollow
|
||||
).delete()
|
||||
except models.UserFollowRequest.DoesNotExist:
|
||||
pass
|
||||
clear_cache(request.user, to_unfollow)
|
||||
|
||||
# this is handled with ajax so it shouldn't really matter
|
||||
return redirect(request.headers.get("Referer", "/"))
|
||||
|
|
|
@ -5,7 +5,6 @@ from django.shortcuts import get_object_or_404, redirect
|
|||
from django.template.response import TemplateResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.views.decorators.debug import sensitive_variables, sensitive_post_parameters
|
||||
|
||||
from bookwyrm import emailing, forms, models
|
||||
|
@ -129,12 +128,22 @@ class ConfirmEmail(View):
|
|||
return ConfirmEmailCode().get(request, code)
|
||||
|
||||
|
||||
@require_POST
|
||||
def resend_link(request):
|
||||
"""resend confirmation link"""
|
||||
email = request.POST.get("email")
|
||||
user = get_object_or_404(models.User, email=email)
|
||||
emailing.email_confirmation_email(user)
|
||||
return TemplateResponse(
|
||||
request, "confirm_email/confirm_email.html", {"valid": True}
|
||||
)
|
||||
class ResendConfirmEmail(View):
|
||||
"""you probably didn't get the email because celery is slow but you can try this"""
|
||||
|
||||
def get(self, request, error=False):
|
||||
"""resend link landing page"""
|
||||
return TemplateResponse(request, "confirm_email/resend.html", {"error": error})
|
||||
|
||||
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}
|
||||
)
|
||||
|
|
97
bookwyrm/views/preferences/export.py
Normal file
97
bookwyrm/views/preferences/export.py
Normal 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
3
bw-dev
|
@ -123,6 +123,7 @@ case "$CMD" in
|
|||
git checkout l10n_main locale/no_NO
|
||||
git checkout l10n_main locale/pt_PT
|
||||
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/zh_Hans
|
||||
git checkout l10n_main locale/zh_Hant
|
||||
|
@ -163,7 +164,7 @@ case "$CMD" in
|
|||
update)
|
||||
git pull
|
||||
docker-compose build
|
||||
./update.sh
|
||||
# ./update.sh
|
||||
runweb python manage.py migrate
|
||||
runweb python manage.py collectstatic --no-input
|
||||
docker-compose up -d
|
||||
|
|
|
@ -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_DEFAULT_QUEUE = "low_priority"
|
||||
CELERY_CREATE_MISSING_QUEUES = True
|
||||
|
||||
CELERY_ACCEPT_CONTENT = ["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
BIN
locale/ro_RO/LC_MESSAGES/django.mo
Normal file
BIN
locale/ro_RO/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
5119
locale/ro_RO/LC_MESSAGES/django.po
Normal file
5119
locale/ro_RO/LC_MESSAGES/django.po
Normal file
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
37
update.sh
Executable 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! --------------- ✨"
|
Loading…
Reference in a new issue