Merge pull request #864 from mouse-reeve/delete-and-redraft

Delete and redraft
This commit is contained in:
Mouse Reeve 2021-04-04 11:00:09 -07:00 committed by GitHub
commit ea837a3879
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 253 additions and 47 deletions

View file

@ -1,18 +1,10 @@
""" Handle user activity """
from django.db import transaction
from django.utils import timezone
from bookwyrm import models
from bookwyrm.sanitize_html import InputHtmlParser
def delete_status(status):
""" replace the status with a tombstone """
status.deleted = True
status.deleted_date = timezone.now()
status.save()
def create_generated_note(user, content, mention_books=None, privacy="public"):
""" a note created by the app about user activity """
# sanitize input html

View file

@ -0,0 +1,34 @@
{% extends 'layout.html' %}
{% load i18n %}
{% load bookwyrm_tags %}
{% block title %}{% trans "Compose status" %}{% endblock %}
{% block content %}
<header class="block content">
<h1>{% trans "Compose status" %}</h1>
</header>
{% with 0|uuid as uuid %}
<div class="box columns">
{% if book %}
<div class="column is-one-third">
<div class="block">
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book %}</a>
</div>
<h3 class="title is-6">{% include 'snippets/book_titleby.html' with book=book %}</h3>
</div>
{% endif %}
<div class="column is-two-thirds">
{% if draft.reply_parent %}
{% include 'snippets/status/status.html' with status=draft.reply_parent no_interact=True %}
{% endif %}
{% if not draft %}
{% include 'snippets/create_status.html' %}
{% else %}
{% include 'snippets/create_status_form.html' %}
{% endif %}
</div>
</div>
{% endwith %}
{% endblock %}

View file

@ -12,6 +12,7 @@
{% if not suggested_books %}
<p>{% trans "There are no books here right now! Try searching for a book to get started" %}</p>
{% else %}
{% with active_book=request.GET.book %}
<div class="tab-group">
<div class="tabs is-small">
<ul role="tablist">
@ -28,8 +29,14 @@
<div class="tabs is-small is-toggle">
<ul>
{% for book in shelf.books %}
<li{% if shelf_counter == 1 and forloop.first %} class="is-active"{% endif %}>
<a href="#book-{{ book.id }}" id="tab-book-{{ book.id }}" role="tab" aria-label="{{ book.title }}" aria-selected="{% if shelf_counter == 1 and forloop.first %}true{% else %}false{% endif %}" aria-controls="book-{{ book.id }}">
<li class="{% if active_book == book.id|stringformat:'d' %}is-active{% elif not active_book and shelf_counter == 1 and forloop.first %}is-active{% endif %}">
<a
href="{{ request.path }}?book={{ book.id }}"
id="tab-book-{{ book.id }}"
role="tab"
aria-label="{{ book.title }}"
aria-selected="{% if active_book == book.id|stringformat:'d' %}true{% elif shelf_counter == 1 and forloop.first %}true{% else %}false{% endif %}"
aria-controls="book-{{ book.id }}">
{% include 'snippets/book_cover.html' with book=book size="medium" %}
</a>
</li>
@ -45,7 +52,13 @@
{% for shelf in suggested_books %}
{% with shelf_counter=forloop.counter %}
{% for book in shelf.books %}
<div class="suggested-tabs card" role="tabpanel" id="book-{{ book.id }}"{% if shelf_counter != 1 or not forloop.first %} hidden{% endif %} aria-labelledby="tab-book-{{ book.id }}">
<div
class="suggested-tabs card"
role="tabpanel"
id="book-{{ book.id }}"
{% if active_book and active_book == book.id|stringformat:'d' %}{% elif not active_book and shelf_counter == 1 and forloop.first %}{% else %} hidden{% endif %}
aria-labelledby="tab-book-{{ book.id }}">
<div class="card-header">
<div class="card-header-title">
<div>
@ -66,6 +79,7 @@
{% endwith %}
{% endfor %}
</div>
{% endwith %}
{% endif %}
{% if goal %}

View file

