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.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",
]

View file

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

View file

@ -42,4 +42,4 @@ class InviteRequestForm(CustomForm):
class Meta:
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
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())

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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" %}
</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 %}

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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" %}

View file

@ -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" %}

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>
{% endif %}
</nav>
<div class="column">
<div class="column is-clipped">
{% block panel %}{% endblock %}
</div>
</div>

View file

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

View file

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

View file

@ -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" %}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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(),
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(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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