Merge pull request #775 from mouse-reeve/invite-request

Invite request
This commit is contained in:
Mouse Reeve 2021-03-21 09:35:09 -07:00 committed by GitHub
commit 9555470f2a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 463 additions and 39 deletions

View file

@ -16,9 +16,6 @@ Social reading and reviewing, decentralized with ActivityPub
## Joining BookWyrm
BookWyrm is still a young piece of software, and isn't at the level of stability and feature-richness that you'd find in a production-ready application. But it does what it says on the box! If you'd like to join an instance, you can check out the [instances](https://github.com/mouse-reeve/bookwyrm/blob/main/instances.md) list.
You can request an invite to https://bookwyrm.social by [email](mailto:mousereeve@riseup.net), [Mastodon direct message](https://friend.camp/@tripofmice), or [Twitter direct message](https://twitter.com/tripofmice).
## Contributing
There are many ways you can contribute to this project, regardless of your level of technical expertise.

View file

@ -1,27 +1,48 @@
""" send emails """
from django.core.mail import send_mail
from django.core.mail import EmailMultiAlternatives
from django.template.loader import get_template
from bookwyrm import models
from bookwyrm.tasks import app
def invite_email(invite_request):
""" send out an invite code """
site = models.SiteSettings.objects.get()
data = {
"site_name": site.name,
"invite_link": invite_request.invite.link,
}
send_email.delay(invite_request.email, "invite", data)
def password_reset_email(reset_code):
""" generate a password reset email """
site = models.SiteSettings.get()
send_email.delay(
reset_code.user.email,
"Reset your password on %s" % site.name,
"Your password reset link: %s" % reset_code.link,
)
site = models.SiteSettings.objects.get()
data = {
"site_name": site.name,
"reset_link": reset_code.link,
}
send_email.delay(reset_code.user.email, "password_reset", data)
@app.task
def send_email(recipient, subject, message):
def send_email(recipient, message_name, data):
""" use a task to send the email """
send_mail(
subject,
message,
None, # sender will be the config default
[recipient],
fail_silently=False,
subject = (
get_template("email/{}/subject.html".format(message_name)).render(data).strip()
)
html_content = (
get_template("email/{}/html_content.html".format(message_name))
.render(data)
.strip()
)
text_content = (
get_template("email/{}/text_content.html".format(message_name))
.render(data)
.strip()
)
email = EmailMultiAlternatives(subject, text_content, None, [recipient])
email.attach_alternative(html_content, "text/html")
email.send()

View file

@ -3,6 +3,7 @@ import datetime
from collections import defaultdict
from django import forms
from django.core.exceptions import ValidationError
from django.forms import ModelForm, PasswordInput, widgets
from django.forms.widgets import Textarea
from django.utils import timezone
@ -202,6 +203,19 @@ class ExpiryWidget(widgets.Select):
return timezone.now() + interval
class InviteRequestForm(CustomForm):
def clean(self):
""" make sure the email isn't in use by a registered user """
cleaned_data = super().clean()
email = cleaned_data.get("email")
if email and models.User.objects.filter(email=email).exists():
self.add_error("email", _("A user with this email already exists."))
class Meta:
model = models.InviteRequest
fields = ["email"]
class CreateInviteForm(CustomForm):
class Meta:
model = models.SiteInvite

View file

@ -0,0 +1,59 @@
# Generated by Django 3.1.6 on 2021-03-21 03:03
import bookwyrm.models.fields
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0055_auto_20210321_0101"),
]
operations = [
migrations.AddField(
model_name="sitesettings",
name="allow_invite_requests",
field=models.BooleanField(default=True),
),
migrations.CreateModel(
name="InviteRequest",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
("updated_date", models.DateTimeField(auto_now=True)),
(
"remote_id",
bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
),
("email", models.EmailField(max_length=255, unique=True)),
("invite_sent", models.BooleanField(default=False)),
("ignored", models.BooleanField(default=False)),
(
"invite",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="bookwyrm.siteinvite",
),
),
],
options={
"abstract": False,
},
),
]

View file

