Merge pull request #1170 from bookwyrm-social/delete-user

Let users delete their accounts
This commit is contained in:
Mouse Reeve 2021-06-14 12:00:32 -07:00 committed by GitHub
commit f4776f3827
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 322 additions and 156 deletions

View file

@ -150,6 +150,12 @@ class LimitedEditUserForm(CustomForm):
help_texts = {f: None for f in fields} help_texts = {f: None for f in fields}
class DeleteUserForm(CustomForm):
class Meta:
model = models.User
fields = ["password"]
class UserGroupForm(CustomForm): class UserGroupForm(CustomForm):
class Meta: class Meta:
model = models.User model = models.User

View file

@ -1,4 +1,4 @@
{% extends 'preferences/preferences_layout.html' %} {% extends 'preferences/layout.html' %}
{% load i18n %} {% load i18n %}
{% block title %}{% trans "Blocked Users" %}{{ author.name }}{% endblock %} {% block title %}{% trans "Blocked Users" %}{{ author.name }}{% endblock %}

View file

@ -1,4 +1,4 @@
{% extends 'preferences/preferences_layout.html' %} {% extends 'preferences/layout.html' %}
{% load i18n %} {% load i18n %}
{% block title %}{% trans "Change Password" %}{% endblock %} {% block title %}{% trans "Change Password" %}{% endblock %}

View file

