Merge branch 'main' into production

This commit is contained in:
Mouse Reeve 2021-01-27 07:37:47 -08:00
commit 00c8fab365
56 changed files with 1206 additions and 241 deletions

View file

@ -65,4 +65,4 @@ jobs:
EMAIL_HOST_PASSWORD: "" EMAIL_HOST_PASSWORD: ""
EMAIL_USE_TLS: true EMAIL_USE_TLS: true
run: | run: |
python manage.py test python manage.py test -v 3

View file

@ -14,7 +14,7 @@ from .person import Person, PublicKey
from .response import ActivitypubResponse from .response import ActivitypubResponse
from .book import Edition, Work, Author from .book import Edition, Work, Author
from .verbs import Create, Delete, Undo, Update 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 from .verbs import Add, AddBook, Remove
# this creates a list of all the Activity types that we can serialize, # this creates a list of all the Activity types that we can serialize,

View file

@ -48,6 +48,10 @@ class Follow(Verb):
''' Follow activity ''' ''' Follow activity '''
type: str = 'Follow' type: str = 'Follow'
@dataclass(init=False)
class Block(Verb):
''' Block activity '''
type: str = 'Block'
@dataclass(init=False) @dataclass(init=False)
class Accept(Verb): class Accept(Verb):

View file

@ -51,6 +51,7 @@ def shared_inbox(request):
'Follow': handle_follow, 'Follow': handle_follow,
'Accept': handle_follow_accept, 'Accept': handle_follow_accept,
'Reject': handle_follow_reject, 'Reject': handle_follow_reject,
'Block': handle_block,
'Create': handle_create, 'Create': handle_create,
'Delete': handle_delete_status, 'Delete': handle_delete_status,
'Like': handle_favorite, 'Like': handle_favorite,
@ -62,6 +63,7 @@ def shared_inbox(request):
'Follow': handle_unfollow, 'Follow': handle_unfollow,
'Like': handle_unfavorite, 'Like': handle_unfavorite,
'Announce': handle_unboost, 'Announce': handle_unboost,
'Block': handle_unblock,
}, },
'Update': { 'Update': {
'Person': handle_update_user, 'Person': handle_update_user,
@ -179,6 +181,27 @@ def handle_follow_reject(activity):
request.delete() request.delete()
#raises models.UserFollowRequest.DoesNotExist #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 @app.task
def handle_create(activity): def handle_create(activity):

View file

@ -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,
},
),
]

View file

@ -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 = [
]

View file

@ -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),
),
]

View file

@ -2,6 +2,15 @@
from django.db import migrations, models 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): class Migration(migrations.Migration):
@ -14,6 +23,12 @@ class Migration(migrations.Migration):
name='shelfbook', name='shelfbook',
options={'ordering': ('-created_date',)}, 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( migrations.AlterField(
model_name='user', model_name='user',
name='email', name='email',

View file

@ -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 = [
]

View file

@ -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)]),
),
]

View file

@ -13,7 +13,7 @@ from .status import Boost
from .attachment import Image from .attachment import Image
from .favorite import Favorite from .favorite import Favorite
from .notification import Notification from .notification import Notification
from .readthrough import ReadThrough from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
from .tag import Tag, UserTag from .tag import Tag, UserTag

View file

@ -72,6 +72,10 @@ class Book(BookDataModel):
''' format a list of authors ''' ''' format a list of authors '''
return ', '.join(a.name for a in self.authors.all()) 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 @property
def edition_info(self): def edition_info(self):
''' properties of this edition, as a string ''' ''' properties of this edition, as a string '''

View file

@ -1,17 +1,26 @@
''' progress in a book ''' ''' progress in a book '''
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
from django.core import validators
from .base_model import BookWyrmModel from .base_model import BookWyrmModel
class ProgressMode(models.TextChoices):
PAGE = 'PG', 'page'
PERCENT = 'PCT', 'percent'
class ReadThrough(BookWyrmModel): 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) user = models.ForeignKey('User', on_delete=models.PROTECT)
book = models.ForeignKey('Edition', 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, null=True,
blank=True) blank=True)
progress_mode = models.CharField(
max_length=3,
choices=ProgressMode.choices,
default=ProgressMode.PAGE)
start_date = models.DateTimeField( start_date = models.DateTimeField(
blank=True, blank=True,
null=True) null=True)
@ -24,3 +33,26 @@ class ReadThrough(BookWyrmModel):
self.user.last_active_date = timezone.now() self.user.last_active_date = timezone.now()
self.user.save() self.user.save()
super().save(*args, **kwargs) 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)

View file