@ -1,5 +1,12 @@
{% load i18n %}
<div class="control{% if not parent_status.content_warning %} hidden{% endif %}" id="spoilers-{{ uuid }}">
<div class="control{% if not parent_status.content_warning and not draft.content_warning %} hidden{% endif %}" id="spoilers-{{ uuid }}">
<label class="is-sr-only" for="id_content_warning-{{ uuid }}">{% trans "Spoiler alert:" %}</label>
<input type="text" name="content_warning" maxlength="255" class="input" id="id_content_warning-{{ uuid }}" placeholder="{% trans 'Spoilers ahead!' %}"{% if parent_status.content_warning %} value="{{ parent_status.content_warning }}"{% endif %}>
<input
type="text"
name="content_warning"
maxlength="255"
class="input"
id="id_content_warning-{{ uuid }}"
placeholder="{% trans 'Spoilers ahead!' %}"
value="{% firstof draft.content_warning parent_status.content_warning '' %}">
</div>

View file

@ -2,36 +2,62 @@
{% load i18n %}
{% load bookwyrm_tags %}
{% with status_type=request.GET.status_type %}
<div class="tab-group">
<div class="tabs is-boxed" role="tablist">
<ul>
<li class="is-active">
<a href="#review-{{ book.id }}" id="tab-review-{{ book.id }}" role="tab" aria-selected="true" aria-controls="review-{{ book.id }}" data-category="tab-option-{{ book.id }}">{% trans "Review" %}</a>
<li class="{% if status_type == 'review' or not status_type %}is-active{% endif %}">
<a
href="{{ request.path }}?status_type=review&book={{ book.id }}"
id="tab-review-{{ book.id }}"
role="tab"
aria-selected="{% if status_type == 'review' or not status_type %}true{% else %}false{% endif %}"
aria-controls="review-{{ book.id }}"
data-category="tab-option-{{ book.id }}">
{% trans "Review" %}
</a>
</li>
<li>
<a href="#comment-{{ book.id}}" id="tab-comment-{{ book.id }}" role="tab" aria-selected="false" aria-controls="comment-{{ book.id}}" data-category="tab-option-{{ book.id }}">{% trans "Comment" %}</a>
<li class="{% if status_type == 'comment' %}is-active{% endif %}">
<a
href="{{ request.path }}?status_type=comment&book={{ book.id}}"
id="tab-comment-{{ book.id }}"
role="tab"
aria-selected="{% if status_type == 'comment' %}true{% else %}false{% endif %}"
aria-controls="comment-{{ book.id}}"
data-category="tab-option-{{ book.id }}">
{% trans "Comment" %}
</a>
</li>
<li>
<a href="#quote-{{ book.id }}" id="tab-quote-{{ book.id }}" role="tab" aria-selected="false" aria-controls="quote-{{ book.id }}" data-category="tab-option-{{ book.id }}">{% trans "Quote" %}</a>
<li class="{% if status_type == 'quote' %}is-active{% endif %}">
<a
href="{{ request.path }}?status_type=quote&book={{ book.id }}"
id="tab-quote-{{ book.id }}"
role="tab"
aria-selected="{% if status_type == 'quote' %}true{% else %}false{% endif %}"
aria-controls="quote-{{ book.id }}"
data-category="tab-option-{{ book.id }}">
{% trans "Quote" %}
</a>
</li>
</ul>
</div>
<div class="tab-option-{{ book.id }}" id="review-{{ book.id }}" role="tabpanel" aria-labelledby="tab-review-{{ book.id }}">
<div class="tab-option-{{ book.id }}" id="review-{{ book.id }}" role="tabpanel" aria-labelledby="tab-review-{{ book.id }}" {% if status_type and status_type != "review" %}hidden{% endif %}>
{% with 0|uuid as uuid %}
{% include 'snippets/create_status_form.html' with type='review' %}
{% endwith %}
</div>
<div class="tab-option-{{ book.id }}" id="comment-{{ book.id }}" role="tabpanel" aria-labelledby="tab-comment-{{ book.id }}" hidden>
<div class="tab-option-{{ book.id }}" id="comment-{{ book.id }}" role="tabpanel" aria-labelledby="tab-comment-{{ book.id }}" {% if status_type != "comment" %}hidden{% endif %}>
{% with 0|uuid as uuid %}
{% include 'snippets/create_status_form.html' with type="comment" placeholder="Some thoughts on '"|add:book.title|add:"'" %}
{% endwith %}
</div>
<div class="tab-option-{{ book.id }}" id="quote-{{ book.id }}" role="tabpanel" aria-labelledby="tab-quote-{{ book.id }}" hidden>
<div class="tab-option-{{ book.id }}" id="quote-{{ book.id }}" role="tabpanel" aria-labelledby="tab-quote-{{ book.id }}" {% if status_type != "quote" %}hidden{% endif %}>
{% with 0|uuid as uuid %}
{% include 'snippets/create_status_form.html' with type="quotation" placeholder="An excerpt from '"|add:book.title|add:"'" %}
{% endwith %}
</div>
</div>
{% endwith %}