@ -0,0 +1,30 @@
{% extends 'preferences/layout.html' %}
{% load i18n %}
{% block title %}{% trans "Delete Account" %}{% endblock %}
{% block header %}
{% trans "Delete Account" %}
{% endblock %}
{% block panel %}
<div class="block">
<h2 class="title is-4">{% trans "Permanently delete account" %}</h2>
<p class="notification is-danger is-light">
{% trans "Deleting your account cannot be undone. The username will not be available to register in the future." %}
</p>
<form name="delete-user" action="{% url 'prefs-delete' %}" method="post">
{% csrf_token %}
<div class="field">
<label class="label" for="id_password">{% trans "Confirm password:" %}</label>
<input class="input {% if form.password.errors %}is-danger{% endif %}" type="password" name="password" id="id_password" required>
{% for error in form.password.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<button type="submit" class="button is-danger">{% trans "Delete Account" %}</button>
</form>
</div>
{% endblock %}

View file

@ -1,4 +1,4 @@
{% extends 'preferences/preferences_layout.html' %} {% extends 'preferences/layout.html' %}
{% load i18n %} {% load i18n %}
{% block title %}{% trans "Edit Profile" %}{% endblock %} {% block title %}{% trans "Edit Profile" %}{% endblock %}

View file

@ -18,6 +18,10 @@
{% url 'prefs-password' as url %} {% url 'prefs-password' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Change Password" %}</a> <a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Change Password" %}</a>
</li> </li>
<li>
{% url 'prefs-delete' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Delete Account" %}</a>
</li>
</ul> </ul>
<h2 class="menu-label">{% trans "Relationships" %}</h2> <h2 class="menu-label">{% trans "Relationships" %}</h2>
<ul class="menu-list"> <ul class="menu-list">

View file

@ -1,6 +1,12 @@
{% load i18n %} {% load i18n %}
<div class="block content"> <div class="block content">
{% if not user.is_active and user.deactivation_reason == "self_deletion" %}
<div class="notification is-danger">
{% trans "Permanently deleted" %}
</div>
{% else %}
<h3>{% trans "Actions" %}</h3> <h3>{% trans "Actions" %}</h3>
<div class="is-flex"> <div class="is-flex">
<p class="mr-1"> <p class="mr-1">
<a class="button" href="{% url 'direct-messages-user' user.username %}">{% trans "Send direct message" %}</a> <a class="button" href="{% url 'direct-messages-user' user.username %}">{% trans "Send direct message" %}</a>
@ -14,6 +20,7 @@
{% endif %} {% endif %}
</form> </form>
</div> </div>
{% if user.local %} {% if user.local %}
<div> <div>
<form name="permission" method="post" action="{% url 'settings-user' user.id %}"> <form name="permission" method="post" action="{% url 'settings-user' user.id %}">
@ -39,4 +46,6 @@
</form> </form>
</div> </div>
{% endif %} {% endif %}
{% endif %}
</div> </div>

View file

@ -0,0 +1,150 @@
""" test for app action functionality """
import json
import pathlib
from unittest.mock import patch
from PIL import Image
from django.contrib.auth.models import AnonymousUser
from django.contrib.sessions.middleware import SessionMiddleware
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import SimpleUploadedFile
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import forms, models, views
class EditUserViews(TestCase):
"""view user and edit profile"""
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",
)
self.rat = models.User.objects.create_user(
"rat@local.com", "rat@rat.rat", "password", local=True, localname="rat"
)
self.book = models.Edition.objects.create(title="test")
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
models.ShelfBook.objects.create(
book=self.book,
user=self.local_user,
shelf=self.local_user.shelf_set.first(),
)
models.SiteSettings.objects.create()
self.anonymous_user = AnonymousUser
self.anonymous_user.is_authenticated = False
def test_edit_user_page(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.EditUser.as_view()
request = self.factory.get("")
request.user = self.local_user
result = view(request)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)
def test_edit_user(self):
"""use a form to update a user"""
view = views.EditUser.as_view()
form = forms.EditUserForm(instance=self.local_user)
form.data["name"] = "New Name"
form.data["email"] = "wow@email.com"
form.data["preferred_timezone"] = "UTC"
request = self.factory.post("", form.data)
request.user = self.local_user
self.assertIsNone(self.local_user.name)
with patch(
"bookwyrm.models.activitypub_mixin.broadcast_task.delay"
) as delay_mock:
view(request)
self.assertEqual(delay_mock.call_count, 1)
self.assertEqual(self.local_user.name, "New Name")
self.assertEqual(self.local_user.email, "wow@email.com")
def test_edit_user_avatar(self):
"""use a form to update a user"""
view = views.EditUser.as_view()
form = forms.EditUserForm(instance=self.local_user)
form.data["name"] = "New Name"
form.data["email"] = "wow@email.com"
form.data["preferred_timezone"] = "UTC"
image_file = pathlib.Path(__file__).parent.joinpath(
"../../static/images/no_cover.jpg"
)
form.data["avatar"] = SimpleUploadedFile(
image_file, open(image_file, "rb").read(), content_type="image/jpeg"
)
request = self.factory.post("", form.data)
request.user = self.local_user
with patch(
"bookwyrm.models.activitypub_mixin.broadcast_task.delay"
) as delay_mock:
view(request)
self.assertEqual(delay_mock.call_count, 1)
self.assertEqual(self.local_user.name, "New Name")
self.assertEqual(self.local_user.email, "wow@email.com")
self.assertIsNotNone(self.local_user.avatar)
self.assertEqual(self.local_user.avatar.width, 120)
self.assertEqual(self.local_user.avatar.height, 120)
def test_crop_avatar(self):
"""reduce that image size"""
image_file = pathlib.Path(__file__).parent.joinpath(
"../../static/images/no_cover.jpg"
)
image = Image.open(image_file)
result = views.edit_user.crop_avatar(image)
self.assertIsInstance(result, ContentFile)
image_result = Image.open(result)
self.assertEqual(image_result.size, (120, 120))
def test_delete_user_page(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.DeleteUser.as_view()
request = self.factory.get("")
request.user = self.local_user
result = view(request)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)
def test_delete_user(self):
"""use a form to update a user"""
view = views.DeleteUser.as_view()
form = forms.DeleteUserForm()
form.data["password"] = "password"
request = self.factory.post("", form.data)
request.user = self.local_user
middleware = SessionMiddleware()
middleware.process_request(request)
request.session.save()
self.assertIsNone(self.local_user.name)
with patch(
"bookwyrm.models.activitypub_mixin.broadcast_task.delay"
) as delay_mock:
view(request)
self.assertEqual(delay_mock.call_count, 1)
activity = json.loads(delay_mock.call_args[0][1])
self.assertEqual(activity["type"], "Delete")
self.assertEqual(activity["actor"], self.local_user.remote_id)
self.assertEqual(
activity["cc"][0], "https://www.w3.org/ns/activitystreams#Public"
)
self.local_user.refresh_from_db()
self.assertFalse(self.local_user.is_active)
self.assertEqual(self.local_user.deactivation_reason, "self_deletion")

View file

@ -1,17 +1,13 @@
""" test for app action functionality """ """ test for app action functionality """
import pathlib
from unittest.mock import patch from unittest.mock import patch
from PIL import Image
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import SimpleUploadedFile
from django.http.response import Http404 from django.http.response import Http404
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.test import TestCase from django.test import TestCase
from django.test.client import RequestFactory from django.test.client import RequestFactory
from bookwyrm import forms, models, views from bookwyrm import models, views
from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.activitypub import ActivitypubResponse
@ -137,71 +133,3 @@ class UserViews(TestCase):
is_api.return_value = False is_api.return_value = False
with self.assertRaises(Http404): with self.assertRaises(Http404):
view(request, "rat") view(request, "rat")
def test_edit_user_page(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.EditUser.as_view()
request = self.factory.get("")
request.user = self.local_user
result = view(request)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)
def test_edit_user(self):
"""use a form to update a user"""
view = views.EditUser.as_view()
form = forms.EditUserForm(instance=self.local_user)
form.data["name"] = "New Name"
form.data["email"] = "wow@email.com"
form.data["preferred_timezone"] = "UTC"
request = self.factory.post("", form.data)
request.user = self.local_user
self.assertIsNone(self.local_user.name)
with patch(
"bookwyrm.models.activitypub_mixin.broadcast_task.delay"
) as delay_mock:
view(request)
self.assertEqual(delay_mock.call_count, 1)
self.assertEqual(self.local_user.name, "New Name")
self.assertEqual(self.local_user.email, "wow@email.com")
def test_edit_user_avatar(self):
"""use a form to update a user"""
view = views.EditUser.as_view()
form = forms.EditUserForm(instance=self.local_user)
form.data["name"] = "New Name"
form.data["email"] = "wow@email.com"
form.data["preferred_timezone"] = "UTC"
image_file = pathlib.Path(__file__).parent.joinpath(
"../../static/images/no_cover.jpg"
)
form.data["avatar"] = SimpleUploadedFile(
image_file, open(image_file, "rb").read(), content_type="image/jpeg"
)
request = self.factory.post("", form.data)
request.user = self.local_user
with patch(
"bookwyrm.models.activitypub_mixin.broadcast_task.delay"
) as delay_mock:
view(request)
self.assertEqual(delay_mock.call_count, 1)
self.assertEqual(self.local_user.name, "New Name")
self.assertEqual(self.local_user.email, "wow@email.com")
self.assertIsNotNone(self.local_user.avatar)
self.assertEqual(self.local_user.avatar.width, 120)
self.assertEqual(self.local_user.avatar.height, 120)
def test_crop_avatar(self):
"""reduce that image size"""
image_file = pathlib.Path(__file__).parent.joinpath(
"../../static/images/no_cover.jpg"
)
image = Image.open(image_file)
result = views.user.crop_avatar(image)
self.assertIsInstance(result, ContentFile)
image_result = Image.open(result)
self.assertEqual(image_result.size, (120, 120))

View file

@ -253,6 +253,7 @@ urlpatterns = [
views.ChangePassword.as_view(), views.ChangePassword.as_view(),
name="prefs-password", name="prefs-password",
), ),
re_path(r"^preferences/delete/?$", views.DeleteUser.as_view(), name="prefs-delete"),
re_path(r"^preferences/block/?$", views.Block.as_view(), name="prefs-block"), re_path(r"^preferences/block/?$", views.Block.as_view(), name="prefs-block"),
re_path(r"^block/(?P<user_id>\d+)/?$", views.Block.as_view()), re_path(r"^block/(?P<user_id>\d+)/?$", views.Block.as_view()),
re_path(r"^unblock/(?P<user_id>\d+)/?$", views.unblock), re_path(r"^unblock/(?P<user_id>\d+)/?$", views.unblock),

