Merge branch 'main' into production

This commit is contained in:
Mouse Reeve 2021-03-21 12:54:00 -07:00
commit bb8cac021b
34 changed files with 661 additions and 48 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,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()

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

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

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

View file

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

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

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

View file

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

View file

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

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

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

View file

@ -0,0 +1,3 @@
{% load i18n %}
{% if user %}{{ user.display_name }},{% else %}{% trans "Hi there," %}{% endif %}
{% block content %}{% endblock %}

View file

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

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

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

View file

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

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

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

View file

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

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)

View file

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

View file

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