@ -1,5 +1,7 @@
''' defines relationships between users ''' ''' defines relationships between users '''
from django.db import models from django.db import models
from django.db.models import Q
from django.dispatch import receiver
from bookwyrm import activitypub from bookwyrm import activitypub
from .base_model import ActivitypubMixin, BookWyrmModel from .base_model import ActivitypubMixin, BookWyrmModel
@ -94,5 +96,23 @@ class UserFollowRequest(UserRelationship):
class UserBlocks(UserRelationship): class UserBlocks(UserRelationship):
''' prevent another user from following you and seeing your posts ''' ''' prevent another user from following you and seeing your posts '''
# TODO: not implemented
status = 'blocks' 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()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,24 @@
{% extends 'preferences_layout.html' %}
{% block header %}
Blocked Users
{% endblock %}
{% block panel %}
{% if not request.user.blocks.exists %}
<p>No users currently blocked.</p>
{% else %}
<ul>
{% for user in request.user.blocks.all %}
<li class="is-flex">
<p>
{% include 'snippets/avatar.html' with user=user %} {% include 'snippets/username.html' with user=user %}
</p>
<p class="mr-2">
{% include 'snippets/block_button.html' with user=user %}
</p>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,19 @@
{% extends 'preferences_layout.html' %}
{% block header %}
Change Password
{% endblock %}
{% block panel %}
<form name="edit-profile" action="/change-password/" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="block">
<label class="label" for="id_password">New password:</label>
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password">
</div>
<div class="block">
<label class="label" for="id_confirm_password">Confirm password:</label>
<input type="password" name="confirm-password" maxlength="128" class="input" required="" id="id_confirm_password">
</div>
<button class="button is-primary" type="submit">Change password</button>
</form>
{% endblock %}

View file

@ -1,66 +1,48 @@
{% extends 'layout.html' %} {% extends 'preferences_layout.html' %}
{% block content %} {% block header %}
<div class="block columns"> Edit Profile
<div class="column is-half"> {% endblock %}
<h1 class="title">Profile</h1>
{% if form.non_field_errors %} {% block panel %}
<p class="notification is-danger">{{ form.non_field_errors }}</p> {% if form.non_field_errors %}
{% endif %} <p class="notification is-danger">{{ form.non_field_errors }}</p>
<form name="edit-profile" action="/edit-profile/" method="post" enctype="multipart/form-data"> {% endif %}
{% csrf_token %} <form name="edit-profile" action="/edit-profile/" method="post" enctype="multipart/form-data">
<div class="block"> {% csrf_token %}
<label class="label" for="id_avatar">Avatar:</label> <div class="block">
{{ form.avatar }} <label class="label" for="id_avatar">Avatar:</label>
{% for error in form.avatar.errors %} {{ form.avatar }}
<p class="help is-danger">{{ error | escape }}</p> {% for error in form.avatar.errors %}
{% endfor %} <p class="help is-danger">{{ error | escape }}</p>
</div> {% endfor %}
<div class="block"> </div>
<label class="label" for="id_name">Display name:</label> <div class="block">
{{ form.name }} <label class="label" for="id_name">Display name:</label>
{% for error in form.name.errors %} {{ form.name }}
<p class="help is-danger">{{ error | escape }}</p> {% for error in form.name.errors %}
{% endfor %} <p class="help is-danger">{{ error | escape }}</p>
</div> {% endfor %}
<div class="block"> </div>
<label class="label" for="id_summary">Summary:</label> <div class="block">
{{ form.summary }} <label class="label" for="id_summary">Summary:</label>
{% for error in form.summary.errors %} {{ form.summary }}
<p class="help is-danger">{{ error | escape }}</p> {% for error in form.summary.errors %}
{% endfor %} <p class="help is-danger">{{ error | escape }}</p>
</div> {% endfor %}
<div class="block"> </div>
<label class="label" for="id_email">Email address:</label> <div class="block">
{{ form.email }} <label class="label" for="id_email">Email address:</label>
{% for error in form.email.errors %} {{ form.email }}
<p class="help is-danger">{{ error | escape }}</p> {% for error in form.email.errors %}
{% endfor %} <p class="help is-danger">{{ error | escape }}</p>
</div> {% endfor %}
<div class="block"> </div>
<label class="checkbox label" for="id_manually_approves_followers"> <div class="block">
Manually approve followers: <label class="checkbox label" for="id_manually_approves_followers">
{{ form.manually_approves_followers }} Manually approve followers:
</label> {{ form.manually_approves_followers }}
</div> </label>
<button class="button is-primary" type="submit">Save</button> </div>
</form> <button class="button is-primary" type="submit">Save</button>
</div> </form>
<div class="column is-half">
<div class="block">
<h2 class="title">Change password</h2>
<form name="edit-profile" action="/change-password/" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="block">
<label class="label" for="id_password">New password:</label>
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password">
</div>
<div class="block">
<label class="label" for="id_confirm_password">Confirm password:</label>
<input type="password" name="confirm-password" maxlength="128" class="input" required="" id="id_confirm_password">
</div>
<button class="button is-primary" type="submit">Change password</button>
</form>
</div>
</div>
</div>
{% endblock %} {% endblock %}