View file

@ -6,6 +6,7 @@ from .block import Block, unblock
from .books import Book, EditBook, ConfirmEditBook, Editions from .books import Book, EditBook, ConfirmEditBook, Editions
from .books import upload_cover, add_description, switch_edition, resolve_book from .books import upload_cover, add_description, switch_edition, resolve_book
from .directory import Directory from .directory import Directory
from .edit_user import EditUser, DeleteUser
from .federation import Federation, FederatedServer from .federation import Federation, FederatedServer
from .federation import AddFederatedServer, ImportServerBlocklist from .federation import AddFederatedServer, ImportServerBlocklist
from .federation import block_server, unblock_server from .federation import block_server, unblock_server
@ -37,6 +38,6 @@ from .shelf import shelve, unshelve
from .site import Site from .site import Site
from .status import CreateStatus, DeleteStatus, DeleteAndRedraft from .status import CreateStatus, DeleteStatus, DeleteAndRedraft
from .updates import get_notification_count, get_unread_status_count from .updates import get_notification_count, get_unread_status_count
from .user import User, EditUser, Followers, Following from .user import User, Followers, Following
from .user_admin import UserAdmin, UserAdminList from .user_admin import UserAdmin, UserAdminList
from .wellknown import * from .wellknown import *

113
bookwyrm/views/edit_user.py Normal file
View file

