From 3beebe4727aa84f5c3628fa79c3dfa9250d08842 Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Mon, 16 Nov 2020 22:33:04 -0800 Subject: [PATCH 01/56] Add initial draft of progress update --- bookwyrm/activitypub/note.py | 6 ++++++ bookwyrm/models/status.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/bookwyrm/activitypub/note.py b/bookwyrm/activitypub/note.py index ebc0cf3ce..f4a22c417 100644 --- a/bookwyrm/activitypub/note.py +++ b/bookwyrm/activitypub/note.py @@ -63,3 +63,9 @@ class Quotation(Comment): ''' a quote and commentary on a book ''' quote: str type: str = 'Quotation' + +@dataclass(init=False) +class Progress(Comment): + ''' a progress update on a book ''' + quote: str + type: str = 'Progress' diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index 09ceda856..c56fa3955 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -201,6 +201,35 @@ class Quotation(Status): activity_serializer = activitypub.Quotation pure_activity_serializer = activitypub.Note +class Progress(Status): + ''' an update of where a user is in a book, using page number or % ''' + class ProgressMode(models.TextChoices): + PAGE = 'PG', 'page' + PERCENT = 'PCT', 'percent' + + progress = models.IntegerField() + mode = models.TextChoices(max_length=3, choices=ProgessMode.choices, default=ProgressMode.PAGE) + book = models.ForeignKey('Edition', on_delete=models.PROTECT) + + @property + def ap_pure_content(self): + ''' indicate the book in question for mastodon (or w/e) users ''' + if self.mode == ProgressMode.PAGE: + return 'on page %d of %d in "%s"' % ( + self.progress, + self.book.pages, + self.book.remote_id, + self.book.title, + ) + else: + return '%d%% of the way through "%s"' % ( + self.progress, + self.book.remote_id, + self.book.title, + ) + + activity_serializer = activitypub.Progress + pure_activity_serializer = activitypub.Note class Review(Status): ''' a book review ''' From 7ffc3114a6f4fcf3ef08de2df2911ed610dd5d6d Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Mon, 16 Nov 2020 22:47:55 -0800 Subject: [PATCH 02/56] Add display and form for existing pages_read Commented out the new update type because it was breaking and I don't need it quite yet --- bookwyrm/models/status.py | 58 ++++++++++++++++++------------------ bookwyrm/templates/book.html | 9 ++++++ bookwyrm/view_actions.py | 8 +++++ 3 files changed, 46 insertions(+), 29 deletions(-) diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index c56fa3955..91906b13d 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -201,35 +201,35 @@ class Quotation(Status): activity_serializer = activitypub.Quotation pure_activity_serializer = activitypub.Note -class Progress(Status): - ''' an update of where a user is in a book, using page number or % ''' - class ProgressMode(models.TextChoices): - PAGE = 'PG', 'page' - PERCENT = 'PCT', 'percent' - - progress = models.IntegerField() - mode = models.TextChoices(max_length=3, choices=ProgessMode.choices, default=ProgressMode.PAGE) - book = models.ForeignKey('Edition', on_delete=models.PROTECT) - - @property - def ap_pure_content(self): - ''' indicate the book in question for mastodon (or w/e) users ''' - if self.mode == ProgressMode.PAGE: - return 'on page %d of %d in "%s"' % ( - self.progress, - self.book.pages, - self.book.remote_id, - self.book.title, - ) - else: - return '%d%% of the way through "%s"' % ( - self.progress, - self.book.remote_id, - self.book.title, - ) - - activity_serializer = activitypub.Progress - pure_activity_serializer = activitypub.Note +#class Progress(Status): +# ''' an update of where a user is in a book, using page number or % ''' +# class ProgressMode(models.TextChoices): +# PAGE = 'PG', 'page' +# PERCENT = 'PCT', 'percent' +# +# progress = models.IntegerField() +# mode = models.TextChoices(max_length=3, choices=ProgessMode.choices, default=ProgressMode.PAGE) +# book = models.ForeignKey('Edition', on_delete=models.PROTECT) +# +# @property +# def ap_pure_content(self): +# ''' indicate the book in question for mastodon (or w/e) users ''' +# if self.mode == ProgressMode.PAGE: +# return 'on page %d of %d in "%s"' % ( +# self.progress, +# self.book.pages, +# self.book.remote_id, +# self.book.title, +# ) +# else: +# return '%d%% of the way through "%s"' % ( +# self.progress, +# self.book.remote_id, +# self.book.title, +# ) +# +# activity_serializer = activitypub.Progress +# pure_activity_serializer = activitypub.Note class Review(Status): ''' a book review ''' diff --git a/bookwyrm/templates/book.html b/bookwyrm/templates/book.html index b0064e1fd..db3864425 100644 --- a/bookwyrm/templates/book.html +++ b/bookwyrm/templates/book.html @@ -74,6 +74,9 @@ {% if readthrough.finish_date %}
Finished reading:
{{ readthrough.finish_date | naturalday }}
+ {% elif readthrough.pages_read %} +
On page:
+
{{ readthrough.pages_read}} of {{ book.pages }}
{% endif %}
@@ -104,6 +107,12 @@
+
+ +
diff --git a/bookwyrm/views.py b/bookwyrm/views.py index 94d9366c4..9748481af 100644 --- a/bookwyrm/views.py +++ b/bookwyrm/views.py @@ -581,6 +581,7 @@ def book_page(request, book_id): 'tags': tags, 'user_tags': user_tags, 'readthroughs': readthroughs, + 'show_progress': ('showprogress' in request.GET), 'path': '/book/%s' % book_id, 'info_fields': [ {'name': 'ISBN', 'value': book.isbn_13}, From a579ea52f48428ca09553997a8a9c40fbc4f3070 Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Thu, 19 Nov 2020 19:38:38 -0800 Subject: [PATCH 08/56] Add initial inline progress update Doesn't work yet --- bookwyrm/static/css/format.css | 13 +++++++++++++ bookwyrm/templates/snippets/progress_update.html | 5 +++++ bookwyrm/templates/snippets/shelve_button.html | 4 ++++ 3 files changed, 22 insertions(+) create mode 100644 bookwyrm/templates/snippets/progress_update.html diff --git a/bookwyrm/static/css/format.css b/bookwyrm/static/css/format.css index db3c20ef3..9c116c7d0 100644 --- a/bookwyrm/static/css/format.css +++ b/bookwyrm/static/css/format.css @@ -57,6 +57,19 @@ input.toggle-control:checked ~ .modal.toggle-content { content: '\e9d7'; } +/* progress update */ +.progress-update { + padding: 0 0.5rem; +} +.progress-update.is-small, +.progress-update input.is-small { + font-size: 0.75rem; +} + +.progress-update input { + width: 3em; +} + /* --- BOOK COVERS --- */ .cover-container { diff --git a/bookwyrm/templates/snippets/progress_update.html b/bookwyrm/templates/snippets/progress_update.html new file mode 100644 index 000000000..df9018a83 --- /dev/null +++ b/bookwyrm/templates/snippets/progress_update.html @@ -0,0 +1,5 @@ +
+ on page + + of +
diff --git a/bookwyrm/templates/snippets/shelve_button.html b/bookwyrm/templates/snippets/shelve_button.html index 3b924324b..d8ed438f4 100644 --- a/bookwyrm/templates/snippets/shelve_button.html +++ b/bookwyrm/templates/snippets/shelve_button.html @@ -63,6 +63,10 @@ {% endwith %} + + {% if active_shelf.identifier == 'reading' %} + {% include 'snippets/progress_update.html' %} + {% endif %} {% endif %} From f57d9ee45d9df20b8292dac91391d7476ea8a438 Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Fri, 20 Nov 2020 21:45:12 -0800 Subject: [PATCH 09/56] Rework to use bulma better --- bookwyrm/static/css/format.css | 14 -------------- bookwyrm/templates/snippets/progress_update.html | 10 ++++++---- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/bookwyrm/static/css/format.css b/bookwyrm/static/css/format.css index 9c116c7d0..f14b06f28 100644 --- a/bookwyrm/static/css/format.css +++ b/bookwyrm/static/css/format.css @@ -57,20 +57,6 @@ input.toggle-control:checked ~ .modal.toggle-content { content: '\e9d7'; } -/* progress update */ -.progress-update { - padding: 0 0.5rem; -} -.progress-update.is-small, -.progress-update input.is-small { - font-size: 0.75rem; -} - -.progress-update input { - width: 3em; -} - - /* --- BOOK COVERS --- */ .cover-container { height: 250px; diff --git a/bookwyrm/templates/snippets/progress_update.html b/bookwyrm/templates/snippets/progress_update.html index df9018a83..a6def952e 100644 --- a/bookwyrm/templates/snippets/progress_update.html +++ b/bookwyrm/templates/snippets/progress_update.html @@ -1,5 +1,7 @@ -
- on page - - of +
+
on page
+
+ +
+
of 100
From e7c036816821c46b65a74b83099018a01be5f9ee Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Wed, 25 Nov 2020 22:36:55 -0800 Subject: [PATCH 10/56] PR feedback --- bookwyrm/activitypub/note.py | 6 ------ bookwyrm/templates/snippets/progress_update.html | 2 +- bookwyrm/views.py | 3 ++- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/bookwyrm/activitypub/note.py b/bookwyrm/activitypub/note.py index f4a22c417..ebc0cf3ce 100644 --- a/bookwyrm/activitypub/note.py +++ b/bookwyrm/activitypub/note.py @@ -63,9 +63,3 @@ class Quotation(Comment): ''' a quote and commentary on a book ''' quote: str type: str = 'Quotation' - -@dataclass(init=False) -class Progress(Comment): - ''' a progress update on a book ''' - quote: str - type: str = 'Progress' diff --git a/bookwyrm/templates/snippets/progress_update.html b/bookwyrm/templates/snippets/progress_update.html index a6def952e..a93ac8397 100644 --- a/bookwyrm/templates/snippets/progress_update.html +++ b/bookwyrm/templates/snippets/progress_update.html @@ -1,7 +1,7 @@
on page
- +
of 100
diff --git a/bookwyrm/views.py b/bookwyrm/views.py index 9748481af..19d3af97d 100644 --- a/bookwyrm/views.py +++ b/bookwyrm/views.py @@ -563,7 +563,8 @@ def book_page(request, book_id): ).order_by('start_date') for readthrough in readthroughs: - readthrough.progress_updates = readthrough.progressupdate_set.all().order_by('date') + readthrough.progress_updates = \ + readthrough.progressupdate_set.all().order_by('date') rating = reviews.aggregate(Avg('rating')) tags = models.Tag.objects.filter( From 090cf2aea7ed459f9ea92c3fbb6f8e023782ca0f Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Wed, 25 Nov 2020 22:37:18 -0800 Subject: [PATCH 11/56] Make inline progress form actually work --- bookwyrm/models/book.py | 4 ++++ bookwyrm/templates/feed.html | 12 +++++++++++- bookwyrm/templates/snippets/progress_update.html | 15 +++++++++++---- bookwyrm/templates/snippets/shelve_button.html | 4 ---- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index d0702a3e1..0c81f0596 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -74,6 +74,10 @@ class Book(ActivitypubMixin, BookWyrmModel): ''' reference the work via local id not remote ''' return self.parent_work.remote_id + @property + def latest_readthrough(self): + return self.readthrough_set.order_by('-updated_date').first() + activity_mappings = [ ActivityMapping('id', 'remote_id'), diff --git a/bookwyrm/templates/feed.html b/bookwyrm/templates/feed.html index ed1fea0f0..474f4fbc9 100644 --- a/bookwyrm/templates/feed.html +++ b/bookwyrm/templates/feed.html @@ -46,7 +46,17 @@ diff --git a/bookwyrm/templates/snippets/progress_update.html b/bookwyrm/templates/snippets/progress_update.html index a93ac8397..51385b973 100644 --- a/bookwyrm/templates/snippets/progress_update.html +++ b/bookwyrm/templates/snippets/progress_update.html @@ -1,7 +1,14 @@ -
+
+ {% csrf_token %} +
on page
- +
-
of 100
-
+ {% if book.pages %} +
of {{ book.pages }}
+ {% endif %} +
+ +
+ diff --git a/bookwyrm/templates/snippets/shelve_button.html b/bookwyrm/templates/snippets/shelve_button.html index d8ed438f4..3b924324b 100644 --- a/bookwyrm/templates/snippets/shelve_button.html +++ b/bookwyrm/templates/snippets/shelve_button.html @@ -63,10 +63,6 @@
{% endwith %} - - {% if active_shelf.identifier == 'reading' %} - {% include 'snippets/progress_update.html' %} - {% endif %} {% endif %} From 64fb88cc101ba6b937d8ad8dfa7049821cbf936e Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Wed, 25 Nov 2020 22:56:41 -0800 Subject: [PATCH 12/56] ProgressUpdate doesn't need its own date field Just use the base model's created_date --- bookwyrm/migrations/0012_progressupdate.py | 1 - bookwyrm/models/status.py | 1 - bookwyrm/templates/book.html | 2 +- bookwyrm/views.py | 2 +- 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/bookwyrm/migrations/0012_progressupdate.py b/bookwyrm/migrations/0012_progressupdate.py index 6fec6ca5e..131419712 100644 --- a/bookwyrm/migrations/0012_progressupdate.py +++ b/bookwyrm/migrations/0012_progressupdate.py @@ -21,7 +21,6 @@ class Migration(migrations.Migration): ('remote_id', models.CharField(max_length=255, null=True)), ('progress', models.IntegerField()), ('mode', models.CharField(choices=[('PG', 'page'), ('PCT', 'percent')], default='PG', max_length=3)), - ('date', models.DateTimeField(auto_now_add=True)), ('readthrough', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.ReadThrough')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), ], diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index d52a10085..934a88387 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -339,7 +339,6 @@ class ProgressUpdate(BookWyrmModel): readthrough = models.ForeignKey('ReadThrough', on_delete=models.PROTECT) progress = models.IntegerField() mode = models.CharField(max_length=3, choices=ProgressMode.choices, default=ProgressMode.PAGE) - date = models.DateTimeField(auto_now_add=True) def save(self, *args, **kwargs): ''' update user active time ''' diff --git a/bookwyrm/templates/book.html b/bookwyrm/templates/book.html index fe2691f13..2bd9fc778 100644 --- a/bookwyrm/templates/book.html +++ b/bookwyrm/templates/book.html @@ -95,7 +95,7 @@ Progress Updates:
{% for progress_update in readthrough.progress_updates %} -
{{ progress_update.date | naturalday }}:
+
{{ progress_update.created_date | naturalday }}:
{{ progress_update.progress }} of {{ book.pages }}
{% endfor %}
diff --git a/bookwyrm/views.py b/bookwyrm/views.py index 19d3af97d..5ccc343e1 100644 --- a/bookwyrm/views.py +++ b/bookwyrm/views.py @@ -564,7 +564,7 @@ def book_page(request, book_id): for readthrough in readthroughs: readthrough.progress_updates = \ - readthrough.progressupdate_set.all().order_by('date') + readthrough.progressupdate_set.all().order_by('updated_date') rating = reviews.aggregate(Avg('rating')) tags = models.Tag.objects.filter( From 692aa0836478f99edaf080ec58e02be2b7f5f8c3 Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Wed, 25 Nov 2020 23:11:30 -0800 Subject: [PATCH 13/56] Remove unneeded class, wrap line --- bookwyrm/models/status.py | 32 ++++---------------------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index 934a88387..406de2408 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -201,33 +201,6 @@ class Quotation(Status): activity_serializer = activitypub.Quotation pure_activity_serializer = activitypub.Note -#class Progress(Status): -# ''' an update of where a user is in a book, using page number or % ''' -# -# progress = models.IntegerField() -# mode = models.TextChoices(max_length=3, choices=ProgessMode.choices, default=ProgressMode.PAGE) -# book = models.ForeignKey('Edition', on_delete=models.PROTECT) -# -# @property -# def ap_pure_content(self): -# ''' indicate the book in question for mastodon (or w/e) users ''' -# if self.mode == ProgressMode.PAGE: -# return 'on page %d of %d in "%s"' % ( -# self.progress, -# self.book.pages, -# self.book.remote_id, -# self.book.title, -# ) -# else: -# return '%d%% of the way through "%s"' % ( -# self.progress, -# self.book.remote_id, -# self.book.title, -# ) -# -# activity_serializer = activitypub.Progress -# pure_activity_serializer = activitypub.Note - class Review(Status): ''' a book review ''' name = models.CharField(max_length=255, null=True) @@ -338,7 +311,10 @@ class ProgressUpdate(BookWyrmModel): user = models.ForeignKey('User', on_delete=models.PROTECT) readthrough = models.ForeignKey('ReadThrough', on_delete=models.PROTECT) progress = models.IntegerField() - mode = models.CharField(max_length=3, choices=ProgressMode.choices, default=ProgressMode.PAGE) + mode = models.CharField( + max_length=3, + choices=ProgressMode.choices, + default=ProgressMode.PAGE) def save(self, *args, **kwargs): ''' update user active time ''' From 97e49c4bd23d81b1c97201e9a2db04cee49bb490 Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Wed, 25 Nov 2020 23:12:05 -0800 Subject: [PATCH 14/56] Undo stray css edit --- bookwyrm/static/css/format.css | 1 + 1 file changed, 1 insertion(+) diff --git a/bookwyrm/static/css/format.css b/bookwyrm/static/css/format.css index f14b06f28..db3c20ef3 100644 --- a/bookwyrm/static/css/format.css +++ b/bookwyrm/static/css/format.css @@ -57,6 +57,7 @@ input.toggle-control:checked ~ .modal.toggle-content { content: '\e9d7'; } + /* --- BOOK COVERS --- */ .cover-container { height: 250px; From 3b0b8f16f66dfbce89b6b947ba978b734f229a12 Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Fri, 27 Nov 2020 16:07:53 -0800 Subject: [PATCH 15/56] Merge migration branches Also add $@ to bw-dev migrations, and factor the shift 1 out --- bookwyrm/migrations/0014_merge_20201128_0007.py | 14 ++++++++++++++ bw-dev | 9 ++++----- 2 files changed, 18 insertions(+), 5 deletions(-) create mode 100644 bookwyrm/migrations/0014_merge_20201128_0007.py diff --git a/bookwyrm/migrations/0014_merge_20201128_0007.py b/bookwyrm/migrations/0014_merge_20201128_0007.py new file mode 100644 index 000000000..e811fa7ff --- /dev/null +++ b/bookwyrm/migrations/0014_merge_20201128_0007.py @@ -0,0 +1,14 @@ +# Generated by Django 3.0.7 on 2020-11-28 00:07 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0013_book_origin_id'), + ('bookwyrm', '0012_progressupdate'), + ] + + operations = [ + ] diff --git a/bw-dev b/bw-dev index 53c8e52d9..a6a1f326d 100755 --- a/bw-dev +++ b/bw-dev @@ -38,7 +38,9 @@ function initdb { execweb python manage.py initdb } -case "$1" in +CMD=$1 +shift +case "$CMD" in up) docker-compose up --build ;; @@ -57,11 +59,10 @@ case "$1" in clean ;; makemigrations) - execweb python manage.py makemigrations + execweb python manage.py makemigrations "$@" ;; migrate) execweb python manage.py rename_app fedireads bookwyrm - shift 1 execweb python manage.py migrate "$@" ;; bash) @@ -77,11 +78,9 @@ case "$1" in docker-compose restart celery_worker ;; test) - shift 1 execweb coverage run --source='.' --omit="*/test*,celerywyrm*,bookwyrm/migrations/*" manage.py test "$@" ;; pytest) - shift 1 execweb pytest "$@" ;; test_report) From 5f2ac6d9610876fbde25e03a6dc1711968b242d7 Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Fri, 27 Nov 2020 16:12:47 -0800 Subject: [PATCH 16/56] Rename fr_* to bw_* --- bookwyrm/tests/connectors/test_bookwyrm_connector.py | 6 +++--- bookwyrm/tests/data/{fr_edition.json => bw_edition.json} | 0 bookwyrm/tests/data/{fr_search.json => bw_search.json} | 0 bookwyrm/tests/data/{fr_work.json => bw_work.json} | 0 bookwyrm/tests/test_broadcast.py | 4 ++-- 5 files changed, 5 insertions(+), 5 deletions(-) rename bookwyrm/tests/data/{fr_edition.json => bw_edition.json} (100%) rename bookwyrm/tests/data/{fr_search.json => bw_search.json} (100%) rename bookwyrm/tests/data/{fr_work.json => bw_work.json} (100%) diff --git a/bookwyrm/tests/connectors/test_bookwyrm_connector.py b/bookwyrm/tests/connectors/test_bookwyrm_connector.py index c41b454c6..94abd6c55 100644 --- a/bookwyrm/tests/connectors/test_bookwyrm_connector.py +++ b/bookwyrm/tests/connectors/test_bookwyrm_connector.py @@ -22,9 +22,9 @@ class BookWyrmConnector(TestCase): self.connector = Connector('example.com') work_file = pathlib.Path(__file__).parent.joinpath( - '../data/fr_work.json') + '../data/bw_work.json') edition_file = pathlib.Path(__file__).parent.joinpath( - '../data/fr_edition.json') + '../data/bw_edition.json') self.work_data = json.loads(work_file.read_bytes()) self.edition_data = json.loads(edition_file.read_bytes()) @@ -35,7 +35,7 @@ class BookWyrmConnector(TestCase): def test_format_search_result(self): - datafile = pathlib.Path(__file__).parent.joinpath('../data/fr_search.json') + datafile = pathlib.Path(__file__).parent.joinpath('../data/bw_search.json') search_data = json.loads(datafile.read_bytes()) results = self.connector.parse_search_data(search_data) self.assertIsInstance(results, list) diff --git a/bookwyrm/tests/data/fr_edition.json b/bookwyrm/tests/data/bw_edition.json similarity index 100% rename from bookwyrm/tests/data/fr_edition.json rename to bookwyrm/tests/data/bw_edition.json diff --git a/bookwyrm/tests/data/fr_search.json b/bookwyrm/tests/data/bw_search.json similarity index 100% rename from bookwyrm/tests/data/fr_search.json rename to bookwyrm/tests/data/bw_search.json diff --git a/bookwyrm/tests/data/fr_work.json b/bookwyrm/tests/data/bw_work.json similarity index 100% rename from bookwyrm/tests/data/fr_work.json rename to bookwyrm/tests/data/bw_work.json diff --git a/bookwyrm/tests/test_broadcast.py b/bookwyrm/tests/test_broadcast.py index 1112b3fa6..addbd55e1 100644 --- a/bookwyrm/tests/test_broadcast.py +++ b/bookwyrm/tests/test_broadcast.py @@ -24,14 +24,14 @@ class Book(TestCase): inbox='http://example.com/u/2/inbox') self.user.followers.add(no_inbox_follower) - non_fr_follower = models.User.objects.create_user( + non_bw_follower = models.User.objects.create_user( 'gerbil', 'gerb@mouse.mouse', 'gerbword', remote_id='http://example.com/u/3', outbox='http://example2.com/u/3/o', inbox='http://example2.com/u/3/inbox', shared_inbox='http://example2.com/inbox', bookwyrm_user=False, local=False) - self.user.followers.add(non_fr_follower) + self.user.followers.add(non_bw_follower) local_follower = models.User.objects.create_user( 'joe', 'joe@mouse.mouse', 'jeoword') From f86140c7e4926a5c2ff7a5ced00e57c1b48994e0 Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Fri, 27 Nov 2020 17:39:15 -0800 Subject: [PATCH 17/56] Move -x down to eliminate pointless noise --- bw-dev | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bw-dev b/bw-dev index a6a1f326d..993520c4e 100755 --- a/bw-dev +++ b/bw-dev @@ -12,9 +12,6 @@ trap showerr EXIT source .env trap - EXIT -# show commands as they're executed -set -x - function clean { docker-compose stop docker-compose rm -f @@ -40,6 +37,10 @@ function initdb { CMD=$1 shift + +# show commands as they're executed +set -x + case "$CMD" in up) docker-compose up --build From 6455cc7fe9cdc0cec7b19dd177f8610055e23eff Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Fri, 27 Nov 2020 18:06:24 -0800 Subject: [PATCH 18/56] Add initial tests and some fixes Make timezones aware, and create a progress update if we can upon starting a readthrough --- bookwyrm/tests/actions/test_readthrough.py | 60 ++++++++++++++++++++++ bookwyrm/view_actions.py | 13 ++++- 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 bookwyrm/tests/actions/test_readthrough.py diff --git a/bookwyrm/tests/actions/test_readthrough.py b/bookwyrm/tests/actions/test_readthrough.py new file mode 100644 index 000000000..1c5cddb88 --- /dev/null +++ b/bookwyrm/tests/actions/test_readthrough.py @@ -0,0 +1,60 @@ +from django.test import TestCase, Client +from django.utils import timezone +from datetime import datetime + +from bookwyrm import view_actions as actions, models + +class ReadThrough(TestCase): + def setUp(self): + self.client = Client() + + self.work = models.Work.objects.create( + title='Example Work' + ) + + self.edition = models.Edition.objects.create( + title='Example Edition', + parent_work=self.work + ) + self.work.default_edition = self.edition + self.work.save() + + self.user = models.User.objects.create() + + self.client.force_login(self.user) + + def test_create_basic_readthrough(self): + """A basic readthrough doesn't create a progress update""" + self.assertEqual(self.edition.readthrough_set.count(), 0) + + self.client.post('/start-reading/{}'.format(self.edition.id), { + 'start_date': '2020-11-27', + }) + + readthroughs = self.edition.readthrough_set.all() + self.assertEqual(len(readthroughs), 1) + self.assertEqual(readthroughs[0].progressupdate_set.count(), 0) + self.assertEqual(readthroughs[0].start_date, + datetime(2020, 11, 27, tzinfo=timezone.utc)) + self.assertEqual(readthroughs[0].pages_read, None) + self.assertEqual(readthroughs[0].finish_date, None) + + def test_create_progress_readthrough(self): + self.assertEqual(self.edition.readthrough_set.count(), 0) + + self.client.post('/start-reading/{}'.format(self.edition.id), { + 'start_date': '2020-11-27', + 'pages_read': 50, + }) + + readthroughs = self.edition.readthrough_set.all() + self.assertEqual(len(readthroughs), 1) + self.assertEqual(readthroughs[0].start_date, + datetime(2020, 11, 27, tzinfo=timezone.utc)) + self.assertEqual(readthroughs[0].pages_read, 50) + self.assertEqual(readthroughs[0].finish_date, None) + + progress_updates = readthroughs[0].progressupdate_set.all() + self.assertEqual(len(progress_updates), 1) + self.assertEqual(progress_updates[0].mode, models.ProgressMode.PAGE) + self.assertEqual(progress_updates[0].progress, 50) diff --git a/bookwyrm/view_actions.py b/bookwyrm/view_actions.py index 130af8f68..f5aee7a42 100644 --- a/bookwyrm/view_actions.py +++ b/bookwyrm/view_actions.py @@ -363,6 +363,13 @@ def start_reading(request, book_id): if readthrough.start_date: readthrough.save() + # create a progress update if we have a page + if readthrough.pages_read: + readthrough.progressupdate_set.create( + user=request.user, + progress=readthrough.pages_read, + mode=models.ProgressMode.PAGE) + # shelve the book if request.POST.get('reshelve', True): try: @@ -728,7 +735,8 @@ def update_readthrough(request, book=None, create=True): start_date = request.POST.get('start_date') if start_date: try: - start_date = dateutil.parser.parse(start_date) + start_date = timezone.make_aware( + dateutil.parser.parse(start_date)) readthrough.start_date = start_date except ParserError: pass @@ -736,7 +744,8 @@ def update_readthrough(request, book=None, create=True): finish_date = request.POST.get('finish_date') if finish_date: try: - finish_date = dateutil.parser.parse(finish_date) + finish_date = timezone.make_aware( + dateutil.parser.parse(finish_date)) readthrough.finish_date = finish_date except ParserError: pass From 9ed7d2300064610bca0d6c62aa6b003a75324105 Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Fri, 27 Nov 2020 18:17:32 -0800 Subject: [PATCH 19/56] Test updating a progress Also remove spurious whitespace change --- bookwyrm/models/status.py | 1 + bookwyrm/tests/actions/test_readthrough.py | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index 732cdd62b..b92685da4 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -210,6 +210,7 @@ class Quotation(Status): activity_serializer = activitypub.Quotation pure_activity_serializer = activitypub.Note + class Review(Status): ''' a book review ''' name = models.CharField(max_length=255, null=True) diff --git a/bookwyrm/tests/actions/test_readthrough.py b/bookwyrm/tests/actions/test_readthrough.py index 1c5cddb88..ba8c2f007 100644 --- a/bookwyrm/tests/actions/test_readthrough.py +++ b/bookwyrm/tests/actions/test_readthrough.py @@ -58,3 +58,15 @@ class ReadThrough(TestCase): self.assertEqual(len(progress_updates), 1) self.assertEqual(progress_updates[0].mode, models.ProgressMode.PAGE) self.assertEqual(progress_updates[0].progress, 50) + + # Update progress + self.client.post('/edit-readthrough', { + 'id': readthroughs[0].id, + 'pages_read': 100, + }) + + progress_updates = readthroughs[0].progressupdate_set\ + .order_by('updated_date').all() + self.assertEqual(len(progress_updates), 2) + self.assertEqual(progress_updates[1].mode, models.ProgressMode.PAGE) + self.assertEqual(progress_updates[1].progress, 100) From 500f05266a3fb46308838169936d60debaa7c0a7 Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Sat, 28 Nov 2020 00:07:04 -0800 Subject: [PATCH 20/56] Add option for progress percentage And rework display on book page as well --- bookwyrm/models/status.py | 20 +++++-- bookwyrm/templates/book.html | 54 ++++++++++++++----- .../templates/snippets/progress_update.html | 23 ++++++-- bookwyrm/tests/actions/test_readthrough.py | 8 +-- bookwyrm/view_actions.py | 27 +++++----- bookwyrm/views.py | 2 +- 6 files changed, 95 insertions(+), 39 deletions(-) diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index b92685da4..2a9a4be14 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -292,13 +292,21 @@ class Boost(Status): # unique_together = ('user', 'boosted_status') +class ProgressMode(models.TextChoices): + PAGE = 'PG', 'page' + PERCENT = 'PCT', 'percent' + class ReadThrough(BookWyrmModel): ''' Store a read through a book in the database. ''' user = models.ForeignKey('User', on_delete=models.PROTECT) book = models.ForeignKey('Book', on_delete=models.PROTECT) - pages_read = models.IntegerField( + progress = models.IntegerField( null=True, blank=True) + progress_mode = models.CharField( + max_length=3, + choices=ProgressMode.choices, + default=ProgressMode.PAGE) start_date = models.DateTimeField( blank=True, null=True) @@ -312,9 +320,13 @@ class ReadThrough(BookWyrmModel): self.user.save() super().save(*args, **kwargs) -class ProgressMode(models.TextChoices): - PAGE = 'PG', 'page' - PERCENT = 'PCT', 'percent' + def create_update(self): + if self.progress: + return self.progressupdate_set.create( + user=self.user, + progress=self.progress, + mode=self.progress_mode) + class ProgressUpdate(BookWyrmModel): ''' Store progress through a book in the database. ''' diff --git a/bookwyrm/templates/book.html b/bookwyrm/templates/book.html index 2bd9fc778..aeafaafb7 100644 --- a/bookwyrm/templates/book.html +++ b/bookwyrm/templates/book.html @@ -74,9 +74,13 @@ {% if readthrough.finish_date %}
Finished reading:
{{ readthrough.finish_date | naturalday }}
- {% elif readthrough.pages_read %} -
On page:
-
{{ readthrough.pages_read }} of {{ book.pages }}
+ {% elif readthrough.progress %} +
Progress:
+ {% if readthrough.progress_mode == 'PG' %} +
on page {{ readthrough.progress }} of {{ book.pages }}
+ {% else %} +
{{ readthrough.progress }}%
+ {% endif %} {% endif %}
@@ -93,12 +97,22 @@
{% if show_progress %} Progress Updates: -
+
    + {% if readthrough.finish_date %} +
  • {{ readthrough.start_date | naturalday }}: finished
  • + {% endif %} {% for progress_update in readthrough.progress_updates %} -
    {{ progress_update.created_date | naturalday }}:
    -
    {{ progress_update.progress }} of {{ book.pages }}
    +
  • + {{ progress_update.created_date | naturalday }}: + {% if progress_update.mode == 'PG' %} + page {{ progress_update.progress }} of {{ book.pages }} + {% else %} + {{ progress_update.progress }}% + {% endif %} +
  • {% endfor %} -
+
  • {{ readthrough.start_date | naturalday }}: started
  • + {% elif readthrough.progress_updates|length %} Show {{ readthrough.progress_updates|length }} Progress Updates {% endif %} @@ -118,11 +132,27 @@ -
    - +
    +
    +
    + +
    +
    +
    +
    + + +
    +
    +{# Only show progress for editing existing readthroughs #} +{% if readthrough.id and not readthrough.finish_date %}
    @@ -29,6 +31,7 @@
    +{% endif %}
    -
    -
    - {% include 'snippets/shelve_button.html' with book=book %} - {% active_shelf book as active_shelf %} -
    - {% if active_shelf.identifier == 'reading' and book.latest_readthrough %} -
    - {% include 'snippets/progress_update.html' with readthrough=book.latest_readthrough %} -
    - {% endif %} -
    +
    +
    + {% include 'snippets/shelve_button.html' with book=book %} +
    + {% active_shelf book as active_shelf %} + {% if active_shelf.shelf.identifier == 'reading' and book.latest_readthrough %} +
    + {% include 'snippets/progress_update.html' with readthrough=book.latest_readthrough %} +
    + {% endif %} +
    {% include 'snippets/create_status.html' with book=book %}
    diff --git a/bookwyrm/templates/snippets/progress_update.html b/bookwyrm/templates/snippets/progress_update.html index 9de51b07b..6936429d3 100644 --- a/bookwyrm/templates/snippets/progress_update.html +++ b/bookwyrm/templates/snippets/progress_update.html @@ -12,7 +12,7 @@ + name="progress" size="3" value="{{ readthrough.progress|default:"" }}">
    {% if readthrough.progress_mode == 'PG' and book.pages %} From a4796cf5c5d87207f83ef1d7230edb4701fb431f Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Sun, 17 Jan 2021 03:14:26 -0800 Subject: [PATCH 25/56] Make the switching work, wows Layout's all wonky now, but hey --- .../templates/snippets/progress_update.html | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/bookwyrm/templates/snippets/progress_update.html b/bookwyrm/templates/snippets/progress_update.html index 6936429d3..79cbf1598 100644 --- a/bookwyrm/templates/snippets/progress_update.html +++ b/bookwyrm/templates/snippets/progress_update.html @@ -1,25 +1,40 @@
    {% csrf_token %} -
    - {% if readthrough.progress_mode == 'PG' %} - on page - {% else %} - currently at - {% endif %} + +
    + +
    -
    - -
    -
    - {% if readthrough.progress_mode == 'PG' and book.pages %} - of {{ book.pages }} - {% else %} - % - {% endif %} +
    + +
    From 6e05dfde927fd6918f839005dab8a222b8ebcb76 Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Sun, 17 Jan 2021 12:40:24 -0800 Subject: [PATCH 26/56] Revert "Make the switching work, wows" Actually this is bad, switching on this page is not useful enough for the UI complexity. Users can switch percent/pages on the book page. This reverts commit a4796cf5c5d87207f83ef1d7230edb4701fb431f. --- .../templates/snippets/progress_update.html | 51 +++++++------------ 1 file changed, 18 insertions(+), 33 deletions(-) diff --git a/bookwyrm/templates/snippets/progress_update.html b/bookwyrm/templates/snippets/progress_update.html index 79cbf1598..6936429d3 100644 --- a/bookwyrm/templates/snippets/progress_update.html +++ b/bookwyrm/templates/snippets/progress_update.html @@ -1,40 +1,25 @@ {% csrf_token %} - -
    - - +
    + {% if readthrough.progress_mode == 'PG' %} + on page + {% else %} + currently at + {% endif %}
    -
    - - +
    + +
    +
    + {% if readthrough.progress_mode == 'PG' and book.pages %} + of {{ book.pages }} + {% else %} + % + {% endif %}
    From ef05ac1f6540e9d175a15d6274c9c0249b0643b6 Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Sun, 17 Jan 2021 12:48:10 -0800 Subject: [PATCH 27/56] Small fixes to old form --- bookwyrm/templates/snippets/progress_update.html | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/bookwyrm/templates/snippets/progress_update.html b/bookwyrm/templates/snippets/progress_update.html index 6936429d3..834766c23 100644 --- a/bookwyrm/templates/snippets/progress_update.html +++ b/bookwyrm/templates/snippets/progress_update.html @@ -10,16 +10,14 @@
    + name="progress" size="3" value="{{ readthrough.progress|default:'' }}">
    - {% if readthrough.progress_mode == 'PG' and book.pages %} - of {{ book.pages }} - {% else %} - % - {% endif %} + {% if readthrough.progress_mode == 'PG' %} + {% if book.pages %} of {{ book.pages }} {% endif %} + {% else %} % {% endif %}
    From 49893f49e10234c097774a87e5390a2edc7df824 Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Sun, 17 Jan 2021 13:09:49 -0800 Subject: [PATCH 28/56] Merge fixes --- bookwyrm/tests/actions/test_readthrough.py | 2 +- bookwyrm/views/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bookwyrm/tests/actions/test_readthrough.py b/bookwyrm/tests/actions/test_readthrough.py index fb16edcb7..b92c9b657 100644 --- a/bookwyrm/tests/actions/test_readthrough.py +++ b/bookwyrm/tests/actions/test_readthrough.py @@ -2,7 +2,7 @@ from django.test import TestCase, Client from django.utils import timezone from datetime import datetime -from bookwyrm import view_actions as actions, models +from bookwyrm import models class ReadThrough(TestCase): def setUp(self): diff --git a/bookwyrm/views/__init__.py b/bookwyrm/views/__init__.py index e1ffeda42..b8de5d6cb 100644 --- a/bookwyrm/views/__init__.py +++ b/bookwyrm/views/__init__.py @@ -15,7 +15,7 @@ from .landing import About, Home, Feed, Discover from .notifications import Notifications from .outbox import Outbox from .reading import edit_readthrough, create_readthrough, delete_readthrough -from .reading import start_reading, finish_reading +from .reading import start_reading, finish_reading, delete_progressupdate from .password import PasswordResetRequest, PasswordReset, ChangePassword from .tag import Tag, AddTag, RemoveTag from .search import Search From 0af4863568b599fa2f7a822b64e2416806665947 Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Sun, 17 Jan 2021 13:20:49 -0800 Subject: [PATCH 29/56] Update merge migration --- ...036_merge_20210114_0348.py => 0037_merge_20210117_2106.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename bookwyrm/migrations/{0036_merge_20210114_0348.py => 0037_merge_20210117_2106.py} (64%) diff --git a/bookwyrm/migrations/0036_merge_20210114_0348.py b/bookwyrm/migrations/0037_merge_20210117_2106.py similarity index 64% rename from bookwyrm/migrations/0036_merge_20210114_0348.py rename to bookwyrm/migrations/0037_merge_20210117_2106.py index 6dd1a7750..40e7a7e1f 100644 --- a/bookwyrm/migrations/0036_merge_20210114_0348.py +++ b/bookwyrm/migrations/0037_merge_20210117_2106.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.7 on 2021-01-14 03:48 +# Generated by Django 3.0.7 on 2021-01-17 21:06 from django.db import migrations @@ -7,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ ('bookwyrm', '0015_auto_20201128_0734'), - ('bookwyrm', '0035_edition_edition_rank'), + ('bookwyrm', '0036_annualgoal'), ] operations = [ From 1ea8ea0c26e7431c7e69149d7c1c580b9ee40460 Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Sun, 17 Jan 2021 20:31:06 -0800 Subject: [PATCH 30/56] Temp commit to make test run verbose, to see what's happening --- .github/workflows/django-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/django-tests.yml b/.github/workflows/django-tests.yml index 3ce368ecd..e734d18ec 100644 --- a/.github/workflows/django-tests.yml +++ b/.github/workflows/django-tests.yml @@ -65,4 +65,4 @@ jobs: EMAIL_HOST_PASSWORD: "" EMAIL_USE_TLS: true run: | - python manage.py test + python manage.py test -v 3 From 79e284e5bee986f38ffcab1fdda0f67f17432971 Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Mon, 18 Jan 2021 19:59:40 -0800 Subject: [PATCH 31/56] Just scootch the migration merge up --- bookwyrm/migrations/0037_merge_20210117_2106.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bookwyrm/migrations/0037_merge_20210117_2106.py b/bookwyrm/migrations/0037_merge_20210117_2106.py index 40e7a7e1f..603ad0ce7 100644 --- a/bookwyrm/migrations/0037_merge_20210117_2106.py +++ b/bookwyrm/migrations/0037_merge_20210117_2106.py @@ -7,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ ('bookwyrm', '0015_auto_20201128_0734'), - ('bookwyrm', '0036_annualgoal'), + ('bookwyrm', '0037_auto_20210118_1954'), ] operations = [ From 60b42827f4b9d8da2718a5ec6d82d6b6e1d08976 Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Mon, 18 Jan 2021 20:00:04 -0800 Subject: [PATCH 32/56] Mock the AP publishing to stop hanging tests --- bookwyrm/tests/actions/test_readthrough.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bookwyrm/tests/actions/test_readthrough.py b/bookwyrm/tests/actions/test_readthrough.py index b92c9b657..bd24cec27 100644 --- a/bookwyrm/tests/actions/test_readthrough.py +++ b/bookwyrm/tests/actions/test_readthrough.py @@ -1,9 +1,11 @@ +from unittest.mock import patch from django.test import TestCase, Client from django.utils import timezone from datetime import datetime from bookwyrm import models +@patch('bookwyrm.broadcast.broadcast_task.delay') class ReadThrough(TestCase): def setUp(self): self.client = Client() @@ -25,7 +27,7 @@ class ReadThrough(TestCase): self.client.force_login(self.user) - def test_create_basic_readthrough(self): + def test_create_basic_readthrough(self, delay_mock): """A basic readthrough doesn't create a progress update""" self.assertEqual(self.edition.readthrough_set.count(), 0) @@ -40,8 +42,9 @@ class ReadThrough(TestCase): datetime(2020, 11, 27, tzinfo=timezone.utc)) self.assertEqual(readthroughs[0].progress, None) self.assertEqual(readthroughs[0].finish_date, None) + self.assertEqual(delay_mock.call_count, 1) - def test_create_progress_readthrough(self): + def test_create_progress_readthrough(self, delay_mock): self.assertEqual(self.edition.readthrough_set.count(), 0) self.client.post('/start-reading/{}'.format(self.edition.id), { @@ -60,6 +63,7 @@ class ReadThrough(TestCase): self.assertEqual(len(progress_updates), 1) self.assertEqual(progress_updates[0].mode, models.ProgressMode.PAGE) self.assertEqual(progress_updates[0].progress, 50) + self.assertEqual(delay_mock.call_count, 1) # Update progress self.client.post('/edit-readthrough', { @@ -72,3 +76,4 @@ class ReadThrough(TestCase): self.assertEqual(len(progress_updates), 2) self.assertEqual(progress_updates[1].mode, models.ProgressMode.PAGE) self.assertEqual(progress_updates[1].progress, 100) + self.assertEqual(delay_mock.call_count, 1) # Edit doesn't publish anything From 32346cf9a3675f8d627c00701827397b98c835c9 Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Tue, 19 Jan 2021 22:30:51 -0800 Subject: [PATCH 33/56] Cascade-delete progress updates Add a warning about it, and update test to confirm it works --- bookwyrm/models/readthrough.py | 2 +- bookwyrm/templates/snippets/components/modal.html | 7 +++---- .../templates/snippets/delete_readthrough_modal.html | 6 +++++- bookwyrm/tests/actions/test_readthrough.py | 9 +++++++++ 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/bookwyrm/models/readthrough.py b/bookwyrm/models/readthrough.py index 3afde3066..8ec3c9c60 100644 --- a/bookwyrm/models/readthrough.py +++ b/bookwyrm/models/readthrough.py @@ -42,7 +42,7 @@ class ReadThrough(BookWyrmModel): class ProgressUpdate(BookWyrmModel): ''' Store progress through a book in the database. ''' user = models.ForeignKey('User', on_delete=models.PROTECT) - readthrough = models.ForeignKey('ReadThrough', on_delete=models.PROTECT) + readthrough = models.ForeignKey('ReadThrough', on_delete=models.CASCADE) progress = models.IntegerField() mode = models.CharField( max_length=3, diff --git a/bookwyrm/templates/snippets/components/modal.html b/bookwyrm/templates/snippets/components/modal.html index 3eec9efae..72402914b 100644 --- a/bookwyrm/templates/snippets/components/modal.html +++ b/bookwyrm/templates/snippets/components/modal.html @@ -7,11 +7,10 @@ {% include 'snippets/toggle/toggle_button.html' with label="close" class="delete" nonbutton=True %} - {% block modal-form-open %}{% endblock %} - - {% block modal-body %}{% endblock %} - +
    {% block modal-footer %}{% endblock %}
    diff --git a/bookwyrm/templates/snippets/delete_readthrough_modal.html b/bookwyrm/templates/snippets/delete_readthrough_modal.html index 2155afb31..c04a1d90e 100644 --- a/bookwyrm/templates/snippets/delete_readthrough_modal.html +++ b/bookwyrm/templates/snippets/delete_readthrough_modal.html @@ -1,6 +1,10 @@ {% extends 'snippets/components/modal.html' %} {% block modal-title %}Delete these read dates?{% endblock %} - +{% block modal-body %} +{% if readthrough.progress_updates|length > 0 %} +You are deleting this readthrough and its {{ readthrough.progress_updates|length }} associated progress updates. +{% endif %} +{% endblock %} {% block modal-footer %} {% csrf_token %} diff --git a/bookwyrm/tests/actions/test_readthrough.py b/bookwyrm/tests/actions/test_readthrough.py index bd24cec27..41d2eaa52 100644 --- a/bookwyrm/tests/actions/test_readthrough.py +++ b/bookwyrm/tests/actions/test_readthrough.py @@ -77,3 +77,12 @@ class ReadThrough(TestCase): self.assertEqual(progress_updates[1].mode, models.ProgressMode.PAGE) self.assertEqual(progress_updates[1].progress, 100) self.assertEqual(delay_mock.call_count, 1) # Edit doesn't publish anything + + self.client.post('/delete-readthrough', { + 'id': readthroughs[0].id, + }) + + readthroughs = self.edition.readthrough_set.all() + updates = self.user.progressupdate_set.all() + self.assertEqual(len(readthroughs), 0) + self.assertEqual(len(updates), 0) From edba55f7c2f8e39fe06a9ba644fbf5bdb48db400 Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Tue, 19 Jan 2021 23:04:08 -0800 Subject: [PATCH 34/56] Flatten and rework sidebar update --- bookwyrm/templates/feed.html | 16 +++----- .../templates/snippets/progress_update.html | 38 ++++++++++--------- 2 files changed, 25 insertions(+), 29 deletions(-) diff --git a/bookwyrm/templates/feed.html b/bookwyrm/templates/feed.html index 7da2a2855..1368660bc 100644 --- a/bookwyrm/templates/feed.html +++ b/bookwyrm/templates/feed.html @@ -47,17 +47,11 @@
    -
    -
    - {% include 'snippets/shelve_button.html' with book=book %} -
    - {% active_shelf book as active_shelf %} - {% if active_shelf.shelf.identifier == 'reading' and book.latest_readthrough %} -
    - {% include 'snippets/progress_update.html' with readthrough=book.latest_readthrough %} -
    - {% endif %} -
    + {% include 'snippets/shelve_button.html' with book=book %} + {% active_shelf book as active_shelf %} + {% if active_shelf.shelf.identifier == 'reading' and book.latest_readthrough %} + {% include 'snippets/progress_update.html' with readthrough=book.latest_readthrough %} + {% endif %} {% include 'snippets/create_status.html' with book=book %}
    diff --git a/bookwyrm/templates/snippets/progress_update.html b/bookwyrm/templates/snippets/progress_update.html index 834766c23..a2b234044 100644 --- a/bookwyrm/templates/snippets/progress_update.html +++ b/bookwyrm/templates/snippets/progress_update.html @@ -1,25 +1,27 @@ - + {% csrf_token %} +
    - {% if readthrough.progress_mode == 'PG' %} - on page - {% else %} - currently at - {% endif %} +
    - -
    -
    - {% if readthrough.progress_mode == 'PG' %} - {% if book.pages %} of {{ book.pages }} {% endif %} - {% else %} % {% endif %} -
    -
    - +
    From 070fa04b63ad313fae9fe0b31f2819e90dfa18e3 Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Tue, 19 Jan 2021 23:40:11 -0800 Subject: [PATCH 35/56] Add validators and more tests I don't think these validators will do anything unless we use them or are submitting a form, but they're there nonetheless --- bookwyrm/models/readthrough.py | 4 +- .../tests/models/test_readthrough_model.py | 51 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 bookwyrm/tests/models/test_readthrough_model.py diff --git a/bookwyrm/models/readthrough.py b/bookwyrm/models/readthrough.py index 8ec3c9c60..7daafaaff 100644 --- a/bookwyrm/models/readthrough.py +++ b/bookwyrm/models/readthrough.py @@ -1,6 +1,7 @@ ''' progress in a book ''' from django.db import models from django.utils import timezone +from django.core import validators from .base_model import BookWyrmModel @@ -13,6 +14,7 @@ class ReadThrough(BookWyrmModel): user = models.ForeignKey('User', on_delete=models.PROTECT) book = models.ForeignKey('Edition', on_delete=models.PROTECT) progress = models.IntegerField( + validators=[validators.MinValueValidator(0)], null=True, blank=True) progress_mode = models.CharField( @@ -43,7 +45,7 @@ class ProgressUpdate(BookWyrmModel): ''' Store progress through a book in the database. ''' user = models.ForeignKey('User', on_delete=models.PROTECT) readthrough = models.ForeignKey('ReadThrough', on_delete=models.CASCADE) - progress = models.IntegerField() + progress = models.IntegerField(validators=[validators.MinValueValidator(0)]) mode = models.CharField( max_length=3, choices=ProgressMode.choices, diff --git a/bookwyrm/tests/models/test_readthrough_model.py b/bookwyrm/tests/models/test_readthrough_model.py new file mode 100644 index 000000000..3fcdf1e4a --- /dev/null +++ b/bookwyrm/tests/models/test_readthrough_model.py @@ -0,0 +1,51 @@ +''' testing models ''' +from django.test import TestCase +from django.core.exceptions import ValidationError + +from bookwyrm import models, settings + + +class ReadThrough(TestCase): + ''' some activitypub oddness ahead ''' + def setUp(self): + ''' look, a shelf ''' + self.user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.mouse', 'mouseword', + local=True, localname='mouse') + + self.work = models.Work.objects.create( + title='Example Work' + ) + + self.edition = models.Edition.objects.create( + title='Example Edition', + parent_work=self.work + ) + self.work.default_edition = self.edition + self.work.save() + + self.readthrough = models.ReadThrough.objects.create( + user=self.user, + book=self.edition) + + def test_progress_update(self): + ''' Test progress updates ''' + self.readthrough.create_update() # No-op, no progress yet + self.readthrough.progress = 10 + self.readthrough.create_update() + self.readthrough.progress = 20 + self.readthrough.progress_mode = models.ProgressMode.PERCENT + self.readthrough.create_update() + + updates = self.readthrough.progressupdate_set \ + .order_by('created_date').all() + self.assertEqual(len(updates), 2) + self.assertEqual(updates[0].progress, 10) + self.assertEqual(updates[0].mode, models.ProgressMode.PAGE) + self.assertEqual(updates[1].progress, 20) + self.assertEqual(updates[1].mode, models.ProgressMode.PERCENT) + + self.readthrough.progress = -10 + self.assertRaises(ValidationError, self.readthrough.clean_fields) + update = self.readthrough.create_update() + self.assertRaises(ValidationError, update.clean_fields) From 57607c3590f73eae2eb854dca53bd6fe667568e6 Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Tue, 19 Jan 2021 23:53:42 -0800 Subject: [PATCH 36/56] Regenerate merge migration --- ...037_merge_20210117_2106.py => 0039_merge_20210120_0753.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename bookwyrm/migrations/{0037_merge_20210117_2106.py => 0039_merge_20210120_0753.py} (64%) diff --git a/bookwyrm/migrations/0037_merge_20210117_2106.py b/bookwyrm/migrations/0039_merge_20210120_0753.py similarity index 64% rename from bookwyrm/migrations/0037_merge_20210117_2106.py rename to bookwyrm/migrations/0039_merge_20210120_0753.py index 603ad0ce7..1af40ee93 100644 --- a/bookwyrm/migrations/0037_merge_20210117_2106.py +++ b/bookwyrm/migrations/0039_merge_20210120_0753.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.7 on 2021-01-17 21:06 +# Generated by Django 3.0.7 on 2021-01-20 07:53 from django.db import migrations @@ -6,8 +6,8 @@ from django.db import migrations class Migration(migrations.Migration): dependencies = [ + ('bookwyrm', '0038_auto_20210119_1534'), ('bookwyrm', '0015_auto_20201128_0734'), - ('bookwyrm', '0037_auto_20210118_1954'), ] operations = [ From 3db0de3dd46c1d8291efb2d480b1edf414bdd731 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Fri, 22 Jan 2021 09:19:02 -0800 Subject: [PATCH 37/56] Makes pages/percents toggle-able in sidebar --- .../migrations/0040_auto_20210122_0057.py | 36 +++++++++++++++++++ .../templates/snippets/progress_update.html | 26 +++++++------- 2 files changed, 49 insertions(+), 13 deletions(-) create mode 100644 bookwyrm/migrations/0040_auto_20210122_0057.py diff --git a/bookwyrm/migrations/0040_auto_20210122_0057.py b/bookwyrm/migrations/0040_auto_20210122_0057.py new file mode 100644 index 000000000..8e528a899 --- /dev/null +++ b/bookwyrm/migrations/0040_auto_20210122_0057.py @@ -0,0 +1,36 @@ +# Generated by Django 3.0.7 on 2021-01-22 00:57 + +import bookwyrm.models.fields +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0039_merge_20210120_0753'), + ] + + operations = [ + migrations.AlterField( + model_name='progressupdate', + name='progress', + field=models.IntegerField(validators=[django.core.validators.MinValueValidator(0)]), + ), + migrations.AlterField( + model_name='progressupdate', + name='readthrough', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.ReadThrough'), + ), + migrations.AlterField( + model_name='progressupdate', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='readthrough', + name='progress', + field=models.IntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0)]), + ), + ] diff --git a/bookwyrm/templates/snippets/progress_update.html b/bookwyrm/templates/snippets/progress_update.html index a2b234044..dbe5cb758 100644 --- a/bookwyrm/templates/snippets/progress_update.html +++ b/bookwyrm/templates/snippets/progress_update.html @@ -1,27 +1,27 @@
    {% csrf_token %} - -
    -
    + -
    - + {% if readthrough.progress_mode == 'PG' and book.pages %} +

    of {{ book.pages }} pages

    + {% endif %}
    From 806b781f1577d40129a0f53a8590f044a002f44f Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Fri, 22 Jan 2021 09:21:58 -0800 Subject: [PATCH 38/56] Adds html form validator for min value --- bookwyrm/templates/snippets/progress_update.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bookwyrm/templates/snippets/progress_update.html b/bookwyrm/templates/snippets/progress_update.html index dbe5cb758..92a7573a9 100644 --- a/bookwyrm/templates/snippets/progress_update.html +++ b/bookwyrm/templates/snippets/progress_update.html @@ -7,7 +7,7 @@
    From 69c5bf71ed7ca81b0241cb041c1069af99c69126 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Fri, 22 Jan 2021 12:51:23 -0800 Subject: [PATCH 39/56] Uses javascript show/hide for reading progress updates --- bookwyrm/templates/snippets/readthrough.html | 104 ++++++++----------- 1 file changed, 46 insertions(+), 58 deletions(-) diff --git a/bookwyrm/templates/snippets/readthrough.html b/bookwyrm/templates/snippets/readthrough.html index 79248891e..618433b5e 100644 --- a/bookwyrm/templates/snippets/readthrough.html +++ b/bookwyrm/templates/snippets/readthrough.html @@ -1,67 +1,55 @@ {% load humanize %}
    -
    - {% if readthrough.start_date %} -
    -
    Started reading:
    -
    {{ readthrough.start_date | naturalday }}
    +
    +
    + Progress Updates: +
    +
      + {% if readthrough.finish_date %} +
    • {{ readthrough.start_date | naturalday }}: finished
    • + {% endif %} + {% if readthrough.progress %} +
    • {% if readthrough.progress_mode == 'PG' %}on page {{ readthrough.progress }}{% if book.pages %} of {{ book.pages }}{% endif %} + {% else %}{{ readthrough.progress }}%{% endif %} + {% include 'snippets/toggle/toggle_button.html' with text="Show all updates" controls_text="updates" controls_uid=readthrough.id class="is-small" %} + +
    • + {% endif %} +
    • {{ readthrough.start_date | naturalday }}: started
    • +
    - {% endif %} - {% if readthrough.finish_date %} -
    -
    Finished reading:
    -
    {{ readthrough.finish_date | naturalday }}
    -
    - {% elif readthrough.progress %} -
    -
    Progress:
    - {% if readthrough.progress_mode == 'PG' %} -
    on page {{ readthrough.progress }} of {{ book.pages }}
    - {% else %} -
    {{ readthrough.progress }}%
    - {% endif %} -
    - {% endif %} - -
    -
    - {% include 'snippets/toggle/toggle_button.html' with class="is-small" text="Edit read dates" icon="pencil" controls_text="edit-readthrough" controls_uid=readthrough.id focus="edit-readthrough" %} -
    -
    - {% include 'snippets/toggle/toggle_button.html' with class="is-small" text="Delete these read dates" icon="x" controls_text="delete-readthrough" controls_uid=readthrough.id focus="modal-title-delete-readthrough" %} +
    +
    +
    + {% include 'snippets/toggle/toggle_button.html' with class="is-small" text="Edit read dates" icon="pencil" controls_text="edit-readthrough" controls_uid=readthrough.id focus="edit-readthrough" %} +
    +
    + {% include 'snippets/toggle/toggle_button.html' with class="is-small" text="Delete these read dates" icon="x" controls_text="delete-readthrough" controls_uid=readthrough.id focus="modal-title-delete-readthrough" %} +
    +
    - {% if show_progress %} - Progress Updates: -
      - {% if readthrough.finish_date %} -
    • {{ readthrough.start_date | naturalday }}: finished
    • - {% endif %} - {% for progress_update in readthrough.progress_updates %} -
    • -
      - {% csrf_token %} - {{ progress_update.created_date | naturalday }}: - {% if progress_update.mode == 'PG' %} - page {{ progress_update.progress }} of {{ book.pages }} - {% else %} - {{ progress_update.progress }}% - {% endif %} - - -
      -
    • - {% endfor %} -
    • {{ readthrough.start_date | naturalday }}: started
    • -
    - {% elif readthrough.progress_updates|length %} - Show {{ readthrough.progress_updates|length }} Progress Updates - {% endif %}
    From 12c23836b711644b4906a516972f9f4439f50bc6 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Fri, 22 Jan 2021 12:54:25 -0800 Subject: [PATCH 40/56] Fixes display of finish date --- bookwyrm/templates/snippets/readthrough.html | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/bookwyrm/templates/snippets/readthrough.html b/bookwyrm/templates/snippets/readthrough.html index 618433b5e..e0b25d8bd 100644 --- a/bookwyrm/templates/snippets/readthrough.html +++ b/bookwyrm/templates/snippets/readthrough.html @@ -6,12 +6,9 @@ Progress Updates:
      - {% if readthrough.finish_date %} -
    • {{ readthrough.start_date | naturalday }}: finished
    • - {% endif %} {% if readthrough.progress %} -
    • {% if readthrough.progress_mode == 'PG' %}on page {{ readthrough.progress }}{% if book.pages %} of {{ book.pages }}{% endif %} - {% else %}{{ readthrough.progress }}%{% endif %} +
    • {% if readthrough.finish_date %} {{ readthrough.finish_date | naturalday }}: finished {% else %}{% if readthrough.progress_mode == 'PG' %}on page {{ readthrough.progress }}{% if book.pages %} of {{ book.pages }}{% endif %} + {% else %}{{ readthrough.progress }}%{% endif %}{% endif %} {% include 'snippets/toggle/toggle_button.html' with text="Show all updates" controls_text="updates" controls_uid=readthrough.id class="is-small" %}
    {# Only show progress for editing existing readthroughs #} {% if readthrough.id and not readthrough.finish_date %} -
    -
    -
    - -
    + +
    +
    +
    -
    -
    - - -
    +
    +
    {% endif %} From 20758b662dbe42244656cd31c5bd145ce94176ad Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Fri, 22 Jan 2021 14:33:03 -0800 Subject: [PATCH 42/56] don't need the showprogress get param any longer --- bookwyrm/views/books.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bookwyrm/views/books.py b/bookwyrm/views/books.py index a6e2667f0..527045606 100644 --- a/bookwyrm/views/books.py +++ b/bookwyrm/views/books.py @@ -74,7 +74,8 @@ class Book(View): for readthrough in readthroughs: readthrough.progress_updates = \ - readthrough.progressupdate_set.all().order_by('-updated_date') + readthrough.progressupdate_set.all() \ + .order_by('-updated_date') user_shelves = models.ShelfBook.objects.filter( added_by=request.user, book=book @@ -98,7 +99,6 @@ class Book(View): 'user_shelves': user_shelves, 'other_edition_shelves': other_edition_shelves, 'readthroughs': readthroughs, - 'show_progress': ('showprogress' in request.GET), 'path': '/book/%s' % book_id, } return TemplateResponse(request, 'book.html', data) From 54f8a65ae2b114cdd40044a120ca8ab22a60b3a9 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Thu, 21 Jan 2021 16:43:40 -0800 Subject: [PATCH 43/56] Adds block option to status menu --- bookwyrm/templates/snippets/status_body.html | 2 -- bookwyrm/templates/snippets/status_options.html | 12 ++++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/bookwyrm/templates/snippets/status_body.html b/bookwyrm/templates/snippets/status_body.html index 59ab234dd..c70f898ad 100644 --- a/bookwyrm/templates/snippets/status_body.html +++ b/bookwyrm/templates/snippets/status_body.html @@ -54,11 +54,9 @@ -{% if status.user == request.user %} -{% endif %} {% endblock %} diff --git a/bookwyrm/templates/snippets/status_options.html b/bookwyrm/templates/snippets/status_options.html index 6cd13dfdb..9b312c7ca 100644 --- a/bookwyrm/templates/snippets/status_options.html +++ b/bookwyrm/templates/snippets/status_options.html @@ -1,4 +1,5 @@ {% extends 'snippets/components/dropdown.html' %} +{% load bookwyrm_tags %} {% block dropdown-trigger %} @@ -7,6 +8,7 @@ {% endblock %} {% block dropdown-list %} +{% if status.user == request.user %}
  • +
  • +{% endif %} +
  • + +
  • {% endblock %} From cc8888dea20bd520a0169549d2a1d0fd7469d4a1 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sat, 23 Jan 2021 11:03:10 -0800 Subject: [PATCH 44/56] Adds incoming handler for blocking --- bookwyrm/activitypub/__init__.py | 2 +- bookwyrm/activitypub/verbs.py | 4 ++++ bookwyrm/incoming.py | 18 ++++++++++++++++++ bookwyrm/models/relationship.py | 2 +- bookwyrm/tests/test_incoming.py | 26 ++++++++++++++++++++++++++ 5 files changed, 50 insertions(+), 2 deletions(-) diff --git a/bookwyrm/activitypub/__init__.py b/bookwyrm/activitypub/__init__.py index a4fef41e5..a74397225 100644 --- a/bookwyrm/activitypub/__init__.py +++ b/bookwyrm/activitypub/__init__.py @@ -14,7 +14,7 @@ from .person import Person, PublicKey from .response import ActivitypubResponse from .book import Edition, Work, Author from .verbs import Create, Delete, Undo, Update -from .verbs import Follow, Accept, Reject +from .verbs import Follow, Accept, Reject, Block from .verbs import Add, AddBook, Remove # this creates a list of all the Activity types that we can serialize, diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py index 7c6279279..6977ee8e8 100644 --- a/bookwyrm/activitypub/verbs.py +++ b/bookwyrm/activitypub/verbs.py @@ -48,6 +48,10 @@ class Follow(Verb): ''' Follow activity ''' type: str = 'Follow' +@dataclass(init=False) +class Block(Verb): + ''' Block activity ''' + type: str = 'Block' @dataclass(init=False) class Accept(Verb): diff --git a/bookwyrm/incoming.py b/bookwyrm/incoming.py index 9653c5d23..5d93756f5 100644 --- a/bookwyrm/incoming.py +++ b/bookwyrm/incoming.py @@ -3,6 +3,7 @@ import json from urllib.parse import urldefrag import django.db.utils +from django.db.models import Q from django.http import HttpResponse from django.http import HttpResponseBadRequest, HttpResponseNotFound from django.views.decorators.csrf import csrf_exempt @@ -51,6 +52,7 @@ def shared_inbox(request): 'Follow': handle_follow, 'Accept': handle_follow_accept, 'Reject': handle_follow_reject, + 'Block': handle_block, 'Create': handle_create, 'Delete': handle_delete_status, 'Like': handle_favorite, @@ -179,6 +181,22 @@ def handle_follow_reject(activity): request.delete() #raises models.UserFollowRequest.DoesNotExist +@app.task +def handle_block(activity): + ''' blocking a user ''' + # create "block" databse entry + block = activitypub.Block(**activity).to_model(models.UserBlocks) + + # remove follow relationships + models.UserFollows.objects.filter( + Q(user_subject=block.user_subject, user_object=block.user_object) | \ + Q(user_subject=block.user_object, user_object=block.user_subject) + ).delete() + models.UserFollowRequest.objects.filter( + Q(user_subject=block.user_subject, user_object=block.user_object) | \ + Q(user_subject=block.user_object, user_object=block.user_subject) + ).delete() + @app.task def handle_create(activity): diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py index 0f3c1dab9..9ea75a8f7 100644 --- a/bookwyrm/models/relationship.py +++ b/bookwyrm/models/relationship.py @@ -94,5 +94,5 @@ class UserFollowRequest(UserRelationship): class UserBlocks(UserRelationship): ''' prevent another user from following you and seeing your posts ''' - # TODO: not implemented status = 'blocks' + activity_serializer = activitypub.Block diff --git a/bookwyrm/tests/test_incoming.py b/bookwyrm/tests/test_incoming.py index 2cd4869e3..024c8e253 100644 --- a/bookwyrm/tests/test_incoming.py +++ b/bookwyrm/tests/test_incoming.py @@ -540,3 +540,29 @@ class Incoming(TestCase): incoming.handle_update_work({'object': bookdata}) book = models.Work.objects.get(id=book.id) self.assertEqual(book.title, 'Piranesi') + + + def test_handle_blocks(self): + ''' create a "block" database entry from an activity ''' + self.local_user.followers.add(self.remote_user) + models.UserFollowRequest.objects.create( + user_subject=self.local_user, + user_object=self.remote_user) + self.assertTrue(models.UserFollows.objects.exists()) + self.assertTrue(models.UserFollowRequest.objects.exists()) + + activity = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/9e1f41ac-9ddd-4159-aede-9f43c6b9314f", + "type": "Block", + "actor": "https://example.com/users/rat", + "object": "https://example.com/user/mouse" + } + + incoming.handle_block(activity) + block = models.UserBlocks.objects.get() + self.assertEqual(block.user_subject, self.remote_user) + self.assertEqual(block.user_object, self.local_user) + + self.assertFalse(models.UserFollows.objects.exists()) + self.assertFalse(models.UserFollowRequest.objects.exists()) From 6cc29a6cf8f57c3fcd9312955276743d7a354a44 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sat, 23 Jan 2021 11:40:41 -0800 Subject: [PATCH 45/56] Hide content from blocked users --- bookwyrm/tests/views/test_helpers.py | 60 ++++++++++++++++++++++++++++ bookwyrm/views/helpers.py | 10 +++++ 2 files changed, 70 insertions(+) diff --git a/bookwyrm/tests/views/test_helpers.py b/bookwyrm/tests/views/test_helpers.py index bd8928962..5e42b3785 100644 --- a/bookwyrm/tests/views/test_helpers.py +++ b/bookwyrm/tests/views/test_helpers.py @@ -248,3 +248,63 @@ class ViewsHelpers(TestCase): views.helpers.handle_reading_status( self.local_user, self.shelf, self.book, 'public') self.assertFalse(models.GeneratedNote.objects.exists()) + + def test_object_visible_to_user(self): + ''' does a user have permission to view an object ''' + obj = models.Status.objects.create( + content='hi', user=self.remote_user, privacy='public') + self.assertTrue( + views.helpers.object_visible_to_user(self.local_user, obj)) + + obj = models.Shelf.objects.create( + name='test', user=self.remote_user, privacy='unlisted') + self.assertTrue( + views.helpers.object_visible_to_user(self.local_user, obj)) + + obj = models.Status.objects.create( + content='hi', user=self.remote_user, privacy='followers') + self.assertFalse( + views.helpers.object_visible_to_user(self.local_user, obj)) + + obj = models.Status.objects.create( + content='hi', user=self.remote_user, privacy='direct') + self.assertFalse( + views.helpers.object_visible_to_user(self.local_user, obj)) + + obj = models.Status.objects.create( + content='hi', user=self.remote_user, privacy='direct') + obj.mention_users.add(self.local_user) + self.assertTrue( + views.helpers.object_visible_to_user(self.local_user, obj)) + + def test_object_visible_to_user_follower(self): + ''' what you can see if you follow a user ''' + self.remote_user.followers.add(self.local_user) + obj = models.Status.objects.create( + content='hi', user=self.remote_user, privacy='followers') + self.assertTrue( + views.helpers.object_visible_to_user(self.local_user, obj)) + + obj = models.Status.objects.create( + content='hi', user=self.remote_user, privacy='direct') + self.assertFalse( + views.helpers.object_visible_to_user(self.local_user, obj)) + + obj = models.Status.objects.create( + content='hi', user=self.remote_user, privacy='direct') + obj.mention_users.add(self.local_user) + self.assertTrue( + views.helpers.object_visible_to_user(self.local_user, obj)) + + def test_object_visible_to_user_blocked(self): + ''' you can't see it if they block you ''' + self.remote_user.blocks.add(self.local_user) + obj = models.Status.objects.create( + content='hi', user=self.remote_user, privacy='public') + self.assertFalse( + views.helpers.object_visible_to_user(self.local_user, obj)) + + obj = models.Shelf.objects.create( + name='test', user=self.remote_user, privacy='unlisted') + self.assertFalse( + views.helpers.object_visible_to_user(self.local_user, obj)) diff --git a/bookwyrm/views/helpers.py b/bookwyrm/views/helpers.py index 601593246..f899680f0 100644 --- a/bookwyrm/views/helpers.py +++ b/bookwyrm/views/helpers.py @@ -38,11 +38,21 @@ def object_visible_to_user(viewer, obj): ''' is a user authorized to view an object? ''' if not obj: return False + + # viewer can't see it if the object's owner blocked them + if viewer in obj.user.blocks.all(): + return False + + # you can see your own posts and any public or unlisted posts if viewer == obj.user or obj.privacy in ['public', 'unlisted']: return True + + # you can see the followers only posts of people you follow if obj.privacy == 'followers' and \ obj.user.followers.filter(id=viewer.id).first(): return True + + # you can see dms you are tagged in if isinstance(obj, models.Status): if obj.privacy == 'direct' and \ obj.mention_users.filter(id=viewer.id).first(): From 4e0ec12052f26d289c3e0bb112492e5efcf617f0 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sun, 24 Jan 2021 16:13:26 -0800 Subject: [PATCH 46/56] hide blocked content from feed --- bookwyrm/templates/user.html | 3 ++- bookwyrm/tests/views/test_helpers.py | 28 ++++++++++++++++++++++++++++ bookwyrm/views/helpers.py | 5 +++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/bookwyrm/templates/user.html b/bookwyrm/templates/user.html index a41623a8a..69b762b03 100644 --- a/bookwyrm/templates/user.html +++ b/bookwyrm/templates/user.html @@ -17,7 +17,7 @@
    {% include 'snippets/user_header.html' with user=user %} - +{% if user.bookwyrm_user %}

    Shelves

    @@ -39,6 +39,7 @@
    See all {{ shelf_count }} shelves
    +{% endif %} {% if goal %}
    diff --git a/bookwyrm/tests/views/test_helpers.py b/bookwyrm/tests/views/test_helpers.py index 5e42b3785..50c3cfc5c 100644 --- a/bookwyrm/tests/views/test_helpers.py +++ b/bookwyrm/tests/views/test_helpers.py @@ -154,6 +154,34 @@ class ViewsHelpers(TestCase): self.assertEqual(statuses[0], rat_mention) + def test_get_activity_feed_blocks(self): + ''' feed generation with blocked users ''' + rat = models.User.objects.create_user( + 'rat', 'rat@rat.rat', 'password', local=True) + + public_status = models.Comment.objects.create( + content='public status', book=self.book, user=self.local_user) + rat_public = models.Status.objects.create( + content='blah blah', user=rat) + + statuses = views.helpers.get_activity_feed( + self.local_user, ['public']) + self.assertEqual(len(statuses), 2) + + # block relationship + rat.blocks.add(self.local_user) + statuses = views.helpers.get_activity_feed( + self.local_user, ['public']) + self.assertEqual(len(statuses), 1) + self.assertEqual(statuses[0], public_status) + + statuses = views.helpers.get_activity_feed( + rat, ['public']) + self.assertEqual(len(statuses), 1) + self.assertEqual(statuses[0], rat_public) + + + def test_is_bookwyrm_request(self): ''' checks if a request came from a bookwyrm instance ''' request = self.factory.get('', {'q': 'Test Book'}) diff --git a/bookwyrm/views/helpers.py b/bookwyrm/views/helpers.py index f899680f0..b0f867b74 100644 --- a/bookwyrm/views/helpers.py +++ b/bookwyrm/views/helpers.py @@ -71,6 +71,11 @@ def get_activity_feed( # exclude deleted queryset = queryset.exclude(deleted=True).order_by('-published_date') + # exclude blocks from both directions + blocked = models.User.objects.filter(id__in=user.blocks.all()).all() + queryset = queryset.exclude( + Q(user__in=blocked) | Q(user__blocks=user)) + # you can't see followers only or direct messages if you're not logged in if user.is_anonymous: privacy = [p for p in privacy if not p in ['followers', 'direct']] From ed830323304a961397a47ef5fe648e15af540509 Mon Sep 17 00:00:00 2001 From: Joel Bradshaw Date: Sun, 24 Jan 2021 16:39:26 -0800 Subject: [PATCH 47/56] Fix migration for if db has multiple empty emails If the database has multiple users with an empty email column, this migration will fail because multiple empty strings break the unique constraint. A fresh database won't have this problem because it won't have any legacy users with empty strings instead of NULL, but for existing databases we need to convert the empty strings to NULL so they don't run awry of the unique constraint. --- bookwyrm/migrations/0037_auto_20210118_1954.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/bookwyrm/migrations/0037_auto_20210118_1954.py b/bookwyrm/migrations/0037_auto_20210118_1954.py index 9da0265d6..97ba8808a 100644 --- a/bookwyrm/migrations/0037_auto_20210118_1954.py +++ b/bookwyrm/migrations/0037_auto_20210118_1954.py @@ -2,6 +2,15 @@ from django.db import migrations, models +def empty_to_null(apps, schema_editor): + User = apps.get_model("bookwyrm", "User") + db_alias = schema_editor.connection.alias + User.objects.using(db_alias).filter(email="").update(email=None) + +def null_to_empty(apps, schema_editor): + User = apps.get_model("bookwyrm", "User") + db_alias = schema_editor.connection.alias + User.objects.using(db_alias).filter(email=None).update(email="") class Migration(migrations.Migration): @@ -14,6 +23,12 @@ class Migration(migrations.Migration): name='shelfbook', options={'ordering': ('-created_date',)}, ), + migrations.AlterField( + model_name='user', + name='email', + field=models.EmailField(max_length=254, null=True), + ), + migrations.RunPython(empty_to_null, null_to_empty), migrations.AlterField( model_name='user', name='email', From d994d8d3c82f7d019a4714bdb2a8591b55f30529 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sun, 24 Jan 2021 17:07:19 -0800 Subject: [PATCH 48/56] Moves blocking side effects to model --- bookwyrm/incoming.py | 11 +---------- bookwyrm/models/relationship.py | 20 ++++++++++++++++++++ bookwyrm/templates/snippets/user_header.html | 4 ++++ bookwyrm/urls.py | 2 ++ bookwyrm/views/__init__.py | 1 + 5 files changed, 28 insertions(+), 10 deletions(-) diff --git a/bookwyrm/incoming.py b/bookwyrm/incoming.py index 5d93756f5..3581ed87b 100644 --- a/bookwyrm/incoming.py +++ b/bookwyrm/incoming.py @@ -186,16 +186,7 @@ def handle_block(activity): ''' blocking a user ''' # create "block" databse entry block = activitypub.Block(**activity).to_model(models.UserBlocks) - - # remove follow relationships - models.UserFollows.objects.filter( - Q(user_subject=block.user_subject, user_object=block.user_object) | \ - Q(user_subject=block.user_object, user_object=block.user_subject) - ).delete() - models.UserFollowRequest.objects.filter( - Q(user_subject=block.user_subject, user_object=block.user_object) | \ - Q(user_subject=block.user_object, user_object=block.user_subject) - ).delete() + # the removing relationships is handled in post-save hook in model @app.task diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py index 9ea75a8f7..ec84d44f0 100644 --- a/bookwyrm/models/relationship.py +++ b/bookwyrm/models/relationship.py @@ -1,5 +1,7 @@ ''' defines relationships between users ''' from django.db import models +from django.db.models import Q +from django.dispatch import receiver from bookwyrm import activitypub from .base_model import ActivitypubMixin, BookWyrmModel @@ -96,3 +98,21 @@ class UserBlocks(UserRelationship): ''' prevent another user from following you and seeing your posts ''' status = 'blocks' activity_serializer = activitypub.Block + + +@receiver(models.signals.post_save, sender=UserBlocks) +#pylint: disable=unused-argument +def execute_after_save(sender, instance, created, *args, **kwargs): + ''' remove follow or follow request rels after a block is created ''' + UserFollows.objects.filter( + Q(user_subject=instance.user_subject, + user_object=instance.user_object) | \ + Q(user_subject=instance.user_object, + user_object=instance.user_subject) + ).delete() + UserFollowRequest.objects.filter( + Q(user_subject=instance.user_subject, + user_object=instance.user_object) | \ + Q(user_subject=instance.user_object, + user_object=instance.user_subject) + ).delete() diff --git a/bookwyrm/templates/snippets/user_header.html b/bookwyrm/templates/snippets/user_header.html index a528bb1ce..14216d4be 100644 --- a/bookwyrm/templates/snippets/user_header.html +++ b/bookwyrm/templates/snippets/user_header.html @@ -36,6 +36,10 @@
    {% if not is_self %} {% include 'snippets/follow_button.html' with user=user %} +
    + {% csrf_token %} + +
    {% endif %} {% if is_self and user.follower_requests.all %} diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index 4da0c0c17..bfd57d0ad 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -136,4 +136,6 @@ urlpatterns = [ re_path(r'^unfollow/?$', views.unfollow), re_path(r'^accept-follow-request/?$', views.accept_follow_request), re_path(r'^delete-follow-request/?$', views.delete_follow_request), + + re_path(r'^block/(?P\d+)/?$', views.Block.as_view()), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/bookwyrm/views/__init__.py b/bookwyrm/views/__init__.py index b9c263880..1521b2682 100644 --- a/bookwyrm/views/__init__.py +++ b/bookwyrm/views/__init__.py @@ -1,6 +1,7 @@ ''' make sure all our nice views are available ''' from .authentication import Login, Register, Logout from .author import Author, EditAuthor +from .block import Block from .books import Book, EditBook, Editions from .books import upload_cover, add_description, switch_edition, resolve_book from .direct_message import DirectMessage From ac2ab2981f15955d8d3a2d07a5e33ef6eb25d01c Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Mon, 25 Jan 2021 14:03:18 -0800 Subject: [PATCH 49/56] ui path to iniate blocks --- bookwyrm/templates/snippets/block_button.html | 11 +++++++ .../templates/snippets/status_options.html | 11 ++----- bookwyrm/templates/snippets/user_header.html | 13 +++++---- bookwyrm/templates/snippets/user_options.html | 14 +++++++++ bookwyrm/views/block.py | 29 +++++++++++++++++++ bookwyrm/views/helpers.py | 7 +++-- 6 files changed, 69 insertions(+), 16 deletions(-) create mode 100644 bookwyrm/templates/snippets/block_button.html create mode 100644 bookwyrm/templates/snippets/user_options.html create mode 100644 bookwyrm/views/block.py diff --git a/bookwyrm/templates/snippets/block_button.html b/bookwyrm/templates/snippets/block_button.html new file mode 100644 index 000000000..ed9bb551f --- /dev/null +++ b/bookwyrm/templates/snippets/block_button.html @@ -0,0 +1,11 @@ +{% if not user in request.user.blocks.all %} +
    + {% csrf_token %} + +
    +{% else %} +
    + {% csrf_token %} + +
    +{% endif %} diff --git a/bookwyrm/templates/snippets/status_options.html b/bookwyrm/templates/snippets/status_options.html index 9b312c7ca..2e2e5d35b 100644 --- a/bookwyrm/templates/snippets/status_options.html +++ b/bookwyrm/templates/snippets/status_options.html @@ -17,14 +17,9 @@ -
  • -{% endif %} +{% else %}
  • - + {% include 'snippets/block_button.html' with user=status.user %}
  • +{% endif %} {% endblock %} diff --git a/bookwyrm/templates/snippets/user_header.html b/bookwyrm/templates/snippets/user_header.html index 14216d4be..8f5e264a4 100644 --- a/bookwyrm/templates/snippets/user_header.html +++ b/bookwyrm/templates/snippets/user_header.html @@ -35,11 +35,14 @@
    {% if not is_self %} - {% include 'snippets/follow_button.html' with user=user %} -
    - {% csrf_token %} - -
    +
    +
    + {% include 'snippets/follow_button.html' with user=user %} +
    +
    + {% include 'snippets/user_options.html' with user=user class="is-small" %} +
    +
    {% endif %} {% if is_self and user.follower_requests.all %} diff --git a/bookwyrm/templates/snippets/user_options.html b/bookwyrm/templates/snippets/user_options.html new file mode 100644 index 000000000..9515d9128 --- /dev/null +++ b/bookwyrm/templates/snippets/user_options.html @@ -0,0 +1,14 @@ +{% extends 'snippets/components/dropdown.html' %} +{% load bookwyrm_tags %} + +{% block dropdown-trigger %} + + More options + +{% endblock %} + +{% block dropdown-list %} +
  • + {% include 'snippets/block_button.html' with user=user %} +
  • +{% endblock %} diff --git a/bookwyrm/views/block.py b/bookwyrm/views/block.py new file mode 100644 index 000000000..36f64f739 --- /dev/null +++ b/bookwyrm/views/block.py @@ -0,0 +1,29 @@ +''' views for actions you can take in the application ''' +from django.contrib.auth.decorators import login_required +from django.shortcuts import get_object_or_404, redirect +from django.utils.decorators import method_decorator +from django.views import View + +from bookwyrm import models +from bookwyrm.broadcast import broadcast + +# pylint: disable= no-self-use +@method_decorator(login_required, name='dispatch') +class Block(View): + ''' blocking users ''' + def get(self, request): + ''' list of blocked users? ''' + + def post(self, request, user_id): + ''' block a user ''' + to_block = get_object_or_404(models.User, id=user_id) + block = models.UserBlocks.objects.create( + user_subject=request.user, user_object=to_block) + if not to_block.local: + broadcast( + request.user, + block.to_activity(), + privacy='direct', + direct_recipients=[to_block] + ) + return redirect('/blocks') diff --git a/bookwyrm/views/helpers.py b/bookwyrm/views/helpers.py index b0f867b74..5872b2de5 100644 --- a/bookwyrm/views/helpers.py +++ b/bookwyrm/views/helpers.py @@ -72,9 +72,10 @@ def get_activity_feed( queryset = queryset.exclude(deleted=True).order_by('-published_date') # exclude blocks from both directions - blocked = models.User.objects.filter(id__in=user.blocks.all()).all() - queryset = queryset.exclude( - Q(user__in=blocked) | Q(user__blocks=user)) + if not user.is_anonymous: + blocked = models.User.objects.filter(id__in=user.blocks.all()).all() + queryset = queryset.exclude( + Q(user__in=blocked) | Q(user__blocks=user)) # you can't see followers only or direct messages if you're not logged in if user.is_anonymous: From 2a6a000e0524af701404e9eaa2be232c55389ddf Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Tue, 26 Jan 2021 08:03:16 -0800 Subject: [PATCH 50/56] Moves avatar crop into function and adds test --- bookwyrm/static/images/med.jpg | Bin 10937 -> 0 bytes bookwyrm/static/images/profile.jpg | Bin 34916 -> 0 bytes bookwyrm/static/images/small.jpg | Bin 1122 -> 0 bytes bookwyrm/tests/views/test_user.py | 16 +++++++++++ bookwyrm/views/user.py | 43 ++++++++++++++++------------- 5 files changed, 40 insertions(+), 19 deletions(-) delete mode 100644 bookwyrm/static/images/med.jpg delete mode 100644 bookwyrm/static/images/profile.jpg delete mode 100644 bookwyrm/static/images/small.jpg diff --git a/bookwyrm/static/images/med.jpg b/bookwyrm/static/images/med.jpg deleted file mode 100644 index c275cd1c85dce6988f39a34f6fb2ad1a4aba7662..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10937 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 XS;q9@zlzB({!JnIp8_-Ra_+wYDD(on diff --git a/bookwyrm/static/images/profile.jpg b/bookwyrm/static/images/profile.jpg deleted file mode 100644 index f150ceabebf5e183818405f854f6de336d063068..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34916 zcmbrlWpEua(=K?-%*@Qp%*@Qp_Az5(j+vR6nHge;nPbKr$IR^5j@j$H@9zDow(kA0 zwLNvDr_|~msi&q#YW4Y8``8Ac%1g^h1Hixl0I<&s@UaVCC?_FdqN=VUEvG2;9|m}$ zgN36zBr5>m=;Yz1E+bB=tEW#2vj;!`zyQDj_yArr3wIYWRaHg6|B)V6{%HpQ7McF( z`agF2-!l*_t=ug>2MGJg=CyEf^Z3M~pIG^ehs!^l_=zzsY|Sh`ap@;!bo(6OC!YJ) zZvJ1q{}0>z7eD^PZkp;60021RCnmM|FJ}CQ&Hs!4&4tv$*3I#=kHaUXbae3i9K(O; z-%Jp#oU}APYr_Bh+yUwU8Gtx|6krbU1lR!_03HCQ&)VtJX8(IR_W#H!1Dro)EIvyY zfH%PHQ^E#d`zg!#+3E>!2Uva9)}QTGpO?dDk5Bq%`+qe6@IN+nx90e#?{mtC@&EwT z-p9uudH?_>2LO1B`1p7!{`hz+0RSM@0DvE<|0D05`f1L~&-TRsX`{>o08l~zfcC!s zX)`Ya06IQxjQiWg%+2gS_ks8+$D9!)?S=j&p_2>Oi3_pGWBmhtlkdTlNP@e@96cjWJA}q`&p&%f@A)=w6qobjq zp5wt`vyFazw*)yz=W_;Fs zaFFmNyuEydr5o3?mM>|tWO7-%Fud~{!`JzrV{zlZ2I&TR`DQT7RnrQ;7iKGXYvi)b z>Pq@+TiVa$7)D==F+1m!<;jRs#`)JGm1}Ir6jTmS`wQo-3+dn#H=+#~M3mm02ioPy zVV2xUVBg^f$B4nsuIAB~#puGOE!syMR(zpW4bAkCJHFi3_&p~2Q>Iz2NjV)FU!i{C zN5jHoCrumWrDFIE^)D%CXWMnX!wZm_XQA5F&2!IL?C|9(VgZ}i5ZT5zUjSVP2^doK zHY_!Gv-8#KJv-U)6KDycKzGdp-sKMHzlT$^<`8PkR63^~TV!dRVE0kE8fP~@NmGtk3N z6v&Nwh@rEpU$`OTG*DTCa7%cVD2WH|h9w z_qLUKg6oD^8@J)zH6g$@Z7U{O*|yxRzXcI#-u#tpCXXLgZ0b$64P}Owy^)poF72=1 zNWPOAxt(*6h;1^puc2I$(2a7W?v=6TW=-223ZZA|!j73sc2i!8{k?@ch}`V*@&3bcD5AQd xZ=@u}2V$>Ihm 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 diff --git a/bookwyrm/tests/views/test_user.py b/bookwyrm/tests/views/test_user.py index 0e2ad9044..6a51aedaa 100644 --- a/bookwyrm/tests/views/test_user.py +++ b/bookwyrm/tests/views/test_user.py @@ -1,5 +1,9 @@ ''' test for app action functionality ''' +import pathlib from unittest.mock import patch +from PIL import Image + +from django.core.files.base import ContentFile from django.template.response import TemplateResponse from django.test import TestCase from django.test.client import RequestFactory @@ -97,3 +101,15 @@ class UserViews(TestCase): with patch('bookwyrm.broadcast.broadcast_task.delay'): view(request) self.assertEqual(self.local_user.name, 'New Name') + + + 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)) diff --git a/bookwyrm/views/user.py b/bookwyrm/views/user.py index 6f7873d5e..b65fb48fe 100644 --- a/bookwyrm/views/user.py +++ b/bookwyrm/views/user.py @@ -159,30 +159,35 @@ class EditUser(View): if 'avatar' in form.files: # crop and resize avatar upload image = Image.open(form.files['avatar']) - 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) - ContentFile(output.getvalue()) + 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, ContentFile(output.getvalue())) + user.avatar.save(filename, image) user.save() broadcast(user, user.to_update_activity(user)) return redirect(user.local_path) + + +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()) From 681f5482fdf50dbb1d05b8a37b4f9649fd4e4ccc Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Tue, 26 Jan 2021 08:07:38 -0800 Subject: [PATCH 51/56] Don't allow blocked users to access user page --- bookwyrm/views/user.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bookwyrm/views/user.py b/bookwyrm/views/user.py index 6f7873d5e..2a4211b85 100644 --- a/bookwyrm/views/user.py +++ b/bookwyrm/views/user.py @@ -31,6 +31,11 @@ class User(View): except models.User.DoesNotExist: return HttpResponseNotFound() + # make sure we're not blocked + if request.user.is_authenticated: + if request.user in user.blocks.all(): + return HttpResponseNotFound() + if is_api_request(request): # we have a json request return ActivitypubResponse(user.to_activity()) From 3f011445e29b63dd1aaf40c1455096caf80b99e1 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Tue, 26 Jan 2021 08:31:55 -0800 Subject: [PATCH 52/56] Hide user pages to blocked users --- bookwyrm/tests/views/test_user.py | 39 +++++++++++++++++++++++++++++++ bookwyrm/views/helpers.py | 6 +++++ bookwyrm/views/user.py | 15 ++++++++---- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/bookwyrm/tests/views/test_user.py b/bookwyrm/tests/views/test_user.py index 0e2ad9044..95c4db0ad 100644 --- a/bookwyrm/tests/views/test_user.py +++ b/bookwyrm/tests/views/test_user.py @@ -16,6 +16,9 @@ class UserViews(TestCase): 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') def test_user_page(self): @@ -37,6 +40,18 @@ class UserViews(TestCase): self.assertEqual(result.status_code, 200) + def test_user_page_blocked(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.User.as_view() + request = self.factory.get('') + request.user = self.local_user + self.rat.blocks.add(self.local_user) + with patch('bookwyrm.views.user.is_api_request') as is_api: + is_api.return_value = False + result = view(request, 'rat') + self.assertEqual(result.status_code, 404) + + def test_followers_page(self): ''' there are so many views, this just makes sure it LOADS ''' view = views.Followers.as_view() @@ -56,6 +71,18 @@ class UserViews(TestCase): self.assertEqual(result.status_code, 200) + def test_followers_page_blocked(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.Followers.as_view() + request = self.factory.get('') + request.user = self.local_user + self.rat.blocks.add(self.local_user) + with patch('bookwyrm.views.user.is_api_request') as is_api: + is_api.return_value = False + result = view(request, 'rat') + self.assertEqual(result.status_code, 404) + + def test_following_page(self): ''' there are so many views, this just makes sure it LOADS ''' view = views.Following.as_view() @@ -75,6 +102,18 @@ class UserViews(TestCase): self.assertEqual(result.status_code, 200) + def test_following_page_blocked(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.Following.as_view() + request = self.factory.get('') + request.user = self.local_user + self.rat.blocks.add(self.local_user) + with patch('bookwyrm.views.user.is_api_request') as is_api: + is_api.return_value = False + result = view(request, 'rat') + self.assertEqual(result.status_code, 404) + + def test_edit_profile_page(self): ''' there are so many views, this just makes sure it LOADS ''' view = views.EditUser.as_view() diff --git a/bookwyrm/views/helpers.py b/bookwyrm/views/helpers.py index 5872b2de5..6bda81c8b 100644 --- a/bookwyrm/views/helpers.py +++ b/bookwyrm/views/helpers.py @@ -190,3 +190,9 @@ def handle_reading_status(user, shelf, book, privacy): status.save() broadcast(user, status.to_create_activity(user)) + +def is_blocked(viewer, user): + ''' is this viewer blocked by the user? ''' + if viewer.is_authenticated and viewer in user.blocks.all(): + return True + return False diff --git a/bookwyrm/views/user.py b/bookwyrm/views/user.py index 2a4211b85..acf19c448 100644 --- a/bookwyrm/views/user.py +++ b/bookwyrm/views/user.py @@ -18,7 +18,7 @@ from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.broadcast import broadcast from bookwyrm.settings import PAGE_LENGTH from .helpers import get_activity_feed, get_user_from_username, is_api_request -from .helpers import object_visible_to_user +from .helpers import is_blocked, object_visible_to_user # pylint: disable= no-self-use @@ -32,9 +32,8 @@ class User(View): return HttpResponseNotFound() # make sure we're not blocked - if request.user.is_authenticated: - if request.user in user.blocks.all(): - return HttpResponseNotFound() + if is_blocked(request.user, user): + return HttpResponseNotFound() if is_api_request(request): # we have a json request @@ -102,6 +101,10 @@ class Followers(View): except models.User.DoesNotExist: return HttpResponseNotFound() + # make sure we're not blocked + if is_blocked(request.user, user): + return HttpResponseNotFound() + if is_api_request(request): return ActivitypubResponse( user.to_followers_activity(**request.GET)) @@ -123,6 +126,10 @@ class Following(View): except models.User.DoesNotExist: return HttpResponseNotFound() + # make sure we're not blocked + if is_blocked(request.user, user): + return HttpResponseNotFound() + if is_api_request(request): return ActivitypubResponse( user.to_following_activity(**request.GET)) From acfc865d4ef1b7d65e5bb2f8b36f7c1056e22055 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Tue, 26 Jan 2021 09:56:01 -0800 Subject: [PATCH 53/56] Adds blocked users view also refactors the setting view --- bookwyrm/templates/blocks.html | 21 +++ bookwyrm/templates/change_password.html | 19 +++ bookwyrm/templates/edit_user.html | 112 ++++++--------- bookwyrm/templates/preferences_layout.html | 31 ++++ bookwyrm/templates/snippets/block_button.html | 4 +- bookwyrm/tests/views/test_authentication.py | 103 ------------- bookwyrm/tests/views/test_password.py | 136 ++++++++++++++++++ bookwyrm/urls.py | 3 +- bookwyrm/views/block.py | 5 +- bookwyrm/views/password.py | 8 ++ bookwyrm/views/user.py | 9 +- 11 files changed, 273 insertions(+), 178 deletions(-) create mode 100644 bookwyrm/templates/blocks.html create mode 100644 bookwyrm/templates/change_password.html create mode 100644 bookwyrm/templates/preferences_layout.html create mode 100644 bookwyrm/tests/views/test_password.py diff --git a/bookwyrm/templates/blocks.html b/bookwyrm/templates/blocks.html new file mode 100644 index 000000000..0e725e4b6 --- /dev/null +++ b/bookwyrm/templates/blocks.html @@ -0,0 +1,21 @@ +{% extends 'preferences_layout.html' %} + +{% block header %} +Blocked Users +{% endblock %} + +{% block panel %} +

    +{% endblock %} + diff --git a/bookwyrm/templates/change_password.html b/bookwyrm/templates/change_password.html new file mode 100644 index 000000000..c373dfc8a --- /dev/null +++ b/bookwyrm/templates/change_password.html @@ -0,0 +1,19 @@ +{% extends 'preferences_layout.html' %} +{% block header %} +Change Password +{% endblock %} + +{% block panel %} +
    + {% csrf_token %} +
    + + +
    +
    + + +
    + +
    +{% endblock %} diff --git a/bookwyrm/templates/edit_user.html b/bookwyrm/templates/edit_user.html index 413f2cae3..ee9ddb222 100644 --- a/bookwyrm/templates/edit_user.html +++ b/bookwyrm/templates/edit_user.html @@ -1,66 +1,48 @@ -{% extends 'layout.html' %} -{% block content %} -
    -
    -

    Profile

    - {% if form.non_field_errors %} -

    {{ form.non_field_errors }}

    - {% endif %} -
    - {% csrf_token %} -
    - - {{ form.avatar }} - {% for error in form.avatar.errors %} -

    {{ error | escape }}

    - {% endfor %} -
    -
    - - {{ form.name }} - {% for error in form.name.errors %} -

    {{ error | escape }}

    - {% endfor %} -
    -
    - - {{ form.summary }} - {% for error in form.summary.errors %} -

    {{ error | escape }}

    - {% endfor %} -
    -
    - - {{ form.email }} - {% for error in form.email.errors %} -

    {{ error | escape }}

    - {% endfor %} -
    -
    - -
    - -
    -
    -
    -
    -

    Change password

    -
    - {% csrf_token %} -
    - - -
    -
    - - -
    - -
    -
    -
    -
    +{% extends 'preferences_layout.html' %} +{% block header %} +Edit Profile +{% endblock %} + +{% block panel %} +{% if form.non_field_errors %} +

    {{ form.non_field_errors }}

    +{% endif %} +
    + {% csrf_token %} +
    + + {{ form.avatar }} + {% for error in form.avatar.errors %} +

    {{ error | escape }}

    + {% endfor %} +
    +
    + + {{ form.name }} + {% for error in form.name.errors %} +

    {{ error | escape }}

    + {% endfor %} +
    +
    + + {{ form.summary }} + {% for error in form.summary.errors %} +

    {{ error | escape }}

    + {% endfor %} +
    +
    + + {{ form.email }} + {% for error in form.email.errors %} +

    {{ error | escape }}

    + {% endfor %} +
    +
    + +
    + +
    {% endblock %} diff --git a/bookwyrm/templates/preferences_layout.html b/bookwyrm/templates/preferences_layout.html new file mode 100644 index 000000000..de2fe0dfc --- /dev/null +++ b/bookwyrm/templates/preferences_layout.html @@ -0,0 +1,31 @@ +{% extends 'layout.html' %} +{% block content %} + +
    +

    {% block header %}{% endblock %}

    +
    + +
    + +
    + {% block panel %}{% endblock %} +
    +
    + +{% endblock %} diff --git a/bookwyrm/templates/snippets/block_button.html b/bookwyrm/templates/snippets/block_button.html index ed9bb551f..9e49254dd 100644 --- a/bookwyrm/templates/snippets/block_button.html +++ b/bookwyrm/templates/snippets/block_button.html @@ -1,11 +1,11 @@ {% if not user in request.user.blocks.all %}
    {% csrf_token %} - +
    {% else %}
    {% csrf_token %} - +
    {% endif %} diff --git a/bookwyrm/tests/views/test_authentication.py b/bookwyrm/tests/views/test_authentication.py index b0d099832..655772084 100644 --- a/bookwyrm/tests/views/test_authentication.py +++ b/bookwyrm/tests/views/test_authentication.py @@ -42,83 +42,6 @@ class AuthenticationViews(TestCase): self.assertEqual(result.status_code, 302) - def test_password_reset_request(self): - ''' there are so many views, this just makes sure it LOADS ''' - view = views.PasswordResetRequest.as_view() - request = self.factory.get('') - request.user = self.local_user - - result = view(request) - self.assertIsInstance(result, TemplateResponse) - self.assertEqual(result.template_name, 'password_reset_request.html') - self.assertEqual(result.status_code, 200) - - - def test_password_reset_request_post(self): - ''' send 'em an email ''' - request = self.factory.post('', {'email': 'aa@bb.ccc'}) - view = views.PasswordResetRequest.as_view() - resp = view(request) - self.assertEqual(resp.status_code, 302) - - request = self.factory.post('', {'email': 'mouse@mouse.com'}) - with patch('bookwyrm.emailing.send_email.delay'): - resp = view(request) - self.assertEqual(resp.template_name, 'password_reset_request.html') - - self.assertEqual( - models.PasswordReset.objects.get().user, self.local_user) - - def test_password_reset(self): - ''' there are so many views, this just makes sure it LOADS ''' - view = views.PasswordReset.as_view() - code = models.PasswordReset.objects.create(user=self.local_user) - request = self.factory.get('') - request.user = self.anonymous_user - result = view(request, code.code) - self.assertIsInstance(result, TemplateResponse) - self.assertEqual(result.template_name, 'password_reset.html') - self.assertEqual(result.status_code, 200) - - - def test_password_reset_post(self): - ''' reset from code ''' - view = views.PasswordReset.as_view() - code = models.PasswordReset.objects.create(user=self.local_user) - request = self.factory.post('', { - 'password': 'hi', - 'confirm-password': 'hi' - }) - with patch('bookwyrm.views.password.login'): - resp = view(request, code.code) - self.assertEqual(resp.status_code, 302) - self.assertFalse(models.PasswordReset.objects.exists()) - - def test_password_reset_wrong_code(self): - ''' reset from code ''' - view = views.PasswordReset.as_view() - models.PasswordReset.objects.create(user=self.local_user) - request = self.factory.post('', { - 'password': 'hi', - 'confirm-password': 'hi' - }) - resp = view(request, 'jhgdkfjgdf') - self.assertEqual(resp.template_name, 'password_reset.html') - self.assertTrue(models.PasswordReset.objects.exists()) - - def test_password_reset_mismatch(self): - ''' reset from code ''' - view = views.PasswordReset.as_view() - code = models.PasswordReset.objects.create(user=self.local_user) - request = self.factory.post('', { - 'password': 'hi', - 'confirm-password': 'hihi' - }) - resp = view(request, code.code) - self.assertEqual(resp.template_name, 'password_reset.html') - self.assertTrue(models.PasswordReset.objects.exists()) - - def test_register(self): ''' create a user ''' view = views.Register.as_view() @@ -274,29 +197,3 @@ class AuthenticationViews(TestCase): with self.assertRaises(Http404): response = view(request) self.assertEqual(models.User.objects.count(), 2) - - - def test_password_change(self): - ''' change password ''' - view = views.ChangePassword.as_view() - password_hash = self.local_user.password - request = self.factory.post('', { - 'password': 'hi', - 'confirm-password': 'hi' - }) - request.user = self.local_user - with patch('bookwyrm.views.password.login'): - view(request) - self.assertNotEqual(self.local_user.password, password_hash) - - def test_password_change_mismatch(self): - ''' change password ''' - view = views.ChangePassword.as_view() - password_hash = self.local_user.password - request = self.factory.post('', { - 'password': 'hi', - 'confirm-password': 'hihi' - }) - request.user = self.local_user - view(request) - self.assertEqual(self.local_user.password, password_hash) diff --git a/bookwyrm/tests/views/test_password.py b/bookwyrm/tests/views/test_password.py new file mode 100644 index 000000000..0f9c89885 --- /dev/null +++ b/bookwyrm/tests/views/test_password.py @@ -0,0 +1,136 @@ +''' test for app action functionality ''' +from unittest.mock import patch + +from django.contrib.auth.models import AnonymousUser +from django.template.response import TemplateResponse +from django.test import TestCase +from django.test.client import RequestFactory + +from bookwyrm import models, views + + +class PasswordViews(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.com', 'password', + local=True, localname='mouse') + self.anonymous_user = AnonymousUser + self.anonymous_user.is_authenticated = False + + + def test_password_reset_request(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.PasswordResetRequest.as_view() + request = self.factory.get('') + request.user = self.local_user + + result = view(request) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'password_reset_request.html') + self.assertEqual(result.status_code, 200) + + + def test_password_reset_request_post(self): + ''' send 'em an email ''' + request = self.factory.post('', {'email': 'aa@bb.ccc'}) + view = views.PasswordResetRequest.as_view() + resp = view(request) + self.assertEqual(resp.status_code, 302) + + request = self.factory.post('', {'email': 'mouse@mouse.com'}) + with patch('bookwyrm.emailing.send_email.delay'): + resp = view(request) + self.assertEqual(resp.template_name, 'password_reset_request.html') + + self.assertEqual( + models.PasswordReset.objects.get().user, self.local_user) + + def test_password_reset(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.PasswordReset.as_view() + code = models.PasswordReset.objects.create(user=self.local_user) + request = self.factory.get('') + request.user = self.anonymous_user + result = view(request, code.code) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'password_reset.html') + self.assertEqual(result.status_code, 200) + + + def test_password_reset_post(self): + ''' reset from code ''' + view = views.PasswordReset.as_view() + code = models.PasswordReset.objects.create(user=self.local_user) + request = self.factory.post('', { + 'password': 'hi', + 'confirm-password': 'hi' + }) + with patch('bookwyrm.views.password.login'): + resp = view(request, code.code) + self.assertEqual(resp.status_code, 302) + self.assertFalse(models.PasswordReset.objects.exists()) + + def test_password_reset_wrong_code(self): + ''' reset from code ''' + view = views.PasswordReset.as_view() + models.PasswordReset.objects.create(user=self.local_user) + request = self.factory.post('', { + 'password': 'hi', + 'confirm-password': 'hi' + }) + resp = view(request, 'jhgdkfjgdf') + self.assertEqual(resp.template_name, 'password_reset.html') + self.assertTrue(models.PasswordReset.objects.exists()) + + def test_password_reset_mismatch(self): + ''' reset from code ''' + view = views.PasswordReset.as_view() + code = models.PasswordReset.objects.create(user=self.local_user) + request = self.factory.post('', { + 'password': 'hi', + 'confirm-password': 'hihi' + }) + resp = view(request, code.code) + self.assertEqual(resp.template_name, 'password_reset.html') + self.assertTrue(models.PasswordReset.objects.exists()) + + + def test_password_change_get(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.ChangePassword.as_view() + request = self.factory.get('') + request.user = self.local_user + + result = view(request) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'change_password.html') + self.assertEqual(result.status_code, 200) + + + def test_password_change(self): + ''' change password ''' + view = views.ChangePassword.as_view() + password_hash = self.local_user.password + request = self.factory.post('', { + 'password': 'hi', + 'confirm-password': 'hi' + }) + request.user = self.local_user + with patch('bookwyrm.views.password.login'): + view(request) + self.assertNotEqual(self.local_user.password, password_hash) + + def test_password_change_mismatch(self): + ''' change password ''' + view = views.ChangePassword.as_view() + password_hash = self.local_user.password + request = self.factory.post('', { + 'password': 'hi', + 'confirm-password': 'hihi' + }) + request.user = self.local_user + view(request) + self.assertEqual(self.local_user.password, password_hash) diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index bfd57d0ad..0569fd9ca 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -47,7 +47,7 @@ urlpatterns = [ re_path(r'^password-reset/?$', views.PasswordResetRequest.as_view()), re_path(r'^password-reset/(?P[A-Za-z0-9]+)/?$', views.PasswordReset.as_view()), - re_path(r'^change-password/?$', views.ChangePassword), + re_path(r'^change-password/?$', views.ChangePassword.as_view()), # invites re_path(r'^invite/?$', views.ManageInvites.as_view()), @@ -137,5 +137,6 @@ urlpatterns = [ re_path(r'^accept-follow-request/?$', views.accept_follow_request), re_path(r'^delete-follow-request/?$', views.delete_follow_request), + re_path(r'^block/?$', views.Block.as_view()), re_path(r'^block/(?P\d+)/?$', views.Block.as_view()), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/bookwyrm/views/block.py b/bookwyrm/views/block.py index 36f64f739..6158f373c 100644 --- a/bookwyrm/views/block.py +++ b/bookwyrm/views/block.py @@ -1,6 +1,7 @@ ''' views for actions you can take in the application ''' from django.contrib.auth.decorators import login_required 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 @@ -13,6 +14,8 @@ class Block(View): ''' blocking users ''' def get(self, request): ''' list of blocked users? ''' + return TemplateResponse( + request, 'blocks.html', {'title': 'Blocked Users'}) def post(self, request, user_id): ''' block a user ''' @@ -26,4 +29,4 @@ class Block(View): privacy='direct', direct_recipients=[to_block] ) - return redirect('/blocks') + return redirect('/block') diff --git a/bookwyrm/views/password.py b/bookwyrm/views/password.py index 915659e3e..06ddc1dad 100644 --- a/bookwyrm/views/password.py +++ b/bookwyrm/views/password.py @@ -88,6 +88,14 @@ class PasswordReset(View): @method_decorator(login_required, name='dispatch') class ChangePassword(View): ''' change password as logged in user ''' + def get(self, request): + ''' change password page ''' + data = { + 'title': 'Change Password', + 'user': request.user, + } + return TemplateResponse(request, 'change_password.html', data) + def post(self, request): ''' allow a user to change their password ''' new_password = request.POST.get('password') diff --git a/bookwyrm/views/user.py b/bookwyrm/views/user.py index acf19c448..25515bb18 100644 --- a/bookwyrm/views/user.py +++ b/bookwyrm/views/user.py @@ -147,14 +147,11 @@ class Following(View): class EditUser(View): ''' edit user view ''' def get(self, request): - ''' profile page for a user ''' - user = request.user - - form = forms.EditUserForm(instance=request.user) + ''' edit profile page for a user ''' data = { 'title': 'Edit profile', - 'form': form, - 'user': user, + 'form': forms.EditUserForm(instance=request.user), + 'user': request.user, } return TemplateResponse(request, 'edit_user.html', data) From 22e4138555317bbcd16126c13795f6cd45a15a12 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Tue, 26 Jan 2021 13:00:36 -0800 Subject: [PATCH 54/56] unblock --- bookwyrm/incoming.py | 18 +++++++++++-- .../templates/snippets/status_options.html | 2 +- bookwyrm/templates/snippets/user_options.html | 2 +- bookwyrm/tests/test_incoming.py | 17 ++++++++++++ bookwyrm/urls.py | 1 + bookwyrm/views/__init__.py | 2 +- bookwyrm/views/block.py | 26 +++++++++++++++++++ 7 files changed, 63 insertions(+), 5 deletions(-) diff --git a/bookwyrm/incoming.py b/bookwyrm/incoming.py index 3581ed87b..1e42d32ae 100644 --- a/bookwyrm/incoming.py +++ b/bookwyrm/incoming.py @@ -3,7 +3,6 @@ import json from urllib.parse import urldefrag import django.db.utils -from django.db.models import Q from django.http import HttpResponse from django.http import HttpResponseBadRequest, HttpResponseNotFound from django.views.decorators.csrf import csrf_exempt @@ -64,6 +63,7 @@ def shared_inbox(request): 'Follow': handle_unfollow, 'Like': handle_unfavorite, 'Announce': handle_unboost, + 'Block': handle_unblock, }, 'Update': { 'Person': handle_update_user, @@ -185,10 +185,24 @@ def handle_follow_reject(activity): def handle_block(activity): ''' blocking a user ''' # create "block" databse entry - block = activitypub.Block(**activity).to_model(models.UserBlocks) + activitypub.Block(**activity).to_model(models.UserBlocks) # the removing relationships is handled in post-save hook in model +@app.task +def handle_unblock(activity): + ''' undoing a block ''' + try: + block_id = activity['object']['id'] + except KeyError: + return + try: + block = models.UserBlocks.objects.get(remote_id=block_id) + except models.UserBlocks.DoesNotExist: + return + block.delete() + + @app.task def handle_create(activity): ''' someone did something, good on them ''' diff --git a/bookwyrm/templates/snippets/status_options.html b/bookwyrm/templates/snippets/status_options.html index 2e2e5d35b..b5887b1d8 100644 --- a/bookwyrm/templates/snippets/status_options.html +++ b/bookwyrm/templates/snippets/status_options.html @@ -19,7 +19,7 @@ {% else %}
  • - {% include 'snippets/block_button.html' with user=status.user %} + {% include 'snippets/block_button.html' with user=status.user class="is-fullwidth" %}
  • {% endif %} {% endblock %} diff --git a/bookwyrm/templates/snippets/user_options.html b/bookwyrm/templates/snippets/user_options.html index 9515d9128..2c163034e 100644 --- a/bookwyrm/templates/snippets/user_options.html +++ b/bookwyrm/templates/snippets/user_options.html @@ -9,6 +9,6 @@ {% block dropdown-list %}
  • - {% include 'snippets/block_button.html' with user=user %} + {% include 'snippets/block_button.html' with user=user class="is-fullwidth" %}
  • {% endblock %} diff --git a/bookwyrm/tests/test_incoming.py b/bookwyrm/tests/test_incoming.py index 024c8e253..1ee7c59ec 100644 --- a/bookwyrm/tests/test_incoming.py +++ b/bookwyrm/tests/test_incoming.py @@ -566,3 +566,20 @@ class Incoming(TestCase): self.assertFalse(models.UserFollows.objects.exists()) self.assertFalse(models.UserFollowRequest.objects.exists()) + + def test_handle_unblock(self): + ''' undoing a block ''' + activity = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://friend.camp/users/tripofmice#blocks/1155/undo", + "type": "Undo", + "actor": "https://friend.camp/users/tripofmice", + "object": { + "id": "https://friend.camp/0a7d85f7-6359-4c03-8ab6-74e61a8fb678", + "type": "Block", + "actor": "https://friend.camp/users/tripofmice", + "object": "https://1b1a78582461.ngrok.io/user/mouse" + } + } + + self.remote_user.blocks.add(self.local_user) diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index 0569fd9ca..4f9a43ea3 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -139,4 +139,5 @@ urlpatterns = [ re_path(r'^block/?$', views.Block.as_view()), re_path(r'^block/(?P\d+)/?$', views.Block.as_view()), + re_path(r'^unblock/(?P\d+)/?$', views.unblock), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/bookwyrm/views/__init__.py b/bookwyrm/views/__init__.py index 1521b2682..e3ac29c84 100644 --- a/bookwyrm/views/__init__.py +++ b/bookwyrm/views/__init__.py @@ -1,7 +1,7 @@ ''' make sure all our nice views are available ''' from .authentication import Login, Register, Logout from .author import Author, EditAuthor -from .block import Block +from .block import Block, unblock from .books import Book, EditBook, Editions from .books import upload_cover, add_description, switch_edition, resolve_book from .direct_message import DirectMessage diff --git a/bookwyrm/views/block.py b/bookwyrm/views/block.py index 6158f373c..fb95479af 100644 --- a/bookwyrm/views/block.py +++ b/bookwyrm/views/block.py @@ -1,9 +1,11 @@ ''' views for actions you can take in the application ''' from django.contrib.auth.decorators import login_required +from django.http import HttpResponseNotFound 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 models from bookwyrm.broadcast import broadcast @@ -30,3 +32,27 @@ class Block(View): direct_recipients=[to_block] ) return redirect('/block') + + +@require_POST +@login_required +def unblock(request, user_id): + ''' undo a block ''' + to_unblock = get_object_or_404(models.User, id=user_id) + try: + block = models.UserBlocks.objects.get( + user_subject=request.user, + user_object=to_unblock, + ) + except models.UserBlocks.DoesNotExist: + return HttpResponseNotFound() + + if not to_unblock.local: + broadcast( + request.user, + block.to_undo_activity(request.user), + privacy='direct', + direct_recipients=[to_unblock] + ) + block.delete() + return redirect('/block') From 369b24f9ec8da77c8c7c1e0c7f211093c5f564b8 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Tue, 26 Jan 2021 13:02:04 -0800 Subject: [PATCH 55/56] null state for block page --- bookwyrm/templates/blocks.html | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bookwyrm/templates/blocks.html b/bookwyrm/templates/blocks.html index 0e725e4b6..1df498163 100644 --- a/bookwyrm/templates/blocks.html +++ b/bookwyrm/templates/blocks.html @@ -5,6 +5,9 @@ Blocked Users {% endblock %} {% block panel %} +{% if not request.user.blocks.exists %} +

    No users currently blocked.

    +{% else %}
      {% for user in request.user.blocks.all %}
    • @@ -15,7 +18,7 @@ Blocked Users {% include 'snippets/block_button.html' with user=user %}

    • -{% endfor %} +{% endfor %}
    +{% endif %} {% endblock %} - From 36486ca731f938ac6de68c804a49e6d4a0090946 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Tue, 26 Jan 2021 13:02:14 -0800 Subject: [PATCH 56/56] block/unblock view tests --- bookwyrm/tests/views/test_block.py | 68 ++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 bookwyrm/tests/views/test_block.py diff --git a/bookwyrm/tests/views/test_block.py b/bookwyrm/tests/views/test_block.py new file mode 100644 index 000000000..fa5254d1a --- /dev/null +++ b/bookwyrm/tests/views/test_block.py @@ -0,0 +1,68 @@ +''' test for app action functionality ''' +from unittest.mock import patch +from django.template.response import TemplateResponse +from django.test import TestCase +from django.test.client import RequestFactory + +from bookwyrm import models, views + + +class BlockViews(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') + with patch('bookwyrm.models.user.set_remote_server.delay'): + self.remote_user = models.User.objects.create_user( + 'rat', 'rat@rat.com', 'ratword', + local=False, + remote_id='https://example.com/users/rat', + inbox='https://example.com/users/rat/inbox', + outbox='https://example.com/users/rat/outbox', + ) + + + def test_block_get(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.Block.as_view() + request = self.factory.get('') + request.user = self.local_user + result = view(request) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'blocks.html') + self.assertEqual(result.status_code, 200) + + def test_block_post(self): + ''' create a "block" database entry from an activity ''' + view = views.Block.as_view() + self.local_user.followers.add(self.remote_user) + models.UserFollowRequest.objects.create( + user_subject=self.local_user, + user_object=self.remote_user) + self.assertTrue(models.UserFollows.objects.exists()) + self.assertTrue(models.UserFollowRequest.objects.exists()) + + request = self.factory.post('') + request.user = self.local_user + with patch('bookwyrm.broadcast.broadcast_task.delay'): + view(request, self.remote_user.id) + block = models.UserBlocks.objects.get() + self.assertEqual(block.user_subject, self.local_user) + self.assertEqual(block.user_object, self.remote_user) + + self.assertFalse(models.UserFollows.objects.exists()) + self.assertFalse(models.UserFollowRequest.objects.exists()) + + def test_unblock(self): + ''' undo a block ''' + self.local_user.blocks.add(self.remote_user) + request = self.factory.post('') + request.user = self.local_user + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + views.block.unblock(request, self.remote_user.id) + + self.assertFalse(models.UserBlocks.objects.exists())