Merge pull request #354 from cincodenada/progress_update

Keep track of progress through books over time
This commit is contained in:
Mouse Reeve 2021-01-21 16:57:16 -08:00 committed by GitHub
commit 6c52afeae0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 423 additions and 26 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

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

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

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

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

@ -7,11 +7,10 @@
</h2>
{% include 'snippets/toggle/toggle_button.html' with label="close" class="delete" nonbutton=True %}
</header>
{% block modal-form-open %}{% 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 }}"/>
<label class="label is-align-self-center mb-0 pr-2" for="progress">Currently at</label>
<div class="control">
<div class="field has-addons">
<div class="control">
<input
aria-label="{% if readthrough.progress_mode == 'PG' %}Current page{% else %}Percent read{% endif %}"
class="input is-small" type="number"
name="progress" size="3" value="{{ readthrough.progress|default:'' }}">
</div>
<div class="control">
<a class="button is-small is-static">
{% if readthrough.progress_mode == 'PG' %}
pages
{% else %}
%
{% endif %}
</a>
</div>
</div>
</div>
<div class="control">
<button class="button is-small px-2" type="submit">Save</button>
</div>
</form>

View file

@ -13,6 +13,15 @@
<dt>Finished reading:</dt>
<dd>{{ readthrough.finish_date | naturalday }}</dd>
</div>
{% elif readthrough.progress %}
<div class="is-flex">
<dt>Progress:</dt>
{% if readthrough.progress_mode == 'PG' %}
<dd>on page {{ readthrough.progress }} of {{ book.pages }}</dd>
{% else %}
<dd>{{ readthrough.progress }}%</dd>
{% endif %}
</div>
{% endif %}
</dl>
<div class="field has-addons">
@ -23,6 +32,36 @@
{% 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>
{% if show_progress %}
Progress Updates:
<ul>
{% if readthrough.finish_date %}
<li>{{ readthrough.start_date | naturalday }}: finished</li>
{% endif %}
{% 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">
<span class="is-sr-only">Delete this progress update</span>
</span>
</button>
</form>
</li>
{% endfor %}
<li>{{ readthrough.start_date | naturalday }}: started</li>
</ul>
{% elif readthrough.progress_updates|length %}
<a href="?showprogress">Show {{ readthrough.progress_updates|length }} Progress Updates</a>
{% endif %}
</div>
</div>

View file

@ -7,6 +7,31 @@
<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 %}
<div class="columns">
<div class="column">
<div class="field">
<label class="label">
Progress
<input type="number" name="progress" class="input" id="id_progress-{{ readthrough.id }}" value="{{ readthrough.progress }}">
</label>
</div>
</div>
<div class="column">
<div class="control mt-5">
<label class="radio">
<input type="radio" name="progress_mode" id="id_progress_mode-{{ readthrough.id }}" value="PG" {% if readthrough.progress_mode == 'PG' %}checked{% endif %}>
pages
</label>
<label class="radio">
<input type="radio" name="progress_mode" id="id_progress_mode-{{ readthrough.id }}" value="PCT" {% if readthrough.progress_mode == 'PCT' %}checked{% endif %}>
percent
</label>
</div>
</div>
</div>
{% endif %}
<div class="field">
<label class="label">
Finished reading

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(

View file

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

View file

@ -15,7 +15,7 @@ from .landing import About, Home, Feed, Discover
from .notifications import Notifications
from .outbox import Outbox
from .reading import edit_readthrough, create_readthrough, delete_readthrough
from .reading import start_reading, finish_reading
from .reading import start_reading, finish_reading, delete_progressupdate
from .password import PasswordResetRequest, PasswordReset, ChangePassword
from .tag import Tag, AddTag, RemoveTag
from .search import Search

View file

@ -72,6 +72,10 @@ 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
)
@ -94,6 +98,7 @@ class Book(View):
'user_shelves': user_shelves,
'other_edition_shelves': other_edition_shelves,
'readthroughs': readthroughs,
'show_progress': ('showprogress' in request.GET),
'path': '/book/%s' % book_id,
}
return TemplateResponse(request, 'book.html', data)

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

17
bw-dev
View file

@ -12,9 +12,6 @@ trap showerr EXIT
source .env
trap - EXIT
# show commands as they're executed
set -x
function clean {
docker-compose stop
docker-compose rm -f
@ -38,9 +35,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
@ -57,12 +60,10 @@ case "$1" in
clean
;;
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)
@ -78,11 +79,9 @@ case "$1" in
docker-compose restart celery_worker
;;
test)
shift 1
execweb coverage run --source='.' --omit="*/test*,celerywyrm*,bookwyrm/migrations/*" manage.py test "$@"
;;
pytest)
shift 1
execweb pytest --no-cov-on-fail "$@"
;;
test_report)