@ -0,0 +1,113 @@
""" edit or delete ones own account"""
from io import BytesIO
from uuid import uuid4
from PIL import Image
from django.contrib.auth import logout
from django.contrib.auth.decorators import login_required
from django.core.files.base import ContentFile
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views import View
from bookwyrm import forms, models
# pylint: disable=no-self-use
@method_decorator(login_required, name="dispatch")
class EditUser(View):
"""edit user view"""
def get(self, request):
"""edit profile page for a user"""
data = {
"form": forms.EditUserForm(instance=request.user),
"user": request.user,
}
return TemplateResponse(request, "preferences/edit_user.html", data)
def post(self, request):
"""les get fancy with images"""
form = forms.EditUserForm(request.POST, request.FILES, instance=request.user)
if not form.is_valid():
data = {"form": form, "user": request.user}
return TemplateResponse(request, "preferences/edit_user.html", data)
user = save_user_form(form)
return redirect(user.local_path)
# pylint: disable=no-self-use
@method_decorator(login_required, name="dispatch")
class DeleteUser(View):
"""delete user view"""
def get(self, request):
"""delete page for a user"""
data = {
"form": forms.DeleteUserForm(),
"user": request.user,
}
return TemplateResponse(request, "preferences/delete_user.html", data)
def post(self, request):
"""les get fancy with images"""
form = forms.DeleteUserForm(request.POST, instance=request.user)
form.is_valid()
# idk why but I couldn't get check_password to work on request.user
user = models.User.objects.get(id=request.user.id)
if form.is_valid() and user.check_password(form.cleaned_data["password"]):
user.deactivation_reason = "self_deletion"
user.delete()
logout(request)
return redirect("/")
form.errors["password"] = ["Invalid password"]
data = {"form": form, "user": request.user}
return TemplateResponse(request, "preferences/delete_user.html", data)
def save_user_form(form):
"""special handling for the user form"""
user = form.save(commit=False)
if "avatar" in form.files:
# crop and resize avatar upload
image = Image.open(form.files["avatar"])
image = crop_avatar(image)
# set the name to a hash
extension = form.files["avatar"].name.split(".")[-1]
filename = "%s.%s" % (uuid4(), extension)
user.avatar.save(filename, image, save=False)
user.save()
return user
def crop_avatar(image):
"""reduce the size and make an avatar square"""
target_size = 120
width, height = image.size
thumbnail_scale = (
height / (width / target_size)
if height > width
else width / (height / target_size)
)
image.thumbnail([thumbnail_scale, thumbnail_scale])
width, height = image.size
width_diff = width - target_size
height_diff = height - target_size
cropped = image.crop(
(
int(width_diff / 2),
int(height_diff / 2),
int(width - (width_diff / 2)),
int(height - (height_diff / 2)),
)
)
output = BytesIO()
cropped.save(output, format=image.format)
return ContentFile(output.getvalue())

