Merge branch 'main' into production

This commit is contained in:
Mouse Reeve 2021-10-15 14:31:13 -07:00
commit 382d98a2e0
26 changed files with 384 additions and 168 deletions

View file

@ -35,6 +35,7 @@ class Note(ActivityObject):
tag: List[Link] = field(default_factory=lambda: [])
attachment: List[Document] = field(default_factory=lambda: [])
sensitive: bool = False
updated: str = None
type: str = "Note"

View file

@ -69,8 +69,9 @@ class Update(Verb):
def action(self):
"""update a model instance from the dataclass"""
if self.object:
self.object.to_model(allow_create=False)
if not self.object:
return
self.object.to_model(allow_create=False)
@dataclass(init=False)

View file

@ -0,0 +1,19 @@
# Generated by Django 3.2.5 on 2021-10-15 15:54
import bookwyrm.models.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0108_alter_user_preferred_language"),
]
operations = [
migrations.AddField(
model_name="status",
name="edited_date",
field=bookwyrm.models.fields.DateTimeField(blank=True, null=True),
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 3.2.5 on 2021-10-15 17:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0109_status_edited_date"),
]
operations = [
migrations.AddField(
model_name="quotation",
name="raw_quote",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="status",
name="raw_content",
field=models.TextField(blank=True, null=True),
),
]

View file

@ -67,16 +67,15 @@ class BookWyrmModel(models.Model):
return
# you can see the followers only posts of people you follow
if (
self.privacy == "followers"
and self.user.followers.filter(id=viewer.id).first()
if self.privacy == "followers" and (
self.user.followers.filter(id=viewer.id).first()
):
return
# you can see dms you are tagged in
if hasattr(self, "mention_users"):
if (
self.privacy == "direct"
self.privacy in ["direct", "followers"]
and self.mention_users.filter(id=viewer.id).first()
):
return

View file

@ -31,6 +31,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
"User", on_delete=models.PROTECT, activitypub_field="attributedTo"
)
content = fields.HtmlField(blank=True, null=True)
raw_content = models.TextField(blank=True, null=True)
mention_users = fields.TagField("User", related_name="mention_user")
mention_books = fields.TagField("Edition", related_name="mention_book")
local = models.BooleanField(default=True)
@ -43,6 +44,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
published_date = fields.DateTimeField(
default=timezone.now, activitypub_field="published"
)
edited_date = fields.DateTimeField(
blank=True, null=True, activitypub_field="updated"
)
deleted = models.BooleanField(default=False)
deleted_date = models.DateTimeField(blank=True, null=True)
favorites = models.ManyToManyField(
@ -220,6 +224,16 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
~Q(Q(user=viewer) | Q(mention_users=viewer)), privacy="direct"
)
@classmethod
def followers_filter(cls, queryset, viewer):
"""Override-able filter for "followers" privacy level"""
return queryset.exclude(
~Q( # not yourself, a follower, or someone who is tagged
Q(user__followers=viewer) | Q(user=viewer) | Q(mention_users=viewer)
),
privacy="followers", # and the status is followers only
)
class GeneratedNote(Status):
"""these are app-generated messages about user activity"""
@ -292,6 +306,7 @@ class Quotation(BookStatus):
"""like a review but without a rating and transient"""
quote = fields.HtmlField()
raw_quote = models.TextField(blank=True, null=True)
position = models.IntegerField(
validators=[MinValueValidator(0)], null=True, blank=True
)

View file

@ -9,12 +9,12 @@ from django.utils.translation import gettext_lazy as _
env = Env()
env.read_env()
DOMAIN = env("DOMAIN")
VERSION = "0.0.1"
VERSION = "0.1.0"
PAGE_LENGTH = env("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
JS_CACHE = "c02929b1"
JS_CACHE = "3eb4edb1"
# email
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")

View file

@ -509,6 +509,20 @@ ol.ordered-list li::before {
border-left: 2px solid #e0e0e0;
}
/* Breadcrumbs
******************************************************************************/
.breadcrumb li:first-child * {
padding-left: 0;
}
.breadcrumb li > * {
align-items: center;
display: flex;
justify-content: center;
padding: 0 0.75em;
}
/* Dimensions
* @todo These could be in rem.
******************************************************************************/

View file

@ -35,7 +35,7 @@ uuid: a unique identifier used to make html "id" attributes unique and clarify j
size="3"
value="{% firstof draft.progress readthrough.progress '' %}"
id="progress_{{ uuid }}"
data-cache-draft="id_progress_comment_{{ book.id }}"
{% if not draft %}data-cache-draft="id_progress_comment_{{ book.id }}"{% endif %}
>
</div>
<div class="control">
@ -43,7 +43,7 @@ uuid: a unique identifier used to make html "id" attributes unique and clarify j
<select
name="progress_mode"
aria-label="Progress mode"
data-cache-draft="id_progress_mode_comment_{{ book.id }}"
{% if not draft %}data-cache-draft="id_progress_mode_comment_{{ book.id }}"{% endif %}
>
<option
value="PG"

View file

@ -11,10 +11,10 @@ draft: an existing Status object that is providing default values for input fiel
<textarea
name="content"
class="textarea save-draft"
data-cache-draft="id_content_{{ type }}_{{ book.id }}{{ reply_parent.id }}"
{% if not draft %}data-cache-draft="id_content_{{ type }}_{{ book.id }}{{ reply_parent.id }}"{% endif %}
id="id_content_{{ type }}_{{ book.id }}{{ reply_parent.id }}{{ uuid }}"
placeholder="{{ placeholder }}"
aria-label="{% if reply_parent %}{% trans 'Reply' %}{% else %}{% trans 'Content' %}{% endif %}"
{% if not optional and type != "quotation" and type != "review" %}required{% endif %}
>{% if reply_parent %}{{ reply_parent|mentions:request.user }}{% endif %}{% if mention %}@{{ mention|username }} {% endif %}{{ draft.content|default:'' }}</textarea>
>{% if reply_parent %}{{ reply_parent|mentions:request.user }}{% endif %}{% if mention %}@{{ mention|username }} {% endif %}{% firstof draft.raw_content draft.content '' %}</textarea>

View file

@ -17,6 +17,6 @@
id="id_content_warning_{{ uuid }}{{ local_uuid }}"
placeholder="{% trans 'Spoilers ahead!' %}"
value="{% firstof draft.content_warning reply_parent.content_warning '' %}"
data-cache-draft="id_content_warning_{{ book.id }}_{{ type }}"
{% if not draft %}data-cache-draft="id_content_warning_{{ book.id }}_{{ type }}"{% endif %}
>
</div>

View file

@ -8,7 +8,7 @@
id="id_show_spoilers_{{ uuid }}{{ local_uuid }}"
{% if draft.content_warning or status.content_warning %}checked{% endif %}
aria-hidden="true"
data-cache-draft="id_sensitive_{{ book.id }}_{{ type }}{{ reply_parent.id }}"
{% if not draft %}data-cache-draft="id_sensitive_{{ book.id }}_{{ type }}{{ reply_parent.id }}"{% endif %}
>
{% trans "Include spoiler alert" as button_text %}
{% firstof draft.content_warning status.content_warning as pressed %}

View file

@ -17,7 +17,11 @@ reply_parent: the Status object this post will be in reply to, if applicable
<form
class="is-flex-grow-1{% if not no_script %} submit-status{% endif %}"
name="{{ type }}"
action="/post/{{ type }}"
{% if draft %}
action="{% url 'create-status' type draft.id %}"
{% else %}
action="{% url 'create-status' type %}"
{% endif %}
method="post"
id="form_{{ type }}_{{ book.id }}{{ reply_parent.id }}"
>

View file

@ -24,8 +24,8 @@ uuid: a unique identifier used to make html "id" attributes unique and clarify j
id="id_quote_{{ book.id }}_{{ type }}"
placeholder="{% blocktrans with book_title=book.title %}An excerpt from '{{ book_title }}'{% endblocktrans %}"
required
data-cache-draft="id_quote_{{ book.id }}_{{ type }}"
>{{ draft.quote|default:'' }}</textarea>
{% if not draft %}data-cache-draft="id_quote_{{ book.id }}_{{ type }}"{% endif %}
>{% firstof draft.raw_quote draft.quote '' %}</textarea>
</div>
</div>
<div class="field">
@ -36,7 +36,7 @@ uuid: a unique identifier used to make html "id" attributes unique and clarify j
<select
name="position_mode"
aria-label="Position mode"
data-cache-draft="id_position_mode_{{ book.id }}_{{ type }}"
{% if not draft %}data-cache-draft="id_position_mode_{{ book.id }}_{{ type }}"{% endif %}
>
<option
value="PG"
@ -63,7 +63,7 @@ uuid: a unique identifier used to make html "id" attributes unique and clarify j
size="3"
value="{% firstof draft.position '' %}"
id="position_{{ uuid }}"
data-cache-draft="id_position_{{ book.id }}_{{ type }}"
{% if not draft %}data-cache-draft="id_position_{{ book.id }}_{{ type }}"{% endif %}
>
</div>
</div>

View file

@ -24,7 +24,7 @@ uuid: a unique identifier used to make html "id" attributes unique and clarify j
id="id_name_{{ book.id }}"
placeholder="{% blocktrans with book_title=book.title %}Your review of '{{ book_title }}'{% endblocktrans %}"
value="{% firstof draft.name ''%}"
data-cache-draft="id_name_{{ book.id }}_{{ type }}"
{% if not draft %}data-cache-draft="id_name_{{ book.id }}_{{ type }}"{% endif %}
>
</div>
</div>

View file

@ -1,7 +1,19 @@
{% spaceless %}
{% load humanize %}
{% load i18n %}
{% if total_pages %}
{% blocktrans with page=page|intcomma total_pages=total_pages|intcomma %}page {{ page }} of {{ total_pages }}{% endblocktrans %}
{% blocktrans trimmed with page=page|intcomma total_pages=total_pages|intcomma %}
page {{ page }} of {{ total_pages }}
{% endblocktrans %}
{% else %}
{% blocktrans with page=page|intcomma %}page {{ page }}{% endblocktrans %}
{% blocktrans trimmed with page=page|intcomma %}
page {{ page }}
{% endblocktrans %}
{% endif %}
{% endspaceless %}

View file

@ -3,7 +3,7 @@
{% if request.user.is_authenticated %}
<span class="is-sr-only">{% trans "Leave a rating" %}</span>
<div class="block">
<form class="hidden-form" name="rate" action="/post/rating" method="POST">
<form class="hidden-form" name="rate" action="{% url 'create-status' 'rating' %}" method="POST">
{% csrf_token %}
<input type="hidden" name="user" value="{{ request.user.id }}">
<input type="hidden" name="book" value="{{ book.id }}">

View file

@ -31,18 +31,38 @@
{% include "snippets/status/header_content.html" %}
</h3>
<p class="is-size-7 is-flex is-align-items-center">
<a href="{{ status.remote_id }}{% if status.user.local %}#anchor-{{ status.id }}{% endif %}">{{ status.published_date|published_date }}</a>
{% if status.progress %}
<span class="ml-1">
{% if status.progress_mode == 'PG' %}
({% include 'snippets/page_text.html' with page=status.progress total_pages=status.book.pages %})
{% else %}
({{ status.progress }}%)
{% endif %}
</span>
{% endif %}
{% include 'snippets/privacy-icons.html' with item=status %}
</p>
<div class="breadcrumb has-dot-separator is-small">
<ul class="is-flex is-align-items-center">
<li>
<a href="{{ status.remote_id }}{% if status.user.local %}#anchor-{{ status.id }}{% endif %}">
{{ status.published_date|published_date }}
</a>
</li>
{% if status.edited_date %}
<li>
<span>
{% blocktrans with date=status.edited_date|published_date %}edited {{ date }}{% endblocktrans %}
</span>
</li>
{% endif %}
{% if status.progress %}
<li class="ml-1">
<span>
{% if status.progress_mode == 'PG' %}
{% include 'snippets/page_text.html' with page=status.progress total_pages=status.book.pages %}
{% else %}
{{ status.progress }}%
{% endif %}
</span>
</li>
{% endif %}
<li>
{% include 'snippets/privacy-icons.html' with item=status %}
</li>
</ul>
</div>
</div>
</div>

View file

@ -67,7 +67,7 @@
{% endblock %}
{% block card-bonus %}
{% if request.user.is_authenticated and not moderation_mode %}
{% if request.user.is_authenticated and not moderation_mode and not no_interact %}
{% with status.id|uuid as uuid %}
<section class="reply-panel is-hidden" id="show_comment_{{ status.id }}">
<div class="card-footer">

View file

@ -20,12 +20,9 @@
</li>
{% if status.status_type != 'GeneratedNote' and status.status_type != 'Rating' %}
<li role="menuitem" class="dropdown-item p-0">
<form class="" name="delete-{{ status.id }}" action="{% url 'redraft' status.id %}" method="post">
{% csrf_token %}
<button class="button is-radiusless is-danger is-light is-fullwidth is-small" type="submit">
{% trans "Delete & re-draft" %}
</button>
</form>
<a href="{% url 'edit-status' status.id %}" class="button is-radiusless is-fullwidth is-small" type="submit">
{% trans "Edit" %}
</a>
</li>
{% endif %}
{% else %}

View file

@ -3,11 +3,13 @@ from unittest.mock import patch
from io import BytesIO
import pathlib
from PIL import Image
from django.http import Http404
from django.core.files.base import ContentFile
from django.db import IntegrityError
from django.contrib.auth.models import AnonymousUser
from django.test import TestCase
from django.utils import timezone
from PIL import Image
import responses
from bookwyrm import activitypub, models, settings
@ -50,6 +52,9 @@ class Status(TestCase):
image.save(output, format=image.format)
self.book.cover.save("test.jpg", ContentFile(output.getvalue()))
self.anonymous_user = AnonymousUser
self.anonymous_user.is_authenticated = False
def test_status_generated_fields(self, *_):
"""setting remote id"""
status = models.Status.objects.create(content="bleh", user=self.local_user)
@ -460,3 +465,60 @@ class Status(TestCase):
responses.add(responses.GET, "http://fish.com/nothing", status=404)
self.assertTrue(models.Status.ignore_activity(activity))
def test_raise_visible_to_user_public(self, *_):
"""privacy settings"""
status = models.Status.objects.create(
content="bleh", user=self.local_user, privacy="public"
)
self.assertIsNone(status.raise_visible_to_user(self.remote_user))
self.assertIsNone(status.raise_visible_to_user(self.local_user))
self.assertIsNone(status.raise_visible_to_user(self.anonymous_user))
def test_raise_visible_to_user_unlisted(self, *_):
"""privacy settings"""
status = models.Status.objects.create(
content="bleh", user=self.local_user, privacy="unlisted"
)
self.assertIsNone(status.raise_visible_to_user(self.remote_user))
self.assertIsNone(status.raise_visible_to_user(self.local_user))
self.assertIsNone(status.raise_visible_to_user(self.anonymous_user))
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
def test_raise_visible_to_user_followers(self, *_):
"""privacy settings"""
status = models.Status.objects.create(
content="bleh", user=self.local_user, privacy="followers"
)
status.raise_visible_to_user(self.local_user)
with self.assertRaises(Http404):
status.raise_visible_to_user(self.remote_user)
with self.assertRaises(Http404):
status.raise_visible_to_user(self.anonymous_user)
self.local_user.followers.add(self.remote_user)
self.assertIsNone(status.raise_visible_to_user(self.remote_user))
def test_raise_visible_to_user_followers_mentioned(self, *_):
"""privacy settings"""
status = models.Status.objects.create(
content="bleh", user=self.local_user, privacy="followers"
)
status.mention_users.set([self.remote_user])
self.assertIsNone(status.raise_visible_to_user(self.remote_user))
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
def test_raise_visible_to_user_direct(self, *_):
"""privacy settings"""
status = models.Status.objects.create(
content="bleh", user=self.local_user, privacy="direct"
)
status.raise_visible_to_user(self.local_user)
with self.assertRaises(Http404):
status.raise_visible_to_user(self.remote_user)
with self.assertRaises(Http404):
status.raise_visible_to_user(self.anonymous_user)
# mentioned user
status.mention_users.set([self.remote_user])
self.assertIsNone(status.raise_visible_to_user(self.remote_user))

View file

@ -37,9 +37,9 @@ class InboxUpdate(TestCase):
outbox="https://example.com/users/rat/outbox",
)
self.create_json = {
self.update_json = {
"id": "hi",
"type": "Create",
"type": "Update",
"actor": "hi",
"to": ["https://www.w3.org/ns/activitystreams#public"],
"cc": ["https://example.com/user/mouse/followers"],
@ -54,26 +54,20 @@ class InboxUpdate(TestCase):
book_list = models.List.objects.create(
name="hi", remote_id="https://example.com/list/22", user=self.local_user
)
activity = {
"type": "Update",
"to": [],
"cc": [],
"actor": "hi",
"id": "sdkjf",
"object": {
"id": "https://example.com/list/22",
"type": "BookList",
"totalItems": 1,
"first": "https://example.com/list/22?page=1",
"last": "https://example.com/list/22?page=1",
"name": "Test List",
"owner": "https://example.com/user/mouse",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://example.com/user/mouse/followers"],
"summary": "summary text",
"curation": "curated",
"@context": "https://www.w3.org/ns/activitystreams",
},
activity = self.update_json
activity["object"] = {
"id": "https://example.com/list/22",
"type": "BookList",
"totalItems": 1,
"first": "https://example.com/list/22?page=1",
"last": "https://example.com/list/22?page=1",
"name": "Test List",
"owner": "https://example.com/user/mouse",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://example.com/user/mouse/followers"],
"summary": "summary text",
"curation": "curated",
"@context": "https://www.w3.org/ns/activitystreams",
}
views.inbox.activity_task(activity)
book_list.refresh_from_db()
@ -176,3 +170,26 @@ class InboxUpdate(TestCase):
)
book = models.Work.objects.get(id=book.id)
self.assertEqual(book.title, "Piranesi")
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay")
@patch("bookwyrm.activitystreams.add_status_task.delay")
def test_update_status(self, *_):
"""edit a status"""
status = models.Status.objects.create(user=self.remote_user, content="hi")
datafile = pathlib.Path(__file__).parent.joinpath("../../data/ap_note.json")
status_data = json.loads(datafile.read_bytes())
status_data["id"] = status.remote_id
status_data["updated"] = "2021-12-13T05:09:29Z"
activity = self.update_json
activity["object"] = status_data
with patch("bookwyrm.activitypub.base_activity.set_related_field.delay"):
views.inbox.activity_task(activity)
status.refresh_from_db()
self.assertEqual(status.content, "test content in note")
self.assertEqual(status.edited_date.year, 2021)
self.assertEqual(status.edited_date.month, 12)
self.assertEqual(status.edited_date.day, 13)

View file

@ -7,6 +7,7 @@ from django.test.client import RequestFactory
from bookwyrm import forms, models, views
from bookwyrm.settings import DOMAIN
from bookwyrm.tests.validate_html import validate_html
# pylint: disable=invalid-name
@ -50,7 +51,7 @@ class StatusViews(TestCase):
)
models.SiteSettings.objects.create()
def test_handle_status(self, *_):
def test_create_status_comment(self, *_):
"""create a status"""
view = views.CreateStatus.as_view()
form = forms.CommentForm(
@ -67,11 +68,13 @@ class StatusViews(TestCase):
view(request, "comment")
status = models.Comment.objects.get()
self.assertEqual(status.raw_content, "hi")
self.assertEqual(status.content, "<p>hi</p>")
self.assertEqual(status.user, self.local_user)
self.assertEqual(status.book, self.book)
self.assertIsNone(status.edited_date)
def test_handle_status_reply(self, *_):
def test_create_status_reply(self, *_):
"""create a status in reply to an existing status"""
view = views.CreateStatus.as_view()
user = models.User.objects.create_user(
@ -98,7 +101,7 @@ class StatusViews(TestCase):
self.assertEqual(status.user, user)
self.assertEqual(models.Notification.objects.get().user, self.local_user)
def test_handle_status_mentions(self, *_):
def test_create_status_mentions(self, *_):
"""@mention a user in a post"""
view = views.CreateStatus.as_view()
user = models.User.objects.create_user(
@ -128,7 +131,7 @@ class StatusViews(TestCase):
status.content, f'<p>hi <a href="{user.remote_id}">@rat</a></p>'
)
def test_handle_status_reply_with_mentions(self, *_):
def test_create_status_reply_with_mentions(self, *_):
"""reply to a post with an @mention'ed user"""
view = views.CreateStatus.as_view()
user = models.User.objects.create_user(
@ -168,60 +171,6 @@ 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
status = models.Comment.objects.create(
content="hi", book=self.book, user=self.local_user
)
with patch("bookwyrm.activitystreams.remove_status_task.delay") 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.add_status_task.delay"):
status = models.ReviewRating.objects.create(
book=self.book, rating=2.0, user=self.local_user
)
with patch("bookwyrm.activitystreams.remove_status_task.delay") as mock:
with self.assertRaises(PermissionDenied):
view(request, status.id)
self.assertFalse(mock.called)
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.add_status_task.delay"):
status = models.GeneratedNote.objects.create(
content="hi", user=self.local_user
)
with patch("bookwyrm.activitystreams.remove_status_task.delay") as mock:
with self.assertRaises(PermissionDenied):
view(request, status.id)
self.assertFalse(mock.called)
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(
@ -349,7 +298,7 @@ http://www.fish.com/"""
result = views.status.to_markdown(text)
self.assertEqual(result, '<p><a href="http://fish.com">hi</a> ' "is rad</p>")
def test_handle_delete_status(self, mock, *_):
def test_delete_status(self, mock, *_):
"""marks a status as deleted"""
view = views.DeleteStatus.as_view()
with patch("bookwyrm.activitystreams.add_status_task.delay"):
@ -367,7 +316,7 @@ http://www.fish.com/"""
status.refresh_from_db()
self.assertTrue(status.deleted)
def test_handle_delete_status_permission_denied(self, *_):
def test_delete_status_permission_denied(self, *_):
"""marks a status as deleted"""
view = views.DeleteStatus.as_view()
with patch("bookwyrm.activitystreams.add_status_task.delay"):
@ -382,7 +331,7 @@ http://www.fish.com/"""
status.refresh_from_db()
self.assertFalse(status.deleted)
def test_handle_delete_status_moderator(self, mock, *_):
def test_delete_status_moderator(self, mock, *_):
"""marks a status as deleted"""
view = views.DeleteStatus.as_view()
with patch("bookwyrm.activitystreams.add_status_task.delay"):
@ -400,3 +349,75 @@ http://www.fish.com/"""
self.assertEqual(activity["object"]["type"], "Tombstone")
status.refresh_from_db()
self.assertTrue(status.deleted)
def test_edit_status_get(self, *_):
"""load the edit status view"""
view = views.EditStatus.as_view()
status = models.Comment.objects.create(
content="status", user=self.local_user, book=self.book
)
request = self.factory.get("")
request.user = self.local_user
result = view(request, status.id)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_edit_status_get_reply(self, *_):
"""load the edit status view"""
view = views.EditStatus.as_view()
parent = models.Comment.objects.create(
content="parent status", user=self.local_user, book=self.book
)
status = models.Status.objects.create(
content="reply", user=self.local_user, reply_parent=parent
)
request = self.factory.get("")
request.user = self.local_user
result = view(request, status.id)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_edit_status_success(self, mock, *_):
"""update an existing status"""
status = models.Status.objects.create(content="status", user=self.local_user)
self.assertIsNone(status.edited_date)
view = views.CreateStatus.as_view()
form = forms.CommentForm(
{
"content": "hi",
"user": self.local_user.id,
"book": self.book.id,
"privacy": "public",
}
)
request = self.factory.post("", form.data)
request.user = self.local_user
view(request, "comment", existing_status_id=status.id)
activity = json.loads(mock.call_args_list[1][0][1])
self.assertEqual(activity["type"], "Update")
self.assertEqual(activity["object"]["id"], status.remote_id)
status.refresh_from_db()
self.assertEqual(status.content, "<p>hi</p>")
self.assertIsNotNone(status.edited_date)
def test_edit_status_permission_denied(self, *_):
"""update an existing status"""
status = models.Status.objects.create(content="status", user=self.local_user)
view = views.CreateStatus.as_view()
form = forms.CommentForm(
{
"content": "hi",
"user": self.local_user.id,
"book": self.book.id,
"privacy": "public",
}
)
request = self.factory.post("", form.data)
request.user = self.remote_user
with self.assertRaises(PermissionDenied):
view(request, "comment", existing_status_id=status.id)

View file

@ -315,6 +315,9 @@ urlpatterns = [
re_path(
rf"{STATUS_PATH}/replies(.json)?/?$", views.Replies.as_view(), name="replies"
),
re_path(
r"^edit/(?P<status_id>\d+)/?$", views.EditStatus.as_view(), name="edit-status"
),
re_path(
r"^post/?$",
views.CreateStatus.as_view(),
@ -325,16 +328,16 @@ urlpatterns = [
views.CreateStatus.as_view(),
name="create-status",
),
re_path(
r"^post/(?P<status_type>\w+)/(?P<existing_status_id>\d+)/?$",
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(), name="fav"),
re_path(

View file

@ -60,7 +60,7 @@ from .search import Search
from .shelf import Shelf
from .shelf import create_shelf, delete_shelf
from .shelf import shelve, unshelve
from .status import CreateStatus, DeleteStatus, DeleteAndRedraft, update_progress
from .status import CreateStatus, EditStatus, DeleteStatus, update_progress
from .status import edit_readthrough
from .updates import get_notification_count, get_unread_status_count
from .user import User, Followers, Following, hide_suggestions

View file

@ -8,6 +8,7 @@ from django.core.exceptions import ValidationError
from django.http import HttpResponse, HttpResponseBadRequest, Http404
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.http import require_POST
@ -21,23 +22,55 @@ from .helpers import handle_remote_webfinger, is_api_request
from .helpers import load_date_in_user_tz_as_utc
# pylint: disable= no-self-use
@method_decorator(login_required, name="dispatch")
class EditStatus(View):
"""the view for *posting*"""
def get(self, request, status_id): # pylint: disable=unused-argument
"""load the edit panel"""
status = get_object_or_404(
models.Status.objects.select_subclasses(), id=status_id
)
status.raise_not_editable(request.user)
status_type = "reply" if status.reply_parent else status.status_type.lower()
data = {
"type": status_type,
"book": getattr(status, "book", None),
"draft": status,
}
return TemplateResponse(request, "compose.html", data)
# pylint: disable= no-self-use
@method_decorator(login_required, name="dispatch")
class CreateStatus(View):
"""the view for *posting*"""
def get(self, request, status_type): # pylint: disable=unused-argument
"""compose view (used for delete-and-redraft)"""
"""compose view (...not used?)"""
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"""
def post(self, request, status_type, existing_status_id=None):
"""create status of whatever type"""
created = not existing_status_id
existing_status = None
if existing_status_id:
existing_status = get_object_or_404(
models.Status.objects.select_subclasses(), id=existing_status_id
)
existing_status.raise_not_editable(request.user)
existing_status.edited_date = timezone.now()
status_type = status_type[0].upper() + status_type[1:]
try:
form = getattr(forms, f"{status_type}Form")(request.POST)
form = getattr(forms, f"{status_type}Form")(
request.POST, instance=existing_status
)
except AttributeError:
return HttpResponseBadRequest()
if not form.is_valid():
@ -46,6 +79,11 @@ class CreateStatus(View):
return redirect(request.headers.get("Referer", "/"))
status = form.save(commit=False)
# save the plain, unformatted version of the status for future editing
status.raw_content = status.content
if hasattr(status, "quote"):
status.raw_quote = status.quote
if not status.sensitive and status.content_warning:
# the cw text field remains populated when you click "remove"
status.content_warning = None
@ -77,7 +115,7 @@ class CreateStatus(View):
if hasattr(status, "quote"):
status.quote = to_markdown(status.quote)
status.save(created=True)
status.save(created=created)
# update a readthorugh, if needed
try:
@ -106,36 +144,6 @@ class DeleteStatus(View):
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
)
# don't let people redraft other people's statuses
status.raise_not_editable(request.user)
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)
@login_required
@require_POST
def update_progress(request, book_id): # pylint: disable=unused-argument