View file

@ -48,6 +48,10 @@
</div> </div>
<div class="card-content"> <div class="card-content">
{% include 'snippets/shelve_button.html' with book=book %} {% 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 %} {% include 'snippets/create_status.html' with book=book %}
</div> </div>
</div> </div>

View file

@ -0,0 +1,31 @@
{% extends 'layout.html' %}
{% block content %}
<header class="block column is-offset-one-quarter pl-1">
<h1 class="title">{% block header %}{% endblock %}</h1>
</header>
<div class="block columns">
<nav class="menu column is-one-quarter">
<h2 class="menu-label">Account</h2>
<ul class="menu-list">
<li>
<a href="/edit-profile"{% if '/edit-profile' in request.path %} class="is-active" aria-selected="true"{% endif %}>Profile</a>
</li>
<li>
<a href="/change-password"{% if '/change-password' in request.path %} class="is-active" aria-selected="true"{% endif %}>Change password</a>
</li>
</ul>
<h2 class="menu-label">Relationships</h2>
<ul class="menu-list">
<li>
<a href="/block"{% if '/block' in request.path %} class="is-active" aria-selected="true"{% endif %}>Blocked users</a>
</li>
</ul>
</nav>
<div class="column content">
{% block panel %}{% endblock %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,11 @@
{% if not user in request.user.blocks.all %}
<form name="blocks" method="post" action="/block/{{ user.id }}">
{% csrf_token %}
<button class="button is-danger is-light is-small {{ class }}" type="submit">Block</button>
</form>
{% else %}
<form name="unblocks" method="post" action="/unblock/{{ user.id }}">
{% csrf_token %}
<button class="button is-small {{ class }}" type="submit">Un-block</button>
</form>
{% endif %}

View file

@ -7,11 +7,10 @@
</h2> </h2>
{% include 'snippets/toggle/toggle_button.html' with label="close" class="delete" nonbutton=True %} {% include 'snippets/toggle/toggle_button.html' with label="close" class="delete" nonbutton=True %}
</header> </header>
{% block modal-form-open %}{% endblock %} {% block modal-form-open %}{% endblock %}
<section class="modal-card-body">
{% block modal-body %}{% endblock %} {% block modal-body %}{% endblock %}
</section>
<footer class="modal-card-foot"> <footer class="modal-card-foot">
{% block modal-footer %}{% endblock %} {% block modal-footer %}{% endblock %}
</footer> </footer>

View file

@ -1,6 +1,10 @@
{% extends 'snippets/components/modal.html' %} {% extends 'snippets/components/modal.html' %}
{% block modal-title %}Delete these read dates?{% endblock %} {% 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 %} {% block modal-footer %}
<form name="delete-readthrough-{{ readthrough.id }}" action="/delete-readthrough" method="POST"> <form name="delete-readthrough-{{ readthrough.id }}" action="/delete-readthrough" method="POST">
{% csrf_token %} {% csrf_token %}

View file

@ -0,0 +1,27 @@
<form class="field is-grouped is-small" action="/edit-readthrough" method="POST">
{% csrf_token %}
<input type="hidden" name="id" value="{{ readthrough.id }}"/>
<div class="field">
<label class="label is-align-self-center mb-0 pr-2" for="progress">Progress:</label>
<div class="field has-addons mb-0">
<div class="control">
<input
aria-label="{% if readthrough.progress_mode == 'PG' %}Current page{% else %}Percent read{% endif %}"
class="input is-small" type="number" min="0"
name="progress" size="3" value="{{ readthrough.progress|default:'' }}">
</div>
<div class="control select is-small">
<select name="progress_mode" aria-label="Progress mode">
<option value="PG" {% if readthrough.progress_mode == 'PG' %}selected{% endif %}>pages</option>
<option value="PCT" {% if readthrough.progress_mode == 'PCT' %}selected{% endif %}>percent</option>
</select>
</div>
<div class="control">
<button class="button is-small px-2 is-primary" type="submit">Save</button>
</div>
</div>
{% if readthrough.progress_mode == 'PG' and book.pages %}
<p class="help">of {{ book.pages }} pages</p>
{% endif %}
</div>
</form>

View file

@ -1,26 +1,50 @@
{% load humanize %} {% load humanize %}
<div class="content block"> <div class="content block">
<div id="hide-edit-readthrough-{{ readthrough.id }}"> <div id="hide-edit-readthrough-{{ readthrough.id }}">
<dl class="mb-1"> <div class="columns">
{% if readthrough.start_date %} <div class="column">
<div class="is-flex"> Progress Updates:
<dt>Started reading:</dt> </dl>
<dd>{{ readthrough.start_date | naturalday }}</dd> <ul>
{% if readthrough.progress %}
<li>{% 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" %}
<ul id="updates-{{ readthrough.id }}" class="hidden">
{% for progress_update in readthrough.progress_updates %}
<li>
<form name="delete-update" action="/delete-progressupdate" method="POST">
{% csrf_token %}
{{ progress_update.created_date | naturalday }}:
{% if progress_update.mode == 'PG' %}
page {{ progress_update.progress }} of {{ book.pages }}
{% else %}
{{ progress_update.progress }}%
{% endif %}
<input type="hidden" name="id" value="{{ progress_update.id }}"/>
<button type="submit" class="button is-small" for="delete-progressupdate-{{ progress_update.id }}" role="button" tabindex="0">
<span class="icon icon-x" title="Delete this progress update">
<span class="is-sr-only">Delete this progress update</span>
</span>
</button>
</form>
</li>
{% endfor %}
</ul>
</li>
{% endif %}
<li>{{ readthrough.start_date | naturalday }}: started</li>
</ul>
</div> </div>
{% endif %} <div class="column is-narrow">
{% if readthrough.finish_date %} <div class="field has-addons">
<div class="is-flex"> <div class="control">
<dt>Finished reading:</dt> {% 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" %}
<dd>{{ readthrough.finish_date | naturalday }}</dd> </div>
</div> <div class="control">
{% endif %} {% 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" %}
</dl> </div>
<div class="field has-addons"> </div>
<div class="control">
{% 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" %}
</div>
<div class="control">
{% 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" %}
</div> </div>
</div> </div>
</div> </div>

View file

@ -7,6 +7,23 @@
<input type="date" name="start_date" class="input" id="id_start_date-{{ readthrough.id }}" value="{{ readthrough.start_date | date:"Y-m-d" }}"> <input type="date" name="start_date" class="input" id="id_start_date-{{ readthrough.id }}" value="{{ readthrough.start_date | date:"Y-m-d" }}">
</label> </label>
</div> </div>
{# Only show progress for editing existing readthroughs #}
{% if readthrough.id and not readthrough.finish_date %}
<label class="label" for="id_progress-{{ readthrough.id }}">
Progress
</label>
<div class="field has-addons">
<div class="control">
<input type="number" name="progress" class="input" id="id_progress-{{ readthrough.id }}" value="{{ readthrough.progress }}">
</div>
<div class="control select">
<select name="progress_mode" aria-label="Progress mode">
<option value="PG" {% if readthrough.progress_mode == 'PG' %}selected{% endif %}>pages</option>
<option value="PCT" {% if readthrough.progress_mode == 'PCT' %}selected{% endif %}>percent</option>
</select>
</div>
</div>
{% endif %}
<div class="field"> <div class="field">
<label class="label"> <label class="label">
Finished reading Finished reading

View file

@ -54,11 +54,9 @@
<div class="card-footer-item"> <div class="card-footer-item">
<a href="{{ status.remote_id }}">{{ status.published_date | post_date }}</a> <a href="{{ status.remote_id }}">{{ status.published_date | post_date }}</a>
</div> </div>
{% if status.user == request.user %}
<div class="card-footer-item"> <div class="card-footer-item">
{% include 'snippets/status_options.html' with class="is-small" right=True %} {% include 'snippets/status_options.html' with class="is-small" right=True %}
</div> </div>
{% endif %}
{% endblock %} {% endblock %}

View file

@ -1,4 +1,5 @@
{% extends 'snippets/components/dropdown.html' %} {% extends 'snippets/components/dropdown.html' %}
{% load bookwyrm_tags %}
{% block dropdown-trigger %} {% block dropdown-trigger %}
<span class="icon icon-dots-three"> <span class="icon icon-dots-three">
@ -7,6 +8,7 @@
{% endblock %} {% endblock %}
{% block dropdown-list %} {% block dropdown-list %}
{% if status.user == request.user %}
<li role="menuitem"> <li role="menuitem">
<form class="dropdown-item pt-0 pb-0" name="delete-{{status.id}}" action="/delete-status/{{ status.id }}" method="post"> <form class="dropdown-item pt-0 pb-0" name="delete-{{status.id}}" action="/delete-status/{{ status.id }}" method="post">
{% csrf_token %} {% csrf_token %}
@ -15,4 +17,9 @@
</button> </button>
</form> </form>
</li> </li>
{% else %}
<li role="menuitem">
{% include 'snippets/block_button.html' with user=status.user class="is-fullwidth" %}
</li>
{% endif %}
{% endblock %} {% endblock %}

View file

@ -35,7 +35,14 @@
</div> </div>
</div> </div>
{% if not is_self %} {% if not is_self %}
{% include 'snippets/follow_button.html' with user=user %} <div class="field has-addons">
<div class="control">
{% include 'snippets/follow_button.html' with user=user %}
</div>
<div class="control">
{% include 'snippets/user_options.html' with user=user class="is-small" %}
</div>
</div>
{% endif %} {% endif %}
{% if is_self and user.follower_requests.all %} {% if is_self and user.follower_requests.all %}

View file

@ -0,0 +1,14 @@
{% extends 'snippets/components/dropdown.html' %}
{% load bookwyrm_tags %}
{% block dropdown-trigger %}
<span class="icon icon-dots-three">
<span class="is-sr-only">More options</span>
</span>
{% endblock %}
{% block dropdown-list %}
<li role="menuitem">
{% include 'snippets/block_button.html' with user=user class="is-fullwidth" %}
</li>
{% endblock %}

View file

@ -17,7 +17,7 @@
</div> </div>
{% include 'snippets/user_header.html' with user=user %} {% include 'snippets/user_header.html' with user=user %}
{% if user.bookwyrm_user %}
<div class="block"> <div class="block">
<h2 class="title">Shelves</h2> <h2 class="title">Shelves</h2>
<div class="columns"> <div class="columns">
@ -39,6 +39,7 @@
</div> </div>
<small><a href="{{ user.local_path }}/shelves">See all {{ shelf_count }} shelves</a></small> <small><a href="{{ user.local_path }}/shelves">See all {{ shelf_count }} shelves</a></small>
</div> </div>
{% endif %}
{% if goal %} {% if goal %}
<div class="block"> <div class="block">

View file

@ -0,0 +1 @@
from . import *

View file

@ -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)

View file

@ -23,9 +23,9 @@ class BookWyrmConnector(TestCase):
self.connector = Connector('example.com') self.connector = Connector('example.com')
work_file = pathlib.Path(__file__).parent.joinpath( work_file = pathlib.Path(__file__).parent.joinpath(
'../data/fr_work.json') '../data/bw_work.json')
edition_file = pathlib.Path(__file__).parent.joinpath( 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.work_data = json.loads(work_file.read_bytes())
self.edition_data = json.loads(edition_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): def test_format_search_result(self):
''' create a SearchResult object from search response json ''' ''' create a SearchResult object from search response json '''
datafile = pathlib.Path(__file__).parent.joinpath( datafile = pathlib.Path(__file__).parent.joinpath(
'../data/fr_search.json') '../data/bw_search.json')
search_data = json.loads(datafile.read_bytes()) search_data = json.loads(datafile.read_bytes())
results = self.connector.parse_search_data(search_data) results = self.connector.parse_search_data(search_data)
self.assertIsInstance(results, list) self.assertIsInstance(results, list)

View file

@ -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)

View file

@ -32,14 +32,14 @@ class Book(TestCase):
inbox='http://example.com/u/2/inbox') inbox='http://example.com/u/2/inbox')
self.user.followers.add(no_inbox_follower) 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', 'gerbil', 'gerb@mouse.mouse', 'gerbword',
remote_id='http://example.com/u/3', remote_id='http://example.com/u/3',
outbox='http://example2.com/u/3/o', outbox='http://example2.com/u/3/o',
inbox='http://example2.com/u/3/inbox', inbox='http://example2.com/u/3/inbox',
shared_inbox='http://example2.com/inbox', shared_inbox='http://example2.com/inbox',
bookwyrm_user=False, local=False) bookwyrm_user=False, local=False)
self.user.followers.add(non_fr_follower) self.user.followers.add(non_bw_follower)
models.User.objects.create_user( models.User.objects.create_user(
'nutria', 'nutria@mouse.mouse', 'nuword', 'nutria', 'nutria@mouse.mouse', 'nuword',

View file

@ -506,7 +506,7 @@ class Incoming(TestCase):
def test_handle_update_edition(self): def test_handle_update_edition(self):
''' update an existing edition ''' ''' update an existing edition '''
datafile = pathlib.Path(__file__).parent.joinpath( datafile = pathlib.Path(__file__).parent.joinpath(
'data/fr_edition.json') 'data/bw_edition.json')
bookdata = json.loads(datafile.read_bytes()) bookdata = json.loads(datafile.read_bytes())
models.Work.objects.create( models.Work.objects.create(
@ -527,7 +527,7 @@ class Incoming(TestCase):
def test_handle_update_work(self): def test_handle_update_work(self):
''' update an existing edition ''' ''' update an existing edition '''
datafile = pathlib.Path(__file__).parent.joinpath( datafile = pathlib.Path(__file__).parent.joinpath(
'data/fr_work.json') 'data/bw_work.json')
bookdata = json.loads(datafile.read_bytes()) bookdata = json.loads(datafile.read_bytes())
book = models.Work.objects.create( book = models.Work.objects.create(
@ -540,3 +540,46 @@ class Incoming(TestCase):
incoming.handle_update_work({'object': bookdata}) incoming.handle_update_work({'object': bookdata})
book = models.Work.objects.get(id=book.id) book = models.Work.objects.get(id=book.id)
self.assertEqual(book.title, 'Piranesi') 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)

View file

@ -42,83 +42,6 @@ class AuthenticationViews(TestCase):
self.assertEqual(result.status_code, 302) 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): def test_register(self):
''' create a user ''' ''' create a user '''
view = views.Register.as_view() view = views.Register.as_view()
@ -274,29 +197,3 @@ class AuthenticationViews(TestCase):
with self.assertRaises(Http404): with self.assertRaises(Http404):
response = view(request) response = view(request)
self.assertEqual(models.User.objects.count(), 2) 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)

View file

@ -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())

View file

@ -154,6 +154,34 @@ class ViewsHelpers(TestCase):
self.assertEqual(statuses[0], rat_mention) 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): def test_is_bookwyrm_request(self):
''' checks if a request came from a bookwyrm instance ''' ''' checks if a request came from a bookwyrm instance '''
request = self.factory.get('', {'q': 'Test Book'}) request = self.factory.get('', {'q': 'Test Book'})
@ -248,3 +276,63 @@ class ViewsHelpers(TestCase):
views.helpers.handle_reading_status( views.helpers.handle_reading_status(
self.local_user, self.shelf, self.book, 'public') self.local_user, self.shelf, self.book, 'public')
self.assertFalse(models.GeneratedNote.objects.exists()) 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))

View file

@ -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)