View file

@ -14,7 +14,7 @@ from django.views import View
from bookwyrm import forms, models from bookwyrm import forms, models
from bookwyrm.connectors import connector_manager from bookwyrm.connectors import connector_manager
from .helpers import get_suggested_users from .helpers import get_suggested_users
from .user import save_user_form from .edit_user import save_user_form
# pylint: disable= no-self-use # pylint: disable= no-self-use

View file

@ -1,25 +1,17 @@
""" non-interactive pages """ """ non-interactive pages """
from io import BytesIO
from uuid import uuid4
from PIL import Image
from django.contrib.auth.decorators import login_required
from django.core.files.base import ContentFile
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.shortcuts import redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View from django.views import View
from bookwyrm import forms, models from bookwyrm import models
from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import PAGE_LENGTH from bookwyrm.settings import PAGE_LENGTH
from .helpers import get_user_from_username, is_api_request from .helpers import get_user_from_username, is_api_request
from .helpers import privacy_filter from .helpers import privacy_filter
# pylint: disable= no-self-use # pylint: disable=no-self-use
class User(View): class User(View):
"""user profile page""" """user profile page"""
@ -122,71 +114,3 @@ class Following(View):
"follow_list": paginated.get_page(request.GET.get("page")), "follow_list": paginated.get_page(request.GET.get("page")),
} }
return TemplateResponse(request, "user/relationships/following.html", data) return TemplateResponse(request, "user/relationships/following.html", data)
@method_decorator(login_required, name="dispatch")
class EditUser(View):
"""edit user view"""
def get(self, request):
"""edit profile page for a user"""
data = {
"form": forms.EditUserForm(instance=request.user),
"user": request.user,
}
return TemplateResponse(request, "preferences/edit_user.html", data)
def post(self, request):
"""les get fancy with images"""
form = forms.EditUserForm(request.POST, request.FILES, instance=request.user)
if not form.is_valid():
data = {"form": form, "user": request.user}
return TemplateResponse(request, "preferences/edit_user.html", data)
user = save_user_form(form)
return redirect(user.local_path)
def save_user_form(form):
"""special handling for the user form"""
user = form.save(commit=False)
if "avatar" in form.files:
# crop and resize avatar upload
image = Image.open(form.files["avatar"])
image = crop_avatar(image)
# set the name to a hash
extension = form.files["avatar"].name.split(".")[-1]
filename = "%s.%s" % (uuid4(), extension)
user.avatar.save(filename, image, save=False)
user.save()
return user
def crop_avatar(image):
"""reduce the size and make an avatar square"""
target_size = 120
width, height = image.size
thumbnail_scale = (
height / (width / target_size)
if height > width
else width / (height / target_size)
)
image.thumbnail([thumbnail_scale, thumbnail_scale])
width, height = image.size
width_diff = width - target_size
height_diff = height - target_size
cropped = image.crop(
(
int(width_diff / 2),
int(height_diff / 2),
int(width - (width_diff / 2)),
int(height - (height_diff / 2)),
)
)
output = BytesIO()
cropped.save(output, format=image.format)
return ContentFile(output.getvalue())