Merge branch 'main' into show-2fa-code

This commit is contained in:
Mouse Reeve 2022-11-19 10:09:27 -08:00 committed by GitHub
commit 1e5a6ec744
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 274 additions and 90 deletions

View file

@ -37,7 +37,7 @@ Keep track of what books you've read, and what books you'd like to read in the f
Federation allows you to interact with users on other instances and services, and also shares metadata about books and authors, which collaboratively builds a decentralized database of books.
### Privacy and moderation
Users and administrators can control who can see thier posts and what other instances to federate with.
Users and administrators can control who can see their posts and what other instances to federate with.
## Tech Stack
Web backend

View file

@ -271,7 +271,7 @@ def resolve_remote_id(
try:
data = get_data(remote_id)
except ConnectorException:
logger.exception("Could not connect to host for remote_id: %s", remote_id)
logger.info("Could not connect to host for remote_id: %s", remote_id)
return None
# determine the model implicitly, if not provided

View file

@ -222,7 +222,7 @@ def dict_from_mappings(data, mappings):
return result
def get_data(url, params=None, timeout=10):
def get_data(url, params=None, timeout=settings.QUERY_TIMEOUT):
"""wrapper for request.get"""
# check if the url is blocked
raise_not_valid_url(url)

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.16 on 2022-11-17 21:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0165_alter_inviterequest_answer"),
]
operations = [
migrations.AddField(
model_name="sitesettings",
name="imports_enabled",
field=models.BooleanField(default=True),
),
]

View file

@ -86,6 +86,9 @@ class SiteSettings(SiteModel):
admin_email = models.EmailField(max_length=255, null=True, blank=True)
footer_item = models.TextField(null=True, blank=True)
# controls
imports_enabled = models.BooleanField(default=True)
field_tracker = FieldTracker(fields=["name", "instance_tagline", "logo"])
@classmethod

View file

@ -244,9 +244,10 @@ class User(OrderedCollectionPageMixin, AbstractUser):
def admins(cls):
"""Get a queryset of the admins for this instance"""
return cls.objects.filter(
models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
| models.Q(is_superuser=True)
)
models.Q(groups__name__in=["moderator", "admin"])
| models.Q(is_superuser=True),
is_active=True,
).distinct()
def update_active_date(self):
"""this user is here! they are doing things!"""

View file

@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _
env = Env()
env.read_env()
DOMAIN = env("DOMAIN")
VERSION = "0.4.6"
VERSION = "0.5.1"
RELEASE_API = env(
"RELEASE_API",

View file

@ -92,6 +92,10 @@ $family-secondary: $family-sans-serif;
color: $grey-light !important;
}
#qrcode svg {
background-color: #a6a6a6;
}
@import "../bookwyrm.scss";
@import "../vendor/icons.css";
@import "../vendor/shepherd.scss";

View file