@ -26,7 +26,7 @@ from .federated_server import FederatedServer
from .import_job import ImportJob, ImportItem
from .site import SiteSettings, SiteInvite, PasswordReset
from .site import SiteSettings, SiteInvite, PasswordReset, InviteRequest
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
activity_models = {

View file

@ -3,10 +3,11 @@ import base64
import datetime
from Crypto import Random
from django.db import models
from django.db import models, IntegrityError
from django.utils import timezone
from bookwyrm.settings import DOMAIN
from .base_model import BookWyrmModel
from .user import User
@ -24,6 +25,7 @@ class SiteSettings(models.Model):
code_of_conduct = models.TextField(default="Add a code of conduct here.")
privacy_policy = models.TextField(default="Add a privacy policy here.")
allow_registration = models.BooleanField(default=True)
allow_invite_requests = models.BooleanField(default=True)
logo = models.ImageField(upload_to="logos/", null=True, blank=True)
logo_small = models.ImageField(upload_to="logos/", null=True, blank=True)
favicon = models.ImageField(upload_to="logos/", null=True, blank=True)
@ -69,6 +71,23 @@ class SiteInvite(models.Model):
return "https://{}/invite/{}".format(DOMAIN, self.code)
class InviteRequest(BookWyrmModel):
""" prospective users can request an invite """
email = models.EmailField(max_length=255, unique=True)
invite = models.ForeignKey(
SiteInvite, on_delete=models.SET_NULL, null=True, blank=True
)
invite_sent = models.BooleanField(default=False)
ignored = models.BooleanField(default=False)
def save(self, *args, **kwargs):
""" don't create a request for a registered email """
if User.objects.filter(email=self.email).exists():
raise IntegrityError()
super().save(*args, **kwargs)
def get_passowrd_reset_expiry():
""" give people a limited time to use the link """
now = timezone.now()

View file

@ -45,9 +45,33 @@
<form name="register" method="post" action="/register">
{% include 'snippets/register_form.html' %}
</form>
{% else %}
<h2 class="title">{% trans "This instance is closed" %}</h2>
<p>{{ site.registration_closed_text | safe}}</p>
{% if site.allow_invite_requests %}
{% if request_received %}
<p>
{% trans "Thank you! Your request has been received." %}
</p>
{% else %}
<h3>{% trans "Request an Invitation" %}</h3>
<form name="invite-request" action="{% url 'invite-request' %}" method="post">
{% csrf_token %}
<div class="block">
<label for="id_request_email" class="label">{% trans "Email address:" %}</label>
<input type="email" name="email" maxlength="255" class="input" required="" id="id_request_email">
{% for error in request_form.email.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<button type="submit" class="button is-link">{% trans "Submit" %}</button>
</form>
{% endif %}
{% endif %}
{% endif %}
</div>
{% else %}

View file

@ -0,0 +1,2 @@
{% load i18n %}
<a href="{{ invite_link }}">{% blocktrans %}Join {{ site_name }}{% endblocktrans %}</a>

View file

@ -0,0 +1,2 @@
{% load i18n %}
{% blocktrans %}You're invited! Join {{ site_name }}{% endblocktrans %}

View file

@ -0,0 +1,2 @@
{% load i18n %}
{% blocktrans %}Join {{ site_name }}: {{ invite_link }}{% endblocktrans %}

View file

@ -0,0 +1,2 @@
{% load i18n %}
{% blocktrans %}Your password reset link is: {{ reset_link }}{% endblocktrans %}

View file

@ -0,0 +1,2 @@
{% load i18n %}
{% blocktrans %}Reset your password on {{ site_name }}{% endblocktrans %}

View file

@ -0,0 +1,2 @@
{% load i18n %}
{% blocktrans %}Your password reset link is: {{ reset_link }}{% endblocktrans %}

View file

@ -15,8 +15,9 @@
<h2 class="menu-label">{% trans "Manage Users" %}</h2>
<ul class="menu-list">
<li>
{% url 'settings-invites' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Invites" %}</a>
{% url 'settings-invite-requests' as url %}
{% url 'settings-invites' as alt_url %}
<a href="{{ url }}"{% if url in request.path or request.path in alt_url %} class="is-active" aria-selected="true"{% endif %}>{% trans "Invites" %}</a>
</li>
<li>
{% url 'settings-reports' as url %}

View file

@ -0,0 +1,88 @@
{% extends 'settings/admin_layout.html' %}
{% load i18n %}
{% load humanize %}
{% block header %}{% trans "Invite Requests" %}{% endblock %}
{% block panel %}
<div class="tabs">
<ul>
{% url 'settings-invite-requests' as url %}
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
<a href="{{ url }}">{% trans "Invite Requests" %}</a>
</li>
{% url 'settings-invites' as url %}
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
<a href="{{ url }}">{% trans "Invites" %}</a>
</li>
</ul>
</div>
<section class="block">
<h2 class="title is-4">
{% if ignored %}
{% trans "Ignored Invite Requests" %}
{% else %}
{% trans "Invite Requests" %}
{% endif %}
</h2>
<table class="table is-striped">
<tr>
<th>{% trans "Date" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Action" %}</th>
</tr>
{% if not requests %}
<tr><td colspan="4">{% trans "No requests" %}</td></tr>
{% endif %}
{% for req in requests %}
<tr>
<td>{{ req.created_date | naturaltime }}</td>
<td>{{ req.email }}</td>
<td>
{% if req.invite.times_used %}
{% trans "Accepted" %}
{% elif req.invite %}
{% trans "Sent" %}
{% else %}
{% trans "Requested" %}
{% endif %}
</td>
<td class="field is-grouped">
<form name="send-invite" method="post" action="{% url 'settings-invite-requests' %}">
{% csrf_token %}
<input type="hidden" name="invite-request" value="{{ req.id }}">
{% if not req.invite %}
<button type="submit" class="button is-link is-light is-small">{% trans "Send invite" %}</button>
{% else %}
<button type="submit" class="button is-link is-light is-small">{% trans "Re-send invite" %}</button>
{% endif %}
</form>
{% if req.invite and not req.invite.times_used %}
{# <button class="button is-danger is-light is-small">{% trans "Revoke invite" %}</button> #}
{% else %}
<form name="ignore-request" method="post" action="{% url 'settings-invite-requests-ignore' %}">
{% csrf_token %}
<input type="hidden" name="invite-request" value="{{ req.id }}">
{% if not req.ignored %}
<button type="submit" class="button is-danger is-light is-small">{% trans "Ignore" %}</button>
{% else %}
<button type="submit" class="button is-danger is-light is-small">{% trans "Un-gnore" %}</button>
{% endif %}
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% include 'snippets/pagination.html' with page=requests path=request.path %}
{% if ignored %}
<p><a href="{% url 'settings-invite-requests' %}">{% trans "Back to pending requests" %}</a></p>
{% else %}
<p><a href="{% url 'settings-invite-requests' %}?ignored=True">{% trans "View ignored requests" %}</a></p>
{% endif %}
</section>
{% endblock %}

View file

@ -3,6 +3,20 @@
{% block header %}{% trans "Invites" %}{% endblock %}
{% load humanize %}
{% block panel %}
<div class="tabs">
<ul>
{% url 'settings-invite-requests' as url %}
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
<a href="{{ url }}">{% trans "Invite Requests" %}</a>
</li>
{% url 'settings-invites' as url %}
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
<a href="{{ url }}">{% trans "Invites" %}</a>
</li>
</ul>
</div>
<section class="block">
<h2 class="title is-4">{% trans "Generate New Invite" %}</h2>

View file

@ -79,6 +79,10 @@
<label class="label" for="id_allow_registration">{% trans "Allow registration:" %}
{{ site_form.allow_registration }}
</div>
<div class="control">
<label class="label" for="id_allow_invite_requests">{% trans "Allow invite requests:" %}
{{ site_form.allow_invite_requests }}
</div>
<div class="control">
<label class="label" for="id_registration_closed_text">{% trans "Registration closed text:" %}</label>
{{ site_form.registration_closed_text }}

View file

@ -6,7 +6,7 @@ from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import models
from bookwyrm import forms, models
from bookwyrm import views
@ -50,3 +50,81 @@ class InviteViews(TestCase):
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)
def test_invite_request(self):
""" request to join a server """
form = forms.InviteRequestForm()
form.data["email"] = "new@user.email"
view = views.InviteRequest.as_view()
request = self.factory.post("", form.data)
result = view(request)
result.render()
req = models.InviteRequest.objects.get()
self.assertEqual(req.email, "new@user.email")
def test_invite_request_email_taken(self):
""" request to join a server with an existing user email """
form = forms.InviteRequestForm()
form.data["email"] = "mouse@mouse.mouse"
view = views.InviteRequest.as_view()
request = self.factory.post("", form.data)
result = view(request)
result.render()
# no request created
self.assertFalse(models.InviteRequest.objects.exists())
def test_manage_invite_requests(self):
""" there are so many views, this just makes sure it LOADS """
view = views.ManageInviteRequests.as_view()
request = self.factory.get("")
request.user = self.local_user
request.user.is_superuser = True
result = view(request)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)
# now with data
models.InviteRequest.objects.create(email="fish@example.com")
result = view(request)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)
def test_manage_invite_requests_send(self):
""" send an invite """
req = models.InviteRequest.objects.create(email="fish@example.com")
view = views.ManageInviteRequests.as_view()
request = self.factory.post("", {"invite-request": req.id})
request.user = self.local_user
request.user.is_superuser = True
with patch("bookwyrm.emailing.send_email.delay") as mock:
view(request)
self.assertEqual(mock.call_count, 1)
req.refresh_from_db()
self.assertIsNotNone(req.invite)
def test_ignore_invite_request(self):
""" don't invite that jerk """
req = models.InviteRequest.objects.create(email="fish@example.com")
view = views.ignore_invite_request
request = self.factory.post("", {"invite-request": req.id})
request.user = self.local_user
request.user.is_superuser = True
view(request)
req.refresh_from_db()
self.assertTrue(req.ignored)
view(request)
req.refresh_from_db()
self.assertFalse(req.ignored)

View file

@ -54,6 +54,19 @@ urlpatterns = [
re_path(
r"^settings/invites/?$", views.ManageInvites.as_view(), name="settings-invites"
),
re_path(
r"^settings/requests/?$",
views.ManageInviteRequests.as_view(),
name="settings-invite-requests",
),
re_path(
r"^settings/requests/ignore?$",
views.ignore_invite_request,
name="settings-invite-requests-ignore",
),
re_path(
r"^invite-request/?$", views.InviteRequest.as_view(), name="invite-request"
),
re_path(r"^invite/(?P<code>[A-Za-z0-9]+)/?$", views.Invite.as_view()),
# moderation
re_path(r"^settings/reports/?$", views.Reports.as_view(), name="settings-reports"),

View file

@ -13,7 +13,8 @@ from .goal import Goal, hide_goal
from .import_data import Import, ImportStatus
from .inbox import Inbox
from .interaction import Favorite, Unfavorite, Boost, Unboost
from .invite import ManageInvites, Invite
from .invite import ManageInvites, Invite, InviteRequest
from .invite import ManageInviteRequests, ignore_invite_request
from .isbn import Isbn
from .landing import About, Home, Discover
from .list import Lists, List, Curate, UserLists

View file

@ -2,7 +2,7 @@
import re
from requests import HTTPError
from django.core.exceptions import FieldError
from django.db.models import Q
from django.db.models import Max, Q
from bookwyrm import activitypub, models
from bookwyrm.connectors import ConnectorException, get_data
@ -216,3 +216,20 @@ def is_blocked(viewer, user):
if viewer.is_authenticated and viewer in user.blocks.all():
return True
return False
def get_discover_books():
""" list of books for the discover page """
return list(
set(
models.Edition.objects.filter(
review__published_date__isnull=False,
review__deleted=False,
review__user__local=True,
review__privacy__in=["public", "unlisted"],
)
.exclude(cover__exact="")
.annotate(Max("review__published_date"))
.order_by("-review__published_date__max")[:6]
)
)

View file

@ -6,9 +6,11 @@ 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 forms, models
from bookwyrm import emailing, forms, models
from bookwyrm.settings import PAGE_LENGTH
from . import helpers
# pylint: disable= no-self-use
@ -77,3 +79,74 @@ class Invite(View):
return TemplateResponse(request, "invite.html", data)
# post handling is in views.authentication.Register
class ManageInviteRequests(View):
""" grant invites like the benevolent lord you are """
def get(self, request):
""" view a list of requests """
ignored = request.GET.get("ignored", False)
try:
page = int(request.GET.get("page", 1))
except ValueError:
page = 1
paginated = Paginator(
models.InviteRequest.objects.filter(ignored=ignored).order_by(
"-created_date"
),
PAGE_LENGTH,
)
data = {
"ignored": ignored,
"requests": paginated.page(page),
}
return TemplateResponse(request, "settings/manage_invite_requests.html", data)
def post(self, request):
""" send out an invite """
invite_request = get_object_or_404(
models.InviteRequest, id=request.POST.get("invite-request")
)
# allows re-sending invites
invite_request.invite, _ = models.SiteInvite.objects.get_or_create(
use_limit=1,
user=request.user,
)
invite_request.save()
emailing.invite_email(invite_request)
return redirect("settings-invite-requests")
class InviteRequest(View):
""" prospective users sign up here """
def post(self, request):
""" create a request """
form = forms.InviteRequestForm(request.POST)
received = False
if form.is_valid():
received = True
form.save()
data = {
"request_form": form,
"request_received": received,
"books": helpers.get_discover_books(),
}
return TemplateResponse(request, "discover/discover.html", data)
@require_POST
def ignore_invite_request(request):
""" hide an invite request """
invite_request = get_object_or_404(
models.InviteRequest, id=request.POST.get("invite-request")
)
invite_request.ignored = not invite_request.ignored
invite_request.save()
return redirect("settings-invite-requests")

View file

@ -1,10 +1,10 @@
""" non-interactive pages """
from django.db.models import Max
from django.template.response import TemplateResponse
from django.views import View
from bookwyrm import forms, models
from .feed import Feed
from . import helpers
# pylint: disable= no-self-use
@ -33,20 +33,9 @@ class Discover(View):
def get(self, request):
""" tiled book activity page """
books = (
models.Edition.objects.filter(
review__published_date__isnull=False,
review__deleted=False,
review__user__local=True,
review__privacy__in=["public", "unlisted"],
)
.exclude(cover__exact="")
.annotate(Max("review__published_date"))
.order_by("-review__published_date__max")[:6]
)
data = {
"register_form": forms.RegisterForm(),
"books": list(set(books)),
"request_form": forms.InviteRequestForm(),
"books": helpers.get_discover_books(),
}
return TemplateResponse(request, "discover/discover.html", data)