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_USE_TLS: true
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 .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,

View file

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

View file

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

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
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',

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 .favorite import Favorite
from .notification import Notification
from .readthrough import ReadThrough
from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
from .tag import Tag, UserTag

View file

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

View file

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

View file

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

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' %}
{% block content %}
<div class="block columns">
<div class="column is-half">
<h1 class="title">Profile</h1>
{% if form.non_field_errors %}
<p class="notification is-danger">{{ form.non_field_errors }}</p>
{% endif %}
<form name="edit-profile" action="/edit-profile/" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="block">
<label class="label" for="id_avatar">Avatar:</label>
{{ form.avatar }}
{% for error in form.avatar.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<div class="block">
<label class="label" for="id_name">Display name:</label>
{{ form.name }}
{% for error in form.name.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<div class="block">
<label class="label" for="id_summary">Summary:</label>
{{ form.summary }}
{% for error in form.summary.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<div class="block">
<label class="label" for="id_email">Email address:</label>
{{ form.email }}
{% for error in form.email.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<div class="block">
<label class="checkbox label" for="id_manually_approves_followers">
Manually approve followers:
{{ form.manually_approves_followers }}
</label>
</div>
<button class="button is-primary" type="submit">Save</button>
</form>
</div>
<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>
{% extends 'preferences_layout.html' %}
{% block header %}
Edit Profile
{% endblock %}
{% block panel %}
{% if form.non_field_errors %}
<p class="notification is-danger">{{ form.non_field_errors }}</p>
{% endif %}
<form name="edit-profile" action="/edit-profile/" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="block">
<label class="label" for="id_avatar">Avatar:</label>
{{ form.avatar }}
{% for error in form.avatar.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<div class="block">
<label class="label" for="id_name">Display name:</label>
{{ form.name }}
{% for error in form.name.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<div class="block">
<label class="label" for="id_summary">Summary:</label>
{{ form.summary }}
{% for error in form.summary.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<div class="block">
<label class="label" for="id_email">Email address:</label>
{{ form.email }}
{% for error in form.email.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<div class="block">
<label class="checkbox label" for="id_manually_approves_followers">
Manually approve followers:
{{ form.manually_approves_followers }}
</label>
</div>
<button class="button is-primary" type="submit">Save</button>
</form>
{% endblock %}

View file

@ -48,6 +48,10 @@
</div>
<div class="card-content">
{% 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 %}
</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>
{% include 'snippets/toggle/toggle_button.html' with label="close" class="delete" nonbutton=True %}
</header>
{% block modal-form-open %}{% endblock %}
{% block modal-body %}{% endblock %}
<section class="modal-card-body">
{% block modal-body %}{% endblock %}
</section>
<footer class="modal-card-foot">
{% block modal-footer %}{% endblock %}
</footer>

View file

@ -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 %}
<form name="delete-readthrough-{{ readthrough.id }}" action="/delete-readthrough" method="POST">
{% 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 %}
<div class="content block">
<div id="hide-edit-readthrough-{{ readthrough.id }}">
<dl class="mb-1">
{% if readthrough.start_date %}
<div class="is-flex">
<dt>Started reading:</dt>
<dd>{{ readthrough.start_date | naturalday }}</dd>
<div class="columns">
<div class="column">
Progress Updates:
</dl>
<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>
{% endif %}
{% if readthrough.finish_date %}
<div class="is-flex">
<dt>Finished reading:</dt>
<dd>{{ readthrough.finish_date | naturalday }}</dd>
</div>
{% endif %}
</dl>
<div class="field has-addons">
<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 class="column is-narrow">
<div class="field has-addons">
<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>

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" }}">
</label>
</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">
<label class="label">
Finished reading

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

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

View file

@ -47,7 +47,7 @@ urlpatterns = [
re_path(r'^password-reset/?$', views.PasswordResetRequest.as_view()),
re_path(r'^password-reset/(?P<code>[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<book_id>\d+)/?$', views.start_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'^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<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)

View file

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

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,
).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
)

View file

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

View file

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

View file

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

View file

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

12
bw-dev
View file

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