@ -11,7 +11,7 @@
{% block about_content %}
{# seven day cache #}
{% cache 604800 about_page %}
{% cache 604800 about_page_superlatives %}
{% get_book_superlatives as superlatives %}
<section class=" pb-4">
@ -97,6 +97,7 @@
</p>
</section>
{% endcache %}
<section class="block">
<header class="content">
@ -145,5 +146,4 @@
</div>
</section>
{% endcache %}
{% endblock %}

View file

@ -1,14 +1,13 @@
{% load layout %}
{% load i18n %}
{% load sass_tags %}
{% load static %}
<!DOCTYPE html>
<html lang="{% get_lang %}">
<head>
<title>{% block title %}BookWyrm{% endblock %} - {{ site.name }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="{% static "css/vendor/bulma.min.css" %}">
<link rel="stylesheet" href="{% static "css/vendor/icons.css" %}">
<link rel="stylesheet" href="{% static "css/bookwyrm.css" %}">
<link href="{% sass_src site_theme %}" rel="stylesheet" type="text/css" />
<base target="_blank">

View file

@ -8,83 +8,94 @@
<div class="block">
<h1 class="title">{% trans "Import Books" %}</h1>
{% if recent_avg_hours or recent_avg_minutes %}
<div class="notification">
<p>
{% if recent_avg_hours %}
{% blocktrans trimmed with hours=recent_avg_hours|floatformat:0|intcomma %}
On average, recent imports have taken {{ hours }} hours.
{% endblocktrans %}
{% else %}
{% blocktrans trimmed with minutes=recent_avg_minutes|floatformat:0|intcomma %}
On average, recent imports have taken {{ minutes }} minutes.
{% endblocktrans %}
{% if site.imports_enabled %}
{% if recent_avg_hours or recent_avg_minutes %}
<div class="notification">
<p>
{% if recent_avg_hours %}
{% blocktrans trimmed with hours=recent_avg_hours|floatformat:0|intcomma %}
On average, recent imports have taken {{ hours }} hours.
{% endblocktrans %}
{% else %}
{% blocktrans trimmed with minutes=recent_avg_minutes|floatformat:0|intcomma %}
On average, recent imports have taken {{ minutes }} minutes.
{% endblocktrans %}
{% endif %}
</p>
</div>
{% endif %}
<form class="box" name="import" action="/import" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="columns">
<div class="column is-half">
<div class="field">
<label class="label" for="source">
{% trans "Data source:" %}
</label>
<div class="select">
<select name="source" id="source" aria-describedby="desc_source">
<option value="Goodreads" {% if current == 'Goodreads' %}selected{% endif %}>
{% trans "Goodreads (CSV)" %}
</option>
<option value="Storygraph" {% if current == 'Storygraph' %}selected{% endif %}>
{% trans "Storygraph (CSV)" %}
</option>
<option value="LibraryThing" {% if current == 'LibraryThing' %}selected{% endif %}>
{% trans "LibraryThing (TSV)" %}
</option>
<option value="OpenLibrary" {% if current == 'OpenLibrary' %}selected{% endif %}>
{% trans "OpenLibrary (CSV)" %}
</option>
<option value="Calibre" {% if current == 'Calibre' %}selected{% endif %}>
{% trans "Calibre (CSV)" %}
</option>
</select>
</div>
<p class="help" id="desc_source">
{% blocktrans trimmed %}
You can download your Goodreads data from the
<a href="https://www.goodreads.com/review/import" target="_blank" rel="nofollow noopener noreferrer">Import/Export page</a>
of your Goodreads account.
{% endblocktrans %}
</p>
</div>
<div class="field">
<label class="label" for="id_csv_file">{% trans "Data file:" %}</label>
{{ import_form.csv_file }}
</div>
</div>
<div class="column is-half">
<div class="field">
<label class="label">
<input type="checkbox" name="include_reviews" checked> {% trans "Include reviews" %}
</label>
</div>
<div class="field">
<label class="label" for="privacy_import">
{% trans "Privacy setting for imported reviews:" %}
</label>
{% include 'snippets/privacy_select.html' with no_label=True privacy_uuid="import" %}
</div>
</div>
</div>
<button class="button is-primary" type="submit">{% trans "Import" %}</button>
</form>
{% else %}
<div class="box notification has-text-centered is-warning m-6 content">
<p class="mt-5">
<span class="icon icon-warning is-size-2" aria-hidden="true"></span>
</p>
<p class="mb-5">
{% trans "Imports are temporarily disabled; thank you for your patience." %}
</p>
</div>
{% endif %}
<form class="box" name="import" action="/import" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="columns">
<div class="column is-half">
<div class="field">
<label class="label" for="source">
{% trans "Data source:" %}
</label>
<div class="select">
<select name="source" id="source" aria-describedby="desc_source">
<option value="Goodreads" {% if current == 'Goodreads' %}selected{% endif %}>
{% trans "Goodreads (CSV)" %}
</option>
<option value="Storygraph" {% if current == 'Storygraph' %}selected{% endif %}>
{% trans "Storygraph (CSV)" %}
</option>
<option value="LibraryThing" {% if current == 'LibraryThing' %}selected{% endif %}>
{% trans "LibraryThing (TSV)" %}
</option>
<option value="OpenLibrary" {% if current == 'OpenLibrary' %}selected{% endif %}>
{% trans "OpenLibrary (CSV)" %}
</option>
<option value="Calibre" {% if current == 'Calibre' %}selected{% endif %}>
{% trans "Calibre (CSV)" %}
</option>
</select>
</div>
<p class="help" id="desc_source">
{% blocktrans trimmed %}
You can download your Goodreads data from the
<a href="https://www.goodreads.com/review/import" target="_blank" rel="nofollow noopener noreferrer">Import/Export page</a>
of your Goodreads account.
{% endblocktrans %}
</p>
</div>
<div class="field">
<label class="label" for="id_csv_file">{% trans "Data file:" %}</label>
{{ import_form.csv_file }}
</div>
</div>
<div class="column is-half">
<div class="field">
<label class="label">
<input type="checkbox" name="include_reviews" checked> {% trans "Include reviews" %}
</label>
</div>
<div class="field">
<label class="label" for="privacy_import">
{% trans "Privacy setting for imported reviews:" %}
</label>
{% include 'snippets/privacy_select.html' with no_label=True privacy_uuid="import" %}
</div>
</div>
</div>
<button class="button is-primary" type="submit">{% trans "Import" %}</button>
</form>
</div>
<div class="content block">

View file

@ -5,7 +5,9 @@
{% load group_tags %}
{% load markdown %}
{% block title %}{% blocktrans with list_name=list.name owner=list.user.display_name %}{{ list_name }}, a list by {{owner}}{% endblocktrans %}{% endblock title %}
{% block title %}{% blocktrans trimmed with list_name=list.name owner=list.user.display_name %}
{{ list_name }}, a list by {{owner}}
{% endblocktrans %}{% endblock title %}
{% block content %}
<div class="mt-3">

View file

@ -45,7 +45,7 @@
<p>{% trans "Scan the QR code with your authentication app and then enter the code from your app below to confirm your app is set up." %}</p>
<div class="columns">
<section class="column is-narrow">
<figure class="m-4">{{ qrcode | safe }}</figure>
<figure class="m-4" id="qrcode">{{ qrcode | safe }}</figure>
<details class="details-panel box">
<summary>
<span role="heading" aria-level="3" class="title is-6">

View file

@ -11,6 +11,54 @@
{% block panel %}
<div class="block">
{% if site.imports_enabled %}
<details class="details-panel box">
<summary>
<span role="heading" aria-level="2" class="title is-6">
{% trans "Disable starting new imports" %}
</span>
<span class="details-close icon icon-x" aria-hidden="true"></span>
</summary>
<form
name="disable-imports"
id="disable-imports"
method="POST"
action="{% url 'settings-imports-disable' %}"
>
<div class="notification">
{% trans "This is only intended to be used when things have gone very wrong with imports and you need to pause the feature while addressing issues." %}
{% trans "While imports are disabled, users will not be allowed to start new imports, but existing imports will not be effected." %}
</div>
{% csrf_token %}
<div class="control">
<button type="submit" class="button is-danger">
{% trans "Disable imports" %}
</button>
</div>
</form>
</details>
{% else %}
<form
name="enable-imports"
id="enable-imports"
method="POST"
action="{% url 'settings-imports-enable' %}"
class="box"
>
<div class="notification is-danger is-light">
{% trans "Users are currently unable to start new imports" %}
</div>
{% csrf_token %}
<div class="control">
<button type="submit" class="button is-success">
{% trans "Enable imports" %}
</button>
</div>
</form>
{% endif %}
</div>
<div class="block">
<div class="tabs">
<ul>

View file

@ -24,7 +24,7 @@
</div>
<div class="column is-2">
<p>
<a href ="{% url 'privacy' %}">{% trans "Code of Conduct" %}</a>
<a href ="{% url 'conduct' %}">{% trans "Code of Conduct" %}</a>
</p>
<p>
<a href ="{% url 'privacy' %}">{% trans "Privacy Policy" %}</a>

View file

@ -64,12 +64,14 @@
<div>
<div class="columns is-mobile">
<h2 class="title column">{% trans "User Activity" %}</h2>
{% if user.local %}
<div class="column is-narrow">
<a target="_blank" href="{{ user.local_path }}/rss" rel="nofollow noopener noreferrer">
<span class="icon icon-rss" aria-hidden="true"></span>
<span class="is-hidden-mobile">{% trans "RSS feed" %}</span>
</a>
</div>
{% endif %}
</div>
{% for activity in activities %}
<div class="block" id="feed_{{ activity.id }}">

View file

@ -42,7 +42,7 @@ def get_relationship(context, user_object):
"""caches the relationship between the logged in user and another user"""
user = context["request"].user
return get_or_set(
f"relationship-{user.id}-{user_object.id}",
f"cached-relationship-{user.id}-{user_object.id}",
get_relationship_name,
user,
user_object,

View file

@ -1,10 +1,12 @@
""" testing models """
import json
from unittest.mock import patch
from django.contrib.auth.models import Group
from django.test import TestCase
import responses
from bookwyrm import models
from bookwyrm.management.commands import initdb
from bookwyrm.settings import USE_HTTPS, DOMAIN
# pylint: disable=missing-class-docstring
@ -12,6 +14,7 @@ from bookwyrm.settings import USE_HTTPS, DOMAIN
class User(TestCase):
protocol = "https://" if USE_HTTPS else "http://"
# pylint: disable=invalid-name
def setUp(self):
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
"bookwyrm.activitystreams.populate_stream_task.delay"
@ -25,6 +28,17 @@ class User(TestCase):
name="hi",
bookwyrm_user=False,
)
self.another_user = models.User.objects.create_user(
f"nutria@{DOMAIN}",
"nutria@nutria.nutria",
"nutriaword",
local=True,
localname="nutria",
name="hi",
bookwyrm_user=False,
)
initdb.init_groups()
initdb.init_permissions()
def test_computed_fields(self):
"""username instead of id here"""
@ -176,3 +190,41 @@ class User(TestCase):
self.assertEqual(activity["type"], "Delete")
self.assertEqual(activity["object"], self.user.remote_id)
self.assertFalse(self.user.is_active)
def test_admins_no_admins(self):
"""list of admins"""
result = models.User.admins()
self.assertFalse(result.exists())
def test_admins_superuser(self):
"""list of admins"""
self.user.is_superuser = True
self.user.save(broadcast=False, update_fields=["is_superuser"])
result = models.User.admins()
self.assertEqual(result.count(), 1)
self.assertEqual(result.first(), self.user)
def test_admins_superuser_and_mod(self):
"""list of admins"""
self.user.is_superuser = True
self.user.save(broadcast=False, update_fields=["is_superuser"])
group = Group.objects.get(name="moderator")
self.another_user.groups.set([group])
results = models.User.admins()
self.assertEqual(results.count(), 2)
self.assertTrue(results.filter(id=self.user.id).exists())
self.assertTrue(results.filter(id=self.another_user.id).exists())
def test_admins_deleted_mod(self):
"""list of admins"""
self.user.is_superuser = True
self.user.save(broadcast=False, update_fields=["is_superuser"])
group = Group.objects.get(name="moderator")
self.another_user.groups.set([group])
self.another_user.is_active = False
self.another_user.save(broadcast=False, update_fields=None)
results = models.User.admins()
self.assertEqual(results.count(), 1)
self.assertEqual(results.first(), self.user)

View file

@ -301,6 +301,16 @@ urlpatterns = [
views.ImportList.as_view(),
name="settings-imports-complete",
),
re_path(
r"^settings/imports/disable/?$",
views.disable_imports,
name="settings-imports-disable",
),
re_path(
r"^settings/imports/enable/?$",
views.enable_imports,
name="settings-imports-enable",
),
re_path(
r"^settings/celery/?$", views.CeleryStatus.as_view(), name="settings-celery"
),

View file

@ -10,7 +10,7 @@ from .admin.federation import Federation, FederatedServer
from .admin.federation import AddFederatedServer, ImportServerBlocklist
from .admin.federation import block_server, unblock_server, refresh_server
from .admin.email_blocklist import EmailBlocklist
from .admin.imports import ImportList
from .admin.imports import ImportList, disable_imports, enable_imports
from .admin.ip_blocklist import IPBlocklist
from .admin.invite import ManageInvites, Invite, InviteRequest
from .admin.invite import ManageInviteRequests, ignore_invite_request

View file

@ -5,6 +5,7 @@ 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 bookwyrm import models
from bookwyrm.settings import PAGE_LENGTH
@ -53,3 +54,25 @@ class ImportList(View):
import_job = get_object_or_404(models.ImportJob, id=import_id)
import_job.stop_job()
return redirect("settings-imports")
@require_POST
@permission_required("bookwyrm.edit_instance_settings", raise_exception=True)
# pylint: disable=unused-argument
def disable_imports(request):
"""When you just need people to please stop starting imports"""
site = models.SiteSettings.objects.get()
site.imports_enabled = False
site.save(update_fields=["imports_enabled"])
return redirect("settings-imports")
@require_POST
@permission_required("bookwyrm.edit_instance_settings", raise_exception=True)
# pylint: disable=unused-argument
def enable_imports(request):
"""When you just need people to please stop starting imports"""
site = models.SiteSettings.objects.get()
site.imports_enabled = True
site.save(update_fields=["imports_enabled"])
return redirect("settings-imports")

View file

@ -85,6 +85,11 @@ class Invite(View):
# post handling is in views.register.Register
@method_decorator(login_required, name="dispatch")
@method_decorator(
permission_required("bookwyrm.create_invites", raise_exception=True),
name="dispatch",
)
class ManageInviteRequests(View):
"""grant invites like the benevolent lord you are"""
@ -177,6 +182,7 @@ class InviteRequest(View):
@require_POST
@permission_required("bookwyrm.create_invites", raise_exception=True)
def ignore_invite_request(request):
"""hide an invite request"""
invite_request = get_object_or_404(

View file

@ -4,6 +4,7 @@ import datetime
from django.contrib.auth.decorators import login_required
from django.db.models import Avg, ExpressionWrapper, F, fields
from django.core.exceptions import PermissionDenied
from django.core.paginator import Paginator
from django.http import HttpResponseBadRequest
from django.shortcuts import redirect
@ -54,6 +55,10 @@ class Import(View):
def post(self, request):
"""ingest a goodreads csv"""
site = models.SiteSettings.objects.get()
if not site.imports_enabled:
raise PermissionDenied()
form = forms.ImportForm(request.POST, request.FILES)
if not form.is_valid():
return HttpResponseBadRequest()