View file

@ -1,5 +1,9 @@
''' test for app action functionality ''' ''' test for app action functionality '''
import pathlib
from unittest.mock import patch from unittest.mock import patch
from PIL import Image
from django.core.files.base import ContentFile
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.test import TestCase from django.test import TestCase
from django.test.client import RequestFactory from django.test.client import RequestFactory
@ -16,6 +20,9 @@ class UserViews(TestCase):
self.local_user = models.User.objects.create_user( self.local_user = models.User.objects.create_user(
'mouse@local.com', 'mouse@mouse.mouse', 'password', 'mouse@local.com', 'mouse@mouse.mouse', 'password',
local=True, localname='mouse') 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): def test_user_page(self):
@ -37,6 +44,18 @@ class UserViews(TestCase):
self.assertEqual(result.status_code, 200) 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): def test_followers_page(self):
''' there are so many views, this just makes sure it LOADS ''' ''' there are so many views, this just makes sure it LOADS '''
view = views.Followers.as_view() view = views.Followers.as_view()
@ -56,6 +75,18 @@ class UserViews(TestCase):
self.assertEqual(result.status_code, 200) 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): def test_following_page(self):
''' there are so many views, this just makes sure it LOADS ''' ''' there are so many views, this just makes sure it LOADS '''
view = views.Following.as_view() view = views.Following.as_view()
@ -75,6 +106,18 @@ class UserViews(TestCase):
self.assertEqual(result.status_code, 200) 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): def test_edit_profile_page(self):
''' there are so many views, this just makes sure it LOADS ''' ''' there are so many views, this just makes sure it LOADS '''
view = views.EditUser.as_view() view = views.EditUser.as_view()
@ -97,3 +140,15 @@ class UserViews(TestCase):
with patch('bookwyrm.broadcast.broadcast_task.delay'): with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request) view(request)
self.assertEqual(self.local_user.name, 'New Name') 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))

View file

@ -47,7 +47,7 @@ urlpatterns = [
re_path(r'^password-reset/?$', views.PasswordResetRequest.as_view()), re_path(r'^password-reset/?$', views.PasswordResetRequest.as_view()),
re_path(r'^password-reset/(?P<code>[A-Za-z0-9]+)/?$', re_path(r'^password-reset/(?P<code>[A-Za-z0-9]+)/?$',
views.PasswordReset.as_view()), views.PasswordReset.as_view()),
re_path(r'^change-password/?$', views.ChangePassword), re_path(r'^change-password/?$', views.ChangePassword.as_view()),
# invites # invites
re_path(r'^invite/?$', views.ManageInvites.as_view()), re_path(r'^invite/?$', views.ManageInvites.as_view()),
@ -126,6 +126,7 @@ urlpatterns = [
re_path(r'^edit-readthrough/?$', views.edit_readthrough), re_path(r'^edit-readthrough/?$', views.edit_readthrough),
re_path(r'^delete-readthrough/?$', views.delete_readthrough), re_path(r'^delete-readthrough/?$', views.delete_readthrough),
re_path(r'^create-readthrough/?$', views.create_readthrough), re_path(r'^create-readthrough/?$', views.create_readthrough),
re_path(r'^delete-progressupdate/?$', views.delete_progressupdate),
re_path(r'^start-reading/(?P<book_id>\d+)/?$', views.start_reading), re_path(r'^start-reading/(?P<book_id>\d+)/?$', views.start_reading),
re_path(r'^finish-reading/(?P<book_id>\d+)/?$', views.finish_reading), re_path(r'^finish-reading/(?P<book_id>\d+)/?$', views.finish_reading),
@ -135,4 +136,8 @@ urlpatterns = [
re_path(r'^unfollow/?$', views.unfollow), re_path(r'^unfollow/?$', views.unfollow),
re_path(r'^accept-follow-request/?$', views.accept_follow_request), re_path(r'^accept-follow-request/?$', views.accept_follow_request),
re_path(r'^delete-follow-request/?$', views.delete_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<user_id>\d+)/?$', views.Block.as_view()),
re_path(r'^unblock/(?P<user_id>\d+)/?$', views.unblock),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View file

@ -1,6 +1,7 @@
''' make sure all our nice views are available ''' ''' make sure all our nice views are available '''
from .authentication import Login, Register, Logout from .authentication import Login, Register, Logout
from .author import Author, EditAuthor from .author import Author, EditAuthor
from .block import Block, unblock
from .books import Book, EditBook, Editions from .books import Book, EditBook, Editions
from .books import upload_cover, add_description, switch_edition, resolve_book from .books import upload_cover, add_description, switch_edition, resolve_book
from .direct_message import DirectMessage from .direct_message import DirectMessage
@ -15,7 +16,7 @@ from .landing import About, Home, Feed, Discover
from .notifications import Notifications from .notifications import Notifications
from .outbox import Outbox from .outbox import Outbox
from .reading import edit_readthrough, create_readthrough, delete_readthrough 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 .password import PasswordResetRequest, PasswordReset, ChangePassword
from .tag import Tag, AddTag, RemoveTag from .tag import Tag, AddTag, RemoveTag
from .search import Search from .search import Search

58
bookwyrm/views/block.py Normal file
View file

@ -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')

View file

@ -72,6 +72,11 @@ class Book(View):
book=book, book=book,
).order_by('start_date') ).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( user_shelves = models.ShelfBook.objects.filter(
added_by=request.user, book=book added_by=request.user, book=book
) )

View file

@ -38,11 +38,21 @@ def object_visible_to_user(viewer, obj):
''' is a user authorized to view an object? ''' ''' is a user authorized to view an object? '''
if not obj: if not obj:
return False 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']: if viewer == obj.user or obj.privacy in ['public', 'unlisted']:
return True return True
# you can see the followers only posts of people you follow
if obj.privacy == 'followers' and \ if obj.privacy == 'followers' and \
obj.user.followers.filter(id=viewer.id).first(): obj.user.followers.filter(id=viewer.id).first():
return True return True
# you can see dms you are tagged in
if isinstance(obj, models.Status): if isinstance(obj, models.Status):
if obj.privacy == 'direct' and \ if obj.privacy == 'direct' and \
obj.mention_users.filter(id=viewer.id).first(): obj.mention_users.filter(id=viewer.id).first():
@ -61,6 +71,12 @@ def get_activity_feed(
# exclude deleted # exclude deleted
queryset = queryset.exclude(deleted=True).order_by('-published_date') 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 # you can't see followers only or direct messages if you're not logged in
if user.is_anonymous: if user.is_anonymous:
privacy = [p for p in privacy if not p in ['followers', 'direct']] 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() status.save()
broadcast(user, status.to_create_activity(user)) 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

View file

@ -88,6 +88,14 @@ class PasswordReset(View):
@method_decorator(login_required, name='dispatch') @method_decorator(login_required, name='dispatch')
class ChangePassword(View): class ChangePassword(View):
''' change password as logged in user ''' ''' 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): def post(self, request):
''' allow a user to change their password ''' ''' allow a user to change their password '''
new_password = request.POST.get('password') new_password = request.POST.get('password')

View file

@ -30,6 +30,9 @@ def start_reading(request, book_id):
if readthrough: if readthrough:
readthrough.save() readthrough.save()
# create a progress update if we have a page
readthrough.create_update()
# shelve the book # shelve the book
if request.POST.get('reshelve', True): if request.POST.get('reshelve', True):
try: try:
@ -104,6 +107,10 @@ def edit_readthrough(request):
return HttpResponseBadRequest() return HttpResponseBadRequest()
readthrough.save() readthrough.save()
# record the progress update individually
# use default now for date field
readthrough.create_update()
return redirect(request.headers.get('Referer', '/')) return redirect(request.headers.get('Referer', '/'))
@ -166,7 +173,36 @@ def update_readthrough(request, book=None, create=True):
except ParserError: except ParserError:
pass 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: if not readthrough.start_date and not readthrough.finish_date:
return None return None
return readthrough 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', '/'))

View file

@ -18,7 +18,7 @@ from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.broadcast import broadcast from bookwyrm.broadcast import broadcast
from bookwyrm.settings import PAGE_LENGTH from bookwyrm.settings import PAGE_LENGTH
from .helpers import get_activity_feed, get_user_from_username, is_api_request 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 # pylint: disable= no-self-use
@ -31,6 +31,10 @@ class User(View):
except models.User.DoesNotExist: except models.User.DoesNotExist:
return HttpResponseNotFound() return HttpResponseNotFound()
# make sure we're not blocked
if is_blocked(request.user, user):
return HttpResponseNotFound()
if is_api_request(request): if is_api_request(request):
# we have a json request # we have a json request
return ActivitypubResponse(user.to_activity()) return ActivitypubResponse(user.to_activity())
@ -97,6 +101,10 @@ class Followers(View):
except models.User.DoesNotExist: except models.User.DoesNotExist:
return HttpResponseNotFound() return HttpResponseNotFound()
# make sure we're not blocked
if is_blocked(request.user, user):
return HttpResponseNotFound()
if is_api_request(request): if is_api_request(request):
return ActivitypubResponse( return ActivitypubResponse(
user.to_followers_activity(**request.GET)) user.to_followers_activity(**request.GET))
@ -118,6 +126,10 @@ class Following(View):
except models.User.DoesNotExist: except models.User.DoesNotExist:
return HttpResponseNotFound() return HttpResponseNotFound()
# make sure we're not blocked
if is_blocked(request.user, user):
return HttpResponseNotFound()
if is_api_request(request): if is_api_request(request):
return ActivitypubResponse( return ActivitypubResponse(
user.to_following_activity(**request.GET)) user.to_following_activity(**request.GET))
@ -135,14 +147,11 @@ class Following(View):
class EditUser(View): class EditUser(View):
''' edit user view ''' ''' edit user view '''
def get(self, request): def get(self, request):
''' profile page for a user ''' ''' edit profile page for a user '''
user = request.user
form = forms.EditUserForm(instance=request.user)
data = { data = {
'title': 'Edit profile', 'title': 'Edit profile',
'form': form, 'form': forms.EditUserForm(instance=request.user),
'user': user, 'user': request.user,
} }
return TemplateResponse(request, 'edit_user.html', data) return TemplateResponse(request, 'edit_user.html', data)
@ -159,30 +168,35 @@ class EditUser(View):
if 'avatar' in form.files: if 'avatar' in form.files:
# crop and resize avatar upload # crop and resize avatar upload
image = Image.open(form.files['avatar']) image = Image.open(form.files['avatar'])
target_size = 120 image = crop_avatar(image)
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())
# set the name to a hash # set the name to a hash
extension = form.files['avatar'].name.split('.')[-1] extension = form.files['avatar'].name.split('.')[-1]
filename = '%s.%s' % (uuid4(), extension) filename = '%s.%s' % (uuid4(), extension)
user.avatar.save(filename, ContentFile(output.getvalue())) user.avatar.save(filename, image)
user.save() user.save()
broadcast(user, user.to_update_activity(user)) broadcast(user, user.to_update_activity(user))
return redirect(user.local_path) 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())

12
bw-dev
View file

@ -28,9 +28,15 @@ function initdb {
execweb python manage.py initdb execweb python manage.py initdb
} }
case "$1" in CMD=$1
shift
# show commands as they're executed
set -x
case "$CMD" in
up) up)
docker-compose up --build docker-compose up --build "$@"
;; ;;
run) run)
docker-compose run --rm --service-ports web docker-compose run --rm --service-ports web
@ -39,12 +45,10 @@ case "$1" in
initdb initdb
;; ;;
makemigrations) makemigrations)
shift 1
execweb python manage.py makemigrations "$@" execweb python manage.py makemigrations "$@"
;; ;;
migrate) migrate)
execweb python manage.py rename_app fedireads bookwyrm execweb python manage.py rename_app fedireads bookwyrm
shift 1
execweb python manage.py migrate "$@" execweb python manage.py migrate "$@"
;; ;;
bash) bash)