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 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..1e42d32ae 100644 --- a/bookwyrm/incoming.py +++ b/bookwyrm/incoming.py @@ -51,6 +51,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, @@ -62,6 +63,7 @@ def shared_inbox(request): 'Follow': handle_unfollow, 'Like': handle_unfavorite, 'Announce': handle_unboost, + 'Block': handle_unblock, }, 'Update': { 'Person': handle_update_user, @@ -179,6 +181,27 @@ def handle_follow_reject(activity): request.delete() #raises models.UserFollowRequest.DoesNotExist +@app.task +def handle_block(activity): + ''' blocking a user ''' + # create "block" databse entry + 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): diff --git a/bookwyrm/migrations/0012_progressupdate.py b/bookwyrm/migrations/0012_progressupdate.py new file mode 100644 index 000000000..131419712 --- /dev/null +++ b/bookwyrm/migrations/0012_progressupdate.py @@ -0,0 +1,31 @@ +# Generated by Django 3.0.7 on 2020-11-17 07:36 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0011_auto_20201113_1727'), + ] + + operations = [ + migrations.CreateModel( + name='ProgressUpdate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(auto_now_add=True)), + ('updated_date', models.DateTimeField(auto_now=True)), + ('remote_id', models.CharField(max_length=255, null=True)), + ('progress', models.IntegerField()), + ('mode', models.CharField(choices=[('PG', 'page'), ('PCT', 'percent')], default='PG', max_length=3)), + ('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)), + ], + options={ + 'abstract': False, + }, + ), + ] 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/bookwyrm/migrations/0015_auto_20201128_0734.py b/bookwyrm/migrations/0015_auto_20201128_0734.py new file mode 100644 index 000000000..c6eb78150 --- /dev/null +++ b/bookwyrm/migrations/0015_auto_20201128_0734.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.7 on 2020-11-28 07:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0014_merge_20201128_0007'), + ] + + operations = [ + migrations.RenameField( + model_name='readthrough', + old_name='pages_read', + new_name='progress', + ), + migrations.AddField( + model_name='readthrough', + name='progress_mode', + field=models.CharField(choices=[('PG', 'page'), ('PCT', 'percent')], default='PG', max_length=3), + ), + ] 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', diff --git a/bookwyrm/migrations/0039_merge_20210120_0753.py b/bookwyrm/migrations/0039_merge_20210120_0753.py new file mode 100644 index 000000000..1af40ee93 --- /dev/null +++ b/bookwyrm/migrations/0039_merge_20210120_0753.py @@ -0,0 +1,14 @@ +# Generated by Django 3.0.7 on 2021-01-20 07:53 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0038_auto_20210119_1534'), + ('bookwyrm', '0015_auto_20201128_0734'), + ] + + operations = [ + ] 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/models/__init__.py b/bookwyrm/models/__init__.py index e71a150ba..b232e98fa 100644 --- a/bookwyrm/models/__init__.py +++ b/bookwyrm/models/__init__.py @@ -13,7 +13,7 @@ from .status import Boost from .attachment import Image from .favorite import Favorite from .notification import Notification -from .readthrough import ReadThrough +from .readthrough import ReadThrough, ProgressUpdate, ProgressMode from .tag import Tag, UserTag diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index 3f2285f74..ea7049774 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -72,6 +72,10 @@ class Book(BookDataModel): ''' format a list of authors ''' return ', '.join(a.name for a in self.authors.all()) + @property + def latest_readthrough(self): + return self.readthrough_set.order_by('-updated_date').first() + @property def edition_info(self): ''' properties of this edition, as a string ''' diff --git a/bookwyrm/models/readthrough.py b/bookwyrm/models/readthrough.py index 61cac7e6a..7daafaaff 100644 --- a/bookwyrm/models/readthrough.py +++ b/bookwyrm/models/readthrough.py @@ -1,17 +1,26 @@ ''' progress in a book ''' from django.db import models from django.utils import timezone +from django.core import validators from .base_model import BookWyrmModel +class ProgressMode(models.TextChoices): + PAGE = 'PG', 'page' + PERCENT = 'PCT', 'percent' class ReadThrough(BookWyrmModel): - ''' Store progress through a book in the database. ''' + ''' Store a read through a book in the database. ''' user = models.ForeignKey('User', on_delete=models.PROTECT) book = models.ForeignKey('Edition', on_delete=models.PROTECT) - pages_read = models.IntegerField( + progress = models.IntegerField( + validators=[validators.MinValueValidator(0)], 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) @@ -24,3 +33,26 @@ class ReadThrough(BookWyrmModel): self.user.last_active_date = timezone.now() self.user.save() super().save(*args, **kwargs) + + 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. ''' + user = models.ForeignKey('User', on_delete=models.PROTECT) + readthrough = models.ForeignKey('ReadThrough', on_delete=models.CASCADE) + progress = models.IntegerField(validators=[validators.MinValueValidator(0)]) + mode = models.CharField( + max_length=3, + choices=ProgressMode.choices, + default=ProgressMode.PAGE) + + def save(self, *args, **kwargs): + ''' update user active time ''' + self.user.last_active_date = timezone.now() + self.user.save() + super().save(*args, **kwargs) diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py index 0f3c1dab9..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 @@ -94,5 +96,23 @@ 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 + + +@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/static/images/med.jpg b/bookwyrm/static/images/med.jpg deleted file mode 100644 index c275cd1c8..000000000 Binary files a/bookwyrm/static/images/med.jpg and /dev/null differ diff --git a/bookwyrm/static/images/profile.jpg b/bookwyrm/static/images/profile.jpg deleted file mode 100644 index f150ceabe..000000000 Binary files a/bookwyrm/static/images/profile.jpg and /dev/null differ diff --git a/bookwyrm/static/images/small.jpg b/bookwyrm/static/images/small.jpg deleted file mode 100644 index 158163b61..000000000 Binary files a/bookwyrm/static/images/small.jpg and /dev/null differ diff --git a/bookwyrm/templates/blocks.html b/bookwyrm/templates/blocks.html new file mode 100644 index 000000000..1df498163 --- /dev/null +++ b/bookwyrm/templates/blocks.html @@ -0,0 +1,24 @@ +{% extends 'preferences_layout.html' %} + +{% block header %} +Blocked Users +{% endblock %} + +{% block panel %} +{% if not request.user.blocks.exists %} +

No users currently blocked.

+{% else %} + +{% endif %} +{% 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/feed.html b/bookwyrm/templates/feed.html index 58acfe985..1368660bc 100644 --- a/bookwyrm/templates/feed.html +++ b/bookwyrm/templates/feed.html @@ -48,6 +48,10 @@
{% 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/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 new file mode 100644 index 000000000..9e49254dd --- /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/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 %} - + 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/templates/snippets/progress_update.html b/bookwyrm/templates/snippets/progress_update.html new file mode 100644 index 000000000..92a7573a9 --- /dev/null +++ b/bookwyrm/templates/snippets/progress_update.html @@ -0,0 +1,27 @@ + + {% csrf_token %} + +
+ +
+
+ +
+
+ +
+
+ +
+
+ {% if readthrough.progress_mode == 'PG' and book.pages %} +

of {{ book.pages }} pages

+ {% endif %} +
+
diff --git a/bookwyrm/templates/snippets/readthrough.html b/bookwyrm/templates/snippets/readthrough.html index a3d20dd50..e0b25d8bd 100644 --- a/bookwyrm/templates/snippets/readthrough.html +++ b/bookwyrm/templates/snippets/readthrough.html @@ -1,26 +1,50 @@ {% load humanize %}
-
- {% if readthrough.start_date %} -
-
Started reading:
-
{{ readthrough.start_date | naturalday }}
+
+
+ Progress Updates: +
+
- {% endif %} - {% if readthrough.finish_date %} -
-
Finished reading:
-
{{ readthrough.finish_date | naturalday }}
-
- {% 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" %} +
+
diff --git a/bookwyrm/templates/snippets/readthrough_form.html b/bookwyrm/templates/snippets/readthrough_form.html index aefb4ab51..21d0a8098 100644 --- a/bookwyrm/templates/snippets/readthrough_form.html +++ b/bookwyrm/templates/snippets/readthrough_form.html @@ -7,6 +7,23 @@
+{# Only show progress for editing existing readthroughs #} +{% if readthrough.id and not readthrough.finish_date %} + +
+
+ +
+
+ +
+
+{% endif %}
{% if not is_self %} - {% include 'snippets/follow_button.html' with user=user %} +
+
+ {% 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..2c163034e --- /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 class="is-fullwidth" %} +
  • +{% endblock %} 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/actions/__init__.py b/bookwyrm/tests/actions/__init__.py new file mode 100644 index 000000000..b6e690fd5 --- /dev/null +++ b/bookwyrm/tests/actions/__init__.py @@ -0,0 +1 @@ +from . import * diff --git a/bookwyrm/tests/actions/test_readthrough.py b/bookwyrm/tests/actions/test_readthrough.py new file mode 100644 index 000000000..41d2eaa52 --- /dev/null +++ b/bookwyrm/tests/actions/test_readthrough.py @@ -0,0 +1,88 @@ +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() + + 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_user( + 'cinco', 'cinco@example.com', 'seissiete', + local=True, localname='cinco') + + self.client.force_login(self.user) + + 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) + + 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].progress, None) + self.assertEqual(readthroughs[0].finish_date, None) + self.assertEqual(delay_mock.call_count, 1) + + 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), { + 'start_date': '2020-11-27', + 'progress': 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].progress, 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) + self.assertEqual(delay_mock.call_count, 1) + + # Update progress + self.client.post('/edit-readthrough', { + 'id': readthroughs[0].id, + 'progress': 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) + 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) diff --git a/bookwyrm/tests/connectors/test_bookwyrm_connector.py b/bookwyrm/tests/connectors/test_bookwyrm_connector.py index 1833ed4e3..6b00b0e3a 100644 --- a/bookwyrm/tests/connectors/test_bookwyrm_connector.py +++ b/bookwyrm/tests/connectors/test_bookwyrm_connector.py @@ -23,9 +23,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()) @@ -33,7 +33,7 @@ class BookWyrmConnector(TestCase): def test_format_search_result(self): ''' create a SearchResult object from search response json ''' datafile = pathlib.Path(__file__).parent.joinpath( - '../data/fr_search.json') + '../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/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) diff --git a/bookwyrm/tests/test_broadcast.py b/bookwyrm/tests/test_broadcast.py index 0150fca82..eea7abd8f 100644 --- a/bookwyrm/tests/test_broadcast.py +++ b/bookwyrm/tests/test_broadcast.py @@ -32,14 +32,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) models.User.objects.create_user( 'nutria', 'nutria@mouse.mouse', 'nuword', diff --git a/bookwyrm/tests/test_incoming.py b/bookwyrm/tests/test_incoming.py index 5d8bd1107..1ee7c59ec 100644 --- a/bookwyrm/tests/test_incoming.py +++ b/bookwyrm/tests/test_incoming.py @@ -506,7 +506,7 @@ class Incoming(TestCase): def test_handle_update_edition(self): ''' update an existing edition ''' datafile = pathlib.Path(__file__).parent.joinpath( - 'data/fr_edition.json') + 'data/bw_edition.json') bookdata = json.loads(datafile.read_bytes()) models.Work.objects.create( @@ -527,7 +527,7 @@ class Incoming(TestCase): def test_handle_update_work(self): ''' update an existing edition ''' datafile = pathlib.Path(__file__).parent.joinpath( - 'data/fr_work.json') + 'data/bw_work.json') bookdata = json.loads(datafile.read_bytes()) book = models.Work.objects.create( @@ -540,3 +540,46 @@ 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()) + + 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/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_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()) diff --git a/bookwyrm/tests/views/test_helpers.py b/bookwyrm/tests/views/test_helpers.py index bd8928962..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'}) @@ -248,3 +276,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/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/tests/views/test_user.py b/bookwyrm/tests/views/test_user.py index 0e2ad9044..07acc4c4f 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 @@ -16,6 +20,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 +44,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 +75,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 +106,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() @@ -97,3 +140,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/urls.py b/bookwyrm/urls.py index ab134b1c5..4f9a43ea3 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()), @@ -126,6 +126,7 @@ urlpatterns = [ re_path(r'^edit-readthrough/?$', views.edit_readthrough), re_path(r'^delete-readthrough/?$', views.delete_readthrough), re_path(r'^create-readthrough/?$', views.create_readthrough), + re_path(r'^delete-progressupdate/?$', views.delete_progressupdate), re_path(r'^start-reading/(?P\d+)/?$', views.start_reading), re_path(r'^finish-reading/(?P\d+)/?$', views.finish_reading), @@ -135,4 +136,8 @@ 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/?$', 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 d37851aa4..e3ac29c84 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, unblock from .books import Book, EditBook, Editions from .books import upload_cover, add_description, switch_edition, resolve_book from .direct_message import DirectMessage @@ -15,7 +16,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 diff --git a/bookwyrm/views/block.py b/bookwyrm/views/block.py new file mode 100644 index 000000000..fb95479af --- /dev/null +++ b/bookwyrm/views/block.py @@ -0,0 +1,58 @@ +''' 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 + +# pylint: disable= no-self-use +@method_decorator(login_required, name='dispatch') +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 ''' + 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('/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') diff --git a/bookwyrm/views/books.py b/bookwyrm/views/books.py index 553c74781..527045606 100644 --- a/bookwyrm/views/books.py +++ b/bookwyrm/views/books.py @@ -72,6 +72,11 @@ class Book(View): book=book, ).order_by('start_date') + for readthrough in readthroughs: + readthrough.progress_updates = \ + readthrough.progressupdate_set.all() \ + .order_by('-updated_date') + user_shelves = models.ShelfBook.objects.filter( added_by=request.user, book=book ) diff --git a/bookwyrm/views/helpers.py b/bookwyrm/views/helpers.py index 601593246..6bda81c8b 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(): @@ -61,6 +71,12 @@ def get_activity_feed( # exclude deleted queryset = queryset.exclude(deleted=True).order_by('-published_date') + # exclude blocks from both directions + 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: privacy = [p for p in privacy if not p in ['followers', 'direct']] @@ -174,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/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/reading.py b/bookwyrm/views/reading.py index 11b6e335e..565fc1797 100644 --- a/bookwyrm/views/reading.py +++ b/bookwyrm/views/reading.py @@ -30,6 +30,9 @@ def start_reading(request, book_id): if readthrough: readthrough.save() + # create a progress update if we have a page + readthrough.create_update() + # shelve the book if request.POST.get('reshelve', True): try: @@ -104,6 +107,10 @@ def edit_readthrough(request): return HttpResponseBadRequest() readthrough.save() + # record the progress update individually + # use default now for date field + readthrough.create_update() + return redirect(request.headers.get('Referer', '/')) @@ -166,7 +173,36 @@ def update_readthrough(request, book=None, create=True): except ParserError: pass + progress = request.POST.get('progress') + if progress: + try: + progress = int(progress) + readthrough.progress = progress + except ValueError: + pass + + progress_mode = request.POST.get('progress_mode') + if progress_mode: + try: + progress_mode = models.ProgressMode(progress_mode) + readthrough.progress_mode = progress_mode + except ValueError: + pass + if not readthrough.start_date and not readthrough.finish_date: return None return readthrough + +@login_required +@require_POST +def delete_progressupdate(request): + ''' remove a progress update ''' + update = get_object_or_404(models.ProgressUpdate, id=request.POST.get('id')) + + # don't let people edit other people's data + if request.user != update.user: + return HttpResponseBadRequest() + + update.delete() + return redirect(request.headers.get('Referer', '/')) diff --git a/bookwyrm/views/user.py b/bookwyrm/views/user.py index 6f7873d5e..668ef205f 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 @@ -31,6 +31,10 @@ class User(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): # we have a json request return ActivitypubResponse(user.to_activity()) @@ -97,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)) @@ -118,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)) @@ -135,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) @@ -159,30 +168,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()) diff --git a/bw-dev b/bw-dev index 83eae0323..7c8d8d4fa 100755 --- a/bw-dev +++ b/bw-dev @@ -28,9 +28,15 @@ function initdb { execweb python manage.py initdb } -case "$1" in +CMD=$1 +shift + +# show commands as they're executed +set -x + +case "$CMD" in up) - docker-compose up --build + docker-compose up --build "$@" ;; run) docker-compose run --rm --service-ports web @@ -39,12 +45,10 @@ case "$1" in initdb ;; makemigrations) - shift 1 execweb python manage.py makemigrations "$@" ;; migrate) execweb python manage.py rename_app fedireads bookwyrm - shift 1 execweb python manage.py migrate "$@" ;; bash)