View file

@ -4,11 +4,11 @@
{% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="user" value="{{ request.user.id }}">
<input type="hidden" name="reply_parent" value="{{ reply_parent.id }}">
<input type="hidden" name="reply_parent" value="{% firstof draft.reply_parent.id reply_parent.id %}">
{% if type == 'review' %}
<div class="control">
<label class="label" for="id_name_{{ book.id }}_{{ type }}">{% trans "Title" %}:</label>
<input type="text" name="name" maxlength="255" class="input" required="" id="id_name_{{ book.id }}_{{ type }}" placeholder="My {{ type }} of '{{ book.title }}'">
<input type="text" name="name" maxlength="255" class="input" required="" id="id_name_{{ book.id }}_{{ type }}" placeholder="My {{ type }} of '{{ book.title }}'" value="{% firstof draft.name ''%}">
</div>
{% endif %}
<div class="control">
@ -28,22 +28,22 @@
<fieldset>
<legend class="is-sr-only">{% trans "Rating" %}</legend>
{% include 'snippets/form_rate_stars.html' with book=book type=type|default:'summary' %}
{% include 'snippets/form_rate_stars.html' with book=book type=type|default:'summary' default_rating=draft.rating %}
</fieldset>
{% endif %}
{% if type == 'quotation' %}
<textarea name="quote" class="textarea" id="id_quote_{{ book.id }}_{{ type }}" placeholder="{{ placeholder }}" required></textarea>
<textarea name="quote" class="textarea" id="id_quote_{{ book.id }}_{{ type }}" placeholder="{{ placeholder }}" required>{{ draft.quote|default:'' }}</textarea>
{% else %}
{% include 'snippets/content_warning_field.html' with parent_status=status %}
<textarea name="content" class="textarea" id="id_content_{{ type }}-{{ book.id }}{{reply_parent.id}}" placeholder="{{ placeholder }}" {% if type == 'reply' %} aria-label="Reply"{% endif %} required>{% if reply_parent %}{{ reply_parent|mentions:request.user }}{% endif %}{% if mentions %}@{{ mentions|username }} {% endif %}</textarea>
<textarea name="content" class="textarea" id="id_content_{{ type }}-{{ book.id }}{{reply_parent.id}}" placeholder="{{ placeholder }}" {% if type == 'reply' %} aria-label="Reply"{% endif %} required>{% if reply_parent %}{{ reply_parent|mentions:request.user }}{% endif %}{% if mentions %}@{{ mentions|username }} {% endif %}{{ draft.content|default:'' }}</textarea>
{% endif %}
</div>
{% if type == 'quotation' %}
<div class="control">
<label class="label" for="id_content_quote-{{ book.id }}">{% trans "Comment" %}:</label>
{% include 'snippets/content_warning_field.html' with parent_status=status %}
<textarea name="content" class="textarea is-small" id="id_content_quote-{{ book.id }}"></textarea>
<textarea name="content" class="textarea is-small" id="id_content_quote-{{ book.id }}">{{ draft.content|default:'' }}</textarea>
</div>
{% elif type == 'comment' %}
<div class="control">
@ -56,12 +56,12 @@
<label class="label" for="progress-{{ uuid }}">{% trans "Progress:" %}</label>
<div class="field has-addons mb-0">
<div class="control">
<input aria-label="{% if readthrough.progress_mode == 'PG' %}Current page{% else %}Percent read{% endif %}" class="input" type="number" min="0" name="progress" size="3" value="{{ readthrough.progress|default:'' }}" id="progress-{{ uuid }}">
<input aria-label="{% if draft.progress_mode == 'PG' or readthrough.progress_mode == 'PG' %}Current page{% else %}Percent read{% endif %}" class="input" type="number" min="0" name="progress" size="3" value="{% firstof draft.progress readthrough.progress '' %}" id="progress-{{ uuid }}">
</div>
<div class="control select">
<select name="progress_mode" aria-label="Progress mode">
<option value="PG" {% if readthrough.progress_mode == 'PG' %}selected{% endif %}>{% trans "pages" %}</option>
<option value="PCT" {% if readthrough.progress_mode == 'PCT' %}selected{% endif %}>{% trans "percent" %}</option>
<option value="PG" {% if draft.progress_mode == 'PG' or readthrough.progress_mode == 'PG' %}selected{% endif %}>{% trans "pages" %}</option>
<option value="PCT" {% if draft.progress_mode == 'PCT' or readthrough.progress_mode == 'PCT' %}selected{% endif %}>{% trans "percent" %}</option>
</select>
</div>
</div>
@ -73,21 +73,26 @@
{% endif %}
</div>
{% endif %}
<input type="checkbox" class="hidden" name="sensitive" id="id_show_spoilers-{{ uuid }}" {% if status.content_warning %}checked{% endif %} aria-hidden="true">
<input type="checkbox" class="hidden" name="sensitive" id="id_show_spoilers-{{ uuid }}" {% if draft.content_warning or status.content_warning %}checked{% endif %} aria-hidden="true">
{# bottom bar #}
<div class="columns pt-1">
<div class="field has-addons column">
<div class="control">
{% trans "Include spoiler alert" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text icon="warning is-size-4" controls_text="spoilers" controls_uid=uuid focus="id_content_warning" checkbox="id_show_spoilers" class="toggle-button" pressed=status.content_warning %}
{% firstof draft.content_warning status.content_warning as pressed %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text icon="warning is-size-4" controls_text="spoilers" controls_uid=uuid focus="id_content_warning" checkbox="id_show_spoilers" class="toggle-button" pressed=pressed %}
</div>
<div class="control">
{% if type == 'direct' %}
<input type="hidden" name="privacy" value="direct">
<button type="button" class="button" aria-label="Privacy" disabled>{% trans "Private" %}</button>
{% else %}
{% if draft %}
{% include 'snippets/privacy_select.html' with current=draft.privacy %}
{% else %}
{% include 'snippets/privacy_select.html' with current=reply_parent.privacy %}
{% endif %}
{% endif %}
</div>
</div>
<div class="column is-narrow">

View file

@ -13,7 +13,7 @@
type="radio"
name="rating"
value="0"
{% if book|user_rating:user == 0 %}checked{% endif %}
{% if default_rating == 0 or not default_rating %}checked{% endif %}
>
<label class="is-sr-only" for="{{ type|slugify }}-{{ book.id }}-no-rating">
@ -27,13 +27,13 @@
type="radio"
name="rating"
value="{{ forloop.counter }}"
{% if book|user_rating:user == forloop.counter %}checked{% endif %}
{% if default_rating == forloop.counter %}checked{% endif %}
/>
<label
class="
icon
{% if forloop.counter <= book|user_rating:user %}
{% if forloop.counter <= default_rating %}
icon-star-full
{% else %}
icon-star-empty

View file

@ -9,7 +9,7 @@
<input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="privacy" value="public">
{% include 'snippets/form_rate_stars.html' with book=book classes='mb-1 has-text-warning-dark' %}
{% include 'snippets/form_rate_stars.html' with book=book classes='mb-1 has-text-warning-dark' default_rating=book|user_rating:request.user %}
<div class="field has-addons hidden">
<div class="control">

View file

@ -27,7 +27,8 @@
{% trans "Delete status" %}
</button>
</form>
{% elif no_interact %}
{# nothing here #}
{% elif request.user.is_authenticated %}
<div class="field has-addons">
<div class="control">

View file

@ -19,6 +19,16 @@
</button>
</form>
</li>
{% if status.status_type != 'GeneratedNote' and status.status_type != 'Rating' %}
<li role="menuitem">
<form class="dropdown-item pt-0 pb-0" name="delete-{{status.id}}" action="{% url 'redraft' status.id %}" method="post">
{% csrf_token %}
<button class="button is-danger is-light is-fullwidth is-small" type="submit">
{% trans "Delete & re-draft" %}
</button>
</form>
</li>
{% endif %}
{% else %}
{# things you can do to other people's statuses #}
<li role="menuitem">

View file

@ -40,6 +40,7 @@ class StatusViews(TestCase):
remote_id="https://example.com/book/1",
parent_work=work,
)
models.SiteSettings.objects.create()
def test_handle_status(self, _):
""" create a status """
@ -166,6 +167,61 @@ class StatusViews(TestCase):
self.assertFalse(self.remote_user in reply.mention_users.all())
self.assertTrue(self.local_user in reply.mention_users.all())
def test_delete_and_redraft(self, _):
""" delete and re-draft a status """
view = views.DeleteAndRedraft.as_view()
request = self.factory.post("")
request.user = self.local_user
with patch("bookwyrm.activitystreams.ActivityStream.add_status"):
status = models.Comment.objects.create(
content="hi", book=self.book, user=self.local_user
)
with patch("bookwyrm.activitystreams.ActivityStream.remove_status") as mock:
result = view(request, status.id)
self.assertTrue(mock.called)
result.render()
# make sure it was deleted
status.refresh_from_db()
self.assertTrue(status.deleted)
def test_delete_and_redraft_invalid_status_type_rating(self, _):
""" you can't redraft generated statuses """
view = views.DeleteAndRedraft.as_view()
request = self.factory.post("")
request.user = self.local_user
with patch("bookwyrm.activitystreams.ActivityStream.add_status"):
status = models.ReviewRating.objects.create(
book=self.book, rating=2.0, user=self.local_user
)
with patch("bookwyrm.activitystreams.ActivityStream.remove_status") as mock:
result = view(request, status.id)
self.assertFalse(mock.called)
self.assertEqual(result.status_code, 400)
status.refresh_from_db()
self.assertFalse(status.deleted)
def test_delete_and_redraft_invalid_status_type_generated_note(self, _):
""" you can't redraft generated statuses """
view = views.DeleteAndRedraft.as_view()
request = self.factory.post("")
request.user = self.local_user
with patch("bookwyrm.activitystreams.ActivityStream.add_status"):
status = models.GeneratedNote.objects.create(
content="hi", user=self.local_user
)
with patch("bookwyrm.activitystreams.ActivityStream.remove_status") as mock:
result = view(request, status.id)
self.assertFalse(mock.called)
self.assertEqual(result.status_code, 400)
status.refresh_from_db()
self.assertFalse(status.deleted)
def test_find_mentions(self, _):
""" detect and look up @ mentions of users """
user = models.User.objects.create_user(

View file

@ -197,11 +197,31 @@ urlpatterns = [
re_path(r"^block/(?P<user_id>\d+)/?$", views.Block.as_view()),
re_path(r"^unblock/(?P<user_id>\d+)/?$", views.unblock),
# statuses
re_path(r"%s(.json)?/?$" % status_path, views.Status.as_view()),
re_path(r"%s/activity/?$" % status_path, views.Status.as_view()),
re_path(r"%s/replies(.json)?/?$" % status_path, views.Replies.as_view()),
re_path(r"^post/(?P<status_type>\w+)/?$", views.CreateStatus.as_view()),
re_path(r"^delete-status/(?P<status_id>\d+)/?$", views.DeleteStatus.as_view()),
re_path(r"%s(.json)?/?$" % status_path, views.Status.as_view(), name="status"),
re_path(r"%s/activity/?$" % status_path, views.Status.as_view(), name="status"),
re_path(
r"%s/replies(.json)?/?$" % status_path, views.Replies.as_view(), name="replies"
),
re_path(
r"^post/?$",
views.CreateStatus.as_view(),
name="create-status",
),
re_path(
r"^post/(?P<status_type>\w+)/?$",
views.CreateStatus.as_view(),
name="create-status",
),
re_path(
r"^delete-status/(?P<status_id>\d+)/?$",
views.DeleteStatus.as_view(),
name="delete-status",
),
re_path(
r"^redraft-status/(?P<status_id>\d+)/?$",
views.DeleteAndRedraft.as_view(),
name="redraft",
),
# interact
re_path(r"^favorite/(?P<status_id>\d+)/?$", views.Favorite.as_view()),
re_path(r"^unfavorite/(?P<status_id>\d+)/?$", views.Unfavorite.as_view()),

View file

@ -31,7 +31,7 @@ from .shelf import Shelf
from .shelf import create_shelf, delete_shelf
from .shelf import shelve, unshelve
from .site import Site
from .status import CreateStatus, DeleteStatus
from .status import CreateStatus, DeleteStatus, DeleteAndRedraft
from .tag import Tag, AddTag, RemoveTag
from .updates import get_notification_count, get_unread_status_count
from .user import User, EditUser, Followers, Following

View file

@ -3,6 +3,7 @@ import re
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseBadRequest
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 markdown import markdown
@ -10,7 +11,6 @@ from markdown import markdown
from bookwyrm import forms, models
from bookwyrm.sanitize_html import InputHtmlParser
from bookwyrm.settings import DOMAIN
from bookwyrm.status import delete_status
from bookwyrm.utils import regex
from .helpers import handle_remote_webfinger
from .reading import edit_readthrough
@ -21,6 +21,12 @@ from .reading import edit_readthrough
class CreateStatus(View):
""" the view for *posting* """
def get(self, request):
""" compose view (used for delete-and-redraft """
book = get_object_or_404(models.Edition, id=request.GET.get("book"))
data = {"book": book}
return TemplateResponse(request, "compose.html", data)
def post(self, request, status_type):
""" create status of whatever type """
status_type = status_type[0].upper() + status_type[1:]
@ -69,9 +75,10 @@ class CreateStatus(View):
# update a readthorugh, if needed
edit_readthrough(request)
return redirect(request.headers.get("Referer", "/"))
return redirect("/")
@method_decorator(login_required, name="dispatch")
class DeleteStatus(View):
""" tombstone that bad boy """
@ -84,10 +91,44 @@ class DeleteStatus(View):
return HttpResponseBadRequest()
# perform deletion
delete_status(status)
status.delete()
return redirect(request.headers.get("Referer", "/"))
@method_decorator(login_required, name="dispatch")
class DeleteAndRedraft(View):
""" delete a status but let the user re-create it """
def post(self, request, status_id):
""" delete and tombstone a status """
status = get_object_or_404(
models.Status.objects.select_subclasses(), id=status_id
)
if isinstance(status, (models.GeneratedNote, models.ReviewRating)):
return HttpResponseBadRequest()
# don't let people redraft other people's statuses
if status.user != request.user:
return HttpResponseBadRequest()
status_type = status.status_type.lower()
if status.reply_parent:
status_type = "reply"
data = {
"draft": status,
"type": status_type,
}
if hasattr(status, "book"):
data["book"] = status.book
elif status.mention_books:
data["book"] = status.mention_books.first()
# perform deletion
status.delete()
return TemplateResponse(request, "compose.html", data)
def find_mentions(content):
""" detect @mentions in raw status content """
if not content: