mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-12-15 20:56:34 +00:00
Merge branch 'main' into production
This commit is contained in:
commit
bb8cac021b
34 changed files with 661 additions and 48 deletions
|
@ -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.
|
||||
|
||||
|
|
|
@ -1,27 +1,64 @@
|
|||
""" 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
|
||||
from bookwyrm.settings import DOMAIN
|
||||
|
||||
|
||||
def email_data():
|
||||
""" fields every email needs """
|
||||
site = models.SiteSettings.objects.get()
|
||||
if site.logo_small:
|
||||
logo_path = "/images/{}".format(site.logo_small.url)
|
||||
else:
|
||||
logo_path = "/static/images/logo-small.png"
|
||||
|
||||
return {
|
||||
"site_name": site.name,
|
||||
"logo": logo_path,
|
||||
"domain": DOMAIN,
|
||||
"user": None,
|
||||
}
|
||||
|
||||
|
||||
def invite_email(invite_request):
|
||||
""" send out an invite code """
|
||||
data = email_data()
|
||||
data["invite_link"] = invite_request.invite.link
|
||||
send_email.delay(invite_request.email, *format_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,
|
||||
data = email_data()
|
||||
data["reset_link"] = reset_code.link
|
||||
data["user"] = reset_code.user.display_name
|
||||
send_email.delay(reset_code.user.email, *format_email("password_reset", data))
|
||||
|
||||
|
||||
def format_email(email_name, data):
|
||||
""" render the email templates """
|
||||
subject = (
|
||||
get_template("email/{}/subject.html".format(email_name)).render(data).strip()
|
||||
)
|
||||
html_content = (
|
||||
get_template("email/{}/html_content.html".format(email_name))
|
||||
.render(data)
|
||||
.strip()
|
||||
)
|
||||
text_content = (
|
||||
get_template("email/{}/text_content.html".format(email_name))
|
||||
.render(data)
|
||||
.strip()
|
||||
)
|
||||
return (subject, html_content, text_content)
|
||||
|
||||
|
||||
@app.task
|
||||
def send_email(recipient, subject, message):
|
||||
def send_email(recipient, subject, html_content, text_content):
|
||||
""" use a task to send the email """
|
||||
send_mail(
|
||||
subject,
|
||||
message,
|
||||
None, # sender will be the config default
|
||||
[recipient],
|
||||
fail_silently=False,
|
||||
)
|
||||
email = EmailMultiAlternatives(subject, text_content, None, [recipient])
|
||||
email.attach_alternative(html_content, "text/html")
|
||||
email.send()
|
||||
|
|
|
@ -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
|
||||
|
|
59
bookwyrm/migrations/0056_auto_20210321_0303.py
Normal file
59
bookwyrm/migrations/0056_auto_20210321_0303.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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 = {
|
||||
|
|
|
@ -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()
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 7.1 KiB |
|
@ -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 %}
|
||||
|
|
26
bookwyrm/templates/email/html_layout.html
Normal file
26
bookwyrm/templates/email/html_layout.html
Normal file
|
@ -0,0 +1,26 @@
|
|||
{% load i18n %}
|
||||
<div style="font-family: BlinkMacSystemFont,-apple-system,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Fira Sans','Droid Sans','Helvetica Neue',Helvetica,Arial,sans-serif; border-radius: 6px; background-color: #efefef; max-width: 632px;">
|
||||
<div style="padding: 1rem; overflow: auto;">
|
||||
<div style="float: left; margin-right: 1rem;">
|
||||
<a style="color: #3273dc;" href="https://{{ domain }}" style="text-decoration: none;"><img src="https://{{ domain }}/{{ logo }}" alt="logo"></a>
|
||||
</div>
|
||||
<div>
|
||||
<a style="color: black; text-decoration: none" href="https://{{ domain }}" style="text-decoration: none;"><strong>{{ site_name }}</strong><br>
|
||||
{{ domain }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding: 1rem; background-color: white;">
|
||||
<p>
|
||||
{% if user %}{{ user }},{% else %}{% trans "Hi there," %}{% endif %}
|
||||
</p>
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<div style="padding: 1rem; font-size: 0.8rem;">
|
||||
<p style="margin: 0; color: #333;">{% blocktrans %}BookWyrm hosted on <a style="color: #3273dc;" href="https://{{ domain }}">{{ site_name }}</a>{% endblocktrans %}</p>
|
||||
{% if user %}
|
||||
<p style="margin: 0; color: #333;"><a style="color: #3273dc;" href="https://{{ domain }}{% url 'prefs-profile' %}">{% trans "Email preference" %}</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
17
bookwyrm/templates/email/invite/html_content.html
Normal file
17
bookwyrm/templates/email/invite/html_content.html
Normal file
|
@ -0,0 +1,17 @@
|
|||
{% extends 'email/html_layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<p>
|
||||
{% blocktrans %}You're invited to join {{ site_name }}!{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
{% trans "Join Now" as text %}
|
||||
{% include 'email/snippets/action.html' with path=invite_link text=text %}
|
||||
|
||||
<p>
|
||||
{% url 'code-of-conduct' as coc_path %}
|
||||
{% url 'about' as about_path %}
|
||||
{% blocktrans %}Learn more <a href="https://{{ domain }}{{ about_path }}">about this instance</a>.{% endblocktrans %}
|
||||
</p>
|
||||
{% endblock %}
|
2
bookwyrm/templates/email/invite/subject.html
Normal file
2
bookwyrm/templates/email/invite/subject.html
Normal file
|
@ -0,0 +1,2 @@
|
|||
{% load i18n %}
|
||||
{% blocktrans %}You're invited to join {{ site_name }}!{% endblocktrans %}
|
10
bookwyrm/templates/email/invite/text_content.html
Normal file
10
bookwyrm/templates/email/invite/text_content.html
Normal file
|
@ -0,0 +1,10 @@
|
|||
{% extends 'email/text_layout.html' %}
|
||||
{% load i18n %}
|
||||
{% block content %}
|
||||
{% blocktrans %}You're invited to join {{ site_name }}! Click the link below to create an account.{% endblocktrans %}
|
||||
|
||||
{{ invite_link }}
|
||||
|
||||
{% trans "Learn more about this instance:" %} https://{{ domain }}{% url 'about' %}
|
||||
|
||||
{% endblock %}
|
15
bookwyrm/templates/email/password_reset/html_content.html
Normal file
15
bookwyrm/templates/email/password_reset/html_content.html
Normal file
|
@ -0,0 +1,15 @@
|
|||
{% extends 'email/html_layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<p>
|
||||
{% blocktrans %}You requested to reset your {{ site_name }} password. Click the link below to set a new password and log in to your account.{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
{% trans "Reset Password" as text %}
|
||||
{% include 'email/snippets/action.html' with path=reset_link text=text %}
|
||||
|
||||
<p>
|
||||
{% trans "If you didn't request to reset your password, you can ignore this email." %}
|
||||
</p>
|
||||
{% endblock %}
|
2
bookwyrm/templates/email/password_reset/subject.html
Normal file
2
bookwyrm/templates/email/password_reset/subject.html
Normal file
|
@ -0,0 +1,2 @@
|
|||
{% load i18n %}
|
||||
{% blocktrans %}Reset your {{ site_name }} password{% endblocktrans %}
|
|
@ -0,0 +1,9 @@
|
|||
{% extends 'email/text_layout.html' %}
|
||||
{% load i18n %}
|
||||
{% block content %}
|
||||
{% blocktrans %}You requested to reset your {{ site_name }} password. Click the link below to set a new password and log in to your account.{% endblocktrans %}
|
||||
|
||||
{{ reset_link }}
|
||||
|
||||
{% trans "If you didn't request to reset your password, you can ignore this email." %}
|
||||
{% endblock %}
|
19
bookwyrm/templates/email/preview.html
Normal file
19
bookwyrm/templates/email/preview.html
Normal file
|
@ -0,0 +1,19 @@
|
|||
<html>
|
||||
<body>
|
||||
<div>
|
||||
<strong>Subject:</strong> {% include subject_path %}
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<strong>Html email:</strong>
|
||||
<div style="border: 1px solid #c0c0c0; margin: 2em; padding: 1em;">
|
||||
{% include html_content_path %}
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<strong>Text email:</strong>
|
||||
<div style="border: 1px solid #c0c0c0; margin: 2em; padding: 1em; white-space: pre;">
|
||||
{% include text_content_path %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
5
bookwyrm/templates/email/snippets/action.html
Normal file
5
bookwyrm/templates/email/snippets/action.html
Normal file
|
@ -0,0 +1,5 @@
|
|||
<p style="text-align: center; margin: 2rem;">
|
||||
<a href="{{ path }}" style="background-color: #3273dc; color: #fff; border-radius: 4px; padding: 0.5rem 1rem; text-decoration: none;">
|
||||
{{ text }}
|
||||
</a>
|
||||
</p>
|
3
bookwyrm/templates/email/text_layout.html
Normal file
3
bookwyrm/templates/email/text_layout.html
Normal file
|
@ -0,0 +1,3 @@
|
|||
{% load i18n %}
|
||||
{% if user %}{{ user.display_name }},{% else %}{% trans "Hi there," %}{% endif %}
|
||||
{% block content %}{% endblock %}
|
|
@ -8,7 +8,9 @@
|
|||
<div class="column is-half">
|
||||
<div class="block">
|
||||
<h1 class="title">{% trans "Reset Password" %}</h1>
|
||||
{% if message %}<p>{{ message }}</p>{% endif %}
|
||||
|
||||
{% if message %}<p class="notification is-primary">{{ message }}</p>{% endif %}
|
||||
|
||||
<p>{% trans "A link to reset your password will be sent to your email address" %}</p>
|
||||
<form name="password-reset" method="post" action="/password-reset">
|
||||
{% csrf_token %}
|
||||
|
@ -16,6 +18,9 @@
|
|||
<label class="label" for="id_email_register">{% trans "Email address:" %}</label>
|
||||
<div class="control">
|
||||
<input type="email" name="email" maxlength="254" class="input" id="id_email_register">
|
||||
{% if error %}
|
||||
<p class="help is-danger">{{ error }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-grouped">
|
||||
|
|
|
@ -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 %}
|
||||
|
|
88
bookwyrm/templates/settings/manage_invite_requests.html
Normal file
88
bookwyrm/templates/settings/manage_invite_requests.html
Normal 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 %}
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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 }}
|
||||
|
|
51
bookwyrm/tests/test_emailing.py
Normal file
51
bookwyrm/tests/test_emailing.py
Normal file
|
@ -0,0 +1,51 @@
|
|||
""" test creating emails """
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test.client import RequestFactory
|
||||
import responses
|
||||
|
||||
from bookwyrm import emailing, models
|
||||
|
||||
|
||||
@patch("bookwyrm.emailing.send_email.delay")
|
||||
class Emailing(TestCase):
|
||||
""" every response to a get request, html or json """
|
||||
|
||||
def setUp(self):
|
||||
""" we need basic test data and mocks """
|
||||
self.factory = RequestFactory()
|
||||
self.local_user = models.User.objects.create_user(
|
||||
"mouse@local.com",
|
||||
"mouse@mouse.mouse",
|
||||
"password",
|
||||
local=True,
|
||||
localname="mouse",
|
||||
)
|
||||
models.SiteSettings.objects.create()
|
||||
|
||||
def test_invite_email(self, email_mock):
|
||||
""" load the invite email """
|
||||
invite_request = models.InviteRequest.objects.create(
|
||||
email="test@email.com",
|
||||
invite=models.SiteInvite.objects.create(user=self.local_user),
|
||||
)
|
||||
|
||||
emailing.invite_email(invite_request)
|
||||
|
||||
self.assertEqual(email_mock.call_count, 1)
|
||||
args = email_mock.call_args[0]
|
||||
self.assertEqual(args[0], "test@email.com")
|
||||
self.assertEqual(args[1], "You're invited to join BookWyrm!")
|
||||
self.assertEqual(len(args), 4)
|
||||
|
||||
def test_password_reset_email(self, email_mock):
|
||||
""" load the password reset email """
|
||||
reset = models.PasswordReset.objects.create(user=self.local_user)
|
||||
emailing.password_reset_email(reset)
|
||||
|
||||
self.assertEqual(email_mock.call_count, 1)
|
||||
args = email_mock.call_args[0]
|
||||
self.assertEqual(args[0], "mouse@mouse.mouse")
|
||||
self.assertEqual(args[1], "Reset your BookWyrm password")
|
||||
self.assertEqual(len(args), 4)
|
|
@ -9,7 +9,6 @@ import responses
|
|||
|
||||
from bookwyrm import models, importer
|
||||
from bookwyrm.goodreads_import import GoodreadsImporter
|
||||
from bookwyrm import importer
|
||||
from bookwyrm.settings import DOMAIN
|
||||
|
||||
|
||||
|
@ -17,8 +16,8 @@ class GoodreadsImport(TestCase):
|
|||
""" importing from goodreads csv """
|
||||
|
||||
def setUp(self):
|
||||
self.importer = GoodreadsImporter()
|
||||
""" use a test csv """
|
||||
self.importer = GoodreadsImporter()
|
||||
datafile = pathlib.Path(__file__).parent.joinpath("data/goodreads.csv")
|
||||
self.csv = open(datafile, "r", encoding=self.importer.encoding)
|
||||
self.user = models.User.objects.create_user(
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -42,7 +42,8 @@ class PasswordViews(TestCase):
|
|||
request = self.factory.post("", {"email": "aa@bb.ccc"})
|
||||
view = views.PasswordResetRequest.as_view()
|
||||
resp = view(request)
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
resp.render()
|
||||
|
||||
request = self.factory.post("", {"email": "mouse@mouse.com"})
|
||||
with patch("bookwyrm.emailing.send_email.delay"):
|
||||
|
|
|
@ -48,12 +48,30 @@ urlpatterns = [
|
|||
),
|
||||
# admin
|
||||
re_path(r"^settings/site-settings", views.Site.as_view(), name="settings-site"),
|
||||
re_path(
|
||||
r"^settings/email-preview",
|
||||
views.site.email_preview,
|
||||
name="settings-email-preview",
|
||||
),
|
||||
re_path(
|
||||
r"^settings/federation", views.Federation.as_view(), name="settings-federation"
|
||||
),
|
||||
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"),
|
||||
|
@ -74,8 +92,8 @@ urlpatterns = [
|
|||
),
|
||||
re_path(r"^report/?$", views.make_report, name="report"),
|
||||
# landing pages
|
||||
re_path(r"^about/?$", views.About.as_view()),
|
||||
path("", views.Home.as_view()),
|
||||
re_path(r"^about/?$", views.About.as_view(), name="about"),
|
||||
path("", views.Home.as_view(), name="landing"),
|
||||
re_path(r"^discover/?$", views.Discover.as_view()),
|
||||
re_path(r"^notifications/?$", views.Notifications.as_view()),
|
||||
# feeds
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -5,6 +5,7 @@ from django.core.exceptions import PermissionDenied
|
|||
from django.shortcuts import redirect
|
||||
from django.template.response import TemplateResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views import View
|
||||
|
||||
from bookwyrm import models
|
||||
|
@ -28,7 +29,8 @@ class PasswordResetRequest(View):
|
|||
try:
|
||||
user = models.User.objects.get(email=email)
|
||||
except models.User.DoesNotExist:
|
||||
return redirect("/password-reset")
|
||||
data = {"error": _("No user with that email address was found.")}
|
||||
return TemplateResponse(request, "password_reset_request.html", data)
|
||||
|
||||
# remove any existing password reset cods for this user
|
||||
models.PasswordReset.objects.filter(user=user).all().delete()
|
||||
|
@ -36,7 +38,7 @@ class PasswordResetRequest(View):
|
|||
# create a new reset code
|
||||
code = models.PasswordReset.objects.create(user=user)
|
||||
password_reset_email(code)
|
||||
data = {"message": "Password reset link sent to %s" % email}
|
||||
data = {"message": _("A password reset link sent to %s" % email)}
|
||||
return TemplateResponse(request, "password_reset_request.html", data)
|
||||
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ from django.template.response import TemplateResponse
|
|||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
|
||||
from bookwyrm import forms, models
|
||||
from bookwyrm import emailing, forms, models
|
||||
|
||||
|
||||
# pylint: disable= no-self-use
|
||||
|
@ -33,3 +33,17 @@ class Site(View):
|
|||
form.save()
|
||||
|
||||
return redirect("settings-site")
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("bookwyrm.edit_instance_settings", raise_exception=True)
|
||||
def email_preview(request):
|
||||
""" for development, renders and example email template """
|
||||
template = request.GET.get("email")
|
||||
data = emailing.email_data()
|
||||
data["subject_path"] = "email/{}/subject.html".format(template)
|
||||
data["html_content_path"] = "email/{}/html_content.html".format(template)
|
||||
data["text_content_path"] = "email/{}/text_content.html".format(template)
|
||||
data["reset_link"] = "https://example.com/link"
|
||||
data["invite_link"] = "https://example.com/link"
|
||||
return TemplateResponse(request, "email/preview.html", data)
|
||||
|
|
Loading…
Reference in a new issue