Merge branch 'main' into progress_update

This commit is contained in:
Joel Bradshaw 2021-01-17 12:59:53 -08:00
commit a4519d55c9
109 changed files with 5683 additions and 4458 deletions

View file

@ -188,3 +188,8 @@ class ShelfForm(CustomForm):
class Meta:
model = models.Shelf
fields = ['user', 'name', 'privacy']
class GoalForm(CustomForm):
class Meta:
model = models.AnnualGoal
fields = ['user', 'year', 'goal', 'privacy']

View file

@ -2,10 +2,11 @@
import csv
import logging
from bookwyrm import outgoing
from bookwyrm.tasks import app
from bookwyrm import models
from bookwyrm.broadcast import broadcast
from bookwyrm.models import ImportJob, ImportItem
from bookwyrm.status import create_notification
from bookwyrm.tasks import app
logger = logging.getLogger(__name__)
@ -62,7 +63,7 @@ def import_data(job_id):
item.save()
# shelves book and handles reviews
outgoing.handle_imported_book(
handle_imported_book(
job.user, item, job.include_reviews, job.privacy)
else:
item.fail_reason = 'Could not find a match for book'
@ -71,3 +72,57 @@ def import_data(job_id):
create_notification(job.user, 'IMPORT', related_import=job)
job.complete = True
job.save()
def handle_imported_book(user, item, include_reviews, privacy):
''' process a goodreads csv and then post about it '''
if isinstance(item.book, models.Work):
item.book = item.book.default_edition
if not item.book:
return
existing_shelf = models.ShelfBook.objects.filter(
book=item.book, added_by=user).exists()
# shelve the book if it hasn't been shelved already
if item.shelf and not existing_shelf:
desired_shelf = models.Shelf.objects.get(
identifier=item.shelf,
user=user
)
shelf_book = models.ShelfBook.objects.create(
book=item.book, shelf=desired_shelf, added_by=user)
broadcast(user, shelf_book.to_add_activity(user), privacy=privacy)
for read in item.reads:
# check for an existing readthrough with the same dates
if models.ReadThrough.objects.filter(
user=user, book=item.book,
start_date=read.start_date,
finish_date=read.finish_date
).exists():
continue
read.book = item.book
read.user = user
read.save()
if include_reviews and (item.rating or item.review):
review_title = 'Review of {!r} on Goodreads'.format(
item.book.title,
) if item.review else ''
# we don't know the publication date of the review,
# but "now" is a bad guess
published_date_guess = item.date_read or item.date_added
review = models.Review.objects.create(
user=user,
book=item.book,
name=review_title,
content=item.review,
rating=item.rating,
published_date=published_date_guess,
privacy=privacy,
)
# we don't need to send out pure activities because non-bookwyrm
# instances don't need this data
broadcast(user, review.to_create_activity(user), privacy=privacy)

View file

@ -9,7 +9,7 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
import requests
from bookwyrm import activitypub, models, outgoing
from bookwyrm import activitypub, models, views
from bookwyrm import status as status_builder
from bookwyrm.tasks import app
from bookwyrm.signatures import Signature
@ -133,7 +133,7 @@ def handle_follow(activity):
related_user=relationship.user_subject
)
if not manually_approves:
outgoing.handle_accept(relationship)
views.handle_accept(relationship)
@app.task

View file

@ -8,7 +8,6 @@ def set_rank(app_registry, schema_editor):
db_alias = schema_editor.connection.alias
books = app_registry.get_model('bookwyrm', 'Edition')
for book in books.objects.using(db_alias):
book.edition_rank = book.get_rank
book.save()
class Migration(migrations.Migration):

View file

@ -0,0 +1,32 @@
# Generated by Django 3.0.7 on 2021-01-16 18:43
import bookwyrm.models.fields
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0035_edition_edition_rank'),
]
operations = [
migrations.CreateModel(
name='AnnualGoal',
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', bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id])),
('goal', models.IntegerField()),
('year', models.IntegerField(default=2021)),
('privacy', models.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'year')},
},
),
]

View file

@ -17,7 +17,7 @@ from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
from .tag import Tag, UserTag
from .user import User, KeyPair
from .user import User, KeyPair, AnnualGoal
from .relationship import UserFollows, UserFollowRequest, UserBlocks
from .federated_server import FederatedServer

View file

@ -225,6 +225,9 @@ class OrderedCollectionPageMixin(ActivitypubMixin):
def to_ordered_collection(self, queryset, \
remote_id=None, page=False, **kwargs):
''' an ordered collection of whatevers '''
if not queryset.ordered:
raise RuntimeError('queryset must be ordered')
remote_id = remote_id or self.remote_id
if page:
return to_ordered_collection_page(

View file

@ -182,7 +182,6 @@ class Edition(Book):
activity_serializer = activitypub.Edition
name_field = 'title'
@property
def get_rank(self):
''' calculate how complete the data is on this edition '''
if self.parent_work and self.parent_work.default_edition == self:
@ -208,7 +207,7 @@ class Edition(Book):
self.isbn_13 = isbn_10_to_13(self.isbn_10)
# set rank
self.edition_rank = self.get_rank
self.edition_rank = self.get_rank()
return super().save(*args, **kwargs)

View file

@ -39,7 +39,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
@property
def collection_queryset(self):
''' list of books for this shelf, overrides OrderedCollectionMixin '''
return self.books.all()
return self.books.all().order_by('shelfbook')
def get_remote_id(self):
''' shelf identifier instead of id '''
@ -90,3 +90,4 @@ class ShelfBook(ActivitypubMixin, BookWyrmModel):
''' an opinionated constraint!
you can't put a book on shelf twice '''
unique_together = ('book', 'shelf')
ordering = ('-created_date',)

View file

@ -6,6 +6,7 @@ from django.apps import apps
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.dispatch import receiver
from django.utils import timezone
from bookwyrm import activitypub
from bookwyrm.connectors import get_data
@ -18,7 +19,7 @@ from bookwyrm.utils import regex
from .base_model import OrderedCollectionPageMixin
from .base_model import ActivitypubMixin, BookWyrmModel
from .federated_server import FederatedServer
from . import fields
from . import fields, Review
class User(OrderedCollectionPageMixin, AbstractUser):
@ -221,6 +222,57 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
return activity_object
class AnnualGoal(BookWyrmModel):
''' set a goal for how many books you read in a year '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
goal = models.IntegerField()
year = models.IntegerField(default=timezone.now().year)
privacy = models.CharField(
max_length=255,
default='public',
choices=fields.PrivacyLevels.choices
)
class Meta:
''' unqiueness constraint '''
unique_together = ('user', 'year')
def get_remote_id(self):
''' put the year in the path '''
return '%s/goal/%d' % (self.user.remote_id, self.year)
@property
def books(self):
''' the books you've read this year '''
return self.user.readthrough_set.filter(
finish_date__year__gte=self.year
).order_by('finish_date').all()
@property
def ratings(self):
''' ratings for books read this year '''
book_ids = [r.book.id for r in self.books]
reviews = Review.objects.filter(
user=self.user,
book__in=book_ids,
)
return {r.book.id: r.rating for r in reviews}
@property
def progress_percent(self):
return int(float(self.book_count / self.goal) * 100)
@property
def book_count(self):
''' how many books you've read this year '''
return self.user.readthrough_set.filter(
finish_date__year__gte=self.year).count()
@receiver(models.signals.post_save, sender=User)
#pylint: disable=unused-argument
def execute_after_save(sender, instance, created, *args, **kwargs):

View file

@ -1,414 +0,0 @@
''' handles all the activity coming out of the server '''
import re
from django.db import IntegrityError, transaction
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET
from markdown import markdown
from requests import HTTPError
from bookwyrm import activitypub
from bookwyrm import models
from bookwyrm.connectors import get_data, ConnectorException
from bookwyrm.broadcast import broadcast
from bookwyrm.sanitize_html import InputHtmlParser
from bookwyrm.status import create_notification
from bookwyrm.status import create_generated_note
from bookwyrm.status import delete_status
from bookwyrm.settings import DOMAIN
from bookwyrm.utils import regex
@csrf_exempt
@require_GET
def outbox(request, username):
''' outbox for the requested user '''
user = get_object_or_404(models.User, localname=username)
filter_type = request.GET.get('type')
if filter_type not in models.status_models:
filter_type = None
return JsonResponse(
user.to_outbox(**request.GET, filter_type=filter_type),
encoder=activitypub.ActivityEncoder
)
def handle_remote_webfinger(query):
''' webfingerin' other servers '''
user = None
# usernames could be @user@domain or user@domain
if not query:
return None
if query[0] == '@':
query = query[1:]
try:
domain = query.split('@')[1]
except IndexError:
return None
try:
user = models.User.objects.get(username=query)
except models.User.DoesNotExist:
url = 'https://%s/.well-known/webfinger?resource=acct:%s' % \
(domain, query)
try:
data = get_data(url)
except (ConnectorException, HTTPError):
return None
for link in data.get('links'):
if link.get('rel') == 'self':
try:
user = activitypub.resolve_remote_id(
models.User, link['href']
)
except KeyError:
return None
return user
def handle_follow(user, to_follow):
''' someone local wants to follow someone '''
relationship, _ = models.UserFollowRequest.objects.get_or_create(
user_subject=user,
user_object=to_follow,
)
activity = relationship.to_activity()
broadcast(user, activity, privacy='direct', direct_recipients=[to_follow])
def handle_unfollow(user, to_unfollow):
''' someone local wants to follow someone '''
relationship = models.UserFollows.objects.get(
user_subject=user,
user_object=to_unfollow
)
activity = relationship.to_undo_activity(user)
broadcast(user, activity, privacy='direct', direct_recipients=[to_unfollow])
to_unfollow.followers.remove(user)
def handle_accept(follow_request):
''' send an acceptance message to a follow request '''
user = follow_request.user_subject
to_follow = follow_request.user_object
with transaction.atomic():
relationship = models.UserFollows.from_request(follow_request)
follow_request.delete()
relationship.save()
activity = relationship.to_accept_activity()
broadcast(to_follow, activity, privacy='direct', direct_recipients=[user])
def handle_reject(follow_request):
''' a local user who managed follows rejects a follow request '''
user = follow_request.user_subject
to_follow = follow_request.user_object
activity = follow_request.to_reject_activity()
follow_request.delete()
broadcast(to_follow, activity, privacy='direct', direct_recipients=[user])
def handle_shelve(user, book, shelf):
''' a local user is getting a book put on their shelf '''
# update the database
shelve = models.ShelfBook(book=book, shelf=shelf, added_by=user)
shelve.save()
broadcast(user, shelve.to_add_activity(user))
def handle_unshelve(user, book, shelf):
''' a local user is getting a book put on their shelf '''
# update the database
row = models.ShelfBook.objects.get(book=book, shelf=shelf)
activity = row.to_remove_activity(user)
row.delete()
broadcast(user, activity)
def handle_reading_status(user, shelf, book, privacy):
''' post about a user reading a book '''
# tell the world about this cool thing that happened
try:
message = {
'to-read': 'wants to read',
'reading': 'started reading',
'read': 'finished reading'
}[shelf.identifier]
except KeyError:
# it's a non-standard shelf, don't worry about it
return
status = create_generated_note(
user,
message,
mention_books=[book],
privacy=privacy
)
status.save()
broadcast(user, status.to_create_activity(user))
def handle_imported_book(user, item, include_reviews, privacy):
''' process a goodreads csv and then post about it '''
if isinstance(item.book, models.Work):
item.book = item.book.default_edition
if not item.book:
return
existing_shelf = models.ShelfBook.objects.filter(
book=item.book, added_by=user).exists()
# shelve the book if it hasn't been shelved already
if item.shelf and not existing_shelf:
desired_shelf = models.Shelf.objects.get(
identifier=item.shelf,
user=user
)
shelf_book = models.ShelfBook.objects.create(
book=item.book, shelf=desired_shelf, added_by=user)
broadcast(user, shelf_book.to_add_activity(user), privacy=privacy)
for read in item.reads:
# check for an existing readthrough with the same dates
if models.ReadThrough.objects.filter(
user=user, book=item.book,
start_date=read.start_date,
finish_date=read.finish_date
).exists():
continue
read.book = item.book
read.user = user
read.save()
if include_reviews and (item.rating or item.review):
review_title = 'Review of {!r} on Goodreads'.format(
item.book.title,
) if item.review else ''
# we don't know the publication date of the review,
# but "now" is a bad guess
published_date_guess = item.date_read or item.date_added
review = models.Review.objects.create(
user=user,
book=item.book,
name=review_title,
content=item.review,
rating=item.rating,
published_date=published_date_guess,
privacy=privacy,
)
# we don't need to send out pure activities because non-bookwyrm
# instances don't need this data
broadcast(user, review.to_create_activity(user), privacy=privacy)
def handle_delete_status(user, status):
''' delete a status and broadcast deletion to other servers '''
delete_status(status)
broadcast(user, status.to_delete_activity(user))
def handle_status(user, form):
''' generic handler for statuses '''
status = form.save(commit=False)
if not status.sensitive and status.content_warning:
# the cw text field remains populated when you click "remove"
status.content_warning = None
status.save()
# inspect the text for user tags
content = status.content
for (mention_text, mention_user) in find_mentions(content):
# add them to status mentions fk
status.mention_users.add(mention_user)
# turn the mention into a link
content = re.sub(
r'%s([^@]|$)' % mention_text,
r'<a href="%s">%s</a>\g<1>' % \
(mention_user.remote_id, mention_text),
content)
# add reply parent to mentions and notify
if status.reply_parent:
status.mention_users.add(status.reply_parent.user)
for mention_user in status.reply_parent.mention_users.all():
status.mention_users.add(mention_user)
if status.reply_parent.user.local:
create_notification(
status.reply_parent.user,
'REPLY',
related_user=user,
related_status=status
)
# deduplicate mentions
status.mention_users.set(set(status.mention_users.all()))
# create mention notifications
for mention_user in status.mention_users.all():
if status.reply_parent and mention_user == status.reply_parent.user:
continue
if mention_user.local:
create_notification(
mention_user,
'MENTION',
related_user=user,
related_status=status
)
# don't apply formatting to generated notes
if not isinstance(status, models.GeneratedNote):
status.content = to_markdown(content)
# do apply formatting to quotes
if hasattr(status, 'quote'):
status.quote = to_markdown(status.quote)
status.save()
broadcast(user, status.to_create_activity(user), software='bookwyrm')
# re-format the activity for non-bookwyrm servers
remote_activity = status.to_create_activity(user, pure=True)
broadcast(user, remote_activity, software='other')
def find_mentions(content):
''' detect @mentions in raw status content '''
for match in re.finditer(regex.strict_username, content):
username = match.group().strip().split('@')[1:]
if len(username) == 1:
# this looks like a local user (@user), fill in the domain
username.append(DOMAIN)
username = '@'.join(username)
mention_user = handle_remote_webfinger(username)
if not mention_user:
# we can ignore users we don't know about
continue
yield (match.group(), mention_user)
def format_links(content):
''' detect and format links '''
return re.sub(
r'([^(href=")]|^|\()(https?:\/\/(%s([\w\.\-_\/+&\?=:;,])*))' % \
regex.domain,
r'\g<1><a href="\g<2>">\g<3></a>',
content)
def to_markdown(content):
''' catch links and convert to markdown '''
content = format_links(content)
content = markdown(content)
# sanitize resulting html
sanitizer = InputHtmlParser()
sanitizer.feed(content)
return sanitizer.get_output()
def handle_favorite(user, status):
''' a user likes a status '''
try:
favorite = models.Favorite.objects.create(
status=status,
user=user
)
except IntegrityError:
# you already fav'ed that
return
fav_activity = favorite.to_activity()
broadcast(
user, fav_activity, privacy='direct', direct_recipients=[status.user])
if status.user.local:
create_notification(
status.user,
'FAVORITE',
related_user=user,
related_status=status
)
def handle_unfavorite(user, status):
''' a user likes a status '''
try:
favorite = models.Favorite.objects.get(
status=status,
user=user
)
except models.Favorite.DoesNotExist:
# can't find that status, idk
return
fav_activity = favorite.to_undo_activity(user)
favorite.delete()
broadcast(user, fav_activity, direct_recipients=[status.user])
# check for notification
if status.user.local:
notification = models.Notification.objects.filter(
user=status.user, related_user=user,
related_status=status, notification_type='FAVORITE'
).first()
if notification:
notification.delete()
def handle_boost(user, status):
''' a user wishes to boost a status '''
# is it boostable?
if not status.boostable:
return
if models.Boost.objects.filter(
boosted_status=status, user=user).exists():
# you already boosted that.
return
boost = models.Boost.objects.create(
boosted_status=status,
privacy=status.privacy,
user=user,
)
boost_activity = boost.to_activity()
broadcast(user, boost_activity)
if status.user.local:
create_notification(
status.user,
'BOOST',
related_user=user,
related_status=status
)
def handle_unboost(user, status):
''' a user regrets boosting a status '''
boost = models.Boost.objects.filter(
boosted_status=status, user=user
).first()
activity = boost.to_undo_activity(user)
boost.delete()
broadcast(user, activity)
# delete related notification
if status.user.local:
notification = models.Notification.objects.filter(
user=status.user, related_user=user,
related_status=status, notification_type='BOOST'
).first()
if notification:
notification.delete()

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -9,6 +9,9 @@
.card {
overflow: visible;
}
.card-header-title {
overflow: hidden;
}
/* --- TOGGLES --- */
input.toggle-control {
@ -145,11 +148,11 @@ input.toggle-control:checked ~ .modal.toggle-content {
position: absolute;
}
.quote blockquote:before {
content: "\e905";
content: "\e906";
top: 0;
left: 0;
}
.quote blockquote:after {
content: "\e904";
content: "\e905";
right: 0;
}

View file

@ -1,10 +1,10 @@
@font-face {
font-family: 'icomoon';
src: url('fonts/icomoon.eot?rd4abb');
src: url('fonts/icomoon.eot?rd4abb#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?rd4abb') format('truetype'),
url('fonts/icomoon.woff?rd4abb') format('woff'),
url('fonts/icomoon.svg?rd4abb#icomoon') format('svg');
src: url('fonts/icomoon.eot?uh765c');
src: url('fonts/icomoon.eot?uh765c#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?uh765c') format('truetype'),
url('fonts/icomoon.woff?uh765c') format('woff'),
url('fonts/icomoon.svg?uh765c#icomoon') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
@ -25,81 +25,102 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-dots-three-vertical:before {
content: "\e918";
.icon-sparkle:before {
content: "\e91a";
}
.icon-check:before {
content: "\e917";
.icon-warning:before {
content: "\e91b";
}
.icon-dots-three:before {
content: "\e916";
}
.icon-envelope:before {
.icon-book:before {
content: "\e900";
}
.icon-arrow-right:before {
.icon-bookmark:before {
content: "\e91c";
}
.icon-envelope:before {
content: "\e901";
}
.icon-bell:before {
.icon-arrow-right:before {
content: "\e902";
}
.icon-x:before {
.icon-bell:before {
content: "\e903";
}
.icon-quote-close:before {
.icon-x:before {
content: "\e904";
}
.icon-quote-open:before {
.icon-quote-close:before {
content: "\e905";
}
.icon-image:before {
.icon-quote-open:before {
content: "\e906";
}
.icon-pencil:before {
.icon-image:before {
content: "\e907";
}
.icon-list:before {
.icon-pencil:before {
content: "\e908";
}
.icon-unlock:before {
.icon-list:before {
content: "\e909";
}
.icon-globe:before {
.icon-unlock:before {
content: "\e90a";
}
.icon-lock:before {
.icon-unlisted:before {
content: "\e90a";
}
.icon-globe:before {
content: "\e90b";
}
.icon-chain-broken:before {
.icon-public:before {
content: "\e90b";
}
.icon-lock:before {
content: "\e90c";
}
.icon-chain:before {
.icon-followers:before {
content: "\e90c";
}
.icon-chain-broken:before {
content: "\e90d";
}
.icon-comments:before {
.icon-chain:before {
content: "\e90e";
}
.icon-comment:before {
.icon-comments:before {
content: "\e90f";
}
.icon-boost:before {
.icon-comment:before {
content: "\e910";
}
.icon-arrow-left:before {
.icon-boost:before {
content: "\e911";
}
.icon-arrow-up:before {
.icon-arrow-left:before {
content: "\e912";
}
.icon-arrow-down:before {
.icon-arrow-up:before {
content: "\e913";
}
.icon-home:before {
.icon-arrow-down:before {
content: "\e914";
}
.icon-local:before {
.icon-home:before {
content: "\e915";
}
.icon-local:before {
content: "\e916";
}
.icon-dots-three:before {
content: "\e917";
}
.icon-check:before {
content: "\e918";
}
.icon-dots-three-vertical:before {
content: "\e919";
}
.icon-search:before {
content: "\e986";
}

View file

@ -1,3 +1,64 @@
// set up javascript listeners
window.onload = function() {
// let buttons set keyboard focus
Array.from(document.getElementsByClassName('toggle-control'))
.forEach(t => t.onclick = toggleAction);
// javascript interactions (boost/fav)
Array.from(document.getElementsByClassName('interaction'))
.forEach(t => t.onsubmit = interact);
// select all
Array.from(document.getElementsByClassName('select-all'))
.forEach(t => t.onclick = selectAll);
// toggle between tabs
Array.from(document.getElementsByClassName('tab-change-nested'))
.forEach(t => t.onclick = tabChangeNested);
Array.from(document.getElementsByClassName('tab-change'))
.forEach(t => t.onclick = tabChange);
// handle aria settings on menus
Array.from(document.getElementsByClassName('pulldown-menu'))
.forEach(t => t.onclick = toggleMenu);
// display based on localstorage vars
document.querySelectorAll('[data-hide]')
.forEach(t => setDisplay(t));
// update localstorage
Array.from(document.getElementsByClassName('set-display'))
.forEach(t => t.onclick = updateDisplay);
};
function updateDisplay(e) {
var key = e.target.getAttribute('data-id');
var value = e.target.getAttribute('data-value');
window.localStorage.setItem(key, value);
document.querySelectorAll('[data-hide="' + key + '"]')
.forEach(t => setDisplay(t));
}
function setDisplay(el) {
var key = el.getAttribute('data-hide');
var value = window.localStorage.getItem(key)
if (!value) {
el.className = el.className.replace('hidden', '');
} else if (value != null && !!value) {
el.className += ' hidden';
}
}
function toggleAction(e) {
// set hover, if appropriate
var hover = e.target.getAttribute('data-hover-target');
if (hover) {
document.getElementById(hover).focus();
}
}
function interact(e) {
e.preventDefault();
ajaxPost(e.target);
@ -13,49 +74,36 @@ function interact(e) {
return true;
}
function reply(e) {
e.preventDefault();
ajaxPost(e.target);
// TODO: display comment
return true;
}
function selectAll(el) {
el.parentElement.querySelectorAll('[type="checkbox"]')
function selectAll(e) {
e.target.parentElement.parentElement.querySelectorAll('[type="checkbox"]')
.forEach(t => t.checked=true);
}
function rate_stars(e) {
e.preventDefault();
ajaxPost(e.target);
rating = e.target.rating.value;
var stars = e.target.parentElement.getElementsByClassName('icon');
for (var i = 0; i < stars.length ; i++) {
stars[i].className = rating > i ? 'icon icon-star-full' : 'icon icon-star-empty';
}
return true;
}
function tabChange(e, nested) {
function tabChangeNested(e) {
var target = e.target.closest('li')
var identifier = target.getAttribute('data-id');
if (nested) {
var parent_element = target.parentElement.closest('li').parentElement;
} else {
var parent_element = target.parentElement;
var parentElement = target.parentElement.closest('li').parentElement;
handleTabChange(target, parentElement)
}
parent_element.querySelectorAll('[aria-selected="true"]')
function tabChange(e) {
var target = e.target.closest('li')
var parentElement = target.parentElement;
handleTabChange(target, parentElement)
}
function handleTabChange(target, parentElement) {
parentElement.querySelectorAll('[aria-selected="true"]')
.forEach(t => t.setAttribute("aria-selected", false));
target.querySelector('[role="tab"]').setAttribute("aria-selected", true);
parent_element.querySelectorAll('li')
parentElement.querySelectorAll('li')
.forEach(t => t.className='');
target.className = 'is-active';
}
function toggleMenu(el) {
function toggleMenu(e) {
var el = e.target.closest('.pulldown-menu');
el.setAttribute('aria-expanded', el.getAttribute('aria-expanded') == 'false');
}

View file

@ -9,7 +9,7 @@
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
<div class="column is-narrow">
<a href="{{ author.local_path }}/edit">
<span class="icon icon-pencil">
<span class="icon icon-pencil" title="Edit Author">
<span class="is-sr-only">Edit Author</span>
</span>
</a>

View file

@ -23,7 +23,7 @@
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
<div class="column is-narrow">
<a href="{{ book.id }}/edit">
<span class="icon icon-pencil">
<span class="icon icon-pencil" title="Edit Book">
<span class="is-sr-only">Edit Book</span>
</span>
</a>
@ -95,14 +95,14 @@
{% if request.user.is_authenticated and perms.bookwyrm.edit_book and not book|book_description %}
<div>
<input class="toggle-control" type="radio" name="add-description" id="hide-description" checked>
<input class="toggle-control" type="radio" name="add-description" id="hide-description-{{ book.id }}" checked>
<div class="toggle-content hidden">
<label class="button" for="add-description" tabindex="0" role="button">Add description</label>
{% include 'snippets/toggle/toggle_button.html' with text="Add description" controls_text="add-description" controls_uid=book.id %}
</div>
</div>
<div>
<input class="toggle-control" type="radio" name="add-description" id="add-description">
<input class="toggle-control" type="radio" name="add-description" id="add-description-{{ book.id }}" data-hover-target="id_description">
<div class="toggle-content hidden">
<div class="box">
<form name="add-description" method="POST" action="/add-description/{{ book.id }}">
@ -113,7 +113,7 @@
</p>
<div class="field">
<button class="button is-primary" type="submit">Save</button>
<label class="button" for="hide-description" tabindex="0" role="button">Cancel</label>
{% include 'snippets/toggle/toggle_button.html' with text="Cancel" controls_text="hide-description" controls_uid=book.id %}
</div>
</form>
</div>
@ -155,7 +155,7 @@
<div>
<input type="radio" class="toggle-control" name="add-readthrough-form" id="hide-create-readthrough" checked>
<div class="toggle-content hidden">
<label class="button" for="add-readthrough" class="button" role="button" tabindex="0">Add read dates</label>
{% include 'snippets/toggle/toggle_button.html' with text="Add read dates" controls_text="add-readthrough" %}
</div>
</div>
<div>
@ -165,7 +165,7 @@
{% include 'snippets/readthrough_form.html' with readthrough=None %}
<div class="field is-grouped">
<button class="button is-primary" type="submit">Create</button>
<label class="button" for="hide-create-readthrough" role="button" tabindex="0">Cancel</label>
{% include 'snippets/toggle/toggle_button.html' with text="Cancel" controls_text="hide-create-readthrough" %}
</div>
</form>
</div>
@ -223,7 +223,7 @@
</div>
</div>
<div class="block">
<div class="block" id="reviews">
{% for review in reviews %}
<div class="block">
{% include 'snippets/status.html' with status=review hide_book=True depth=1 %}
@ -251,6 +251,9 @@
</div>
{% endfor %}
</div>
<div class="block">
{% include 'snippets/pagination.html' with page=reviews path=book.local_path anchor="#reviews" %}
</div>
</div>
{% endblock %}

View file

@ -13,25 +13,7 @@
</div>
{% endfor %}
<nav class="pagination" role="navigation" aria-label="pagination">
{% if prev %}
<p class="pagination-previous">
<a href="{{ prev }}">
<span class="icon icon-arrow-left"></span>
Previous
</a>
</p>
{% endif %}
{% if next %}
<p class="pagination-next">
<a href="{{ next }}">
Next
<span class="icon icon-arrow-right"></span>
</a>
</p>
{% endif %}
</nav>
{% include 'snippets/pagination.html' with page=activities path="direct-messages" %}
</div>
{% endblock %}

View file

@ -16,7 +16,7 @@
<div class="tile is-child box has-background-primary-light content">
{% if site.allow_registration %}
<h2 class="title">Join {{ site.name }}</h2>
<form name="register" method="post" action="/user-register">
<form name="register" method="post" action="/register">
{% include 'snippets/register_form.html' %}
</form>
{% else %}

View file

@ -1,25 +1,16 @@
{% extends 'layout.html' %}
{% load humanize %}
{% block content %}
<div class="block">
<div class="level">
<header class="block">
<h1 class="title level-left">
Edit "{{ author.name }}"
</h1>
<div class="level-right">
<a href="/author/{{ author.id }}">
<span class="edit-link icon icon-close">
<span class="is-sr-only">Close</span>
</span>
</a>
</div>
</div>
<div>
<p>Added: {{ author.created_date | naturaltime }}</p>
<p>Updated: {{ author.updated_date | naturaltime }}</p>
<p>Last edited by: <a href="{{ author.last_edited_by.remote_id }}">{{ author.last_edited_by.display_name }}</a></p>
</div>
</div>
</header>
{% if form.non_field_errors %}
<div class="block">
@ -27,7 +18,7 @@
</div>
{% endif %}
<form class="block" name="edit-author" action="/edit-author/{{ author.id }}" method="post">
<form class="block" name="edit-author" action="{{ author.local_path }}/edit" method="post">
{% csrf_token %}
<input type="hidden" name="last_edited_by" value="{{ request.user.id }}">

View file

@ -1,25 +1,16 @@
{% extends 'layout.html' %}
{% load humanize %}
{% block content %}
<div class="block">
<div class="level">
<header class="block">
<h1 class="title level-left">
Edit "{{ book.title }}"
</h1>
<div class="level-right">
<a href="/book/{{ book.id }}">
<span class="edit-link icon icon-close">
<span class="is-sr-only">Close</span>
</span>
</a>
</div>
</div>
<div>
<p>Added: {{ book.created_date | naturaltime }}</p>
<p>Updated: {{ book.updated_date | naturaltime }}</p>
<p>Last edited by: <a href="{{ book.last_edited_by.remote_id }}">{{ book.last_edited_by.display_name }}</a></p>
</div>
</div>
</header>
{% if form.non_field_errors %}
<div class="block">
@ -27,7 +18,7 @@
</div>
{% endif %}
<form class="block" name="edit-book" action="/edit-book/{{ book.id }}" method="post" enctype="multipart/form-data">
<form class="block" name="edit-book" action="{{ book.local_path }}/edit" method="post" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="last_edited_by" value="{{ request.user.id }}">
<div class="columns">

View file

@ -21,7 +21,7 @@
<ul>
{% for book in shelf.books %}
<li class="{% if shelf_counter == 1 and forloop.first %}is-active{% endif %}" data-id="tab-book-{{ book.id }}">
<label for="book-{{ book.id }}" onclick="tabChange(event, nested=true)">
<label for="book-{{ book.id }}" class="tab-change-nested">
<div role="tab" tabindex="0" aria-selected="{% if shelf_counter == 1 and forloop.first %}true{% else %}false{% endif %}" aria-controls="book-{{ book.id }}-panel">
<a>
{% include 'snippets/book_cover.html' with book=book size="medium" %}
@ -48,9 +48,9 @@
<div class="card-header">
<p class="card-header-title">
<span>{% include 'snippets/book_titleby.html' with book=book %}</span>
</>
</p>
<div class="card-header-icon is-hidden-tablet">
<label class="delete" for="no-book" aria-label="close" role="button"></label>
{% include 'snippets/toggle/toggle_button.html' with label="close" controls_text="no-book" class="delete" %}
</div>
</div>
<div class="card-content">
@ -77,6 +77,15 @@
<input class="toggle-control" type="radio" name="recent-books" id="no-book">
</div>
{% endif %}
{% if goal %}
<section class="section">
<div class="block">
<h3 class="title is-4">{{ goal.year }} Reading Goal</h3>
{% include 'snippets/goal_progress.html' with goal=goal %}
</div>
</section>
{% endif %}
</div>
<div class="column is-two-thirds" id="feed">
@ -95,6 +104,33 @@
</ul>
</div>
{# announcements and system messages #}
{% if not goal and tab == 'home' %}
{% now 'Y' as year %}
<section class="block hidden" aria-title="Announcements" data-hide="hide-{{ year }}-reading-goal">
<article class="card">
<header class="card-header">
<h3 class="card-header-title has-background-primary has-text-white">
<span class="icon icon-book is-size-3 mr-2" aria-hidden="true"></span> {{ year }} reading goal
</h3>
</header>
<section class="card-content content">
<p>Set a goal for how many books you'll finish reading in {{ year }}, and track your progress throughout the year.</p>
{% include 'snippets/goal_form.html' %}
</section>
<footer class="card-footer has-background-white-bis">
<div class="card-footer-item is-flex-direction-column">
<button class="button is-danger is-light is-block set-display" data-id="hide-{{ year }}-reading-goal" data-value="true">Dismiss message</button>
<p class="help">You can set or change your reading goal any time from your <a href="{{ request.user.local_path }}">profile page</a></p>
</div>
</footer>
</article>
<hr>
</section>
{% endif %}
{# activity feed #}
{% if not activities %}
<p>There aren't any activities right now! Try following a user to get started</p>
{% endif %}
@ -104,25 +140,7 @@
</div>
{% endfor %}
<nav class="pagination" role="navigation" aria-label="pagination">
{% if prev %}
<p class="pagination-previous">
<a href="{{ prev }}">
<span class="icon icon-arrow-left"></span>
Previous
</a>
</p>
{% endif %}
{% if next %}
<p class="pagination-next">
<a href="{{ next }}">
Next
<span class="icon icon-arrow-right"></span>
</a>
</p>
{% endif %}
</nav>
{% include 'snippets/pagination.html' with page=activities path='/'|add:tab anchor="#feed" %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,60 @@
{% extends 'layout.html' %}
{% block content %}
<section class="block">
<h1 class="title">{{ year }} Reading Progress</h1>
{% if user == request.user %}
<div class="block">
{% if goal %}
<input type="radio" class="toggle-control" name="edit-goal" id="hide-edit-goal" checked>
<div class="toggle-content hidden">
{% include 'snippets/toggle/toggle_button.html' with text="Edit goal" controls_text="show-edit-goal" %}
</div>
{% endif %}
</div>
<div class="block">
<input type="radio" class="toggle-control" name="edit-goal" id="show-edit-goal" data-hover-target="edit-form-header">
<div class="toggle-content{% if goal %} hidden{% endif %}">
{% now 'Y' as year %}
<section class="card">
<header class="card-header">
<h2 class="card-header-title has-background-primary has-text-white" tabindex="0" id="edit-form-header">
<span class="icon icon-book is-size-3 mr-2" aria-hidden="true"></span> {{ year }} reading goal
</h2>
</header>
<section class="card-content content">
<p>Set a goal for how many books you'll finish reading in {{ year }}, and track your progress throughout the year.</p>
{% include 'snippets/goal_form.html' with goal=goal year=year %}
</section>
</section>
</div>
</div>
{% endif %}
{% if not goal and user != request.user %}
<p>{{ user.display_name }} hasn't set a reading goal for {{ year }}.</p>
{% endif %}
{% if goal %}
{% include 'snippets/goal_progress.html' with goal=goal %}
{% endif %}
</section>
{% if goal.books %}
<section>
<h2 class="title">{% if goal.user == request.user %}Your{% else %}{{ goal.user.display_name }}'s{% endif %} {{ year }} Books</h2>
<div class="columns is-multiline">
{% for book in goal.books %}
<div class="column is-narrow">
<div class="box">
<a href="{{ book.book.local_path }}">
{% include 'snippets/discover/small-book.html' with book=book.book rating=goal.ratings %}
</a>
</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
{% endblock %}

View file

@ -3,7 +3,7 @@
{% block content %}
<div class="block">
<h1 class="title">Import Books from GoodReads</h1>
<form name="import" action="/import-data/" method="post" enctype="multipart/form-data">
<form name="import" action="/import" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="field">
{{ import_form.as_p }}
@ -30,7 +30,7 @@
{% endif %}
<ul>
{% for job in jobs %}
<li><a href="/import-status/{{ job.id }}">{{ job.created_date | naturaltime }}</a></li>
<li><a href="/import/{{ job.id }}">{{ job.created_date | naturaltime }}</a></li>
{% endfor %}
</ul>
</div>

View file

@ -30,9 +30,8 @@
<div class="block">
<h2 class="title is-4">Failed to load</h2>
{% if not job.retry %}
<form name="retry" action="/retry-import/" method="post">
<form name="retry" action="/import/{{ job.id }}" method="post">
{% csrf_token %}
<input type="hidden" name="import_job" value="{{ job.id }}">
<ul>
<fieldset>
{% for item in failed_items %}
@ -50,7 +49,7 @@
{% endfor %}
</fieldset>
</ul>
<div class="block pt-1" onclick="selectAll(this)">
<div class="block pt-1 select-all">
<label class="label">
<input type="checkbox" class="checkbox">
Select all

View file

@ -7,7 +7,7 @@
{% if valid %}
<h1 class="title">Create an Account</h1>
<div>
<form name="register" method="post" action="/user-register">
<form name="register" method="post" action="/register">
<input type=hidden name="invite_code" value="{{ invite.code }}">
{% include 'snippets/register_form.html' %}
</form>

View file

@ -33,7 +33,7 @@
</div>
<div class="control">
<button class="button" type="submit">
<span class="icon icon-search">
<span class="icon icon-search" title="Search">
<span class="is-sr-only">search</span>
</span>
</button>
@ -41,9 +41,9 @@
</div>
</form>
<label for="main-nav" role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="mainNav" onclick="toggleMenu(this)" tabindex="0">
<label for="main-nav" role="button" class="navbar-burger pulldown-menu" aria-label="menu" aria-expanded="false" data-target="mainNav" tabindex="0">
<div class="navbar-item mt-3">
<div class="icon icon-dots-three-vertical">
<div class="icon icon-dots-three-vertical" title="Main navigation menu">
<span class="is-sr-only">Main navigation menu</span>
</div>
</div>
@ -66,7 +66,7 @@
<div class="navbar-end">
{% if request.user.is_authenticated %}
<div class="navbar-item has-dropdown is-hoverable">
<div class="navbar-link" role="button" aria-expanded=false" onclick="toggleMenu(this)" tabindex="0" aria-haspopup="true" aria-controls="navbar-dropdown"><p>
<div class="navbar-link pulldown-menu" role="button" aria-expanded="false" tabindex="0" aria-haspopup="true" aria-controls="navbar-dropdown"><p>
{% include 'snippets/avatar.html' with user=request.user %}
{% include 'snippets/username.html' with user=request.user %}
</p></div>
@ -82,7 +82,7 @@
</a>
</li>
<li>
<a href="/user-edit" class="navbar-item">
<a href="/edit-profile" class="navbar-item">
Settings
</a>
</li>
@ -110,7 +110,7 @@
<a href="/notifications">
<div class="tags has-addons">
<span class="tag is-medium">
<span class="icon icon-bell">
<span class="icon icon-bell" title="Notifications">
<span class="is-sr-only">Notifications</span>
</span>
</span>
@ -122,23 +122,25 @@
</div>
{% else %}
<div class="navbar-item">
{% if request.path != '/login' and request.path != '/login/' and request.path != '/user-login' %}
{% if request.path != '/login' and request.path != '/login/' %}
<div class="columns">
<div class="column">
<form name="login" method="post" action="/user-login">
<form name="login" method="post" action="/login">
{% csrf_token %}
<div class="field is-grouped">
<div class="control">
<div class="columns is-variable is-1">
<div class="column">
<label class="is-sr-only" for="id_localname">Username:</label>
<input type="text" name="localname" maxlength="150" class="input" required="" id="id_localname" placeholder="username">
</div>
<div class="control">
<div class="column">
<label class="is-sr-only" for="id_password">Username:</label>
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password" placeholder="password">
<p class="help"><a href="/password-reset">Forgot your password?</a></p>
</div>
<div class="column is-narrow">
<button class="button is-primary" type="submit">Log in</button>
</div>
</div>
</form>
</div>
{% if site.allow_registration and request.path != '' and request.path != '/' %}

View file

@ -8,7 +8,7 @@
{% if login_form.non_field_errors %}
<p class="notification is-danger">{{ login_form.non_field_errors }}</p>
{% endif %}
<form name="login" method="post" action="/user-login">
<form name="login" method="post" action="/login">
{% csrf_token %}
<div class="field">
<label class="label" for="id_localname">Username:</label>
@ -38,7 +38,7 @@
<div class="box has-background-primary-light">
{% if site.allow_registration %}
<h2 class="title">Create an Account</h2>
<form name="register" method="post" action="/user-register">
<form name="register" method="post" action="/register">
{% include 'snippets/register_form.html' %}
</form>
{% else %}

View file

@ -27,7 +27,7 @@
<div class="block">
<h2 class="title is-4">Generate New Invite</h2>
<form name="invite" action="/create-invite/" method="post">
<form name="invite" action="/invite/" method="post">
{% csrf_token %}
<div class="field is-grouped">
<div class="control">

View file

@ -5,7 +5,7 @@
<div class="block">
<h1 class="title">Notifications</h1>
<form name="clear" action="/clear-notifications" method="POST">
<form name="clear" action="/notifications" method="POST">
{% csrf_token %}
<button class="button is-danger is-light" type="submit" class="secondary">Delete notifications</button>
</form>
@ -63,7 +63,7 @@
boosted your <a href="{{ related_status.local_path }}">{{ related_status | status_preview_name|safe }}</a>
{% endif %}
{% else %}
your <a href="/import-status/{{ notification.related_import.id }}">import</a> completed.
your <a href="/import/{{ notification.related_import.id }}">import</a> completed.
{% endif %}
</p>
</div>

View file

@ -8,9 +8,8 @@
{% for error in errors %}
<p class="is-danger">{{ error }}</p>
{% endfor %}
<form name="reset-password" method="post" action="/reset-password">
<form name="password-reset" method="post" action="/password-reset/{{ code }}">
{% csrf_token %}
<input type="hidden" name="reset-code" value="{{ code }}">
<div class="field">
<label class="label" for="id_password">Password:</label>
<div class="control">

View file

@ -7,7 +7,7 @@
<h1 class="title">Reset Password</h1>
{% if message %}<p>{{ message }}</p>{% endif %}
<p>A link to reset your password will be sent to your email address</p>
<form name="reset-password" method="post" action="/reset-password-request">
<form name="password-reset" method="post" action="/password-reset">
{% csrf_token %}
<div class="field">
<label class="label" for="id_email_register">Email address:</label>

View file

@ -30,9 +30,7 @@
<input class="toggle-control" type="radio" name="more-results" id="fewer-results" checked>
<div class="toggle-content hidden">
<label class="button is-small" for="more-results">
<div role="button" tabindex="0">Show results from other catalogues</div>
</label>
{% include 'snippets/toggle/toggle_button.html' with text="Show results from other catalogues" small=True controls_text="more-results" %}
</div>
</div>
{% endif %}
@ -64,9 +62,7 @@
{% endif %}
{% endfor %}
{% if local_results.results %}
<label class="button is-small" for="fewer-results">
<div role="button" tabindex="0">Hide results from other catalogues</div>
</label>
{% include 'snippets/toggle/toggle_button.html' with text="Hide results from other catalogues" small=True controls_text="fewer-results" %}
{% endif %}
</div>
</div>

View file

@ -33,18 +33,13 @@
<div class="column is-narrow">
<input type="radio" id="create-shelf-form-hide" name="create-shelf-form" class="toggle-control" checked>
<div class="toggle-content hidden">
<label for="create-shelf-form-show">
<div role="button" tabindex="0">
<span class="icon icon-plus">
<span class="is-sr-only">Create new shelf</span>
</span>
</div>
</label>
{% include 'snippets/toggle/toggle_button.html' with text="Create shelf" icon="plus" class="is-clickable" controls_text="create-shelf-form-show" %}
</div>
</div>
{% endif %}
</div>
<div>
<input type="radio" id="create-shelf-form-show" name="create-shelf-form" class="toggle-control">
<div class="toggle-content hidden">
<div class="box mb-5">
@ -63,11 +58,12 @@
</label>
<div class="field is-grouped">
<button class="button is-primary" type="submit">Create shelf</button>
<label role="button" class="button" for="create-shelf-form-hide" tabindex="0">Cancel<label>
{% include 'snippets/toggle/toggle_button.html' with text="Cancel" controls_text="create-shelf-form-hide" %}
</div>
</form>
</div>
</div>
</div>
<div class="block columns">
<div class="column">
@ -82,13 +78,7 @@
<div class="column is-narrow">
<input type="radio" id="edit-shelf-form-hide" name="edit-shelf-form" class="toggle-control" checked>
<div class="toggle-content hidden">
<label for="edit-shelf-form-show">
<div role="button" tabindex="0">
<span class="icon icon-pencil">
<span class="is-sr-only">Edit shelf</span>
</span>
</div>
</label>
{% include 'snippets/toggle/toggle_button.html' with text="Edit shelf" icon="pencil" class="is-clickable" controls_text="edit-shelf-form-show" %}
</div>
</div>
{% endif %}
@ -116,7 +106,7 @@
</label>
<div class="field is-grouped">
<button class="button is-primary" type="submit">Update shelf</button>
<label role="button" class="button" for="edit-shelf-form-hide" tabindex="0">Cancel<label>
{% include 'snippets/toggle/toggle_button.html' with text="Cancel" controls_text="edit-shelf-form-hide" %}
</div>
</form>
</div>

View file

@ -1,18 +1,18 @@
{% load bookwyrm_tags %}
{% with status.id|uuid as uuid %}
<form name="boost" action="/boost/{{ status.id }}" method="post" onsubmit="return interact(event)" class="boost-{{ status.id }}-{{ uuid }} {% if request.user|boosted:status %}hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
<form name="boost" action="/boost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} {% if request.user|boosted:status %}hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
{% csrf_token %}
<button class="button is-small" type="submit" {% if not status.boostable %}disabled{% endif %}>
<span class="icon icon-boost">
<span class="icon icon-boost" title="Boost status">
<span class="is-sr-only">Boost status</span>
</span>
</button>
</form>
<form name="unboost" action="/unboost/{{ status.id }}" method="post" onsubmit="return interact(event)" class="boost-{{ status.id }}-{{ uuid }} active {% if not request.user|boosted:status %}hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
<form name="unboost" action="/unboost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} active {% if not request.user|boosted:status %}hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
{% csrf_token %}
<button class="button is-small is-success" type="submit">
<span class="icon icon-boost">
<span class="icon icon-boost" title="Un-boost status">
<span class="is-sr-only">Un-boost status</span>
</span>
</button>

View file

@ -2,18 +2,18 @@
{% with 0|uuid as uuid %}
<div class="control">
<div>
<input type="radio" class="toggle-control" name="sensitive" value="false" id="hide-spoilers-{{ uuid }}" {% if not parent_status.content_warning %}checked{% endif %}>
<input type="radio" class="toggle-control" id="include-spoilers-{{ uuid }}" name="sensitive" value="true" {% if parent_status.content_warning %}checked{% endif %} data-hover-target="id_content_warning_{{ uuid }}">
<div class="toggle-content hidden">
<label class="button is-small" role="button" tabindex="0" for="include-spoilers-{{ uuid }}">Add spoilers/content warning</label>
<label class="is-sr-only" for="id_content_warning_{{ uuid }}">Spoilers/content warning:</label>
<input type="text" name="content_warning" maxlength="255" class="input" id="id_content_warning_{{ uuid }}" placeholder="Spoilers ahead!"{% if parent_status.content_warning %} value="{{ parent_status.content_warning }}"{% endif %}>
{% include 'snippets/toggle/toggle_button.html' with controls_text="hide-spoilers" controls_uid=uuid text="Remove spoilers/content warning" small=True %}
</div>
</div>
<div>
<input type="radio" class="toggle-control" id="include-spoilers-{{ uuid }}" name="sensitive" value="true" {% if parent_status.content_warning %}checked{% endif %}>
<input type="radio" class="toggle-control" name="sensitive" value="false" id="hide-spoilers-{{ uuid }}" {% if not parent_status.content_warning %}checked{% endif %}>
<div class="toggle-content hidden">
<label class="button is-small" role="button" tabindex="0" for="hide-spoilers-{{ uuid }}">Remove spoilers/content warning</label>
<label class="is-sr-only" for="id_content_warning_{{ uuid }}">Spoilers/content warning:</label>
<input type="text" name="content_warning" maxlength="255" class="input" id="id_content_warning_{{ uuid }}" placeholder="Spoilers ahead!"{% if parent_status.content_warning %} value="{{ parent_status.content_warning }}"{% endif %}>
{% include 'snippets/toggle/toggle_button.html' with controls_text="include-spoilers" controls_uid=uuid text="Add spoilers/content warning" small=True %}
</div>
</div>
</div>

View file

@ -5,21 +5,21 @@
<ul role="tablist">
<li class="is-active" data-id="tab-review-{{ book.id }}" data-category="tab-option-{{ book.id }}">
<label for="review-{{ book.id }}">
<div onclick="tabChange(event)" role="tab" aria-selected="true" tabindex="0">
<div class="tab-change" role="tab" aria-selected="true" tabindex="0">
<a>Review</a>
</div>
</label>
</li>
<li data-id="tab-comment-{{ book.id }}" data-category="tab-option-{{ book.id }}">
<label for="comment-{{ book.id}}">
<div onclick="tabChange(event)" role="tab" tabindex="0">
<div class="tab-change" role="tab" tabindex="0">
<a>Comment</a>
</div>
</label>
</li>
<li data-id="tab-quotation-{{ book.id }}" data-category="tab-option-{{ book.id }}">
<label for="quote-{{ book.id }}">
<div onclick="tabChange(event)" role="tab" tabindex="0">
<div class="tab-change" role="tab" tabindex="0">
<a>Quote</a>
</div>
</label>
@ -39,5 +39,5 @@
<div>
<input class="toggle-control" type="radio" name="status-tabs-{{ book.id }}" id="quote-{{ book.id }}">
{% include 'snippets/create_status_form.html' with type="quote" placeholder="An excerpt from '"|add:book.title|add:"'" %}
{% include 'snippets/create_status_form.html' with type="quotation" placeholder="An excerpt from '"|add:book.title|add:"'" %}
</div>

View file

@ -1,4 +1,4 @@
<form class="toggle-content hidden tab-option-{{ book.id }}" name="{{ type }}" action="/{{ type }}" method="post" id="tab-{{ type }}-{{ book.id }}">
<form class="toggle-content hidden tab-option-{{ book.id }}" name="{{ type }}" action="/post/{{ type }}" method="post" id="tab-{{ type }}-{{ book.id }}">
{% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="user" value="{{ request.user.id }}">
@ -9,7 +9,8 @@
</div>
{% endif %}
<div class="control">
<label class="label" for="id_{% if type == 'quote' %}quote{% else %}content{% endif %}_{{ book.id }}_{{ type }}">{{ type|title }}:</label>
<label class="label" for="id_{% if type == 'quotation' %}quote{% else %}content{% endif %}_{{ book.id }}_{{ type }}">{{ type|title }}:</label>
{% include 'snippets/content_warning_field.html' %}
{% if type == 'review' %}
<fieldset>
@ -27,16 +28,13 @@
</fieldset>
{% endif %}
{% include 'snippets/content_warning_field.html' %}
{% if type == 'quote' %}
{% if type == 'quotation' %}
<textarea name="quote" class="textarea" id="id_quote_{{ book.id }}_{{ type }}" placeholder="{{ placeholder }}" required></textarea>
{% else %}
<textarea name="content" class="textarea" id="id_content_{{ book.id }}_{{ type }}" placeholder="{{ placeholder }}" required></textarea>
{% endif %}
</div>
{% if type == 'quote' %}
{% if type == 'quotation' %}
<div class="control">
<label class="label" for="id_content_{{ book.id }}_quote">Comment:</label>
<textarea name="content" class="textarea is-small" id="id_content_{{ book.id }}_quote"></textarea>

View file

@ -1,7 +1,9 @@
{% load bookwyrm_tags %}
{% if book %}
{% include 'snippets/book_cover.html' with book=book %}
{% if ratings %}
{% include 'snippets/stars.html' with rating=ratings|dict_key:book.id %}
{% endif %}
<h3 class="title is-6"><a href="/book/{{ book.id }}">{{ book.title }}</a></h3>
{% if book.authors %}

View file

@ -1,17 +1,17 @@
{% load bookwyrm_tags %}
{% with status.id|uuid as uuid %}
<form name="favorite" action="/favorite/{{ status.id }}" method="POST" onsubmit="return interact(event)" class="fav-{{ status.id }}-{{ uuid }} {% if request.user|liked:status %}hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
<form name="favorite" action="/favorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} {% if request.user|liked:status %}hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
{% csrf_token %}
<button class="button is-small" type="submit">
<span class="icon icon-heart">
<span class="icon icon-heart" title="Like status">
<span class="is-sr-only">Like status</span>
</span>
</button>
</form>
<form name="unfavorite" action="/unfavorite/{{ status.id }}" method="POST" onsubmit="return interact(event)" class="fav-{{ status.id }}-{{ uuid }} active {% if not request.user|liked:status %}hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
<form name="unfavorite" action="/unfavorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} active {% if not request.user|liked:status %}hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
{% csrf_token %}
<button class="button is-success is-small" type="submit">
<span class="icon icon-heart">
<span class="icon icon-heart" title="Un-like status">
<span class="is-sr-only">Un-like status</span>
</span>
</button>

View file

@ -6,7 +6,7 @@
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Finish "{{ book.title }}"</p>
<label class="delete" for="finish-reading-{{ uuid }}" aria-label="close" role="button"></label>
{% include 'snippets/toggle/toggle_button.html' with label="close" controls_text="finish-reading" controls_uid=uuid class="delete" %}
</header>
{% active_read_through book user as readthrough %}
<form name="finish-reading" action="/finish-reading/{{ book.id }}" method="post">
@ -29,15 +29,15 @@
<footer class="modal-card-foot">
<div class="columns">
<div class="column field">
<label for="post-status">
<input type="checkbox" name="post-status" class="checkbox" checked>
<label for="post_status-{{ uuid }}">
<input type="checkbox" name="post-status" class="checkbox" id="post_status-{{ uuid }}" checked>
Post to feed
</label>
{% include 'snippets/privacy_select.html' %}
</div>
<div class="column">
<button type="submit" class="button is-success">Save</button>
<label for="finish-reading-{{ uuid }}" class="button" role="button">Cancel</button>
{% include 'snippets/toggle/toggle_button.html' with text="Cancel" controls_text="finish-reading" controls_uid=uuid %}
</div>
</div>
</footer>

View file

@ -7,7 +7,7 @@ Follow request already sent.
{% else %}
<form action="/follow/" method="POST" onsubmit="interact(event)" class="follow-{{ user.id }} {% if request.user in user.followers.all %}hidden{%endif %}" data-id="follow-{{ user.id }}">
<form action="/follow/" method="POST" class="interaction follow-{{ user.id }} {% if request.user in user.followers.all %}hidden{%endif %}" data-id="follow-{{ user.id }}">
{% csrf_token %}
<input type="hidden" name="user" value="{{ user.username }}">
{% if user.manually_approves_followers %}
@ -16,7 +16,7 @@ Follow request already sent.
<button class="button is-small is-link" type="submit">Follow</button>
{% endif %}
</form>
<form action="/unfollow/" method="POST" onsubmit="interact(event)" class="follow-{{ user.id }} {% if not request.user in user.followers.all %}hidden{%endif %}" data-id="follow-{{ user.id }}">
<form action="/unfollow/" method="POST" class="interaction follow-{{ user.id }} {% if not request.user in user.followers.all %}hidden{%endif %}" data-id="follow-{{ user.id }}">
{% csrf_token %}
<input type="hidden" name="user" value="{{ user.username }}">
<button class="button is-small is-danger is-light" type="submit">Unfollow</button>

View file

@ -0,0 +1,34 @@
<form method="post" name="goal" action="{{ request.user.local_path }}/goal/{{ year }}">
{% csrf_token %}
<input type="hidden" name="year" value="{% if goal %}{{ goal.year }}{% else %}{{ year }}{% endif %}">
<input type="hidden" name="user" value="{{ request.user.id }}">
<div class="columns">
<div class="column">
<label class="label" for="id_goal">Reading goal:</label>
<div class="field has-addons">
<div class="control">
<input type="number" class="input" name="goal" id="id_goal" value="{% if goal %}{{ goal.goal }}{% else %}12{% endif %}">
</div>
<p class="button is-static" aria-hidden="true">books</p>
</div>
</div>
<div class="column">
<label class="label"><p class="mb-2">Goal privacy:</p>
{% include 'snippets/privacy_select.html' with no_label=True current=goal.privacy %}
</label>
</div>
</div>
<label for="post_status" class="label">
<input type="checkbox" name="post-status" id="post_status" class="checkbox" checked>
Post to feed
</label>
<p>
<button type="submit" class="button is-link">Set goal</button>
{% if goal %}
{% include 'snippets/toggle/toggle_button.html' with text="Cancel" controls_text="hide-edit-goal" %}
{% endif %}
</p>
</form>

View file

@ -0,0 +1,10 @@
<p>
{% if goal.progress_percent >= 100 %}
Success!
{% elif goal.progress_percent %}
{{ goal.progress_percent }}% complete!
{% endif %}
{% if goal.user == request.user %}You've{% else %}{{ goal.user.display_name }} has{% endif %} read {% if request.path != goal.local_path %}<a href="{{ goal.local_path }}">{% endif %}{{ goal.book_count }} of {{ goal.goal }} books{% if request.path != goal.local_path %}</a>{% endif %}.
</p>
<progress class="progress is-large" value="{{ goal.book_count }}" max="{{ goal.goal }}" aria-hidden="true">{{ goal.progress_percent }}%</progress>

View file

@ -0,0 +1,19 @@
<nav class="pagination" role="navigation" aria-label="pagination">
{% if page.has_previous %}
<p class="pagination-previous">
<a href="{{ path }}?page={{ page.previous_page_number }}{{ anchor }}">
<span class="icon icon-arrow-left"></span>
Previous
</a>
</p>
{% endif %}
{% if page.has_next %}
<p class="pagination-next">
<a href="{{ path }}?page={{ page.next_page_number }}{{ anchor }}">
Next
<span class="icon icon-arrow-right"></span>
</a>
</p>
{% endif %}
</nav>

View file

@ -1,17 +1,17 @@
{% if item.privacy == 'public' %}
<span class="icon icon-globe">
<span class="icon icon-globe" title="Public post">
<span class="is-sr-only">Public post</span>
</span>
{% elif item.privacy == 'unlisted' %}
<span class="icon icon-unlock">
<span class="icon icon-unlock" title="Unlisted post">
<span class="is-sr-only">Unlisted post</span>
</span>
{% elif item.privacy == 'followers' %}
<span class="icon icon-lock">
<span class="icon icon-lock" title="Followers-only post">
<span class="is-sr-only">Followers-only post</span>
</span>
{% else %}
<span class="icon icon-envelope">
<span class="icon icon-envelope" title="Private post">
<span class="is-sr-only">Private post</span>
</span>
{% endif %}

View file

@ -26,16 +26,8 @@
{% endif %}
</dl>
<div class="field is-grouped">
<label class="button is-small" for="edit-readthrough-{{ readthrough.id }}" role="button" tabindex="0">
<span class="icon icon-pencil">
<span class="is-sr-only">Edit read-through dates</span>
</span>
</label>
<label class="button is-small" for="delete-readthrough-{{ readthrough.id }}" role="button" tabindex="0">
<span class="icon icon-x">
<span class="is-sr-only">Delete this read-through</span>
</span>
</label>
{% include 'snippets/toggle/toggle_button.html' with small=True text="Edit read dates" icon="pencil" controls_text="edit-readthrough" controls_uid=readthrough.id %}
{% include 'snippets/toggle/toggle_button.html' with small=True text="Delete these read dates" icon="x" controls_text="delete-readthrough" controls_uid=readthrough.id %}
</div>
{% if show_progress %}
Progress Updates:
@ -78,7 +70,7 @@
{% include 'snippets/readthrough_form.html' with readthrough=readthrough %}
<div class="field is-grouped">
<button class="button is-primary" type="submit">Save</button>
<label class="button" for="show-readthrough-{{ readthrough.id }}" role="button" tabindex="0">Cancel</label>
{% include 'snippets/toggle/toggle_button.html' with text="Cancel" controls_text="show-readthrough" controls_uid=readthrough.id %}
</div>
</form>
</div>
@ -101,7 +93,7 @@
<button class="button is-danger is-light" type="submit">
Delete
</button>
<label for="delete-readthrough-{{ readthrough.id }}" class="button" role="button" tabindex="0">Cancel</button>
{% include 'snippets/toggle/toggle_button.html' with text="Cancel" controls_text="delete-readthrough" controls_uid=readthrough.id %}
</form>
</footer>
</div>

View file

@ -1,12 +1,10 @@
{% load bookwyrm_tags %}
{% with status.id|uuid as uuid %}
<form class="is-flex-grow-1" name="reply" action="/reply" method="post" onsubmit="return reply(event)">
<div class="columns">
<form class="is-flex-grow-1" name="reply" action="/post/reply" method="post">
<div class="columns is-align-items-flex-end">
{% csrf_token %}
<input type="hidden" name="reply_parent" value="{{ status.id }}">
<input type="hidden" name="user" value="{{ request.user.id }}">
<div class="column">
{% include 'snippets/content_warning_field.html' with parent_status=status %}
<label for="id_content_{{ status.id }}-{{ uuid }}" class="is-sr-only">Reply</label>
<div class="field">
@ -25,4 +23,3 @@
</div>
</div>
</form>
{% endwith %}

View file

@ -1,6 +1,6 @@
<div class="dropdown">
<div class="dropdown-trigger">
<label for="shelf-select-dropdown-{{ book.id }}-toggle" role="button" aria-expanded="false" onclick="toggleMenu(this)" tabindex="0" aria-haspopup="true" aria-controls="shelf-select-{{ book.id }}">
<label for="shelf-select-dropdown-{{ book.id }}-toggle" role="button" aria-expanded="false" class="pulldown-menu" tabindex="0" aria-haspopup="true" aria-controls="shelf-select-{{ book.id }}">
<div class="button">
<span>Change shelf</span>
<span class="icon icon-arrow-down" aria-hidden="true"></span>

View file

@ -13,15 +13,9 @@
<span>Read</span> <span class="icon icon-check"></span>
</button>
{% elif active_shelf.shelf.identifier == 'reading' %}
<label class="button is-small" for="finish-reading-{{ uuid }}" role="button" tabindex="0">
I'm done!
</label>
{% include 'snippets/finish_reading_modal.html' with book=active_shelf.book %}
{% include 'snippets/toggle/toggle_button.html' with small=True text="I'm done!" controls_text="finish-reading" controls_uid=uuid %}
{% elif active_shelf.shelf.identifier == 'to-read' %}
<label class="button is-small" for="start-reading-{{ uuid }}" role="button" tabindex="0">
Start reading
</label>
{% include 'snippets/start_reading_modal.html' with book=active_shelf.book %}
{% include 'snippets/toggle/toggle_button.html' with small=True text="Start reading" controls_text="start-reading" controls_uid=uuid %}
{% else %}
<form name="shelve" action="/shelve/" method="post">
{% csrf_token %}
@ -33,7 +27,7 @@
<div class="dropdown">
<div class="dropdown-trigger">
<label for="shelf-select-dropdown-{{ uuid }}-toggle" role="button" aria-expanded="false" onclick="toggleMenu(this)" tabindex="0" aria-haspopup="true" aria-controls="shelf-select-{{ uuid }}">
<label for="shelf-select-dropdown-{{ uuid }}-toggle" role="button" aria-expanded="false" class="pulldown-menu" tabindex="0" aria-haspopup="true" aria-controls="shelf-select-{{ uuid }}">
<div class="button is-small">
<span class="icon icon-arrow-down"><span class="is-sr-only">More shelves</span></span>
</div>
@ -46,10 +40,7 @@
<li role="menuitem">
{% if active_shelf.shelf.identifier != 'reading' and shelf.identifier == 'reading' %}
<div class="dropdown-item pt-0 pb-0">
<label class="button is-small" for="start-reading-{{ uuid }}" role="button" tabindex="0">
Start reading
</label>
{% include 'snippets/start_reading_modal.html' with book=active_shelf.book %}
{% include 'snippets/toggle/toggle_button.html' with small=True text="Start reading" controls_text="start-reading" controls_uid=uuid %}
</div>
{% else %}
<form class="dropdown-item pt-0 pb-0" name="shelve" action="/shelve/" method="post">
@ -68,5 +59,7 @@
</div>
{% endif %}
</div>
{% include 'snippets/start_reading_modal.html' with book=active_shelf.book %}
{% include 'snippets/finish_reading_modal.html' with book=active_shelf.book %}
{% endwith %}
{% endif %}

View file

@ -5,7 +5,7 @@
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Start "{{ book.title }}"</p>
<label class="delete" for="start-reading-{{ uuid }}" aria-label="close" role="button" tabindex="0"></label>
{% include 'snippets/toggle/toggle_button.html' with label="close" controls_text="start-reading" controls_uid=uuid class="delete" %}
</header>
<form name="start-reading" action="/start-reading/{{ book.id }}" method="post">
<section class="modal-card-body">
@ -20,15 +20,15 @@
<footer class="modal-card-foot">
<div class="columns">
<div class="column field">
<label for="post-status">
<input type="checkbox" name="post-status" class="checkbox" checked>
<label for="post_status_start-{{ uuid }}">
<input type="checkbox" name="post-status" class="checkbox" id="post_status_start-{{ uuid }}" checked>
Post to feed
</label>
{% include 'snippets/privacy_select.html' %}
</div>
<div class="column">
<button class="button is-success" type="submit">Save</button>
<label for="start-reading-{{ uuid }}" class="button" role="button" tabindex="0">Cancel</button>
{% include 'snippets/toggle/toggle_button.html' with text="Cancel" controls_text="start-reading" controls_uid=uuid %}
</div>
</div>
</footer>

View file

@ -21,27 +21,21 @@
<div class="card-footer has-background-white-bis">
<div class="card-footer-item">
{% if request.user.is_authenticated %}
<label class="button is-small" for="show-comment-{{ status.id }}">
<div role="button" tabindex="0">
<span class="icon icon-comment">
<span class="is-sr-only">Reply</span>
</span>
</div>
</label>
{% include 'snippets/toggle/toggle_button.html' with controls_text="show-comment" controls_uid=status.id text="Reply" icon='comment' small=True %}
{% include 'snippets/boost_button.html' with status=status %}
{% include 'snippets/fav_button.html' with status=status %}
{% else %}
<a href="/login">
<span class="icon icon-comment">
<span class="icon icon-comment" title="Reply">
<span class="is-sr-only">Reply</span>
</span>
<span class="icon icon-boost">
<span class="icon icon-boost" title="Boost status">
<span class="is-sr-only">Boost status</span>
</span>
<span class="icon icon-heart">
<span class="icon icon-heart" title="Like status">
<span class="is-sr-only">Like status</span>
</span>
</a>
@ -57,17 +51,14 @@
</div>
{% if status.user == request.user %}
<div class="card-footer-item">
<label class="button is-small" for="more-info-{{ status.id }}">
<div class="icon icon-dots-three">
<span class="is-sr-only">More options</span>
</div>
</label>
{% include 'snippets/toggle/toggle_button.html' with controls_text="more-info" controls_uid=status.id text="More options" icon="dots-three" small=True %}
</div>
{% endif %}
</div>
{% if request.user.is_authenticated %}
<input class="toggle-control" type="checkbox" name="show-comment-{{ status.id }}" id="show-comment-{{ status.id }}">
{% with status.id|uuid as uuid %}
<input class="toggle-control" type="checkbox" name="show-comment-{{ status.id }}" id="show-comment-{{ status.id }}" data-hover-target="id_content_{{ status.id }}-{{ uuid }}">
<div class="toggle-content hidden">
<div class="card-footer">
<div class="card-footer-item">
@ -75,6 +66,7 @@
</div>
</div>
</div>
{% endwith %}
{% endif %}
{% if status.user == request.user %}

View file

@ -14,7 +14,7 @@
<p>{{ status.content_warning }}</p>
<input class="toggle-control" type="radio" name="toggle-status-cw-{{ status.id }}" id="hide-status-cw-{{ status.id }}" checked>
<div class="toggle-content hidden">
<label class="button is-small" for="show-status-cw-{{ status.id }}" tabindex="0" role="button">Show More</label>
{% include 'snippets/toggle/toggle_button.html' with text="Show More" small=True controls_text="show-status-cw" controls_uid=status.id %}
</div>
</div>
@ -22,7 +22,7 @@
{% endif %}
<div{% if status.content_warning %} class="toggle-content hidden"{% endif %}>
{% if status.content_warning %}
<label class="button is-small" for="hide-status-cw-{{ status.id }}" tabindex="0" role="button">Show Less</label>
{% include 'snippets/toggle/toggle_button.html' with text="Show Less" small=True controls_text="hide-status-cw" controls_uid=status.id %}
{% endif %}
{% if status.quote %}

View file

@ -0,0 +1,9 @@
<label class="{% if class %}{{ class }}{% else %}button{% endif %}{% if small %} is-small{% endif %}" for="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}" tabindex="0" role="button"{% if label %} aria-label="{{ label }}"{% endif %}>
{% if icon %}
<span class="icon icon-{{ icon }}" title="{{ text }}">
<span class="is-sr-only">{{ text }}</span>
</span>
{% else %}
{{ text }}
{% endif %}
</label>

View file

@ -7,13 +7,13 @@
<div>
<input type="radio" name="show-hide-{{ uuid }}" id="show-{{ uuid }}" class="toggle-control" checked>
<blockquote class="content toggle-content hidden"><span dir="auto">{{ trimmed | to_markdown | safe }}</span>
<label class="button is-small" for="hide-{{ uuid }}"><div role="button" tabindex="0">show more</div></label>
{% include 'snippets/toggle/toggle_button.html' with text="show more" controls_text="hide" controls_uid=uuid class="has-text-link is-clickable" %}
</blockquote>
</div>
<div>
<input type="radio" name="show-hide-{{ uuid }}" id="hide-{{ uuid }}" class="toggle-control">
<blockquote class="content toggle-content hidden"><span dir="auto">{{ full | to_markdown | safe }}</span>
<label class="button is-small" for="show-{{ uuid }}"><div role="button" tabindex="0">show less</div></label>
{% include 'snippets/toggle/toggle_button.html' with text="show less" controls_text="show" controls_uid=uuid class="has-text-link is-clickable" %}
</blockquote>
</div>
{% else %}

View file

@ -7,8 +7,8 @@
</div>
{% if is_self %}
<div class="column is-narrow">
<a href="/user-edit/">
<span class="icon icon-pencil">
<a href="/edit-profile">
<span class="icon icon-pencil" title="Edit profile">
<span class="is-sr-only">Edit profile</span>
</span>
</a>
@ -40,12 +40,23 @@
<small><a href="{{ user.local_path }}/shelves">See all {{ shelf_count }} shelves</a></small>
</div>
{% if goal %}
<div class="block">
<h2 class="title">{% now 'Y' %} Reading Goal</h2>
{% include 'snippets/goal_progress.html' with goal=goal %}
</div>
{% elif user == request.user %}
<div class="block">
<h2 class="title is-4"><a href="{{ user.local_path }}/goal/{% now 'Y' %}">Set a reading goal for {% now 'Y' %}</a></h2>
</div>
{% endif %}
<div>
<div class="block">
<h2 class="title">User Activity</h2>
</div>
{% for activity in activities %}
<div class="block">
<div class="block" id="feed">
{% include 'snippets/status.html' with status=activity %}
</div>
{% endfor %}
@ -55,25 +66,7 @@
</div>
{% endif %}
<nav class="pagination" role="navigation" aria-label="pagination">
{% if prev %}
<p class="pagination-previous">
<a href="{{ prev }}">
<span class="icon icon-arrow-left"></span>
Previous
</a>
</p>
{% endif %}
{% if next %}
<p class="pagination-next">
<a href="{{ next }}">
Next
<span class="icon icon-arrow-right"></span>
</a>
</p>
{% endif %}
</nav>
{% include 'snippets/pagination.html' with page=activities path=user.local_path anchor="#feed" %}
</div>
{% endblock %}

View file

@ -7,7 +7,7 @@ from django import template
from django.utils import timezone
from bookwyrm import models
from bookwyrm.outgoing import to_markdown
from bookwyrm.views.status import to_markdown
register = template.Library()

View file

@ -1,5 +1,6 @@
''' testing import '''
from collections import namedtuple
import csv
import pathlib
from unittest.mock import patch
@ -30,6 +31,12 @@ class GoodreadsImport(TestCase):
search_url='https://%s/search?q=' % DOMAIN,
priority=1,
)
work = models.Work.objects.create(title='Test Work')
self.book = models.Edition.objects.create(
title='Example Edition',
remote_id='https://example.com/book/1',
parent_work=work
)
def test_create_job(self):
@ -97,8 +104,140 @@ class GoodreadsImport(TestCase):
'bookwyrm.models.import_job.ImportItem.get_book_from_isbn'
) as resolve:
resolve.return_value = book
with patch('bookwyrm.outgoing.handle_imported_book'):
with patch('bookwyrm.goodreads_import.handle_imported_book'):
goodreads_import.import_data(import_job.id)
import_item = models.ImportItem.objects.get(job=import_job, index=0)
self.assertEqual(import_item.book.id, book.id)
def test_handle_imported_book(self):
''' goodreads import added a book, this adds related connections '''
shelf = self.user.shelf_set.filter(identifier='read').first()
self.assertIsNone(shelf.books.first())
import_job = models.ImportJob.objects.create(user=self.user)
datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv')
csv_file = open(datafile, 'r')
for index, entry in enumerate(list(csv.DictReader(csv_file))):
import_item = models.ImportItem.objects.create(
job_id=import_job.id, index=index, data=entry, book=self.book)
break
with patch('bookwyrm.broadcast.broadcast_task.delay'):
goodreads_import.handle_imported_book(
self.user, import_item, False, 'public')
shelf.refresh_from_db()
self.assertEqual(shelf.books.first(), self.book)
readthrough = models.ReadThrough.objects.get(user=self.user)
self.assertEqual(readthrough.book, self.book)
# I can't remember how to create dates and I don't want to look it up.
self.assertEqual(readthrough.start_date.year, 2020)
self.assertEqual(readthrough.start_date.month, 10)
self.assertEqual(readthrough.start_date.day, 21)
self.assertEqual(readthrough.finish_date.year, 2020)
self.assertEqual(readthrough.finish_date.month, 10)
self.assertEqual(readthrough.finish_date.day, 25)
def test_handle_imported_book_already_shelved(self):
''' goodreads import added a book, this adds related connections '''
shelf = self.user.shelf_set.filter(identifier='to-read').first()
models.ShelfBook.objects.create(
shelf=shelf, added_by=self.user, book=self.book)
import_job = models.ImportJob.objects.create(user=self.user)
datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv')
csv_file = open(datafile, 'r')
for index, entry in enumerate(list(csv.DictReader(csv_file))):
import_item = models.ImportItem.objects.create(
job_id=import_job.id, index=index, data=entry, book=self.book)
break
with patch('bookwyrm.broadcast.broadcast_task.delay'):
goodreads_import.handle_imported_book(
self.user, import_item, False, 'public')
shelf.refresh_from_db()
self.assertEqual(shelf.books.first(), self.book)
self.assertIsNone(
self.user.shelf_set.get(identifier='read').books.first())
readthrough = models.ReadThrough.objects.get(user=self.user)
self.assertEqual(readthrough.book, self.book)
self.assertEqual(readthrough.start_date.year, 2020)
self.assertEqual(readthrough.start_date.month, 10)
self.assertEqual(readthrough.start_date.day, 21)
self.assertEqual(readthrough.finish_date.year, 2020)
self.assertEqual(readthrough.finish_date.month, 10)
self.assertEqual(readthrough.finish_date.day, 25)
def test_handle_import_twice(self):
''' re-importing books '''
shelf = self.user.shelf_set.filter(identifier='read').first()
import_job = models.ImportJob.objects.create(user=self.user)
datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv')
csv_file = open(datafile, 'r')
for index, entry in enumerate(list(csv.DictReader(csv_file))):
import_item = models.ImportItem.objects.create(
job_id=import_job.id, index=index, data=entry, book=self.book)
break
with patch('bookwyrm.broadcast.broadcast_task.delay'):
goodreads_import.handle_imported_book(
self.user, import_item, False, 'public')
goodreads_import.handle_imported_book(
self.user, import_item, False, 'public')
shelf.refresh_from_db()
self.assertEqual(shelf.books.first(), self.book)
readthrough = models.ReadThrough.objects.get(user=self.user)
self.assertEqual(readthrough.book, self.book)
# I can't remember how to create dates and I don't want to look it up.
self.assertEqual(readthrough.start_date.year, 2020)
self.assertEqual(readthrough.start_date.month, 10)
self.assertEqual(readthrough.start_date.day, 21)
self.assertEqual(readthrough.finish_date.year, 2020)
self.assertEqual(readthrough.finish_date.month, 10)
self.assertEqual(readthrough.finish_date.day, 25)
def test_handle_imported_book_review(self):
''' goodreads review import '''
import_job = models.ImportJob.objects.create(user=self.user)
datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv')
csv_file = open(datafile, 'r')
entry = list(csv.DictReader(csv_file))[2]
import_item = models.ImportItem.objects.create(
job_id=import_job.id, index=0, data=entry, book=self.book)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
goodreads_import.handle_imported_book(
self.user, import_item, True, 'unlisted')
review = models.Review.objects.get(book=self.book, user=self.user)
self.assertEqual(review.content, 'mixed feelings')
self.assertEqual(review.rating, 2)
self.assertEqual(review.published_date.year, 2019)
self.assertEqual(review.published_date.month, 7)
self.assertEqual(review.published_date.day, 8)
self.assertEqual(review.privacy, 'unlisted')
def test_handle_imported_book_reviews_disabled(self):
''' goodreads review import '''
import_job = models.ImportJob.objects.create(user=self.user)
datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv')
csv_file = open(datafile, 'r')
entry = list(csv.DictReader(csv_file))[2]
import_item = models.ImportItem.objects.create(
job_id=import_job.id, index=0, data=entry, book=self.book)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
goodreads_import.handle_imported_book(
self.user, import_item, False, 'unlisted')
self.assertFalse(models.Review.objects.filter(
book=self.book, user=self.user
).exists())

View file

@ -1,705 +0,0 @@
''' sending out activities '''
import csv
import json
import pathlib
from unittest.mock import patch
from django.http import JsonResponse
from django.test import TestCase
from django.test.client import RequestFactory
import responses
from bookwyrm import forms, models, outgoing
from bookwyrm.settings import DOMAIN
# pylint: disable=too-many-public-methods
class Outgoing(TestCase):
''' sends out activities '''
def setUp(self):
''' we'll need some data '''
self.factory = RequestFactory()
with patch('bookwyrm.models.user.set_remote_server'):
self.remote_user = models.User.objects.create_user(
'rat', 'rat@email.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',
)
self.local_user = models.User.objects.create_user(
'mouse@local.com', 'mouse@mouse.com', 'mouseword',
local=True, localname='mouse',
remote_id='https://example.com/users/mouse',
)
datafile = pathlib.Path(__file__).parent.joinpath(
'data/ap_user.json'
)
self.userdata = json.loads(datafile.read_bytes())
del self.userdata['icon']
work = models.Work.objects.create(title='Test Work')
self.book = models.Edition.objects.create(
title='Example Edition',
remote_id='https://example.com/book/1',
parent_work=work
)
self.shelf = models.Shelf.objects.create(
name='Test Shelf',
identifier='test-shelf',
user=self.local_user
)
def test_outbox(self):
''' returns user's statuses '''
request = self.factory.get('')
result = outgoing.outbox(request, 'mouse')
self.assertIsInstance(result, JsonResponse)
def test_outbox_bad_method(self):
''' can't POST to outbox '''
request = self.factory.post('')
result = outgoing.outbox(request, 'mouse')
self.assertEqual(result.status_code, 405)
def test_outbox_unknown_user(self):
''' should 404 for unknown and remote users '''
request = self.factory.post('')
result = outgoing.outbox(request, 'beepboop')
self.assertEqual(result.status_code, 405)
result = outgoing.outbox(request, 'rat')
self.assertEqual(result.status_code, 405)
def test_outbox_privacy(self):
''' don't show dms et cetera in outbox '''
models.Status.objects.create(
content='PRIVATE!!', user=self.local_user, privacy='direct')
models.Status.objects.create(
content='bffs ONLY', user=self.local_user, privacy='followers')
models.Status.objects.create(
content='unlisted status', user=self.local_user, privacy='unlisted')
models.Status.objects.create(
content='look at this', user=self.local_user, privacy='public')
request = self.factory.get('')
result = outgoing.outbox(request, 'mouse')
self.assertIsInstance(result, JsonResponse)
data = json.loads(result.content)
self.assertEqual(data['type'], 'OrderedCollection')
self.assertEqual(data['totalItems'], 2)
def test_outbox_filter(self):
''' if we only care about reviews, only get reviews '''
models.Review.objects.create(
content='look at this', name='hi', rating=1,
book=self.book, user=self.local_user)
models.Status.objects.create(
content='look at this', user=self.local_user)
request = self.factory.get('', {'type': 'bleh'})
result = outgoing.outbox(request, 'mouse')
self.assertIsInstance(result, JsonResponse)
data = json.loads(result.content)
self.assertEqual(data['type'], 'OrderedCollection')
self.assertEqual(data['totalItems'], 2)
request = self.factory.get('', {'type': 'Review'})
result = outgoing.outbox(request, 'mouse')
self.assertIsInstance(result, JsonResponse)
data = json.loads(result.content)
self.assertEqual(data['type'], 'OrderedCollection')
self.assertEqual(data['totalItems'], 1)
def test_handle_follow(self):
''' send a follow request '''
self.assertEqual(models.UserFollowRequest.objects.count(), 0)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_follow(self.local_user, self.remote_user)
rel = models.UserFollowRequest.objects.get()
self.assertEqual(rel.user_subject, self.local_user)
self.assertEqual(rel.user_object, self.remote_user)
self.assertEqual(rel.status, 'follow_request')
def test_handle_unfollow(self):
''' send an unfollow '''
self.remote_user.followers.add(self.local_user)
self.assertEqual(self.remote_user.followers.count(), 1)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_unfollow(self.local_user, self.remote_user)
self.assertEqual(self.remote_user.followers.count(), 0)
def test_handle_accept(self):
''' accept a follow request '''
rel = models.UserFollowRequest.objects.create(
user_subject=self.local_user,
user_object=self.remote_user
)
rel_id = rel.id
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_accept(rel)
# request should be deleted
self.assertEqual(
models.UserFollowRequest.objects.filter(id=rel_id).count(), 0
)
# follow relationship should exist
self.assertEqual(self.remote_user.followers.first(), self.local_user)
def test_handle_reject(self):
''' reject a follow request '''
rel = models.UserFollowRequest.objects.create(
user_subject=self.local_user,
user_object=self.remote_user
)
rel_id = rel.id
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_reject(rel)
# request should be deleted
self.assertEqual(
models.UserFollowRequest.objects.filter(id=rel_id).count(), 0
)
# follow relationship should not exist
self.assertEqual(
models.UserFollows.objects.filter(id=rel_id).count(), 0
)
def test_existing_user(self):
''' simple database lookup by username '''
result = outgoing.handle_remote_webfinger('@mouse@local.com')
self.assertEqual(result, self.local_user)
result = outgoing.handle_remote_webfinger('mouse@local.com')
self.assertEqual(result, self.local_user)
@responses.activate
def test_load_user(self):
''' find a remote user using webfinger '''
username = 'mouse@example.com'
wellknown = {
"subject": "acct:mouse@example.com",
"links": [{
"rel": "self",
"type": "application/activity+json",
"href": "https://example.com/user/mouse"
}]
}
responses.add(
responses.GET,
'https://example.com/.well-known/webfinger?resource=acct:%s' \
% username,
json=wellknown,
status=200)
responses.add(
responses.GET,
'https://example.com/user/mouse',
json=self.userdata,
status=200)
with patch('bookwyrm.models.user.set_remote_server.delay'):
result = outgoing.handle_remote_webfinger('@mouse@example.com')
self.assertIsInstance(result, models.User)
self.assertEqual(result.username, 'mouse@example.com')
def test_handle_shelve(self):
''' shelve a book '''
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_shelve(self.local_user, self.book, self.shelf)
# make sure the book is on the shelf
self.assertEqual(self.shelf.books.get(), self.book)
def test_handle_shelve_to_read(self):
''' special behavior for the to-read shelf '''
shelf = models.Shelf.objects.get(identifier='to-read')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_shelve(self.local_user, self.book, shelf)
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
def test_handle_shelve_reading(self):
''' special behavior for the reading shelf '''
shelf = models.Shelf.objects.get(identifier='reading')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_shelve(self.local_user, self.book, shelf)
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
def test_handle_shelve_read(self):
''' special behavior for the read shelf '''
shelf = models.Shelf.objects.get(identifier='read')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_shelve(self.local_user, self.book, shelf)
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
def test_handle_unshelve(self):
''' remove a book from a shelf '''
self.shelf.books.add(self.book)
self.shelf.save()
self.assertEqual(self.shelf.books.count(), 1)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_unshelve(self.local_user, self.book, self.shelf)
self.assertEqual(self.shelf.books.count(), 0)
def test_handle_reading_status_to_read(self):
''' posts shelve activities '''
shelf = self.local_user.shelf_set.get(identifier='to-read')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_reading_status(
self.local_user, shelf, self.book, 'public')
status = models.GeneratedNote.objects.get()
self.assertEqual(status.user, self.local_user)
self.assertEqual(status.mention_books.first(), self.book)
self.assertEqual(status.content, 'wants to read')
def test_handle_reading_status_reading(self):
''' posts shelve activities '''
shelf = self.local_user.shelf_set.get(identifier='reading')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_reading_status(
self.local_user, shelf, self.book, 'public')
status = models.GeneratedNote.objects.get()
self.assertEqual(status.user, self.local_user)
self.assertEqual(status.mention_books.first(), self.book)
self.assertEqual(status.content, 'started reading')
def test_handle_reading_status_read(self):
''' posts shelve activities '''
shelf = self.local_user.shelf_set.get(identifier='read')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_reading_status(
self.local_user, shelf, self.book, 'public')
status = models.GeneratedNote.objects.get()
self.assertEqual(status.user, self.local_user)
self.assertEqual(status.mention_books.first(), self.book)
self.assertEqual(status.content, 'finished reading')
def test_handle_reading_status_other(self):
''' posts shelve activities '''
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_reading_status(
self.local_user, self.shelf, self.book, 'public')
self.assertFalse(models.GeneratedNote.objects.exists())
def test_handle_imported_book(self):
''' goodreads import added a book, this adds related connections '''
shelf = self.local_user.shelf_set.filter(identifier='read').first()
self.assertIsNone(shelf.books.first())
import_job = models.ImportJob.objects.create(user=self.local_user)
datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv')
csv_file = open(datafile, 'r')
for index, entry in enumerate(list(csv.DictReader(csv_file))):
import_item = models.ImportItem.objects.create(
job_id=import_job.id, index=index, data=entry, book=self.book)
break
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_imported_book(
self.local_user, import_item, False, 'public')
shelf.refresh_from_db()
self.assertEqual(shelf.books.first(), self.book)
readthrough = models.ReadThrough.objects.get(user=self.local_user)
self.assertEqual(readthrough.book, self.book)
# I can't remember how to create dates and I don't want to look it up.
self.assertEqual(readthrough.start_date.year, 2020)
self.assertEqual(readthrough.start_date.month, 10)
self.assertEqual(readthrough.start_date.day, 21)
self.assertEqual(readthrough.finish_date.year, 2020)
self.assertEqual(readthrough.finish_date.month, 10)
self.assertEqual(readthrough.finish_date.day, 25)
def test_handle_imported_book_already_shelved(self):
''' goodreads import added a book, this adds related connections '''
shelf = self.local_user.shelf_set.filter(identifier='to-read').first()
models.ShelfBook.objects.create(
shelf=shelf, added_by=self.local_user, book=self.book)
import_job = models.ImportJob.objects.create(user=self.local_user)
datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv')
csv_file = open(datafile, 'r')
for index, entry in enumerate(list(csv.DictReader(csv_file))):
import_item = models.ImportItem.objects.create(
job_id=import_job.id, index=index, data=entry, book=self.book)
break
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_imported_book(
self.local_user, import_item, False, 'public')
shelf.refresh_from_db()
self.assertEqual(shelf.books.first(), self.book)
self.assertIsNone(
self.local_user.shelf_set.get(identifier='read').books.first())
readthrough = models.ReadThrough.objects.get(user=self.local_user)
self.assertEqual(readthrough.book, self.book)
self.assertEqual(readthrough.start_date.year, 2020)
self.assertEqual(readthrough.start_date.month, 10)
self.assertEqual(readthrough.start_date.day, 21)
self.assertEqual(readthrough.finish_date.year, 2020)
self.assertEqual(readthrough.finish_date.month, 10)
self.assertEqual(readthrough.finish_date.day, 25)
def test_handle_import_twice(self):
''' re-importing books '''
shelf = self.local_user.shelf_set.filter(identifier='read').first()
import_job = models.ImportJob.objects.create(user=self.local_user)
datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv')
csv_file = open(datafile, 'r')
for index, entry in enumerate(list(csv.DictReader(csv_file))):
import_item = models.ImportItem.objects.create(
job_id=import_job.id, index=index, data=entry, book=self.book)
break
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_imported_book(
self.local_user, import_item, False, 'public')
outgoing.handle_imported_book(
self.local_user, import_item, False, 'public')
shelf.refresh_from_db()
self.assertEqual(shelf.books.first(), self.book)
readthrough = models.ReadThrough.objects.get(user=self.local_user)
self.assertEqual(readthrough.book, self.book)
# I can't remember how to create dates and I don't want to look it up.
self.assertEqual(readthrough.start_date.year, 2020)
self.assertEqual(readthrough.start_date.month, 10)
self.assertEqual(readthrough.start_date.day, 21)
self.assertEqual(readthrough.finish_date.year, 2020)
self.assertEqual(readthrough.finish_date.month, 10)
self.assertEqual(readthrough.finish_date.day, 25)
def test_handle_imported_book_review(self):
''' goodreads review import '''
import_job = models.ImportJob.objects.create(user=self.local_user)
datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv')
csv_file = open(datafile, 'r')
entry = list(csv.DictReader(csv_file))[2]
import_item = models.ImportItem.objects.create(
job_id=import_job.id, index=0, data=entry, book=self.book)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_imported_book(
self.local_user, import_item, True, 'unlisted')
review = models.Review.objects.get(book=self.book, user=self.local_user)
self.assertEqual(review.content, 'mixed feelings')
self.assertEqual(review.rating, 2)
self.assertEqual(review.published_date.year, 2019)
self.assertEqual(review.published_date.month, 7)
self.assertEqual(review.published_date.day, 8)
self.assertEqual(review.privacy, 'unlisted')
def test_handle_imported_book_reviews_disabled(self):
''' goodreads review import '''
import_job = models.ImportJob.objects.create(user=self.local_user)
datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv')
csv_file = open(datafile, 'r')
entry = list(csv.DictReader(csv_file))[2]
import_item = models.ImportItem.objects.create(
job_id=import_job.id, index=0, data=entry, book=self.book)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_imported_book(
self.local_user, import_item, False, 'unlisted')
self.assertFalse(models.Review.objects.filter(
book=self.book, user=self.local_user
).exists())
def test_handle_delete_status(self):
''' marks a status as deleted '''
status = models.Status.objects.create(
user=self.local_user, content='hi')
self.assertFalse(status.deleted)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_delete_status(self.local_user, status)
status.refresh_from_db()
self.assertTrue(status.deleted)
def test_handle_status(self):
''' create a status '''
form = forms.CommentForm({
'content': 'hi',
'user': self.local_user.id,
'book': self.book.id,
'privacy': 'public',
})
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_status(self.local_user, form)
status = models.Comment.objects.get()
self.assertEqual(status.content, '<p>hi</p>')
self.assertEqual(status.user, self.local_user)
self.assertEqual(status.book, self.book)
def test_handle_status_reply(self):
''' create a status in reply to an existing status '''
user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'password', local=True)
parent = models.Status.objects.create(
content='parent status', user=self.local_user)
form = forms.ReplyForm({
'content': 'hi',
'user': user.id,
'reply_parent': parent.id,
'privacy': 'public',
})
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_status(user, form)
status = models.Status.objects.get(user=user)
self.assertEqual(status.content, '<p>hi</p>')
self.assertEqual(status.user, user)
self.assertEqual(
models.Notification.objects.get().user, self.local_user)
def test_handle_status_mentions(self):
''' @mention a user in a post '''
user = models.User.objects.create_user(
'rat@%s' % DOMAIN, 'rat@rat.com', 'password',
local=True, localname='rat')
form = forms.CommentForm({
'content': 'hi @rat',
'user': self.local_user.id,
'book': self.book.id,
'privacy': 'public',
})
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_status(self.local_user, form)
status = models.Status.objects.get()
self.assertEqual(list(status.mention_users.all()), [user])
self.assertEqual(models.Notification.objects.get().user, user)
self.assertEqual(
status.content,
'<p>hi <a href="%s">@rat</a></p>' % user.remote_id)
def test_handle_status_reply_with_mentions(self):
''' reply to a post with an @mention'ed user '''
user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'password',
local=True, localname='rat')
form = forms.CommentForm({
'content': 'hi @rat@example.com',
'user': self.local_user.id,
'book': self.book.id,
'privacy': 'public',
})
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_status(self.local_user, form)
status = models.Status.objects.get()
form = forms.ReplyForm({
'content': 'right',
'user': user,
'privacy': 'public',
'reply_parent': status.id
})
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_status(user, form)
reply = models.Status.replies(status).first()
self.assertEqual(reply.content, '<p>right</p>')
self.assertEqual(reply.user, user)
self.assertTrue(self.remote_user in reply.mention_users.all())
self.assertTrue(self.local_user in reply.mention_users.all())
def test_find_mentions(self):
''' detect and look up @ mentions of users '''
user = models.User.objects.create_user(
'nutria@%s' % DOMAIN, 'nutria@nutria.com', 'password',
local=True, localname='nutria')
self.assertEqual(user.username, 'nutria@%s' % DOMAIN)
self.assertEqual(
list(outgoing.find_mentions('@nutria'))[0],
('@nutria', user)
)
self.assertEqual(
list(outgoing.find_mentions('leading text @nutria'))[0],
('@nutria', user)
)
self.assertEqual(
list(outgoing.find_mentions('leading @nutria trailing text'))[0],
('@nutria', user)
)
self.assertEqual(
list(outgoing.find_mentions('@rat@example.com'))[0],
('@rat@example.com', self.remote_user)
)
multiple = list(outgoing.find_mentions('@nutria and @rat@example.com'))
self.assertEqual(multiple[0], ('@nutria', user))
self.assertEqual(multiple[1], ('@rat@example.com', self.remote_user))
with patch('bookwyrm.outgoing.handle_remote_webfinger') as rw:
rw.return_value = self.local_user
self.assertEqual(
list(outgoing.find_mentions('@beep@beep.com'))[0],
('@beep@beep.com', self.local_user)
)
with patch('bookwyrm.outgoing.handle_remote_webfinger') as rw:
rw.return_value = None
self.assertEqual(list(outgoing.find_mentions('@beep@beep.com')), [])
self.assertEqual(
list(outgoing.find_mentions('@nutria@%s' % DOMAIN))[0],
('@nutria@%s' % DOMAIN, user)
)
def test_format_links(self):
''' find and format urls into a tags '''
url = 'http://www.fish.com/'
self.assertEqual(
outgoing.format_links(url),
'<a href="%s">www.fish.com/</a>' % url)
self.assertEqual(
outgoing.format_links('(%s)' % url),
'(<a href="%s">www.fish.com/</a>)' % url)
url = 'https://archive.org/details/dli.granth.72113/page/n25/mode/2up'
self.assertEqual(
outgoing.format_links(url),
'<a href="%s">' \
'archive.org/details/dli.granth.72113/page/n25/mode/2up</a>' \
% url)
url = 'https://openlibrary.org/search' \
'?q=arkady+strugatsky&mode=everything'
self.assertEqual(
outgoing.format_links(url),
'<a href="%s">openlibrary.org/search' \
'?q=arkady+strugatsky&mode=everything</a>' % url)
def test_to_markdown(self):
''' this is mostly handled in other places, but nonetheless '''
text = '_hi_ and http://fish.com is <marquee>rad</marquee>'
result = outgoing.to_markdown(text)
self.assertEqual(
result,
'<p><em>hi</em> and <a href="http://fish.com">fish.com</a> ' \
'is rad</p>')
def test_handle_favorite(self):
''' create and broadcast faving a status '''
status = models.Status.objects.create(
user=self.local_user, content='hi')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_favorite(self.remote_user, status)
fav = models.Favorite.objects.get()
self.assertEqual(fav.status, status)
self.assertEqual(fav.user, self.remote_user)
notification = models.Notification.objects.get()
self.assertEqual(notification.notification_type, 'FAVORITE')
self.assertEqual(notification.user, self.local_user)
self.assertEqual(notification.related_user, self.remote_user)
def test_handle_unfavorite(self):
''' unfav a status '''
status = models.Status.objects.create(
user=self.local_user, content='hi')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_favorite(self.remote_user, status)
self.assertEqual(models.Favorite.objects.count(), 1)
self.assertEqual(models.Notification.objects.count(), 1)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_unfavorite(self.remote_user, status)
self.assertEqual(models.Favorite.objects.count(), 0)
self.assertEqual(models.Notification.objects.count(), 0)
def test_handle_boost(self):
''' boost a status '''
status = models.Status.objects.create(
user=self.local_user, content='hi')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_boost(self.remote_user, status)
boost = models.Boost.objects.get()
self.assertEqual(boost.boosted_status, status)
self.assertEqual(boost.user, self.remote_user)
self.assertEqual(boost.privacy, 'public')
notification = models.Notification.objects.get()
self.assertEqual(notification.notification_type, 'BOOST')
self.assertEqual(notification.user, self.local_user)
self.assertEqual(notification.related_user, self.remote_user)
self.assertEqual(notification.related_status, status)
def test_handle_boost_unlisted(self):
''' boost a status '''
status = models.Status.objects.create(
user=self.local_user, content='hi', privacy='unlisted')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_boost(self.remote_user, status)
boost = models.Boost.objects.get()
self.assertEqual(boost.privacy, 'unlisted')
def test_handle_boost_private(self):
''' boost a status '''
status = models.Status.objects.create(
user=self.local_user, content='hi', privacy='followers')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_boost(self.remote_user, status)
self.assertFalse(models.Boost.objects.exists())
def test_handle_boost_twice(self):
''' boost a status '''
status = models.Status.objects.create(
user=self.local_user, content='hi')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_boost(self.remote_user, status)
outgoing.handle_boost(self.remote_user, status)
self.assertEqual(models.Boost.objects.count(), 1)
def test_handle_unboost(self):
''' undo a boost '''
status = models.Status.objects.create(
user=self.local_user, content='hi')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_boost(self.remote_user, status)
self.assertEqual(models.Boost.objects.count(), 1)
self.assertEqual(models.Notification.objects.count(), 1)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_unboost(self.remote_user, status)
self.assertEqual(models.Boost.objects.count(), 0)
self.assertEqual(models.Notification.objects.count(), 0)

View file

@ -2,7 +2,6 @@
import re
from unittest.mock import patch
from dateutil.parser import parse
from dateutil.relativedelta import relativedelta
from django.test import TestCase
from django.utils import timezone
@ -213,3 +212,53 @@ class TemplateTags(TestCase):
r'[A-Z][a-z]{2} \d?\d \d{4}',
bookwyrm_tags.time_since(years_ago)
))
def test_get_markdown(self):
''' mardown format data '''
result = bookwyrm_tags.get_markdown('_hi_')
self.assertEqual(result, '<p><em>hi</em></p>')
result = bookwyrm_tags.get_markdown('<marquee>_hi_</marquee>')
self.assertEqual(result, '<p><em>hi</em></p>')
def test_get_mentions(self):
''' list of people mentioned '''
status = models.Status.objects.create(
content='hi', user=self.remote_user)
result = bookwyrm_tags.get_mentions(status, self.user)
self.assertEqual(result, '@rat@example.com')
def test_get_status_preview_name(self):
''' status context string '''
status = models.Status.objects.create(content='hi', user=self.user)
result = bookwyrm_tags.get_status_preview_name(status)
self.assertEqual(result, 'status')
status = models.Review.objects.create(
content='hi', user=self.user, book=self.book)
result = bookwyrm_tags.get_status_preview_name(status)
self.assertEqual(result, 'review of <em>Test Book</em>')
status = models.Comment.objects.create(
content='hi', user=self.user, book=self.book)
result = bookwyrm_tags.get_status_preview_name(status)
self.assertEqual(result, 'comment on <em>Test Book</em>')
status = models.Quotation.objects.create(
content='hi', user=self.user, book=self.book)
result = bookwyrm_tags.get_status_preview_name(status)
self.assertEqual(result, 'quotation from <em>Test Book</em>')
def test_related_status(self):
''' gets the subclass model for a notification status '''
status = models.Status.objects.create(content='hi', user=self.user)
notification = models.Notification.objects.create(
user=self.user, notification_type='MENTION',
related_status=status)
result = bookwyrm_tags.related_status(notification)
self.assertIsInstance(result, models.Status)

View file

@ -1,534 +0,0 @@
''' test for app action functionality '''
from unittest.mock import patch
import dateutil
from django.core.exceptions import PermissionDenied
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.http.response import Http404
from django.test import TestCase
from django.test.client import RequestFactory
from django.utils import timezone
from bookwyrm import forms, models, view_actions as actions
from bookwyrm.settings import DOMAIN
#pylint: disable=too-many-public-methods
class ViewActions(TestCase):
''' a lot here: all handlers for receiving activitypub requests '''
def setUp(self):
''' we need basic things, like users '''
self.local_user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword',
local=True, localname='mouse')
self.local_user.remote_id = 'https://example.com/user/mouse'
self.local_user.save()
self.group = Group.objects.create(name='editor')
self.group.permissions.add(
Permission.objects.create(
name='edit_book',
codename='edit_book',
content_type=ContentType.objects.get_for_model(models.User)).id
)
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',
)
self.status = models.Status.objects.create(
user=self.local_user,
content='Test status',
remote_id='https://example.com/status/1',
)
self.work = models.Work.objects.create(title='Test Work')
self.book = models.Edition.objects.create(
title='Test Book', parent_work=self.work)
self.settings = models.SiteSettings.objects.create(id=1)
self.factory = RequestFactory()
def test_register(self):
''' create a user '''
self.assertEqual(models.User.objects.count(), 2)
request = self.factory.post(
'register/',
{
'localname': 'nutria-user.user_nutria',
'password': 'mouseword',
'email': 'aa@bb.cccc'
})
with patch('bookwyrm.view_actions.login'):
response = actions.register(request)
self.assertEqual(models.User.objects.count(), 3)
self.assertEqual(response.status_code, 302)
nutria = models.User.objects.last()
self.assertEqual(nutria.username, 'nutria-user.user_nutria@%s' % DOMAIN)
self.assertEqual(nutria.localname, 'nutria-user.user_nutria')
self.assertEqual(nutria.local, True)
def test_register_trailing_space(self):
''' django handles this so weirdly '''
request = self.factory.post(
'register/',
{
'localname': 'nutria ',
'password': 'mouseword',
'email': 'aa@bb.ccc'
})
with patch('bookwyrm.view_actions.login'):
response = actions.register(request)
self.assertEqual(models.User.objects.count(), 3)
self.assertEqual(response.status_code, 302)
nutria = models.User.objects.last()
self.assertEqual(nutria.username, 'nutria@%s' % DOMAIN)
self.assertEqual(nutria.localname, 'nutria')
self.assertEqual(nutria.local, True)
def test_register_invalid_email(self):
''' gotta have an email '''
self.assertEqual(models.User.objects.count(), 2)
request = self.factory.post(
'register/',
{
'localname': 'nutria',
'password': 'mouseword',
'email': 'aa'
})
response = actions.register(request)
self.assertEqual(models.User.objects.count(), 2)
self.assertEqual(response.template_name, 'login.html')
def test_register_invalid_username(self):
''' gotta have an email '''
self.assertEqual(models.User.objects.count(), 2)
request = self.factory.post(
'register/',
{
'localname': 'nut@ria',
'password': 'mouseword',
'email': 'aa@bb.ccc'
})
response = actions.register(request)
self.assertEqual(models.User.objects.count(), 2)
self.assertEqual(response.template_name, 'login.html')
request = self.factory.post(
'register/',
{
'localname': 'nutr ia',
'password': 'mouseword',
'email': 'aa@bb.ccc'
})
response = actions.register(request)
self.assertEqual(models.User.objects.count(), 2)
self.assertEqual(response.template_name, 'login.html')
request = self.factory.post(
'register/',
{
'localname': 'nut@ria',
'password': 'mouseword',
'email': 'aa@bb.ccc'
})
response = actions.register(request)
self.assertEqual(models.User.objects.count(), 2)
self.assertEqual(response.template_name, 'login.html')
def test_register_closed_instance(self):
''' you can't just register '''
self.settings.allow_registration = False
self.settings.save()
request = self.factory.post(
'register/',
{
'localname': 'nutria ',
'password': 'mouseword',
'email': 'aa@bb.ccc'
})
with self.assertRaises(PermissionDenied):
actions.register(request)
def test_register_invite(self):
''' you can't just register '''
self.settings.allow_registration = False
self.settings.save()
models.SiteInvite.objects.create(
code='testcode', user=self.local_user, use_limit=1)
self.assertEqual(models.SiteInvite.objects.get().times_used, 0)
request = self.factory.post(
'register/',
{
'localname': 'nutria',
'password': 'mouseword',
'email': 'aa@bb.ccc',
'invite_code': 'testcode'
})
with patch('bookwyrm.view_actions.login'):
response = actions.register(request)
self.assertEqual(models.User.objects.count(), 3)
self.assertEqual(response.status_code, 302)
self.assertEqual(models.SiteInvite.objects.get().times_used, 1)
# invite already used to max capacity
request = self.factory.post(
'register/',
{
'localname': 'nutria2',
'password': 'mouseword',
'email': 'aa@bb.ccc',
'invite_code': 'testcode'
})
with self.assertRaises(PermissionDenied):
response = actions.register(request)
self.assertEqual(models.User.objects.count(), 3)
# bad invite code
request = self.factory.post(
'register/',
{
'localname': 'nutria3',
'password': 'mouseword',
'email': 'aa@bb.ccc',
'invite_code': 'dkfkdjgdfkjgkdfj'
})
with self.assertRaises(Http404):
response = actions.register(request)
self.assertEqual(models.User.objects.count(), 3)
def test_password_reset_request(self):
''' send 'em an email '''
request = self.factory.post('', {'email': 'aa@bb.ccc'})
resp = actions.password_reset_request(request)
self.assertEqual(resp.status_code, 302)
request = self.factory.post(
'', {'email': 'mouse@mouse.com'})
with patch('bookwyrm.emailing.send_email.delay'):
resp = actions.password_reset_request(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):
''' reset from code '''
code = models.PasswordReset.objects.create(user=self.local_user)
request = self.factory.post('', {
'reset-code': code.code,
'password': 'hi',
'confirm-password': 'hi'
})
with patch('bookwyrm.view_actions.login'):
resp = actions.password_reset(request)
self.assertEqual(resp.status_code, 302)
self.assertFalse(models.PasswordReset.objects.exists())
def test_password_reset_wrong_code(self):
''' reset from code '''
models.PasswordReset.objects.create(user=self.local_user)
request = self.factory.post('', {
'reset-code': 'jhgdkfjgdf',
'password': 'hi',
'confirm-password': 'hi'
})
resp = actions.password_reset(request)
self.assertEqual(resp.template_name, 'password_reset.html')
self.assertTrue(models.PasswordReset.objects.exists())
def test_password_reset_mismatch(self):
''' reset from code '''
code = models.PasswordReset.objects.create(user=self.local_user)
request = self.factory.post('', {
'reset-code': code.code,
'password': 'hi',
'confirm-password': 'hihi'
})
resp = actions.password_reset(request)
self.assertEqual(resp.template_name, 'password_reset.html')
self.assertTrue(models.PasswordReset.objects.exists())
def test_password_change(self):
''' change password '''
password_hash = self.local_user.password
request = self.factory.post('', {
'password': 'hi',
'confirm-password': 'hi'
})
request.user = self.local_user
with patch('bookwyrm.view_actions.login'):
actions.password_change(request)
self.assertNotEqual(self.local_user.password, password_hash)
def test_password_change_mismatch(self):
''' change password '''
password_hash = self.local_user.password
request = self.factory.post('', {
'password': 'hi',
'confirm-password': 'hihi'
})
request.user = self.local_user
actions.password_change(request)
self.assertEqual(self.local_user.password, password_hash)
def test_edit_user(self):
''' use a form to update a user '''
form = forms.EditUserForm(instance=self.local_user)
form.data['name'] = 'New Name'
request = self.factory.post('', form.data)
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
actions.edit_profile(request)
self.assertEqual(self.local_user.name, 'New Name')
def test_edit_book(self):
''' lets a user edit a book '''
self.local_user.groups.add(self.group)
form = forms.EditionForm(instance=self.book)
form.data['title'] = 'New Title'
form.data['last_edited_by'] = self.local_user.id
request = self.factory.post('', form.data)
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
actions.edit_book(request, self.book.id)
self.book.refresh_from_db()
self.assertEqual(self.book.title, 'New Title')
def test_switch_edition(self):
''' updates user's relationships to a book '''
work = models.Work.objects.create(title='test work')
edition1 = models.Edition.objects.create(
title='first ed', parent_work=work)
edition2 = models.Edition.objects.create(
title='second ed', parent_work=work)
shelf = models.Shelf.objects.create(
name='Test Shelf', user=self.local_user)
shelf.books.add(edition1)
models.ReadThrough.objects.create(
user=self.local_user, book=edition1)
self.assertEqual(models.ShelfBook.objects.get().book, edition1)
self.assertEqual(models.ReadThrough.objects.get().book, edition1)
request = self.factory.post('', {
'edition': edition2.id
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
actions.switch_edition(request)
self.assertEqual(models.ShelfBook.objects.get().book, edition2)
self.assertEqual(models.ReadThrough.objects.get().book, edition2)
def test_edit_author(self):
''' edit an author '''
author = models.Author.objects.create(name='Test Author')
self.local_user.groups.add(self.group)
form = forms.AuthorForm(instance=author)
form.data['name'] = 'New Name'
form.data['last_edited_by'] = self.local_user.id
request = self.factory.post('', form.data)
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
actions.edit_author(request, author.id)
author.refresh_from_db()
self.assertEqual(author.name, 'New Name')
self.assertEqual(author.last_edited_by, self.local_user)
def test_edit_author_non_editor(self):
''' edit an author with invalid post data'''
author = models.Author.objects.create(name='Test Author')
form = forms.AuthorForm(instance=author)
form.data['name'] = 'New Name'
form.data['last_edited_by'] = self.local_user.id
request = self.factory.post('', form.data)
request.user = self.local_user
with self.assertRaises(PermissionDenied):
actions.edit_author(request, author.id)
author.refresh_from_db()
self.assertEqual(author.name, 'Test Author')
def test_edit_author_invalid_form(self):
''' edit an author with invalid post data'''
author = models.Author.objects.create(name='Test Author')
self.local_user.groups.add(self.group)
form = forms.AuthorForm(instance=author)
form.data['name'] = ''
form.data['last_edited_by'] = self.local_user.id
request = self.factory.post('', form.data)
request.user = self.local_user
resp = actions.edit_author(request, author.id)
author.refresh_from_db()
self.assertEqual(author.name, 'Test Author')
self.assertEqual(resp.template_name, 'edit_author.html')
def test_edit_shelf_privacy(self):
''' set name or privacy on shelf '''
shelf = self.local_user.shelf_set.get(identifier='to-read')
self.assertEqual(shelf.privacy, 'public')
request = self.factory.post(
'', {
'privacy': 'unlisted',
'user': self.local_user.id,
'name': 'To Read',
})
request.user = self.local_user
actions.edit_shelf(request, shelf.id)
shelf.refresh_from_db()
self.assertEqual(shelf.privacy, 'unlisted')
def test_edit_shelf_name(self):
''' change the name of an editable shelf '''
shelf = models.Shelf.objects.create(
name='Test Shelf', user=self.local_user)
self.assertEqual(shelf.privacy, 'public')
request = self.factory.post(
'', {
'privacy': 'public',
'user': self.local_user.id,
'name': 'cool name'
})
request.user = self.local_user
actions.edit_shelf(request, shelf.id)
shelf.refresh_from_db()
self.assertEqual(shelf.name, 'cool name')
self.assertEqual(shelf.identifier, 'testshelf-%d' % shelf.id)
def test_edit_shelf_name_not_editable(self):
''' can't change the name of an non-editable shelf '''
shelf = self.local_user.shelf_set.get(identifier='to-read')
self.assertEqual(shelf.privacy, 'public')
request = self.factory.post(
'', {
'privacy': 'public',
'user': self.local_user.id,
'name': 'cool name'
})
request.user = self.local_user
actions.edit_shelf(request, shelf.id)
self.assertEqual(shelf.name, 'To Read')
def test_edit_readthrough(self):
''' adding dates to an ongoing readthrough '''
start = timezone.make_aware(dateutil.parser.parse('2021-01-03'))
readthrough = models.ReadThrough.objects.create(
book=self.book, user=self.local_user, start_date=start)
request = self.factory.post(
'', {
'start_date': '2017-01-01',
'finish_date': '2018-03-07',
'book': '',
'id': readthrough.id,
})
request.user = self.local_user
actions.edit_readthrough(request)
readthrough.refresh_from_db()
self.assertEqual(readthrough.start_date.year, 2017)
self.assertEqual(readthrough.start_date.month, 1)
self.assertEqual(readthrough.start_date.day, 1)
self.assertEqual(readthrough.finish_date.year, 2018)
self.assertEqual(readthrough.finish_date.month, 3)
self.assertEqual(readthrough.finish_date.day, 7)
self.assertEqual(readthrough.book, self.book)
def test_delete_readthrough(self):
''' remove a readthrough '''
readthrough = models.ReadThrough.objects.create(
book=self.book, user=self.local_user)
models.ReadThrough.objects.create(
book=self.book, user=self.local_user)
request = self.factory.post(
'', {
'id': readthrough.id,
})
request.user = self.local_user
actions.delete_readthrough(request)
self.assertFalse(
models.ReadThrough.objects.filter(id=readthrough.id).exists())
def test_create_readthrough(self):
''' adding new read dates '''
request = self.factory.post(
'', {
'start_date': '2017-01-01',
'finish_date': '2018-03-07',
'book': self.book.id,
'id': '',
})
request.user = self.local_user
actions.create_readthrough(request)
readthrough = models.ReadThrough.objects.get()
self.assertEqual(readthrough.start_date.year, 2017)
self.assertEqual(readthrough.start_date.month, 1)
self.assertEqual(readthrough.start_date.day, 1)
self.assertEqual(readthrough.finish_date.year, 2018)
self.assertEqual(readthrough.finish_date.month, 3)
self.assertEqual(readthrough.finish_date.day, 7)
self.assertEqual(readthrough.book, self.book)
self.assertEqual(readthrough.user, self.local_user)
def test_tag(self):
''' add a tag to a book '''
request = self.factory.post(
'', {
'name': 'A Tag!?',
'book': self.book.id,
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
actions.tag(request)
tag = models.Tag.objects.get()
user_tag = models.UserTag.objects.get()
self.assertEqual(tag.name, 'A Tag!?')
self.assertEqual(tag.identifier, 'A+Tag%21%3F')
self.assertEqual(user_tag.user, self.local_user)
self.assertEqual(user_tag.book, self.book)
def test_untag(self):
''' remove a tag from a book '''
tag = models.Tag.objects.create(name='A Tag!?')
models.UserTag.objects.create(
user=self.local_user, book=self.book, tag=tag)
request = self.factory.post(
'', {
'user': self.local_user.id,
'book': self.book.id,
'name': tag.name,
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
actions.untag(request)
self.assertTrue(models.Tag.objects.filter(name='A Tag!?').exists())
self.assertFalse(models.UserTag.objects.exists())

View file

@ -1,597 +0,0 @@
''' test for app action functionality '''
import json
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
from django.http import JsonResponse
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import models, views
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.connectors import abstract_connector
from bookwyrm.settings import DOMAIN, USER_AGENT
# pylint: disable=too-many-public-methods
class Views(TestCase):
''' every response to a get request, html or json '''
def setUp(self):
''' we need basic test data and mocks '''
self.factory = RequestFactory()
self.work = models.Work.objects.create(title='Test Work')
self.book = models.Edition.objects.create(
title='Test Book', parent_work=self.work)
models.Connector.objects.create(
identifier='self',
connector_file='self_connector',
local=True
)
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_get_edition(self):
''' given an edition or a work, returns an edition '''
self.assertEqual(
views.get_edition(self.book.id), self.book)
self.assertEqual(
views.get_edition(self.work.id), self.book)
def test_get_user_from_username(self):
''' works for either localname or username '''
self.assertEqual(
views.get_user_from_username('mouse'), self.local_user)
self.assertEqual(
views.get_user_from_username('mouse@local.com'), self.local_user)
with self.assertRaises(models.User.DoesNotExist):
views.get_user_from_username('mojfse@example.com')
def test_is_api_request(self):
''' should it return html or json '''
request = self.factory.get('/path')
request.headers = {'Accept': 'application/json'}
self.assertTrue(views.is_api_request(request))
request = self.factory.get('/path.json')
request.headers = {'Accept': 'Praise'}
self.assertTrue(views.is_api_request(request))
request = self.factory.get('/path')
request.headers = {'Accept': 'Praise'}
self.assertFalse(views.is_api_request(request))
def test_home_tab(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
result = views.home_tab(request, 'local')
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'feed.html')
self.assertEqual(result.status_code, 200)
def test_direct_messages_page(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
result = views.direct_messages_page(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'direct_messages.html')
self.assertEqual(result.status_code, 200)
def test_get_activity_feed(self):
''' loads statuses '''
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)
direct_status = models.Status.objects.create(
content='direct', user=self.local_user, privacy='direct')
rat_public = models.Status.objects.create(
content='blah blah', user=rat)
rat_unlisted = models.Status.objects.create(
content='blah blah', user=rat, privacy='unlisted')
remote_status = models.Status.objects.create(
content='blah blah', user=self.remote_user)
followers_status = models.Status.objects.create(
content='blah', user=rat, privacy='followers')
rat_mention = models.Status.objects.create(
content='blah blah blah', user=rat, privacy='followers')
rat_mention.mention_users.set([self.local_user])
statuses = views.get_activity_feed(
self.local_user,
['public', 'unlisted', 'followers'],
following_only=True,
queryset=models.Comment.objects
)
self.assertEqual(len(statuses), 1)
self.assertEqual(statuses[0], public_status)
statuses = views.get_activity_feed(
self.local_user,
['public', 'followers'],
local_only=True
)
self.assertEqual(len(statuses), 2)
self.assertEqual(statuses[1], public_status)
self.assertEqual(statuses[0], rat_public)
statuses = views.get_activity_feed(self.local_user, 'direct')
self.assertEqual(len(statuses), 1)
self.assertEqual(statuses[0], direct_status)
statuses = views.get_activity_feed(
self.local_user,
['public', 'followers'],
)
self.assertEqual(len(statuses), 3)
self.assertEqual(statuses[2], public_status)
self.assertEqual(statuses[1], rat_public)
self.assertEqual(statuses[0], remote_status)
statuses = views.get_activity_feed(
self.local_user,
['public', 'unlisted', 'followers'],
following_only=True
)
self.assertEqual(len(statuses), 2)
self.assertEqual(statuses[1], public_status)
self.assertEqual(statuses[0], rat_mention)
rat.followers.add(self.local_user)
statuses = views.get_activity_feed(
self.local_user,
['public', 'unlisted', 'followers'],
following_only=True
)
self.assertEqual(len(statuses), 5)
self.assertEqual(statuses[4], public_status)
self.assertEqual(statuses[3], rat_public)
self.assertEqual(statuses[2], rat_unlisted)
self.assertEqual(statuses[1], followers_status)
self.assertEqual(statuses[0], rat_mention)
def test_search_json_response(self):
''' searches local data only and returns book data in json format '''
# we need a connector for this, sorry
request = self.factory.get('', {'q': 'Test Book'})
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = True
response = views.search(request)
self.assertIsInstance(response, JsonResponse)
data = json.loads(response.content)
self.assertEqual(len(data), 1)
self.assertEqual(data[0]['title'], 'Test Book')
self.assertEqual(
data[0]['key'], 'https://%s/book/%d' % (DOMAIN, self.book.id))
def test_search_html_response(self):
''' searches remote connectors '''
class TestConnector(abstract_connector.AbstractMinimalConnector):
''' nothing added here '''
def format_search_result(self, search_result):
pass
def get_or_create_book(self, remote_id):
pass
def parse_search_data(self, data):
pass
models.Connector.objects.create(
identifier='example.com',
connector_file='openlibrary',
base_url='https://example.com',
books_url='https://example.com/books',
covers_url='https://example.com/covers',
search_url='https://example.com/search?q=',
)
connector = TestConnector('example.com')
search_result = abstract_connector.SearchResult(
key='http://www.example.com/book/1',
title='Gideon the Ninth',
author='Tamsyn Muir',
year='2019',
connector=connector
)
request = self.factory.get('', {'q': 'Test Book'})
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = False
with patch(
'bookwyrm.connectors.connector_manager.search') as manager:
manager.return_value = [search_result]
response = views.search(request)
self.assertIsInstance(response, TemplateResponse)
self.assertEqual(response.template_name, 'search_results.html')
self.assertEqual(
response.context_data['book_results'][0].title, 'Gideon the Ninth')
def test_search_html_response_users(self):
''' searches remote connectors '''
request = self.factory.get('', {'q': 'mouse'})
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = False
with patch('bookwyrm.connectors.connector_manager.search'):
response = views.search(request)
self.assertIsInstance(response, TemplateResponse)
self.assertEqual(response.template_name, 'search_results.html')
self.assertEqual(
response.context_data['user_results'][0], self.local_user)
def test_import_page(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
result = views.import_page(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'import.html')
self.assertEqual(result.status_code, 200)
def test_import_status(self):
''' there are so many views, this just makes sure it LOADS '''
import_job = models.ImportJob.objects.create(user=self.local_user)
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.tasks.app.AsyncResult') as async_result:
async_result.return_value = []
result = views.import_status(request, import_job.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'import_status.html')
self.assertEqual(result.status_code, 200)
def test_login_page(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = AnonymousUser
result = views.login_page(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'login.html')
self.assertEqual(result.status_code, 200)
request.user = self.local_user
result = views.login_page(request)
self.assertEqual(result.url, '/')
self.assertEqual(result.status_code, 302)
def test_about_page(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
result = views.about_page(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'about.html')
self.assertEqual(result.status_code, 200)
def test_password_reset_request(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
result = views.password_reset_request(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'password_reset_request.html')
self.assertEqual(result.status_code, 200)
def test_password_reset(self):
''' there are so many views, this just makes sure it LOADS '''
code = models.PasswordReset.objects.create(user=self.local_user)
request = self.factory.get('')
request.user = AnonymousUser
result = views.password_reset(request, code.code)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'password_reset.html')
self.assertEqual(result.status_code, 200)
def test_invite_page(self):
''' there are so many views, this just makes sure it LOADS '''
models.SiteInvite.objects.create(code='hi', user=self.local_user)
request = self.factory.get('')
request.user = AnonymousUser
# why?? this is annoying.
request.user.is_authenticated = False
with patch('bookwyrm.models.site.SiteInvite.valid') as invite:
invite.return_value = True
result = views.invite_page(request, 'hi')
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'invite.html')
self.assertEqual(result.status_code, 200)
def test_manage_invites(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
request.user.is_superuser = True
result = views.manage_invites(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'manage_invites.html')
self.assertEqual(result.status_code, 200)
def test_notifications_page(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
result = views.notifications_page(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'notifications.html')
self.assertEqual(result.status_code, 200)
def test_user_page(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = False
result = views.user_page(request, 'mouse')
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'user.html')
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = True
result = views.user_page(request, 'mouse')
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_followers_page(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = False
result = views.followers_page(request, 'mouse')
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'followers.html')
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = True
result = views.followers_page(request, 'mouse')
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_following_page(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = False
result = views.following_page(request, 'mouse')
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'following.html')
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = True
result = views.following_page(request, 'mouse')
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_status_page(self):
''' there are so many views, this just makes sure it LOADS '''
status = models.Status.objects.create(
content='hi', user=self.local_user)
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = False
result = views.status_page(request, 'mouse', status.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'status.html')
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = True
result = views.status_page(request, 'mouse', status.id)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_replies_page(self):
''' there are so many views, this just makes sure it LOADS '''
status = models.Status.objects.create(
content='hi', user=self.local_user)
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = False
result = views.replies_page(request, 'mouse', status.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'status.html')
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = True
result = views.replies_page(request, 'mouse', status.id)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_edit_profile_page(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
result = views.edit_profile_page(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'edit_user.html')
self.assertEqual(result.status_code, 200)
def test_book_page(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = False
result = views.book_page(request, self.book.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'book.html')
self.assertEqual(result.status_code, 200)
request = self.factory.get('')
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = True
result = views.book_page(request, self.book.id)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_edit_book_page(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
request.user.is_superuser = True
result = views.edit_book_page(request, self.book.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'edit_book.html')
self.assertEqual(result.status_code, 200)
def test_edit_author_page(self):
''' there are so many views, this just makes sure it LOADS '''
author = models.Author.objects.create(name='Test Author')
request = self.factory.get('')
request.user = self.local_user
request.user.is_superuser = True
result = views.edit_author_page(request, author.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'edit_author.html')
self.assertEqual(result.status_code, 200)
def test_editions_page(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = False
result = views.editions_page(request, self.work.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'editions.html')
self.assertEqual(result.status_code, 200)
request = self.factory.get('')
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = True
result = views.editions_page(request, self.work.id)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_author_page(self):
''' there are so many views, this just makes sure it LOADS '''
author = models.Author.objects.create(name='Jessica')
request = self.factory.get('')
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = False
result = views.author_page(request, author.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'author.html')
self.assertEqual(result.status_code, 200)
request = self.factory.get('')
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = True
result = views.author_page(request, author.id)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_tag_page(self):
''' there are so many views, this just makes sure it LOADS '''
tag = models.Tag.objects.create(name='hi there')
models.UserTag.objects.create(
tag=tag, user=self.local_user, book=self.book)
request = self.factory.get('')
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = False
result = views.tag_page(request, tag.identifier)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'tag.html')
self.assertEqual(result.status_code, 200)
request = self.factory.get('')
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = True
result = views.tag_page(request, tag.identifier)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_shelf_page(self):
''' there are so many views, this just makes sure it LOADS '''
shelf = self.local_user.shelf_set.first()
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = False
result = views.shelf_page(
request, self.local_user.username, shelf.identifier)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'shelf.html')
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = True
result = views.shelf_page(
request, self.local_user.username, shelf.identifier)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
request = self.factory.get('/?page=1')
request.user = self.local_user
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = True
result = views.shelf_page(
request, self.local_user.username, shelf.identifier)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_is_bookwyrm_request(self):
''' checks if a request came from a bookwyrm instance '''
request = self.factory.get('', {'q': 'Test Book'})
self.assertFalse(views.is_bookworm_request(request))
request = self.factory.get(
'', {'q': 'Test Book'},
HTTP_USER_AGENT=\
"http.rb/4.4.1 (Mastodon/3.3.0; +https://mastodon.social/)"
)
self.assertFalse(views.is_bookworm_request(request))
request = self.factory.get(
'', {'q': 'Test Book'}, HTTP_USER_AGENT=USER_AGENT)
self.assertTrue(views.is_bookworm_request(request))

View file

@ -0,0 +1,302 @@
''' test for app action functionality '''
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import PermissionDenied
from django.http.response import Http404
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import models, views
from bookwyrm.settings import DOMAIN
# pylint: disable=too-many-public-methods
class AuthenticationViews(TestCase):
''' login and password management '''
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
self.settings = models.SiteSettings.objects.create(id=1)
def test_login_get(self):
''' there are so many views, this just makes sure it LOADS '''
login = views.Login.as_view()
request = self.factory.get('')
request.user = self.anonymous_user
result = login(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'login.html')
self.assertEqual(result.status_code, 200)
request.user = self.local_user
result = login(request)
self.assertEqual(result.url, '/')
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()
self.assertEqual(models.User.objects.count(), 1)
request = self.factory.post(
'register/',
{
'localname': 'nutria-user.user_nutria',
'password': 'mouseword',
'email': 'aa@bb.cccc'
})
with patch('bookwyrm.views.authentication.login'):
response = view(request)
self.assertEqual(models.User.objects.count(), 2)
self.assertEqual(response.status_code, 302)
nutria = models.User.objects.last()
self.assertEqual(nutria.username, 'nutria-user.user_nutria@%s' % DOMAIN)
self.assertEqual(nutria.localname, 'nutria-user.user_nutria')
self.assertEqual(nutria.local, True)
def test_register_trailing_space(self):
''' django handles this so weirdly '''
view = views.Register.as_view()
request = self.factory.post(
'register/',
{
'localname': 'nutria ',
'password': 'mouseword',
'email': 'aa@bb.ccc'
})
with patch('bookwyrm.views.authentication.login'):
response = view(request)
self.assertEqual(models.User.objects.count(), 2)
self.assertEqual(response.status_code, 302)
nutria = models.User.objects.last()
self.assertEqual(nutria.username, 'nutria@%s' % DOMAIN)
self.assertEqual(nutria.localname, 'nutria')
self.assertEqual(nutria.local, True)
def test_register_invalid_email(self):
''' gotta have an email '''
view = views.Register.as_view()
self.assertEqual(models.User.objects.count(), 1)
request = self.factory.post(
'register/',
{
'localname': 'nutria',
'password': 'mouseword',
'email': 'aa'
})
response = view(request)
self.assertEqual(models.User.objects.count(), 1)
self.assertEqual(response.template_name, 'login.html')
def test_register_invalid_username(self):
''' gotta have an email '''
view = views.Register.as_view()
self.assertEqual(models.User.objects.count(), 1)
request = self.factory.post(
'register/',
{
'localname': 'nut@ria',
'password': 'mouseword',
'email': 'aa@bb.ccc'
})
response = view(request)
self.assertEqual(models.User.objects.count(), 1)
self.assertEqual(response.template_name, 'login.html')
request = self.factory.post(
'register/',
{
'localname': 'nutr ia',
'password': 'mouseword',
'email': 'aa@bb.ccc'
})
response = view(request)
self.assertEqual(models.User.objects.count(), 1)
self.assertEqual(response.template_name, 'login.html')
request = self.factory.post(
'register/',
{
'localname': 'nut@ria',
'password': 'mouseword',
'email': 'aa@bb.ccc'
})
response = view(request)
self.assertEqual(models.User.objects.count(), 1)
self.assertEqual(response.template_name, 'login.html')
def test_register_closed_instance(self):
''' you can't just register '''
view = views.Register.as_view()
self.settings.allow_registration = False
self.settings.save()
request = self.factory.post(
'register/',
{
'localname': 'nutria ',
'password': 'mouseword',
'email': 'aa@bb.ccc'
})
with self.assertRaises(PermissionDenied):
view(request)
def test_register_invite(self):
''' you can't just register '''
view = views.Register.as_view()
self.settings.allow_registration = False
self.settings.save()
models.SiteInvite.objects.create(
code='testcode', user=self.local_user, use_limit=1)
self.assertEqual(models.SiteInvite.objects.get().times_used, 0)
request = self.factory.post(
'register/',
{
'localname': 'nutria',
'password': 'mouseword',
'email': 'aa@bb.ccc',
'invite_code': 'testcode'
})
with patch('bookwyrm.views.authentication.login'):
response = view(request)
self.assertEqual(models.User.objects.count(), 2)
self.assertEqual(response.status_code, 302)
self.assertEqual(models.SiteInvite.objects.get().times_used, 1)
# invite already used to max capacity
request = self.factory.post(
'register/',
{
'localname': 'nutria2',
'password': 'mouseword',
'email': 'aa@bb.ccc',
'invite_code': 'testcode'
})
with self.assertRaises(PermissionDenied):
response = view(request)
self.assertEqual(models.User.objects.count(), 2)
# bad invite code
request = self.factory.post(
'register/',
{
'localname': 'nutria3',
'password': 'mouseword',
'email': 'aa@bb.ccc',
'invite_code': 'dkfkdjgdfkjgkdfj'
})
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,119 @@
''' test for app action functionality '''
from unittest.mock import patch
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import forms, models, views
from bookwyrm.activitypub import ActivitypubResponse
class AuthorViews(TestCase):
''' author views'''
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', 'mouseword',
local=True, localname='mouse',
remote_id='https://example.com/users/mouse',
)
self.group = Group.objects.create(name='editor')
self.group.permissions.add(
Permission.objects.create(
name='edit_book',
codename='edit_book',
content_type=ContentType.objects.get_for_model(models.User)).id
)
self.work = models.Work.objects.create(title='Test Work')
self.book = models.Edition.objects.create(
title='Example Edition',
remote_id='https://example.com/book/1',
parent_work=self.work
)
def test_author_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.Author.as_view()
author = models.Author.objects.create(name='Jessica')
request = self.factory.get('')
with patch('bookwyrm.views.author.is_api_request') as is_api:
is_api.return_value = False
result = view(request, author.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'author.html')
self.assertEqual(result.status_code, 200)
request = self.factory.get('')
with patch('bookwyrm.views.author.is_api_request') as is_api:
is_api.return_value = True
result = view(request, author.id)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_edit_author_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.EditAuthor.as_view()
author = models.Author.objects.create(name='Test Author')
request = self.factory.get('')
request.user = self.local_user
request.user.is_superuser = True
result = view(request, author.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'edit_author.html')
self.assertEqual(result.status_code, 200)
def test_edit_author(self):
''' edit an author '''
view = views.EditAuthor.as_view()
author = models.Author.objects.create(name='Test Author')
self.local_user.groups.add(self.group)
form = forms.AuthorForm(instance=author)
form.data['name'] = 'New Name'
form.data['last_edited_by'] = self.local_user.id
request = self.factory.post('', form.data)
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request, author.id)
author.refresh_from_db()
self.assertEqual(author.name, 'New Name')
self.assertEqual(author.last_edited_by, self.local_user)
def test_edit_author_non_editor(self):
''' edit an author with invalid post data'''
view = views.EditAuthor.as_view()
author = models.Author.objects.create(name='Test Author')
form = forms.AuthorForm(instance=author)
form.data['name'] = 'New Name'
form.data['last_edited_by'] = self.local_user.id
request = self.factory.post('', form.data)
request.user = self.local_user
with self.assertRaises(PermissionDenied):
view(request, author.id)
author.refresh_from_db()
self.assertEqual(author.name, 'Test Author')
def test_edit_author_invalid_form(self):
''' edit an author with invalid post data'''
view = views.EditAuthor.as_view()
author = models.Author.objects.create(name='Test Author')
self.local_user.groups.add(self.group)
form = forms.AuthorForm(instance=author)
form.data['name'] = ''
form.data['last_edited_by'] = self.local_user.id
request = self.factory.post('', form.data)
request.user = self.local_user
resp = view(request, author.id)
author.refresh_from_db()
self.assertEqual(author.name, 'Test Author')
self.assertEqual(resp.template_name, 'edit_author.html')

View file

@ -0,0 +1,127 @@
''' test for app action functionality '''
from unittest.mock import patch
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import forms, models, views
from bookwyrm.activitypub import ActivitypubResponse
class BookViews(TestCase):
''' books books books '''
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', 'mouseword',
local=True, localname='mouse',
remote_id='https://example.com/users/mouse',
)
self.group = Group.objects.create(name='editor')
self.group.permissions.add(
Permission.objects.create(
name='edit_book',
codename='edit_book',
content_type=ContentType.objects.get_for_model(models.User)).id
)
self.work = models.Work.objects.create(title='Test Work')
self.book = models.Edition.objects.create(
title='Example Edition',
remote_id='https://example.com/book/1',
parent_work=self.work
)
def test_book_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.Book.as_view()
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.views.books.is_api_request') as is_api:
is_api.return_value = False
result = view(request, self.book.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'book.html')
self.assertEqual(result.status_code, 200)
request = self.factory.get('')
with patch('bookwyrm.views.books.is_api_request') as is_api:
is_api.return_value = True
result = view(request, self.book.id)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_edit_book_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.EditBook.as_view()
request = self.factory.get('')
request.user = self.local_user
request.user.is_superuser = True
result = view(request, self.book.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'edit_book.html')
self.assertEqual(result.status_code, 200)
def test_edit_book(self):
''' lets a user edit a book '''
view = views.EditBook.as_view()
self.local_user.groups.add(self.group)
form = forms.EditionForm(instance=self.book)
form.data['title'] = 'New Title'
form.data['last_edited_by'] = self.local_user.id
request = self.factory.post('', form.data)
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request, self.book.id)
self.book.refresh_from_db()
self.assertEqual(self.book.title, 'New Title')
def test_switch_edition(self):
''' updates user's relationships to a book '''
work = models.Work.objects.create(title='test work')
edition1 = models.Edition.objects.create(
title='first ed', parent_work=work)
edition2 = models.Edition.objects.create(
title='second ed', parent_work=work)
shelf = models.Shelf.objects.create(
name='Test Shelf', user=self.local_user)
shelf.books.add(edition1)
models.ReadThrough.objects.create(
user=self.local_user, book=edition1)
self.assertEqual(models.ShelfBook.objects.get().book, edition1)
self.assertEqual(models.ReadThrough.objects.get().book, edition1)
request = self.factory.post('', {
'edition': edition2.id
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.switch_edition(request)
self.assertEqual(models.ShelfBook.objects.get().book, edition2)
self.assertEqual(models.ReadThrough.objects.get().book, edition2)
def test_editions_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.Editions.as_view()
request = self.factory.get('')
with patch('bookwyrm.views.books.is_api_request') as is_api:
is_api.return_value = False
result = view(request, self.work.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'editions.html')
self.assertEqual(result.status_code, 200)
request = self.factory.get('')
with patch('bookwyrm.views.books.is_api_request') as is_api:
is_api.return_value = True
result = view(request, self.work.id)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)

View file

@ -0,0 +1,28 @@
''' test for app action functionality '''
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import models
from bookwyrm import views
class DirectMessageViews(TestCase):
''' dms '''
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')
def test_direct_messages_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.DirectMessage.as_view()
request = self.factory.get('')
request.user = self.local_user
result = view(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'direct_messages.html')
self.assertEqual(result.status_code, 200)

View file

@ -0,0 +1,108 @@
''' test for app action functionality '''
from unittest.mock import patch
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import models, views
class BookViews(TestCase):
''' books books books '''
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', 'mouseword',
local=True, localname='mouse',
remote_id='https://example.com/users/mouse',
)
with patch('bookwyrm.models.user.set_remote_server'):
self.remote_user = models.User.objects.create_user(
'rat', 'rat@email.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',
)
self.group = Group.objects.create(name='editor')
self.group.permissions.add(
Permission.objects.create(
name='edit_book',
codename='edit_book',
content_type=ContentType.objects.get_for_model(models.User)).id
)
self.work = models.Work.objects.create(title='Test Work')
self.book = models.Edition.objects.create(
title='Example Edition',
remote_id='https://example.com/book/1',
parent_work=self.work
)
def test_handle_follow(self):
''' send a follow request '''
request = self.factory.post('', {'user': self.remote_user.username})
request.user = self.local_user
self.assertEqual(models.UserFollowRequest.objects.count(), 0)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.follow(request)
rel = models.UserFollowRequest.objects.get()
self.assertEqual(rel.user_subject, self.local_user)
self.assertEqual(rel.user_object, self.remote_user)
self.assertEqual(rel.status, 'follow_request')
def test_handle_unfollow(self):
''' send an unfollow '''
request = self.factory.post('', {'user': self.remote_user.username})
request.user = self.local_user
self.remote_user.followers.add(self.local_user)
self.assertEqual(self.remote_user.followers.count(), 1)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.unfollow(request)
self.assertEqual(self.remote_user.followers.count(), 0)
def test_handle_accept(self):
''' accept a follow request '''
request = self.factory.post('', {'user': self.remote_user.username})
request.user = self.local_user
rel = models.UserFollowRequest.objects.create(
user_subject=self.remote_user,
user_object=self.local_user
)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.accept_follow_request(request)
# request should be deleted
self.assertEqual(
models.UserFollowRequest.objects.filter(id=rel.id).count(), 0
)
# follow relationship should exist
self.assertEqual(self.local_user.followers.first(), self.remote_user)
def test_handle_reject(self):
''' reject a follow request '''
request = self.factory.post('', {'user': self.remote_user.username})
request.user = self.local_user
rel = models.UserFollowRequest.objects.create(
user_subject=self.remote_user,
user_object=self.local_user
)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.delete_follow_request(request)
# request should be deleted
self.assertEqual(
models.UserFollowRequest.objects.filter(id=rel.id).count(), 0
)
# follow relationship should not exist
self.assertEqual(
models.UserFollows.objects.filter(id=rel.id).count(), 0
)

View file

@ -0,0 +1,114 @@
''' 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 GoalViews(TestCase):
''' viewing and creating statuses '''
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', 'mouseword',
local=True, localname='mouse',
remote_id='https://example.com/users/mouse',
)
self.rat = models.User.objects.create_user(
'rat@local.com', 'rat@rat.com', 'ratword',
local=True, localname='rat',
remote_id='https://example.com/users/rat',
)
self.book = models.Edition.objects.create(
title='Example Edition',
remote_id='https://example.com/book/1',
)
self.anonymous_user = AnonymousUser
self.anonymous_user.is_authenticated = False
def test_goal_page_no_goal(self):
''' view a reading goal page for another's unset goal '''
view = views.Goal.as_view()
request = self.factory.get('')
request.user = self.rat
result = view(request, self.local_user.localname, 2020)
self.assertEqual(result.status_code, 404)
def test_goal_page_no_goal_self(self):
''' view a reading goal page for your own unset goal '''
view = views.Goal.as_view()
request = self.factory.get('')
request.user = self.local_user
result = view(request, self.local_user.localname, 2020)
self.assertIsInstance(result, TemplateResponse)
def test_goal_page_anonymous(self):
''' can't view it without login '''
view = views.Goal.as_view()
request = self.factory.get('')
request.user = self.anonymous_user
result = view(request, self.local_user.localname, 2020)
self.assertEqual(result.status_code, 302)
def test_goal_page_public(self):
''' view a user's public goal '''
models.AnnualGoal.objects.create(
user=self.local_user,
year=2020,
goal=128937123,
privacy='public')
view = views.Goal.as_view()
request = self.factory.get('')
request.user = self.rat
result = view(request, self.local_user.localname, 2020)
self.assertIsInstance(result, TemplateResponse)
def test_goal_page_private(self):
''' view a user's private goal '''
models.AnnualGoal.objects.create(
user=self.local_user,
year=2020,
goal=15,
privacy='followers')
view = views.Goal.as_view()
request = self.factory.get('')
request.user = self.rat
result = view(request, self.local_user.localname, 2020)
self.assertEqual(result.status_code, 404)
def test_create_goal(self):
''' create a new goal '''
view = views.Goal.as_view()
request = self.factory.post('', {
'user': self.local_user.id,
'goal': 10,
'year': 2020,
'privacy': 'unlisted',
'post-status': True
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request, self.local_user.localname, 2020)
goal = models.AnnualGoal.objects.get()
self.assertEqual(goal.user, self.local_user)
self.assertEqual(goal.goal, 10)
self.assertEqual(goal.year, 2020)
self.assertEqual(goal.privacy, 'unlisted')
status = models.GeneratedNote.objects.get()
self.assertEqual(status.user, self.local_user)
self.assertEqual(status.privacy, 'unlisted')

View file

@ -0,0 +1,250 @@
''' test for app action functionality '''
import json
from unittest.mock import patch
import pathlib
from django.test import TestCase
from django.test.client import RequestFactory
import responses
from bookwyrm import models, views
from bookwyrm.settings import USER_AGENT
class ViewsHelpers(TestCase):
''' viewing and creating statuses '''
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', 'mouseword',
local=True, localname='mouse',
remote_id='https://example.com/users/mouse',
)
self.work = models.Work.objects.create(title='Test Work')
self.book = models.Edition.objects.create(
title='Test Book',
remote_id='https://example.com/book/1',
parent_work=self.work
)
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',
)
datafile = pathlib.Path(__file__).parent.joinpath(
'../data/ap_user.json'
)
self.userdata = json.loads(datafile.read_bytes())
del self.userdata['icon']
self.shelf = models.Shelf.objects.create(
name='Test Shelf',
identifier='test-shelf',
user=self.local_user
)
def test_get_edition(self):
''' given an edition or a work, returns an edition '''
self.assertEqual(
views.helpers.get_edition(self.book.id), self.book)
self.assertEqual(
views.helpers.get_edition(self.work.id), self.book)
def test_get_user_from_username(self):
''' works for either localname or username '''
self.assertEqual(
views.helpers.get_user_from_username('mouse'), self.local_user)
self.assertEqual(
views.helpers.get_user_from_username(
'mouse@local.com'), self.local_user)
with self.assertRaises(models.User.DoesNotExist):
views.helpers.get_user_from_username('mojfse@example.com')
def test_is_api_request(self):
''' should it return html or json '''
request = self.factory.get('/path')
request.headers = {'Accept': 'application/json'}
self.assertTrue(views.helpers.is_api_request(request))
request = self.factory.get('/path.json')
request.headers = {'Accept': 'Praise'}
self.assertTrue(views.helpers.is_api_request(request))
request = self.factory.get('/path')
request.headers = {'Accept': 'Praise'}
self.assertFalse(views.helpers.is_api_request(request))
def test_get_activity_feed(self):
''' loads statuses '''
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)
direct_status = models.Status.objects.create(
content='direct', user=self.local_user, privacy='direct')
rat_public = models.Status.objects.create(
content='blah blah', user=rat)
rat_unlisted = models.Status.objects.create(
content='blah blah', user=rat, privacy='unlisted')
remote_status = models.Status.objects.create(
content='blah blah', user=self.remote_user)
followers_status = models.Status.objects.create(
content='blah', user=rat, privacy='followers')
rat_mention = models.Status.objects.create(
content='blah blah blah', user=rat, privacy='followers')
rat_mention.mention_users.set([self.local_user])
statuses = views.helpers.get_activity_feed(
self.local_user,
['public', 'unlisted', 'followers'],
following_only=True,
queryset=models.Comment.objects
)
self.assertEqual(len(statuses), 1)
self.assertEqual(statuses[0], public_status)
statuses = views.helpers.get_activity_feed(
self.local_user,
['public', 'followers'],
local_only=True
)
self.assertEqual(len(statuses), 2)
self.assertEqual(statuses[1], public_status)
self.assertEqual(statuses[0], rat_public)
statuses = views.helpers.get_activity_feed(self.local_user, 'direct')
self.assertEqual(len(statuses), 1)
self.assertEqual(statuses[0], direct_status)
statuses = views.helpers.get_activity_feed(
self.local_user,
['public', 'followers'],
)
self.assertEqual(len(statuses), 3)
self.assertEqual(statuses[2], public_status)
self.assertEqual(statuses[1], rat_public)
self.assertEqual(statuses[0], remote_status)
statuses = views.helpers.get_activity_feed(
self.local_user,
['public', 'unlisted', 'followers'],
following_only=True
)
self.assertEqual(len(statuses), 2)
self.assertEqual(statuses[1], public_status)
self.assertEqual(statuses[0], rat_mention)
rat.followers.add(self.local_user)
statuses = views.helpers.get_activity_feed(
self.local_user,
['public', 'unlisted', 'followers'],
following_only=True
)
self.assertEqual(len(statuses), 5)
self.assertEqual(statuses[4], public_status)
self.assertEqual(statuses[3], rat_public)
self.assertEqual(statuses[2], rat_unlisted)
self.assertEqual(statuses[1], followers_status)
self.assertEqual(statuses[0], rat_mention)
def test_is_bookwyrm_request(self):
''' checks if a request came from a bookwyrm instance '''
request = self.factory.get('', {'q': 'Test Book'})
self.assertFalse(views.helpers.is_bookworm_request(request))
request = self.factory.get(
'', {'q': 'Test Book'},
HTTP_USER_AGENT=\
"http.rb/4.4.1 (Mastodon/3.3.0; +https://mastodon.social/)"
)
self.assertFalse(views.helpers.is_bookworm_request(request))
request = self.factory.get(
'', {'q': 'Test Book'}, HTTP_USER_AGENT=USER_AGENT)
self.assertTrue(views.helpers.is_bookworm_request(request))
def test_existing_user(self):
''' simple database lookup by username '''
result = views.helpers.handle_remote_webfinger('@mouse@local.com')
self.assertEqual(result, self.local_user)
result = views.helpers.handle_remote_webfinger('mouse@local.com')
self.assertEqual(result, self.local_user)
@responses.activate
def test_load_user(self):
''' find a remote user using webfinger '''
username = 'mouse@example.com'
wellknown = {
"subject": "acct:mouse@example.com",
"links": [{
"rel": "self",
"type": "application/activity+json",
"href": "https://example.com/user/mouse"
}]
}
responses.add(
responses.GET,
'https://example.com/.well-known/webfinger?resource=acct:%s' \
% username,
json=wellknown,
status=200)
responses.add(
responses.GET,
'https://example.com/user/mouse',
json=self.userdata,
status=200)
with patch('bookwyrm.models.user.set_remote_server.delay'):
result = views.helpers.handle_remote_webfinger('@mouse@example.com')
self.assertIsInstance(result, models.User)
self.assertEqual(result.username, 'mouse@example.com')
def test_handle_reading_status_to_read(self):
''' posts shelve activities '''
shelf = self.local_user.shelf_set.get(identifier='to-read')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.helpers.handle_reading_status(
self.local_user, shelf, self.book, 'public')
status = models.GeneratedNote.objects.get()
self.assertEqual(status.user, self.local_user)
self.assertEqual(status.mention_books.first(), self.book)
self.assertEqual(status.content, 'wants to read')
def test_handle_reading_status_reading(self):
''' posts shelve activities '''
shelf = self.local_user.shelf_set.get(identifier='reading')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.helpers.handle_reading_status(
self.local_user, shelf, self.book, 'public')
status = models.GeneratedNote.objects.get()
self.assertEqual(status.user, self.local_user)
self.assertEqual(status.mention_books.first(), self.book)
self.assertEqual(status.content, 'started reading')
def test_handle_reading_status_read(self):
''' posts shelve activities '''
shelf = self.local_user.shelf_set.get(identifier='read')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.helpers.handle_reading_status(
self.local_user, shelf, self.book, 'public')
status = models.GeneratedNote.objects.get()
self.assertEqual(status.user, self.local_user)
self.assertEqual(status.mention_books.first(), self.book)
self.assertEqual(status.content, 'finished reading')
def test_handle_reading_status_other(self):
''' posts shelve activities '''
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.helpers.handle_reading_status(
self.local_user, self.shelf, self.book, 'public')
self.assertFalse(models.GeneratedNote.objects.exists())

View file

@ -0,0 +1,43 @@
''' 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
from bookwyrm import views
class ImportViews(TestCase):
''' goodreads import views '''
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')
def test_import_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.Import.as_view()
request = self.factory.get('')
request.user = self.local_user
result = view(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'import.html')
self.assertEqual(result.status_code, 200)
def test_import_status(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.ImportStatus.as_view()
import_job = models.ImportJob.objects.create(user=self.local_user)
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.tasks.app.AsyncResult') as async_result:
async_result.return_value = []
result = view(request, import_job.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'import_status.html')
self.assertEqual(result.status_code, 200)

View file

@ -0,0 +1,152 @@
''' test for app action functionality '''
from unittest.mock import patch
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import models, views
class InteractionViews(TestCase):
''' viewing and creating statuses '''
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', 'mouseword',
local=True, localname='mouse',
remote_id='https://example.com/users/mouse',
)
with patch('bookwyrm.models.user.set_remote_server'):
self.remote_user = models.User.objects.create_user(
'rat', 'rat@email.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',
)
work = models.Work.objects.create(title='Test Work')
self.book = models.Edition.objects.create(
title='Example Edition',
remote_id='https://example.com/book/1',
parent_work=work
)
def test_handle_favorite(self):
''' create and broadcast faving a status '''
view = views.Favorite.as_view()
request = self.factory.post('')
request.user = self.remote_user
status = models.Status.objects.create(
user=self.local_user, content='hi')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request, status.id)
fav = models.Favorite.objects.get()
self.assertEqual(fav.status, status)
self.assertEqual(fav.user, self.remote_user)
notification = models.Notification.objects.get()
self.assertEqual(notification.notification_type, 'FAVORITE')
self.assertEqual(notification.user, self.local_user)
self.assertEqual(notification.related_user, self.remote_user)
def test_handle_unfavorite(self):
''' unfav a status '''
view = views.Unfavorite.as_view()
request = self.factory.post('')
request.user = self.remote_user
status = models.Status.objects.create(
user=self.local_user, content='hi')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.Favorite.as_view()(request, status.id)
self.assertEqual(models.Favorite.objects.count(), 1)
self.assertEqual(models.Notification.objects.count(), 1)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request, status.id)
self.assertEqual(models.Favorite.objects.count(), 0)
self.assertEqual(models.Notification.objects.count(), 0)
def test_handle_boost(self):
''' boost a status '''
view = views.Boost.as_view()
request = self.factory.post('')
request.user = self.remote_user
status = models.Status.objects.create(
user=self.local_user, content='hi')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request, status.id)
boost = models.Boost.objects.get()
self.assertEqual(boost.boosted_status, status)
self.assertEqual(boost.user, self.remote_user)
self.assertEqual(boost.privacy, 'public')
notification = models.Notification.objects.get()
self.assertEqual(notification.notification_type, 'BOOST')
self.assertEqual(notification.user, self.local_user)
self.assertEqual(notification.related_user, self.remote_user)
self.assertEqual(notification.related_status, status)
def test_handle_boost_unlisted(self):
''' boost a status '''
view = views.Boost.as_view()
request = self.factory.post('')
request.user = self.local_user
status = models.Status.objects.create(
user=self.local_user, content='hi', privacy='unlisted')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request, status.id)
boost = models.Boost.objects.get()
self.assertEqual(boost.privacy, 'unlisted')
def test_handle_boost_private(self):
''' boost a status '''
view = views.Boost.as_view()
request = self.factory.post('')
request.user = self.local_user
status = models.Status.objects.create(
user=self.local_user, content='hi', privacy='followers')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request, status.id)
self.assertFalse(models.Boost.objects.exists())
def test_handle_boost_twice(self):
''' boost a status '''
view = views.Boost.as_view()
request = self.factory.post('')
request.user = self.local_user
status = models.Status.objects.create(
user=self.local_user, content='hi')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request, status.id)
view(request, status.id)
self.assertEqual(models.Boost.objects.count(), 1)
def test_handle_unboost(self):
''' undo a boost '''
view = views.Unboost.as_view()
request = self.factory.post('')
request.user = self.remote_user
status = models.Status.objects.create(
user=self.local_user, content='hi')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.Boost.as_view()(request, status.id)
self.assertEqual(models.Boost.objects.count(), 1)
self.assertEqual(models.Notification.objects.count(), 1)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request, status.id)
self.assertEqual(models.Boost.objects.count(), 0)
self.assertEqual(models.Notification.objects.count(), 0)

View file

@ -0,0 +1,48 @@
''' 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
from bookwyrm import views
class InviteViews(TestCase):
''' every response to a get request, html or json '''
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')
def test_invite_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.Invite.as_view()
models.SiteInvite.objects.create(code='hi', user=self.local_user)
request = self.factory.get('')
request.user = AnonymousUser
# why?? this is annoying.
request.user.is_authenticated = False
with patch('bookwyrm.models.site.SiteInvite.valid') as invite:
invite.return_value = True
result = view(request, 'hi')
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'invite.html')
self.assertEqual(result.status_code, 200)
def test_manage_invites(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.ManageInvites.as_view()
request = self.factory.get('')
request.user = self.local_user
request.user.is_superuser = True
result = view(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'manage_invites.html')
self.assertEqual(result.status_code, 200)

View file

@ -0,0 +1,84 @@
''' test for app action functionality '''
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
from bookwyrm import views
class LandingViews(TestCase):
''' pages you land on without really trying '''
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')
self.anonymous_user = AnonymousUser
self.anonymous_user.is_authenticated = False
self.book = models.Edition.objects.create(
title='Example Edition',
remote_id='https://example.com/book/1',
)
def test_home_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.Home.as_view()
request = self.factory.get('')
request.user = self.local_user
result = view(request)
self.assertEqual(result.status_code, 200)
self.assertEqual(result.template_name, 'feed.html')
request.user = self.anonymous_user
result = view(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.status_code, 200)
self.assertEqual(result.template_name, 'discover.html')
def test_about_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.About.as_view()
request = self.factory.get('')
request.user = self.local_user
result = view(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'about.html')
self.assertEqual(result.status_code, 200)
def test_feed(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.Feed.as_view()
request = self.factory.get('')
request.user = self.local_user
result = view(request, 'local')
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'feed.html')
self.assertEqual(result.status_code, 200)
def test_discover(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.Discover.as_view()
request = self.factory.get('')
result = view(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'discover.html')
self.assertEqual(result.status_code, 200)
def test_get_suggested_book(self):
''' gets books the ~*~ algorithm ~*~ thinks you want to post about '''
models.ShelfBook.objects.create(
book=self.book,
added_by=self.local_user,
shelf=self.local_user.shelf_set.get(identifier='reading')
)
suggestions = views.landing.get_suggested_books(self.local_user)
self.assertEqual(suggestions[0]['name'], 'Currently Reading')
self.assertEqual(suggestions[0]['books'][0], self.book)

View file

@ -0,0 +1,41 @@
''' test for app action functionality '''
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import models
from bookwyrm import views
class NotificationViews(TestCase):
''' notifications '''
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')
def test_notifications_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.Notifications.as_view()
request = self.factory.get('')
request.user = self.local_user
result = view(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'notifications.html')
self.assertEqual(result.status_code, 200)
def test_clear_notifications(self):
''' erase notifications '''
models.Notification.objects.create(
user=self.local_user, notification_type='MENTION')
models.Notification.objects.create(
user=self.local_user, notification_type='MENTION', read=True)
self.assertEqual(models.Notification.objects.count(), 2)
view = views.Notifications.as_view()
request = self.factory.post('')
request.user = self.local_user
result = view(request)
self.assertEqual(result.status_code, 302)
self.assertEqual(models.Notification.objects.count(), 1)

View file

@ -0,0 +1,88 @@
''' sending out activities '''
import json
from django.http import JsonResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import models, views
# pylint: disable=too-many-public-methods
class OutboxView(TestCase):
''' sends out activities '''
def setUp(self):
''' we'll need some data '''
self.factory = RequestFactory()
self.local_user = models.User.objects.create_user(
'mouse@local.com', 'mouse@mouse.com', 'mouseword',
local=True, localname='mouse',
remote_id='https://example.com/users/mouse',
)
work = models.Work.objects.create(title='Test Work')
self.book = models.Edition.objects.create(
title='Example Edition',
remote_id='https://example.com/book/1',
parent_work=work
)
def test_outbox(self):
''' returns user's statuses '''
request = self.factory.get('')
result = views.Outbox.as_view()(request, 'mouse')
self.assertIsInstance(result, JsonResponse)
def test_outbox_bad_method(self):
''' can't POST to outbox '''
request = self.factory.post('')
result = views.Outbox.as_view()(request, 'mouse')
self.assertEqual(result.status_code, 405)
def test_outbox_unknown_user(self):
''' should 404 for unknown and remote users '''
request = self.factory.post('')
result = views.Outbox.as_view()(request, 'beepboop')
self.assertEqual(result.status_code, 405)
result = views.Outbox.as_view()(request, 'rat')
self.assertEqual(result.status_code, 405)
def test_outbox_privacy(self):
''' don't show dms et cetera in outbox '''
models.Status.objects.create(
content='PRIVATE!!', user=self.local_user, privacy='direct')
models.Status.objects.create(
content='bffs ONLY', user=self.local_user, privacy='followers')
models.Status.objects.create(
content='unlisted status', user=self.local_user, privacy='unlisted')
models.Status.objects.create(
content='look at this', user=self.local_user, privacy='public')
request = self.factory.get('')
result = views.Outbox.as_view()(request, 'mouse')
self.assertIsInstance(result, JsonResponse)
data = json.loads(result.content)
self.assertEqual(data['type'], 'OrderedCollection')
self.assertEqual(data['totalItems'], 2)
def test_outbox_filter(self):
''' if we only care about reviews, only get reviews '''
models.Review.objects.create(
content='look at this', name='hi', rating=1,
book=self.book, user=self.local_user)
models.Status.objects.create(
content='look at this', user=self.local_user)
request = self.factory.get('', {'type': 'bleh'})
result = views.Outbox.as_view()(request, 'mouse')
self.assertIsInstance(result, JsonResponse)
data = json.loads(result.content)
self.assertEqual(data['type'], 'OrderedCollection')
self.assertEqual(data['totalItems'], 2)
request = self.factory.get('', {'type': 'Review'})
result = views.Outbox.as_view()(request, 'mouse')
self.assertIsInstance(result, JsonResponse)
data = json.loads(result.content)
self.assertEqual(data['type'], 'OrderedCollection')
self.assertEqual(data['totalItems'], 1)

View file

@ -0,0 +1,176 @@
''' test for app action functionality '''
from unittest.mock import patch
import dateutil
from django.test import TestCase
from django.test.client import RequestFactory
from django.utils import timezone
from bookwyrm import models, views
class ReadingViews(TestCase):
''' viewing and creating statuses '''
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', 'mouseword',
local=True, localname='mouse',
remote_id='https://example.com/users/mouse',
)
self.work = models.Work.objects.create(title='Test Work')
self.book = models.Edition.objects.create(
title='Test Book',
remote_id='https://example.com/book/1',
parent_work=self.work
)
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_start_reading(self):
''' begin a book '''
shelf = self.local_user.shelf_set.get(identifier='reading')
self.assertFalse(shelf.books.exists())
self.assertFalse(models.Status.objects.exists())
request = self.factory.post('', {
'post-status': True,
'privacy': 'followers',
'start_date': '2020-01-05',
})
request.user = self.local_user
views.start_reading(request, self.book.id)
self.assertEqual(shelf.books.get(), self.book)
status = models.GeneratedNote.objects.get()
self.assertEqual(status.user, self.local_user)
self.assertEqual(status.mention_books.get(), self.book)
self.assertEqual(status.privacy, 'followers')
readthrough = models.ReadThrough.objects.get()
self.assertIsNotNone(readthrough.start_date)
self.assertIsNone(readthrough.finish_date)
self.assertEqual(readthrough.user, self.local_user)
self.assertEqual(readthrough.book, self.book)
def test_start_reading_reshelf(self):
''' begin a book '''
to_read_shelf = self.local_user.shelf_set.get(identifier='to-read')
models.ShelfBook.objects.create(
shelf=to_read_shelf, book=self.book, added_by=self.local_user)
shelf = self.local_user.shelf_set.get(identifier='reading')
self.assertEqual(to_read_shelf.books.get(), self.book)
self.assertFalse(shelf.books.exists())
self.assertFalse(models.Status.objects.exists())
request = self.factory.post('')
request.user = self.local_user
views.start_reading(request, self.book.id)
self.assertFalse(to_read_shelf.books.exists())
self.assertEqual(shelf.books.get(), self.book)
def test_finish_reading(self):
''' begin a book '''
shelf = self.local_user.shelf_set.get(identifier='read')
self.assertFalse(shelf.books.exists())
self.assertFalse(models.Status.objects.exists())
readthrough = models.ReadThrough.objects.create(
user=self.local_user,
start_date=timezone.now(),
book=self.book)
request = self.factory.post('', {
'post-status': True,
'privacy': 'followers',
'finish_date': '2020-01-07',
'id': readthrough.id,
})
request.user = self.local_user
views.finish_reading(request, self.book.id)
self.assertEqual(shelf.books.get(), self.book)
status = models.GeneratedNote.objects.get()
self.assertEqual(status.user, self.local_user)
self.assertEqual(status.mention_books.get(), self.book)
self.assertEqual(status.privacy, 'followers')
readthrough = models.ReadThrough.objects.get()
self.assertIsNotNone(readthrough.start_date)
self.assertIsNotNone(readthrough.finish_date)
self.assertEqual(readthrough.user, self.local_user)
self.assertEqual(readthrough.book, self.book)
def test_edit_readthrough(self):
''' adding dates to an ongoing readthrough '''
start = timezone.make_aware(dateutil.parser.parse('2021-01-03'))
readthrough = models.ReadThrough.objects.create(
book=self.book, user=self.local_user, start_date=start)
request = self.factory.post(
'', {
'start_date': '2017-01-01',
'finish_date': '2018-03-07',
'book': '',
'id': readthrough.id,
})
request.user = self.local_user
views.edit_readthrough(request)
readthrough.refresh_from_db()
self.assertEqual(readthrough.start_date.year, 2017)
self.assertEqual(readthrough.start_date.month, 1)
self.assertEqual(readthrough.start_date.day, 1)
self.assertEqual(readthrough.finish_date.year, 2018)
self.assertEqual(readthrough.finish_date.month, 3)
self.assertEqual(readthrough.finish_date.day, 7)
self.assertEqual(readthrough.book, self.book)
def test_delete_readthrough(self):
''' remove a readthrough '''
readthrough = models.ReadThrough.objects.create(
book=self.book, user=self.local_user)
models.ReadThrough.objects.create(
book=self.book, user=self.local_user)
request = self.factory.post(
'', {
'id': readthrough.id,
})
request.user = self.local_user
views.delete_readthrough(request)
self.assertFalse(
models.ReadThrough.objects.filter(id=readthrough.id).exists())
def test_create_readthrough(self):
''' adding new read dates '''
request = self.factory.post(
'', {
'start_date': '2017-01-01',
'finish_date': '2018-03-07',
'book': self.book.id,
'id': '',
})
request.user = self.local_user
views.create_readthrough(request)
readthrough = models.ReadThrough.objects.get()
self.assertEqual(readthrough.start_date.year, 2017)
self.assertEqual(readthrough.start_date.month, 1)
self.assertEqual(readthrough.start_date.day, 1)
self.assertEqual(readthrough.finish_date.year, 2018)
self.assertEqual(readthrough.finish_date.month, 3)
self.assertEqual(readthrough.finish_date.day, 7)
self.assertEqual(readthrough.book, self.book)
self.assertEqual(readthrough.user, self.local_user)

View file

@ -0,0 +1,108 @@
''' test for app action functionality '''
import json
from unittest.mock import patch
from django.http import JsonResponse
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import models, views
from bookwyrm.connectors import abstract_connector
from bookwyrm.settings import DOMAIN
class ShelfViews(TestCase):
''' tag views'''
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', 'mouseword',
local=True, localname='mouse',
remote_id='https://example.com/users/mouse',
)
self.work = models.Work.objects.create(title='Test Work')
self.book = models.Edition.objects.create(
title='Test Book',
remote_id='https://example.com/book/1',
parent_work=self.work
)
models.Connector.objects.create(
identifier='self',
connector_file='self_connector',
local=True
)
def test_search_json_response(self):
''' searches local data only and returns book data in json format '''
view = views.Search.as_view()
# we need a connector for this, sorry
request = self.factory.get('', {'q': 'Test Book'})
with patch('bookwyrm.views.search.is_api_request') as is_api:
is_api.return_value = True
response = view(request)
self.assertIsInstance(response, JsonResponse)
data = json.loads(response.content)
self.assertEqual(len(data), 1)
self.assertEqual(data[0]['title'], 'Test Book')
self.assertEqual(
data[0]['key'], 'https://%s/book/%d' % (DOMAIN, self.book.id))
def test_search_html_response(self):
''' searches remote connectors '''
view = views.Search.as_view()
class TestConnector(abstract_connector.AbstractMinimalConnector):
''' nothing added here '''
def format_search_result(self, search_result):
pass
def get_or_create_book(self, remote_id):
pass
def parse_search_data(self, data):
pass
models.Connector.objects.create(
identifier='example.com',
connector_file='openlibrary',
base_url='https://example.com',
books_url='https://example.com/books',
covers_url='https://example.com/covers',
search_url='https://example.com/search?q=',
)
connector = TestConnector('example.com')
search_result = abstract_connector.SearchResult(
key='http://www.example.com/book/1',
title='Gideon the Ninth',
author='Tamsyn Muir',
year='2019',
connector=connector
)
request = self.factory.get('', {'q': 'Test Book'})
with patch('bookwyrm.views.search.is_api_request') as is_api:
is_api.return_value = False
with patch(
'bookwyrm.connectors.connector_manager.search') as manager:
manager.return_value = [search_result]
response = view(request)
self.assertIsInstance(response, TemplateResponse)
self.assertEqual(response.template_name, 'search_results.html')
self.assertEqual(
response.context_data['book_results'][0].title, 'Gideon the Ninth')
def test_search_html_response_users(self):
''' searches remote connectors '''
view = views.Search.as_view()
request = self.factory.get('', {'q': 'mouse'})
with patch('bookwyrm.views.search.is_api_request') as is_api:
is_api.return_value = False
with patch('bookwyrm.connectors.connector_manager.search'):
response = view(request)
self.assertIsInstance(response, TemplateResponse)
self.assertEqual(response.template_name, 'search_results.html')
self.assertEqual(
response.context_data['user_results'][0], self.local_user)

View file

@ -0,0 +1,192 @@
''' 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
from bookwyrm.activitypub import ActivitypubResponse
class ShelfViews(TestCase):
''' tag views'''
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', 'mouseword',
local=True, localname='mouse',
remote_id='https://example.com/users/mouse',
)
self.work = models.Work.objects.create(title='Test Work')
self.book = models.Edition.objects.create(
title='Example Edition',
remote_id='https://example.com/book/1',
parent_work=self.work
)
self.shelf = models.Shelf.objects.create(
name='Test Shelf',
identifier='test-shelf',
user=self.local_user
)
def test_shelf_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.Shelf.as_view()
shelf = self.local_user.shelf_set.first()
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.views.shelf.is_api_request') as is_api:
is_api.return_value = False
result = view(request, self.local_user.username, shelf.identifier)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'shelf.html')
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.shelf.is_api_request') as is_api:
is_api.return_value = True
result = view(
request, self.local_user.username, shelf.identifier)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
request = self.factory.get('/?page=1')
request.user = self.local_user
with patch('bookwyrm.views.shelf.is_api_request') as is_api:
is_api.return_value = True
result = view(
request, self.local_user.username, shelf.identifier)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_edit_shelf_privacy(self):
''' set name or privacy on shelf '''
view = views.Shelf.as_view()
shelf = self.local_user.shelf_set.get(identifier='to-read')
self.assertEqual(shelf.privacy, 'public')
request = self.factory.post(
'', {
'privacy': 'unlisted',
'user': self.local_user.id,
'name': 'To Read',
})
request.user = self.local_user
view(request, self.local_user.username, shelf.id)
shelf.refresh_from_db()
self.assertEqual(shelf.privacy, 'unlisted')
def test_edit_shelf_name(self):
''' change the name of an editable shelf '''
view = views.Shelf.as_view()
shelf = models.Shelf.objects.create(
name='Test Shelf', user=self.local_user)
self.assertEqual(shelf.privacy, 'public')
request = self.factory.post(
'', {
'privacy': 'public',
'user': self.local_user.id,
'name': 'cool name'
})
request.user = self.local_user
view(request, request.user.username, shelf.id)
shelf.refresh_from_db()
self.assertEqual(shelf.name, 'cool name')
self.assertEqual(shelf.identifier, 'testshelf-%d' % shelf.id)
def test_edit_shelf_name_not_editable(self):
''' can't change the name of an non-editable shelf '''
view = views.Shelf.as_view()
shelf = self.local_user.shelf_set.get(identifier='to-read')
self.assertEqual(shelf.privacy, 'public')
request = self.factory.post(
'', {
'privacy': 'public',
'user': self.local_user.id,
'name': 'cool name'
})
request.user = self.local_user
view(request, request.user.username, shelf.id)
self.assertEqual(shelf.name, 'To Read')
def test_handle_shelve(self):
''' shelve a book '''
request = self.factory.post('', {
'book': self.book.id,
'shelf': self.shelf.identifier
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.shelve(request)
# make sure the book is on the shelf
self.assertEqual(self.shelf.books.get(), self.book)
def test_handle_shelve_to_read(self):
''' special behavior for the to-read shelf '''
shelf = models.Shelf.objects.get(identifier='to-read')
request = self.factory.post('', {
'book': self.book.id,
'shelf': shelf.identifier
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.shelve(request)
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
def test_handle_shelve_reading(self):
''' special behavior for the reading shelf '''
shelf = models.Shelf.objects.get(identifier='reading')
request = self.factory.post('', {
'book': self.book.id,
'shelf': shelf.identifier
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.shelve(request)
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
def test_handle_shelve_read(self):
''' special behavior for the read shelf '''
shelf = models.Shelf.objects.get(identifier='read')
request = self.factory.post('', {
'book': self.book.id,
'shelf': shelf.identifier
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.shelve(request)
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
def test_handle_unshelve(self):
''' remove a book from a shelf '''
self.shelf.books.add(self.book)
self.shelf.save()
self.assertEqual(self.shelf.books.count(), 1)
request = self.factory.post('', {
'book': self.book.id,
'shelf': self.shelf.id
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.unshelve(request)
self.assertEqual(self.shelf.books.count(), 0)

View file

@ -0,0 +1,273 @@
''' 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 forms, models, views
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import DOMAIN
class StatusViews(TestCase):
''' viewing and creating statuses '''
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', 'mouseword',
local=True, localname='mouse',
remote_id='https://example.com/users/mouse',
)
with patch('bookwyrm.models.user.set_remote_server'):
self.remote_user = models.User.objects.create_user(
'rat', 'rat@email.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',
)
work = models.Work.objects.create(title='Test Work')
self.book = models.Edition.objects.create(
title='Example Edition',
remote_id='https://example.com/book/1',
parent_work=work
)
def test_status_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.Status.as_view()
status = models.Status.objects.create(
content='hi', user=self.local_user)
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.views.status.is_api_request') as is_api:
is_api.return_value = False
result = view(request, 'mouse', status.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'status.html')
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.status.is_api_request') as is_api:
is_api.return_value = True
result = view(request, 'mouse', status.id)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_replies_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.Replies.as_view()
status = models.Status.objects.create(
content='hi', user=self.local_user)
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.views.status.is_api_request') as is_api:
is_api.return_value = False
result = view(request, 'mouse', status.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'status.html')
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.status.is_api_request') as is_api:
is_api.return_value = True
result = view(request, 'mouse', status.id)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_handle_status(self):
''' create a status '''
view = views.CreateStatus.as_view()
form = forms.CommentForm({
'content': 'hi',
'user': self.local_user.id,
'book': self.book.id,
'privacy': 'public',
})
request = self.factory.post('', form.data)
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request, 'comment')
status = models.Comment.objects.get()
self.assertEqual(status.content, '<p>hi</p>')
self.assertEqual(status.user, self.local_user)
self.assertEqual(status.book, self.book)
def test_handle_status_reply(self):
''' create a status in reply to an existing status '''
view = views.CreateStatus.as_view()
user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'password', local=True)
parent = models.Status.objects.create(
content='parent status', user=self.local_user)
form = forms.ReplyForm({
'content': 'hi',
'user': user.id,
'reply_parent': parent.id,
'privacy': 'public',
})
request = self.factory.post('', form.data)
request.user = user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request, 'reply')
status = models.Status.objects.get(user=user)
self.assertEqual(status.content, '<p>hi</p>')
self.assertEqual(status.user, user)
self.assertEqual(
models.Notification.objects.get().user, self.local_user)
def test_handle_status_mentions(self):
''' @mention a user in a post '''
view = views.CreateStatus.as_view()
user = models.User.objects.create_user(
'rat@%s' % DOMAIN, 'rat@rat.com', 'password',
local=True, localname='rat')
form = forms.CommentForm({
'content': 'hi @rat',
'user': self.local_user.id,
'book': self.book.id,
'privacy': 'public',
})
request = self.factory.post('', form.data)
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request, 'comment')
status = models.Status.objects.get()
self.assertEqual(list(status.mention_users.all()), [user])
self.assertEqual(models.Notification.objects.get().user, user)
self.assertEqual(
status.content,
'<p>hi <a href="%s">@rat</a></p>' % user.remote_id)
def test_handle_status_reply_with_mentions(self):
''' reply to a post with an @mention'ed user '''
view = views.CreateStatus.as_view()
user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'password',
local=True, localname='rat')
form = forms.CommentForm({
'content': 'hi @rat@example.com',
'user': self.local_user.id,
'book': self.book.id,
'privacy': 'public',
})
request = self.factory.post('', form.data)
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request, 'comment')
status = models.Status.objects.get()
form = forms.ReplyForm({
'content': 'right',
'user': user.id,
'privacy': 'public',
'reply_parent': status.id
})
request = self.factory.post('', form.data)
request.user = user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request, 'reply')
reply = models.Status.replies(status).first()
self.assertEqual(reply.content, '<p>right</p>')
self.assertEqual(reply.user, user)
self.assertTrue(self.remote_user in reply.mention_users.all())
self.assertTrue(self.local_user in reply.mention_users.all())
def test_find_mentions(self):
''' detect and look up @ mentions of users '''
user = models.User.objects.create_user(
'nutria@%s' % DOMAIN, 'nutria@nutria.com', 'password',
local=True, localname='nutria')
self.assertEqual(user.username, 'nutria@%s' % DOMAIN)
self.assertEqual(
list(views.status.find_mentions('@nutria'))[0],
('@nutria', user)
)
self.assertEqual(
list(views.status.find_mentions('leading text @nutria'))[0],
('@nutria', user)
)
self.assertEqual(
list(views.status.find_mentions(
'leading @nutria trailing text'))[0],
('@nutria', user)
)
self.assertEqual(
list(views.status.find_mentions(
'@rat@example.com'))[0],
('@rat@example.com', self.remote_user)
)
multiple = list(views.status.find_mentions(
'@nutria and @rat@example.com'))
self.assertEqual(multiple[0], ('@nutria', user))
self.assertEqual(multiple[1], ('@rat@example.com', self.remote_user))
with patch('bookwyrm.views.status.handle_remote_webfinger') as rw:
rw.return_value = self.local_user
self.assertEqual(
list(views.status.find_mentions('@beep@beep.com'))[0],
('@beep@beep.com', self.local_user)
)
with patch('bookwyrm.views.status.handle_remote_webfinger') as rw:
rw.return_value = None
self.assertEqual(list(views.status.find_mentions(
'@beep@beep.com')), [])
self.assertEqual(
list(views.status.find_mentions('@nutria@%s' % DOMAIN))[0],
('@nutria@%s' % DOMAIN, user)
)
def test_format_links(self):
''' find and format urls into a tags '''
url = 'http://www.fish.com/'
self.assertEqual(
views.status.format_links(url),
'<a href="%s">www.fish.com/</a>' % url)
self.assertEqual(
views.status.format_links('(%s)' % url),
'(<a href="%s">www.fish.com/</a>)' % url)
url = 'https://archive.org/details/dli.granth.72113/page/n25/mode/2up'
self.assertEqual(
views.status.format_links(url),
'<a href="%s">' \
'archive.org/details/dli.granth.72113/page/n25/mode/2up</a>' \
% url)
url = 'https://openlibrary.org/search' \
'?q=arkady+strugatsky&mode=everything'
self.assertEqual(
views.status.format_links(url),
'<a href="%s">openlibrary.org/search' \
'?q=arkady+strugatsky&mode=everything</a>' % url)
def test_to_markdown(self):
''' this is mostly handled in other places, but nonetheless '''
text = '_hi_ and http://fish.com is <marquee>rad</marquee>'
result = views.status.to_markdown(text)
self.assertEqual(
result,
'<p><em>hi</em> and <a href="http://fish.com">fish.com</a> ' \
'is rad</p>')
def test_handle_delete_status(self):
''' marks a status as deleted '''
view = views.DeleteStatus.as_view()
status = models.Status.objects.create(
user=self.local_user, content='hi')
self.assertFalse(status.deleted)
request = self.factory.post('')
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request, status.id)
status.refresh_from_db()
self.assertTrue(status.deleted)

View file

@ -0,0 +1,99 @@
''' test for app action functionality '''
from unittest.mock import patch
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import models, views
from bookwyrm.activitypub import ActivitypubResponse
class TagViews(TestCase):
''' tag views'''
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', 'mouseword',
local=True, localname='mouse',
remote_id='https://example.com/users/mouse',
)
self.group = Group.objects.create(name='editor')
self.group.permissions.add(
Permission.objects.create(
name='edit_book',
codename='edit_book',
content_type=ContentType.objects.get_for_model(models.User)).id
)
self.work = models.Work.objects.create(title='Test Work')
self.book = models.Edition.objects.create(
title='Example Edition',
remote_id='https://example.com/book/1',
parent_work=self.work
)
def test_tag_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.Tag.as_view()
tag = models.Tag.objects.create(name='hi there')
models.UserTag.objects.create(
tag=tag, user=self.local_user, book=self.book)
request = self.factory.get('')
with patch('bookwyrm.views.tag.is_api_request') as is_api:
is_api.return_value = False
result = view(request, tag.identifier)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'tag.html')
self.assertEqual(result.status_code, 200)
request = self.factory.get('')
with patch('bookwyrm.views.tag.is_api_request') as is_api:
is_api.return_value = True
result = view(request, tag.identifier)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_tag(self):
''' add a tag to a book '''
view = views.AddTag.as_view()
request = self.factory.post(
'', {
'name': 'A Tag!?',
'book': self.book.id,
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request)
tag = models.Tag.objects.get()
user_tag = models.UserTag.objects.get()
self.assertEqual(tag.name, 'A Tag!?')
self.assertEqual(tag.identifier, 'A+Tag%21%3F')
self.assertEqual(user_tag.user, self.local_user)
self.assertEqual(user_tag.book, self.book)
def test_untag(self):
''' remove a tag from a book '''
view = views.RemoveTag.as_view()
tag = models.Tag.objects.create(name='A Tag!?')
models.UserTag.objects.create(
user=self.local_user, book=self.book, tag=tag)
request = self.factory.post(
'', {
'user': self.local_user.id,
'book': self.book.id,
'name': tag.name,
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request)
self.assertTrue(models.Tag.objects.filter(name='A Tag!?').exists())
self.assertFalse(models.UserTag.objects.exists())

View file

@ -0,0 +1,99 @@
''' 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 forms, models, views
from bookwyrm.activitypub import ActivitypubResponse
class UserViews(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')
def test_user_page(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
with patch('bookwyrm.views.user.is_api_request') as is_api:
is_api.return_value = False
result = view(request, 'mouse')
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'user.html')
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.user.is_api_request') as is_api:
is_api.return_value = True
result = view(request, 'mouse')
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_followers_page(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
with patch('bookwyrm.views.user.is_api_request') as is_api:
is_api.return_value = False
result = view(request, 'mouse')
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'followers.html')
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.user.is_api_request') as is_api:
is_api.return_value = True
result = view(request, 'mouse')
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_following_page(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
with patch('bookwyrm.views.user.is_api_request') as is_api:
is_api.return_value = False
result = view(request, 'mouse')
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'following.html')
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.user.is_api_request') as is_api:
is_api.return_value = True
result = view(request, 'mouse')
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_edit_profile_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.EditUser.as_view()
request = self.factory.get('')
request.user = self.local_user
result = view(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'edit_user.html')
self.assertEqual(result.status_code, 200)
def test_edit_user(self):
''' use a form to update a user '''
view = views.EditUser.as_view()
form = forms.EditUserForm(instance=self.local_user)
form.data['name'] = 'New Name'
request = self.factory.post('', form.data)
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request)
self.assertEqual(self.local_user.name, 'New Name')

View file

@ -3,8 +3,7 @@ from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path, re_path
from bookwyrm import incoming, outgoing, views, settings, wellknown
from bookwyrm import view_actions as actions
from bookwyrm import incoming, settings, views, wellknown
from bookwyrm.utils import regex
user_path = r'^user/(?P<username>%s)' % regex.username
@ -31,7 +30,7 @@ urlpatterns = [
# federation endpoints
re_path(r'^inbox/?$', incoming.shared_inbox),
re_path(r'%s/inbox/?$' % local_user_path, incoming.inbox),
re_path(r'%s/outbox/?$' % local_user_path, outgoing.outbox),
re_path(r'%s/outbox/?$' % local_user_path, views.Outbox.as_view()),
# .well-known endpoints
re_path(r'^.well-known/webfinger/?$', wellknown.webfinger),
@ -39,110 +38,101 @@ urlpatterns = [
re_path(r'^nodeinfo/2\.0/?$', wellknown.nodeinfo),
re_path(r'^api/v1/instance/?$', wellknown.instance_info),
re_path(r'^api/v1/instance/peers/?$', wellknown.peers),
# TODO: re_path(r'^.well-known/host-meta/?$', incoming.host_meta),
# TODO: robots.txt
# ui views
re_path(r'^login/?$', views.login_page),
re_path(r'^about/?$', views.about_page),
re_path(r'^password-reset/?$', views.password_reset_request),
re_path(r'^password-reset/(?P<code>[A-Za-z0-9]+)/?$', views.password_reset),
re_path(r'^invite/?$', views.manage_invites),
re_path(r'^invite/(?P<code>[A-Za-z0-9]+)/?$', views.invite_page),
# authentication
re_path(r'^login/?$', views.Login.as_view()),
re_path(r'^register/?$', views.Register.as_view()),
re_path(r'^logout/?$', views.Logout.as_view()),
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),
path('', views.home),
re_path(r'^(?P<tab>home|local|federated)/?$', views.home_tab),
re_path(r'^discover/?$', views.discover_page),
re_path(r'^notifications/?$', views.notifications_page),
re_path(r'^direct-messages/?$', views.direct_messages_page),
re_path(r'^import/?$', views.import_page),
re_path(r'^import-status/(\d+)/?$', views.import_status),
re_path(r'^user-edit/?$', views.edit_profile_page),
# invites
re_path(r'^invite/?$', views.ManageInvites.as_view()),
re_path(r'^invite/(?P<code>[A-Za-z0-9]+)/?$', views.Invite.as_view()),
# landing pages
re_path(r'^about/?$', views.About.as_view()),
path('', views.Home.as_view()),
re_path(r'^(?P<tab>home|local|federated)/?$', views.Feed.as_view()),
re_path(r'^discover/?$', views.Discover.as_view()),
re_path(r'^notifications/?$', views.Notifications.as_view()),
re_path(r'^direct-messages/?$', views.DirectMessage.as_view()),
# search
re_path(r'^search/?$', views.Search.as_view()),
# imports
re_path(r'^import/?$', views.Import.as_view()),
re_path(r'^import/(\d+)/?$', views.ImportStatus.as_view()),
# should return a ui view or activitypub json blob as requested
# users
re_path(r'%s/?$' % user_path, views.user_page),
re_path(r'%s\.json$' % local_user_path, views.user_page),
re_path(r'%s/?$' % local_user_path, views.user_page),
re_path(r'%s/shelves/?$' % local_user_path, views.user_shelves_page),
re_path(r'%s/followers(.json)?/?$' % local_user_path, views.followers_page),
re_path(r'%s/following(.json)?/?$' % local_user_path, views.following_page),
re_path(r'%s/?$' % user_path, views.User.as_view()),
re_path(r'%s\.json$' % user_path, views.User.as_view()),
re_path(r'%s/shelves/?$' % user_path, views.user_shelves_page),
re_path(r'%s/followers(.json)?/?$' % user_path, views.Followers.as_view()),
re_path(r'%s/following(.json)?/?$' % user_path, views.Following.as_view()),
re_path(r'^edit-profile/?$', views.EditUser.as_view()),
# reading goals
re_path(r'%s/goal/(?P<year>\d{4})/?$' % user_path, views.Goal.as_view()),
# statuses
re_path(r'%s(.json)?/?$' % status_path, views.status_page),
re_path(r'%s/activity/?$' % status_path, views.status_page),
re_path(r'%s/replies(.json)?/?$' % status_path, views.replies_page),
re_path(r'%s(.json)?/?$' % status_path, views.Status.as_view()),
re_path(r'%s/activity/?$' % status_path, views.Status.as_view()),
re_path(r'%s/replies(.json)?/?$' % status_path, views.Replies.as_view()),
re_path(r'^post/(?P<status_type>\w+)/?$', views.CreateStatus.as_view()),
re_path(r'^delete-status/(?P<status_id>\d+)/?$',
views.DeleteStatus.as_view()),
# interact
re_path(r'^favorite/(?P<status_id>\d+)/?$', views.Favorite.as_view()),
re_path(r'^unfavorite/(?P<status_id>\d+)/?$', views.Unfavorite.as_view()),
re_path(r'^boost/(?P<status_id>\d+)/?$', views.Boost.as_view()),
re_path(r'^unboost/(?P<status_id>\d+)/?$', views.Unboost.as_view()),
# books
re_path(r'%s(.json)?/?$' % book_path, views.book_page),
re_path(r'%s/edit/?$' % book_path, views.edit_book_page),
re_path(r'^author/(?P<author_id>[\w\-]+)/edit/?$', views.edit_author_page),
re_path(r'%s/editions(.json)?/?$' % book_path, views.editions_page),
re_path(r'%s(.json)?/?$' % book_path, views.Book.as_view()),
re_path(r'%s/edit/?$' % book_path, views.EditBook.as_view()),
re_path(r'%s/editions(.json)?/?$' % book_path, views.Editions.as_view()),
re_path(r'^upload-cover/(?P<book_id>\d+)/?$', views.upload_cover),
re_path(r'^add-description/(?P<book_id>\d+)/?$', views.add_description),
re_path(r'^resolve-book/?$', views.resolve_book),
re_path(r'^switch-edition/?$', views.switch_edition),
re_path(r'^author/(?P<author_id>[\w\-]+)(.json)?/?$', views.author_page),
re_path(r'^tag/(?P<tag_id>.+)\.json/?$', views.tag_page),
re_path(r'^tag/(?P<tag_id>.+)/?$', views.tag_page),
# author
re_path(r'^author/(?P<author_id>\d+)(.json)?/?$', views.Author.as_view()),
re_path(r'^author/(?P<author_id>\d+)/edit/?$', views.EditAuthor.as_view()),
# tags
re_path(r'^tag/(?P<tag_id>.+)\.json/?$', views.Tag.as_view()),
re_path(r'^tag/(?P<tag_id>.+)/?$', views.Tag.as_view()),
re_path(r'^tag/?$', views.AddTag.as_view()),
re_path(r'^untag/?$', views.RemoveTag.as_view()),
# shelf
re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % \
user_path, views.shelf_page),
user_path, views.Shelf.as_view()),
re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % \
local_user_path, views.shelf_page),
local_user_path, views.Shelf.as_view()),
re_path(r'^create-shelf/?$', views.create_shelf),
re_path(r'^delete-shelf/(?P<shelf_id>\d+)?$', views.delete_shelf),
re_path(r'^shelve/?$', views.shelve),
re_path(r'^unshelve/?$', views.unshelve),
re_path(r'^search/?$', views.search),
# reading progress
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),
# internal action endpoints
re_path(r'^logout/?$', actions.user_logout),
re_path(r'^user-login/?$', actions.user_login),
re_path(r'^user-register/?$', actions.register),
re_path(r'^reset-password-request/?$', actions.password_reset_request),
re_path(r'^reset-password/?$', actions.password_reset),
re_path(r'^change-password/?$', actions.password_change),
re_path(r'^edit-profile/?$', actions.edit_profile),
re_path(r'^import-data/?$', actions.import_data),
re_path(r'^retry-import/?$', actions.retry_import),
re_path(r'^resolve-book/?$', actions.resolve_book),
re_path(r'^edit-book/(?P<book_id>\d+)/?$', actions.edit_book),
re_path(r'^upload-cover/(?P<book_id>\d+)/?$', actions.upload_cover),
re_path(r'^add-description/(?P<book_id>\d+)/?$', actions.add_description),
re_path(r'^edit-author/(?P<author_id>\d+)/?$', actions.edit_author),
re_path(r'^switch-edition/?$', actions.switch_edition),
re_path(r'^edit-readthrough/?$', actions.edit_readthrough),
re_path(r'^delete-readthrough/?$', actions.delete_readthrough),
re_path(r'^create-readthrough/?$', actions.create_readthrough),
re_path(r'^delete-progressupdate/?$', actions.delete_progressupdate),
re_path(r'^rate/?$', actions.rate),
re_path(r'^review/?$', actions.review),
re_path(r'^quote/?$', actions.quotate),
re_path(r'^comment/?$', actions.comment),
re_path(r'^tag/?$', actions.tag),
re_path(r'^untag/?$', actions.untag),
re_path(r'^reply/?$', actions.reply),
re_path(r'^favorite/(?P<status_id>\d+)/?$', actions.favorite),
re_path(r'^unfavorite/(?P<status_id>\d+)/?$', actions.unfavorite),
re_path(r'^boost/(?P<status_id>\d+)/?$', actions.boost),
re_path(r'^unboost/(?P<status_id>\d+)/?$', actions.unboost),
re_path(r'^delete-status/(?P<status_id>\d+)/?$', actions.delete_status),
re_path(r'^create-shelf/?$', actions.create_shelf),
re_path(r'^edit-shelf/(?P<shelf_id>\d+)?$', actions.edit_shelf),
re_path(r'^delete-shelf/(?P<shelf_id>\d+)?$', actions.delete_shelf),
re_path(r'^shelve/?$', actions.shelve),
re_path(r'^unshelve/?$', actions.unshelve),
re_path(r'^start-reading/(?P<book_id>\d+)/?$', actions.start_reading),
re_path(r'^finish-reading/(?P<book_id>\d+)/?$', actions.finish_reading),
re_path(r'^follow/?$', actions.follow),
re_path(r'^unfollow/?$', actions.unfollow),
re_path(r'^accept-follow-request/?$', actions.accept_follow_request),
re_path(r'^delete-follow-request/?$', actions.delete_follow_request),
re_path(r'^clear-notifications/?$', actions.clear_notifications),
re_path(r'^create-invite/?$', actions.create_invite),
re_path(r'^start-reading/(?P<book_id>\d+)/?$', views.start_reading),
re_path(r'^finish-reading/(?P<book_id>\d+)/?$', views.finish_reading),
# following
re_path(r'^follow/?$', views.follow),
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),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View file

@ -1,892 +0,0 @@
''' views for actions you can take in the application '''
from io import BytesIO, TextIOWrapper
from uuid import uuid4
from PIL import Image
import dateutil.parser
from dateutil.parser import ParserError
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required, permission_required
from django.core.exceptions import PermissionDenied
from django.core.files.base import ContentFile
from django.db import transaction
from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.utils import timezone
from django.views.decorators.http import require_GET, require_POST
from bookwyrm import forms, models, outgoing, goodreads_import
from bookwyrm.connectors import connector_manager
from bookwyrm.broadcast import broadcast
from bookwyrm.emailing import password_reset_email
from bookwyrm.settings import DOMAIN
from bookwyrm.views import get_user_from_username, get_edition
@require_POST
def user_login(request):
''' authenticate user login '''
login_form = forms.LoginForm(request.POST)
localname = login_form.data['localname']
username = '%s@%s' % (localname, DOMAIN)
password = login_form.data['password']
user = authenticate(request, username=username, password=password)
if user is not None:
# successful login
login(request, user)
user.last_active_date = timezone.now()
return redirect(request.GET.get('next', '/'))
login_form.non_field_errors = 'Username or password are incorrect'
register_form = forms.RegisterForm()
data = {
'login_form': login_form,
'register_form': register_form
}
return TemplateResponse(request, 'login.html', data)
@require_POST
def register(request):
''' join the server '''
if not models.SiteSettings.get().allow_registration:
invite_code = request.POST.get('invite_code')
if not invite_code:
raise PermissionDenied
invite = get_object_or_404(models.SiteInvite, code=invite_code)
if not invite.valid():
raise PermissionDenied
else:
invite = None
form = forms.RegisterForm(request.POST)
errors = False
if not form.is_valid():
errors = True
localname = form.data['localname'].strip()
email = form.data['email']
password = form.data['password']
# check localname and email uniqueness
if models.User.objects.filter(localname=localname).first():
form.errors['localname'] = ['User with this username already exists']
errors = True
if errors:
data = {
'login_form': forms.LoginForm(),
'register_form': form,
'invite': invite,
'valid': invite.valid() if invite else True,
}
if invite:
return TemplateResponse(request, 'invite.html', data)
return TemplateResponse(request, 'login.html', data)
username = '%s@%s' % (localname, DOMAIN)
user = models.User.objects.create_user(
username, email, password, localname=localname, local=True)
if invite:
invite.times_used += 1
invite.save()
login(request, user)
return redirect('/')
@login_required
@require_GET
def user_logout(request):
''' done with this place! outa here! '''
logout(request)
return redirect('/')
@require_POST
def password_reset_request(request):
''' create a password reset token '''
email = request.POST.get('email')
try:
user = models.User.objects.get(email=email)
except models.User.DoesNotExist:
return redirect('/password-reset')
# remove any existing password reset cods for this user
models.PasswordReset.objects.filter(user=user).all().delete()
# create a new reset code
code = models.PasswordReset.objects.create(user=user)
password_reset_email(code)
data = {'message': 'Password reset link sent to %s' % email}
return TemplateResponse(request, 'password_reset_request.html', data)
@require_POST
def password_reset(request):
''' allow a user to change their password through an emailed token '''
try:
reset_code = models.PasswordReset.objects.get(
code=request.POST.get('reset-code')
)
except models.PasswordReset.DoesNotExist:
data = {'errors': ['Invalid password reset link']}
return TemplateResponse(request, 'password_reset.html', data)
user = reset_code.user
new_password = request.POST.get('password')
confirm_password = request.POST.get('confirm-password')
if new_password != confirm_password:
data = {'errors': ['Passwords do not match']}
return TemplateResponse(request, 'password_reset.html', data)
user.set_password(new_password)
user.save()
login(request, user)
reset_code.delete()
return redirect('/')
@login_required
@require_POST
def password_change(request):
''' allow a user to change their password '''
new_password = request.POST.get('password')
confirm_password = request.POST.get('confirm-password')
if new_password != confirm_password:
return redirect('/user-edit')
request.user.set_password(new_password)
request.user.save()
login(request, request.user)
return redirect('/user/%s' % request.user.localname)
@login_required
@require_POST
def edit_profile(request):
''' les get fancy with images '''
form = forms.EditUserForm(
request.POST, request.FILES, instance=request.user)
if not form.is_valid():
data = {'form': form, 'user': request.user}
return TemplateResponse(request, 'edit_user.html', data)
user = form.save(commit=False)
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())
# 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.save()
broadcast(user, user.to_update_activity(user))
return redirect('/user/%s' % request.user.localname)
@require_POST
def resolve_book(request):
''' figure out the local path to a book from a remote_id '''
remote_id = request.POST.get('remote_id')
connector = connector_manager.get_or_create_connector(remote_id)
book = connector.get_or_create_book(remote_id)
return redirect('/book/%d' % book.id)
@login_required
@permission_required('bookwyrm.edit_book', raise_exception=True)
@require_POST
def edit_book(request, book_id):
''' edit a book cool '''
book = get_object_or_404(models.Edition, id=book_id)
form = forms.EditionForm(request.POST, request.FILES, instance=book)
if not form.is_valid():
data = {
'title': 'Edit Book',
'book': book,
'form': form
}
return TemplateResponse(request, 'edit_book.html', data)
book = form.save()
broadcast(request.user, book.to_update_activity(request.user))
return redirect('/book/%s' % book.id)
@login_required
@require_POST
@transaction.atomic
def switch_edition(request):
''' switch your copy of a book to a different edition '''
edition_id = request.POST.get('edition')
new_edition = get_object_or_404(models.Edition, id=edition_id)
shelfbooks = models.ShelfBook.objects.filter(
book__parent_work=new_edition.parent_work,
shelf__user=request.user
)
for shelfbook in shelfbooks.all():
broadcast(request.user, shelfbook.to_remove_activity(request.user))
shelfbook.book = new_edition
shelfbook.save()
broadcast(request.user, shelfbook.to_add_activity(request.user))
readthroughs = models.ReadThrough.objects.filter(
book__parent_work=new_edition.parent_work,
user=request.user
)
for readthrough in readthroughs.all():
readthrough.book = new_edition
readthrough.save()
return redirect('/book/%d' % new_edition.id)
@login_required
@require_POST
def upload_cover(request, book_id):
''' upload a new cover '''
book = get_object_or_404(models.Edition, id=book_id)
form = forms.CoverForm(request.POST, request.FILES, instance=book)
if not form.is_valid():
return redirect('/book/%d' % book.id)
book.cover = form.files['cover']
book.save()
broadcast(request.user, book.to_update_activity(request.user))
return redirect('/book/%s' % book.id)
@login_required
@require_POST
@permission_required('bookwyrm.edit_book', raise_exception=True)
def add_description(request, book_id):
''' upload a new cover '''
if not request.method == 'POST':
return redirect('/')
book = get_object_or_404(models.Edition, id=book_id)
description = request.POST.get('description')
book.description = description
book.save()
broadcast(request.user, book.to_update_activity(request.user))
return redirect('/book/%s' % book.id)
@login_required
@permission_required('bookwyrm.edit_book', raise_exception=True)
@require_POST
def edit_author(request, author_id):
''' edit a author cool '''
author = get_object_or_404(models.Author, id=author_id)
form = forms.AuthorForm(request.POST, request.FILES, instance=author)
if not form.is_valid():
data = {
'title': 'Edit Author',
'author': author,
'form': form
}
return TemplateResponse(request, 'edit_author.html', data)
author = form.save()
broadcast(request.user, author.to_update_activity(request.user))
return redirect('/author/%s' % author.id)
@login_required
@require_POST
def create_shelf(request):
''' user generated shelves '''
form = forms.ShelfForm(request.POST)
if not form.is_valid():
return redirect(request.headers.get('Referer', '/'))
shelf = form.save()
return redirect('/user/%s/shelf/%s' % \
(request.user.localname, shelf.identifier))
@login_required
@require_POST
def edit_shelf(request, shelf_id):
''' user generated shelves '''
shelf = get_object_or_404(models.Shelf, id=shelf_id)
if request.user != shelf.user:
return HttpResponseBadRequest()
if not shelf.editable and request.POST.get('name') != shelf.name:
return HttpResponseBadRequest()
form = forms.ShelfForm(request.POST, instance=shelf)
if not form.is_valid():
return redirect(shelf.local_path)
shelf = form.save()
return redirect(shelf.local_path)
@login_required
@require_POST
def delete_shelf(request, shelf_id):
''' user generated shelves '''
shelf = get_object_or_404(models.Shelf, id=shelf_id)
if request.user != shelf.user or not shelf.editable:
return HttpResponseBadRequest()
shelf.delete()
return redirect('/user/%s/shelves' % request.user.localname)
@login_required
@require_POST
def shelve(request):
''' put a on a user's shelf '''
book = get_edition(request.POST['book'])
desired_shelf = models.Shelf.objects.filter(
identifier=request.POST['shelf'],
user=request.user
).first()
if request.POST.get('reshelve', True):
try:
current_shelf = models.Shelf.objects.get(
user=request.user,
edition=book
)
outgoing.handle_unshelve(request.user, book, current_shelf)
except models.Shelf.DoesNotExist:
# this just means it isn't currently on the user's shelves
pass
outgoing.handle_shelve(request.user, book, desired_shelf)
# post about "want to read" shelves
if desired_shelf.identifier == 'to-read':
outgoing.handle_reading_status(
request.user,
desired_shelf,
book,
privacy=desired_shelf.privacy
)
return redirect('/')
@login_required
@require_POST
def unshelve(request):
''' put a on a user's shelf '''
book = models.Edition.objects.get(id=request.POST['book'])
current_shelf = models.Shelf.objects.get(id=request.POST['shelf'])
outgoing.handle_unshelve(request.user, book, current_shelf)
return redirect(request.headers.get('Referer', '/'))
@login_required
@require_POST
def start_reading(request, book_id):
''' begin reading a book '''
book = get_edition(book_id)
shelf = models.Shelf.objects.filter(
identifier='reading',
user=request.user
).first()
# create a readthrough
readthrough = update_readthrough(request, book=book)
if readthrough.start_date:
readthrough.save()
# create a progress update if we have a page
readthrough.create_update()
# shelve the book
if request.POST.get('reshelve', True):
try:
current_shelf = models.Shelf.objects.get(
user=request.user,
edition=book
)
outgoing.handle_unshelve(request.user, book, current_shelf)
except models.Shelf.DoesNotExist:
# this just means it isn't currently on the user's shelves
pass
outgoing.handle_shelve(request.user, book, shelf)
# post about it (if you want)
if request.POST.get('post-status'):
privacy = request.POST.get('privacy')
outgoing.handle_reading_status(request.user, shelf, book, privacy)
return redirect(request.headers.get('Referer', '/'))
@login_required
@require_POST
def finish_reading(request, book_id):
''' a user completed a book, yay '''
book = get_edition(book_id)
shelf = models.Shelf.objects.filter(
identifier='read',
user=request.user
).first()
# update or create a readthrough
readthrough = update_readthrough(request, book=book)
if readthrough.start_date or readthrough.finish_date:
readthrough.save()
# shelve the book
if request.POST.get('reshelve', True):
try:
current_shelf = models.Shelf.objects.get(
user=request.user,
edition=book
)
outgoing.handle_unshelve(request.user, book, current_shelf)
except models.Shelf.DoesNotExist:
# this just means it isn't currently on the user's shelves
pass
outgoing.handle_shelve(request.user, book, shelf)
# post about it (if you want)
if request.POST.get('post-status'):
privacy = request.POST.get('privacy')
outgoing.handle_reading_status(request.user, shelf, book, privacy)
return redirect(request.headers.get('Referer', '/'))
@login_required
@require_POST
def edit_readthrough(request):
''' can't use the form because the dates are too finnicky '''
readthrough = update_readthrough(request, create=False)
if not readthrough:
return HttpResponseNotFound()
# don't let people edit other people's data
if request.user != readthrough.user:
return HttpResponseBadRequest()
readthrough.save()
# record the progress update individually
# use default now for date field
readthrough.create_update()
return redirect(request.headers.get('Referer', '/'))
@login_required
@require_POST
def delete_readthrough(request):
''' remove a readthrough '''
readthrough = get_object_or_404(
models.ReadThrough, id=request.POST.get('id'))
# don't let people edit other people's data
if request.user != readthrough.user:
return HttpResponseBadRequest()
readthrough.delete()
return redirect(request.headers.get('Referer', '/'))
@login_required
@require_POST
def create_readthrough(request):
''' can't use the form because the dates are too finnicky '''
book = get_object_or_404(models.Edition, id=request.POST.get('book'))
readthrough = update_readthrough(request, create=True, book=book)
if not readthrough:
return redirect(book.local_path)
readthrough.save()
return redirect(request.headers.get('Referer', '/'))
@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', '/'))
@login_required
@require_POST
def rate(request):
''' just a star rating for a book '''
form = forms.RatingForm(request.POST)
return handle_status(request, form)
@login_required
@require_POST
def review(request):
''' create a book review '''
form = forms.ReviewForm(request.POST)
return handle_status(request, form)
@login_required
@require_POST
def quotate(request):
''' create a book quotation '''
form = forms.QuotationForm(request.POST)
return handle_status(request, form)
@login_required
@require_POST
def comment(request):
''' create a book comment '''
form = forms.CommentForm(request.POST)
return handle_status(request, form)
@login_required
@require_POST
def reply(request):
''' respond to a book review '''
form = forms.ReplyForm(request.POST)
return handle_status(request, form)
def handle_status(request, form):
''' all the "create a status" functions are the same '''
if not form.is_valid():
return redirect(request.headers.get('Referer', '/'))
outgoing.handle_status(request.user, form)
return redirect(request.headers.get('Referer', '/'))
@login_required
@require_POST
def tag(request):
''' tag a book '''
# I'm not using a form here because sometimes "name" is sent as a hidden
# field which doesn't validate
name = request.POST.get('name')
book_id = request.POST.get('book')
book = get_object_or_404(models.Edition, id=book_id)
tag_obj, created = models.Tag.objects.get_or_create(
name=name,
)
user_tag, _ = models.UserTag.objects.get_or_create(
user=request.user,
book=book,
tag=tag_obj,
)
if created:
broadcast(request.user, user_tag.to_add_activity(request.user))
return redirect('/book/%s' % book_id)
@login_required
@require_POST
def untag(request):
''' untag a book '''
name = request.POST.get('name')
tag_obj = get_object_or_404(models.Tag, name=name)
book_id = request.POST.get('book')
book = get_object_or_404(models.Edition, id=book_id)
user_tag = get_object_or_404(
models.UserTag, tag=tag_obj, book=book, user=request.user)
tag_activity = user_tag.to_remove_activity(request.user)
user_tag.delete()
broadcast(request.user, tag_activity)
return redirect('/book/%s' % book_id)
@login_required
@require_POST
def favorite(request, status_id):
''' like a status '''
status = models.Status.objects.get(id=status_id)
outgoing.handle_favorite(request.user, status)
return redirect(request.headers.get('Referer', '/'))
@login_required
@require_POST
def unfavorite(request, status_id):
''' like a status '''
status = models.Status.objects.get(id=status_id)
outgoing.handle_unfavorite(request.user, status)
return redirect(request.headers.get('Referer', '/'))
@login_required
@require_POST
def boost(request, status_id):
''' boost a status '''
status = models.Status.objects.get(id=status_id)
outgoing.handle_boost(request.user, status)
return redirect(request.headers.get('Referer', '/'))
@login_required
@require_POST
def unboost(request, status_id):
''' boost a status '''
status = models.Status.objects.get(id=status_id)
outgoing.handle_unboost(request.user, status)
return redirect(request.headers.get('Referer', '/'))
@login_required
@require_POST
def delete_status(request, status_id):
''' delete and tombstone a status '''
status = get_object_or_404(models.Status, id=status_id)
# don't let people delete other people's statuses
if status.user != request.user:
return HttpResponseBadRequest()
# perform deletion
outgoing.handle_delete_status(request.user, status)
return redirect(request.headers.get('Referer', '/'))
@login_required
@require_POST
def follow(request):
''' follow another user, here or abroad '''
username = request.POST['user']
try:
to_follow = get_user_from_username(username)
except models.User.DoesNotExist:
return HttpResponseBadRequest()
outgoing.handle_follow(request.user, to_follow)
user_slug = to_follow.localname if to_follow.localname \
else to_follow.username
return redirect('/user/%s' % user_slug)
@login_required
@require_POST
def unfollow(request):
''' unfollow a user '''
username = request.POST['user']
try:
to_unfollow = get_user_from_username(username)
except models.User.DoesNotExist:
return HttpResponseBadRequest()
outgoing.handle_unfollow(request.user, to_unfollow)
user_slug = to_unfollow.localname if to_unfollow.localname \
else to_unfollow.username
return redirect('/user/%s' % user_slug)
@login_required
def clear_notifications(request):
''' permanently delete notification for user '''
request.user.notification_set.filter(read=True).delete()
return redirect('/notifications')
@login_required
@require_POST
def accept_follow_request(request):
''' a user accepts a follow request '''
username = request.POST['user']
try:
requester = get_user_from_username(username)
except models.User.DoesNotExist:
return HttpResponseBadRequest()
try:
follow_request = models.UserFollowRequest.objects.get(
user_subject=requester,
user_object=request.user
)
except models.UserFollowRequest.DoesNotExist:
# Request already dealt with.
pass
else:
outgoing.handle_accept(follow_request)
return redirect('/user/%s' % request.user.localname)
@login_required
@require_POST
def delete_follow_request(request):
''' a user rejects a follow request '''
username = request.POST['user']
try:
requester = get_user_from_username(username)
except models.User.DoesNotExist:
return HttpResponseBadRequest()
try:
follow_request = models.UserFollowRequest.objects.get(
user_subject=requester,
user_object=request.user
)
except models.UserFollowRequest.DoesNotExist:
return HttpResponseBadRequest()
outgoing.handle_reject(follow_request)
return redirect('/user/%s' % request.user.localname)
@login_required
@require_POST
def import_data(request):
''' ingest a goodreads csv '''
form = forms.ImportForm(request.POST, request.FILES)
if form.is_valid():
include_reviews = request.POST.get('include_reviews') == 'on'
privacy = request.POST.get('privacy')
try:
job = goodreads_import.create_job(
request.user,
TextIOWrapper(
request.FILES['csv_file'],
encoding=request.encoding),
include_reviews,
privacy,
)
except (UnicodeDecodeError, ValueError):
return HttpResponseBadRequest('Not a valid csv file')
goodreads_import.start_import(job)
return redirect('/import-status/%d' % job.id)
return HttpResponseBadRequest()
@login_required
@require_POST
def retry_import(request):
''' ingest a goodreads csv '''
job = get_object_or_404(models.ImportJob, id=request.POST.get('import_job'))
items = []
for item in request.POST.getlist('import_item'):
items.append(get_object_or_404(models.ImportItem, id=item))
job = goodreads_import.create_retry_job(
request.user,
job,
items,
)
goodreads_import.start_import(job)
return redirect('/import-status/%d' % job.id)
@login_required
@require_POST
@permission_required('bookwyrm.create_invites', raise_exception=True)
def create_invite(request):
''' creates a user invite database entry '''
form = forms.CreateInviteForm(request.POST)
if not form.is_valid():
return HttpResponseBadRequest("ERRORS : %s" % (form.errors,))
invite = form.save(commit=False)
invite.user = request.user
invite.save()
return redirect('/invite')
def update_readthrough(request, book=None, create=True):
''' updates but does not save dates on a readthrough '''
try:
read_id = request.POST.get('id')
if not read_id:
raise models.ReadThrough.DoesNotExist
readthrough = models.ReadThrough.objects.get(id=read_id)
except models.ReadThrough.DoesNotExist:
if not create or not book:
return None
readthrough = models.ReadThrough(
user=request.user,
book=book,
)
start_date = request.POST.get('start_date')
if start_date:
try:
start_date = timezone.make_aware(dateutil.parser.parse(start_date))
readthrough.start_date = start_date
except ParserError:
pass
finish_date = request.POST.get('finish_date')
if finish_date:
try:
finish_date = timezone.make_aware(
dateutil.parser.parse(finish_date))
readthrough.finish_date = finish_date
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

View file

@ -1,842 +0,0 @@
''' views for pages you can go to in the application '''
import re
from django.contrib.auth.decorators import login_required, permission_required
from django.contrib.postgres.search import TrigramSimilarity
from django.core.paginator import Paginator
from django.db.models import Avg, Q, Max
from django.db.models.functions import Greatest
from django.http import HttpResponseNotFound, JsonResponse
from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET
from bookwyrm import outgoing
from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.connectors import connector_manager
from bookwyrm.settings import PAGE_LENGTH
from bookwyrm.tasks import app
from bookwyrm.utils import regex
def get_edition(book_id):
''' look up a book in the db and return an edition '''
book = models.Book.objects.select_subclasses().get(id=book_id)
if isinstance(book, models.Work):
book = book.get_default_edition()
return book
def get_user_from_username(username):
''' helper function to resolve a localname or a username to a user '''
# raises DoesNotExist if user is now found
try:
return models.User.objects.get(localname=username)
except models.User.DoesNotExist:
return models.User.objects.get(username=username)
def is_api_request(request):
''' check whether a request is asking for html or data '''
return 'json' in request.headers.get('Accept') or \
request.path[-5:] == '.json'
def is_bookworm_request(request):
''' check if the request is coming from another bookworm instance '''
user_agent = request.headers.get('User-Agent')
if user_agent is None or \
re.search(regex.bookwyrm_user_agent, user_agent) is None:
return False
return True
def server_error_page(request):
''' 500 errors '''
return TemplateResponse(
request, 'error.html', {'title': 'Oops!'}, status=500)
def not_found_page(request, _):
''' 404s '''
return TemplateResponse(
request, 'notfound.html', {'title': 'Not found'}, status=404)
@require_GET
def home(request):
''' this is the same as the feed on the home tab '''
if request.user.is_authenticated:
return home_tab(request, 'home')
return discover_page(request)
@login_required
@require_GET
def home_tab(request, tab):
''' user's homepage with activity feed '''
try:
page = int(request.GET.get('page', 1))
except ValueError:
page = 1
suggested_books = get_suggested_books(request.user)
if tab == 'home':
activities = get_activity_feed(
request.user, ['public', 'unlisted', 'followers'],
following_only=True)
elif tab == 'local':
activities = get_activity_feed(
request.user, ['public', 'followers'], local_only=True)
else:
activities = get_activity_feed(
request.user, ['public', 'followers'])
paginated = Paginator(activities, PAGE_LENGTH)
activity_page = paginated.page(page)
prev_page = next_page = None
if activity_page.has_next():
next_page = '/%s/?page=%d#feed' % \
(tab, activity_page.next_page_number())
if activity_page.has_previous():
prev_page = '/%s/?page=%d#feed' % \
(tab, activity_page.previous_page_number())
data = {
'title': 'Updates Feed',
'user': request.user,
'suggested_books': suggested_books,
'activities': activity_page.object_list,
'tab': tab,
'next': next_page,
'prev': prev_page,
}
return TemplateResponse(request, 'feed.html', data)
def get_suggested_books(user, max_books=5):
''' helper to get a user's recent books '''
book_count = 0
preset_shelves = [
('reading', max_books), ('read', 2), ('to-read', max_books)
]
suggested_books = []
for (preset, shelf_max) in preset_shelves:
limit = shelf_max if shelf_max < (max_books - book_count) \
else max_books - book_count
shelf = user.shelf_set.get(identifier=preset)
shelf_books = shelf.shelfbook_set.order_by(
'-updated_date'
).all()[:limit]
if not shelf_books:
continue
shelf_preview = {
'name': shelf.name,
'books': [s.book for s in shelf_books]
}
suggested_books.append(shelf_preview)
book_count += len(shelf_preview['books'])
return suggested_books
@require_GET
def discover_page(request):
''' tiled book activity page '''
books = models.Edition.objects.filter(
review__published_date__isnull=False,
review__user__local=True,
review__privacy__in=['public', 'unlisted'],
).exclude(
cover__exact=''
).annotate(
Max('review__published_date')
).order_by('-review__published_date__max')[:6]
ratings = {}
for book in books:
reviews = models.Review.objects.filter(
book__in=book.parent_work.editions.all()
)
reviews = get_activity_feed(
request.user, ['public', 'unlisted'], queryset=reviews)
ratings[book.id] = reviews.aggregate(Avg('rating'))['rating__avg']
data = {
'title': 'Discover',
'register_form': forms.RegisterForm(),
'books': list(set(books)),
'ratings': ratings
}
return TemplateResponse(request, 'discover.html', data)
@login_required
@require_GET
def direct_messages_page(request, page=1):
''' like a feed but for dms only '''
activities = get_activity_feed(request.user, 'direct')
paginated = Paginator(activities, PAGE_LENGTH)
activity_page = paginated.page(page)
prev_page = next_page = None
if activity_page.has_next():
next_page = '/direct-message/?page=%d#feed' % \
activity_page.next_page_number()
if activity_page.has_previous():
prev_page = '/direct-messages/?page=%d#feed' % \
activity_page.previous_page_number()
data = {
'title': 'Direct Messages',
'user': request.user,
'activities': activity_page.object_list,
'next': next_page,
'prev': prev_page,
}
return TemplateResponse(request, 'direct_messages.html', data)
def get_activity_feed(
user, privacy, local_only=False, following_only=False,
queryset=models.Status.objects):
''' get a filtered queryset of statuses '''
privacy = privacy if isinstance(privacy, list) else [privacy]
# if we're looking at Status, we need this. We don't if it's Comment
if hasattr(queryset, 'select_subclasses'):
queryset = queryset.select_subclasses()
# exclude deleted
queryset = queryset.exclude(deleted=True).order_by('-published_date')
# 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']]
# filter to only privided privacy levels
queryset = queryset.filter(privacy__in=privacy)
# only include statuses the user follows
if following_only:
queryset = queryset.exclude(
~Q(# remove everythign except
Q(user__in=user.following.all()) | # user follwoing
Q(user=user) |# is self
Q(mention_users=user)# mentions user
),
)
# exclude followers-only statuses the user doesn't follow
elif 'followers' in privacy:
queryset = queryset.exclude(
~Q(# user isn't following and it isn't their own status
Q(user__in=user.following.all()) | Q(user=user)
),
privacy='followers' # and the status is followers only
)
# exclude direct messages not intended for the user
if 'direct' in privacy:
queryset = queryset.exclude(
~Q(
Q(user=user) | Q(mention_users=user)
), privacy='direct'
)
# filter for only local status
if local_only:
queryset = queryset.filter(user__local=True)
# remove statuses that have boosts in the same queryset
try:
queryset = queryset.filter(~Q(boosters__in=queryset))
except ValueError:
pass
return queryset
@require_GET
def search(request):
''' that search bar up top '''
query = request.GET.get('q')
min_confidence = request.GET.get('min_confidence', 0.1)
if is_api_request(request):
# only return local book results via json so we don't cause a cascade
book_results = connector_manager.local_search(
query, min_confidence=min_confidence)
return JsonResponse([r.json() for r in book_results], safe=False)
# use webfinger for mastodon style account@domain.com username
if re.match(r'\B%s' % regex.full_username, query):
outgoing.handle_remote_webfinger(query)
# do a local user search
user_results = models.User.objects.annotate(
similarity=Greatest(
TrigramSimilarity('username', query),
TrigramSimilarity('localname', query),
)
).filter(
similarity__gt=0.5,
).order_by('-similarity')[:10]
book_results = connector_manager.search(
query, min_confidence=min_confidence)
data = {
'title': 'Search Results',
'book_results': book_results,
'user_results': user_results,
'query': query,
}
return TemplateResponse(request, 'search_results.html', data)
@login_required
@require_GET
def import_page(request):
''' import history from goodreads '''
return TemplateResponse(request, 'import.html', {
'title': 'Import Books',
'import_form': forms.ImportForm(),
'jobs': models.ImportJob.
objects.filter(user=request.user).order_by('-created_date'),
})
@login_required
@require_GET
def import_status(request, job_id):
''' status of an import job '''
job = models.ImportJob.objects.get(id=job_id)
if job.user != request.user:
raise PermissionDenied
task = app.AsyncResult(job.task_id)
items = job.items.order_by('index').all()
failed_items = [i for i in items if i.fail_reason]
items = [i for i in items if not i.fail_reason]
return TemplateResponse(request, 'import_status.html', {
'title': 'Import Status',
'job': job,
'items': items,
'failed_items': failed_items,
'task': task
})
@require_GET
def login_page(request):
''' authentication '''
if request.user.is_authenticated:
return redirect('/')
# send user to the login page
data = {
'title': 'Login',
'login_form': forms.LoginForm(),
'register_form': forms.RegisterForm(),
}
return TemplateResponse(request, 'login.html', data)
@require_GET
def about_page(request):
''' more information about the instance '''
data = {
'title': 'About',
}
return TemplateResponse(request, 'about.html', data)
@require_GET
def password_reset_request(request):
''' invite management page '''
return TemplateResponse(
request,
'password_reset_request.html',
{'title': 'Reset Password'}
)
@require_GET
def password_reset(request, code):
''' endpoint for sending invites '''
if request.user.is_authenticated:
return redirect('/')
try:
reset_code = models.PasswordReset.objects.get(code=code)
if not reset_code.valid():
raise PermissionDenied
except models.PasswordReset.DoesNotExist:
raise PermissionDenied
return TemplateResponse(
request,
'password_reset.html',
{'title': 'Reset Password', 'code': reset_code.code}
)
@require_GET
def invite_page(request, code):
''' endpoint for sending invites '''
if request.user.is_authenticated:
return redirect('/')
invite = get_object_or_404(models.SiteInvite, code=code)
data = {
'title': 'Join',
'register_form': forms.RegisterForm(),
'invite': invite,
'valid': invite.valid() if invite else True,
}
return TemplateResponse(request, 'invite.html', data)
@login_required
@permission_required('bookwyrm.create_invites', raise_exception=True)
@require_GET
def manage_invites(request):
''' invite management page '''
data = {
'title': 'Invitations',
'invites': models.SiteInvite.objects.filter(
user=request.user).order_by('-created_date'),
'form': forms.CreateInviteForm(),
}
return TemplateResponse(request, 'manage_invites.html', data)
@login_required
@require_GET
def notifications_page(request):
''' list notitications '''
notifications = request.user.notification_set.all() \
.order_by('-created_date')
unread = [n.id for n in notifications.filter(read=False)]
data = {
'title': 'Notifications',
'notifications': notifications,
'unread': unread,
}
notifications.update(read=True)
return TemplateResponse(request, 'notifications.html', data)
@csrf_exempt
@require_GET
def user_page(request, username):
''' profile page for a user '''
try:
user = get_user_from_username(username)
except models.User.DoesNotExist:
return HttpResponseNotFound()
if is_api_request(request):
# we have a json request
return ActivitypubResponse(user.to_activity())
# otherwise we're at a UI view
try:
page = int(request.GET.get('page', 1))
except ValueError:
page = 1
shelf_preview = []
# only show other shelves that should be visible
shelves = user.shelf_set
is_self = request.user.id == user.id
if not is_self:
follower = user.followers.filter(id=request.user.id).exists()
if follower:
shelves = shelves.filter(privacy__in=['public', 'followers'])
else:
shelves = shelves.filter(privacy='public')
for user_shelf in shelves.all():
if not user_shelf.books.count():
continue
shelf_preview.append({
'name': user_shelf.name,
'local_path': user_shelf.local_path,
'books': user_shelf.books.all()[:3],
'size': user_shelf.books.count(),
})
if len(shelf_preview) > 2:
break
# user's posts
activities = get_activity_feed(
request.user,
['public', 'unlisted', 'followers'],
queryset=models.Status.objects.filter(user=user)
)
paginated = Paginator(activities, PAGE_LENGTH)
activity_page = paginated.page(page)
prev_page = next_page = None
if activity_page.has_next():
next_page = '/user/%s/?page=%d' % \
(username, activity_page.next_page_number())
if activity_page.has_previous():
prev_page = '/user/%s/?page=%d' % \
(username, activity_page.previous_page_number())
data = {
'title': user.name,
'user': user,
'is_self': is_self,
'shelves': shelf_preview,
'shelf_count': shelves.count(),
'activities': activity_page.object_list,
'next': next_page,
'prev': prev_page,
}
return TemplateResponse(request, 'user.html', data)
@csrf_exempt
@require_GET
def followers_page(request, username):
''' list of followers '''
try:
user = get_user_from_username(username)
except models.User.DoesNotExist:
return HttpResponseNotFound()
if is_api_request(request):
return ActivitypubResponse(user.to_followers_activity(**request.GET))
data = {
'title': '%s: followers' % user.name,
'user': user,
'is_self': request.user.id == user.id,
'followers': user.followers.all(),
}
return TemplateResponse(request, 'followers.html', data)
@csrf_exempt
@require_GET
def following_page(request, username):
''' list of followers '''
try:
user = get_user_from_username(username)
except models.User.DoesNotExist:
return HttpResponseNotFound()
if is_api_request(request):
return ActivitypubResponse(user.to_following_activity(**request.GET))
data = {
'title': '%s: following' % user.name,
'user': user,
'is_self': request.user.id == user.id,
'following': user.following.all(),
}
return TemplateResponse(request, 'following.html', data)
@csrf_exempt
@require_GET
def status_page(request, username, status_id):
''' display a particular status (and replies, etc) '''
try:
user = get_user_from_username(username)
status = models.Status.objects.select_subclasses().get(id=status_id)
except ValueError:
return HttpResponseNotFound()
# the url should have the poster's username in it
if user != status.user:
return HttpResponseNotFound()
# make sure the user is authorized to see the status
if not status_visible_to_user(request.user, status):
return HttpResponseNotFound()
if is_api_request(request):
return ActivitypubResponse(
status.to_activity(pure=not is_bookworm_request(request)))
data = {
'title': 'Status by %s' % user.username,
'status': status,
}
return TemplateResponse(request, 'status.html', data)
def status_visible_to_user(viewer, status):
''' is a user authorized to view a status? '''
if viewer == status.user or status.privacy in ['public', 'unlisted']:
return True
if status.privacy == 'followers' and \
status.user.followers.filter(id=viewer.id).first():
return True
if status.privacy == 'direct' and \
status.mention_users.filter(id=viewer.id).first():
return True
return False
@csrf_exempt
@require_GET
def replies_page(request, username, status_id):
''' ordered collection of replies to a status '''
if not is_api_request(request):
return status_page(request, username, status_id)
status = models.Status.objects.get(id=status_id)
if status.user.localname != username:
return HttpResponseNotFound()
return ActivitypubResponse(status.to_replies(**request.GET))
@login_required
@require_GET
def edit_profile_page(request):
''' profile page for a user '''
user = request.user
form = forms.EditUserForm(instance=request.user)
data = {
'title': 'Edit profile',
'form': form,
'user': user,
}
return TemplateResponse(request, 'edit_user.html', data)
@require_GET
def book_page(request, book_id):
''' info about a book '''
try:
page = int(request.GET.get('page', 1))
except ValueError:
page = 1
try:
book = models.Book.objects.select_subclasses().get(id=book_id)
except models.Book.DoesNotExist:
return HttpResponseNotFound()
if is_api_request(request):
return ActivitypubResponse(book.to_activity())
if isinstance(book, models.Work):
book = book.get_default_edition()
if not book:
return HttpResponseNotFound()
work = book.parent_work
if not work:
return HttpResponseNotFound()
reviews = models.Review.objects.filter(
book__in=work.editions.all(),
)
# all reviews for the book
reviews = get_activity_feed(
request.user,
['public', 'unlisted', 'followers', 'direct'],
queryset=reviews
)
# the reviews to show
paginated = Paginator(reviews.exclude(
Q(content__isnull=True) | Q(content='')
), PAGE_LENGTH)
reviews_page = paginated.page(page)
prev_page = next_page = None
if reviews_page.has_next():
next_page = '/book/%d/?page=%d' % \
(book_id, reviews_page.next_page_number())
if reviews_page.has_previous():
prev_page = '/book/%s/?page=%d' % \
(book_id, reviews_page.previous_page_number())
user_tags = readthroughs = user_shelves = other_edition_shelves = []
if request.user.is_authenticated:
user_tags = models.UserTag.objects.filter(
book=book, user=request.user
).values_list('tag__identifier', flat=True)
readthroughs = models.ReadThrough.objects.filter(
user=request.user,
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
)
other_edition_shelves = models.ShelfBook.objects.filter(
~Q(book=book),
added_by=request.user,
book__parent_work=book.parent_work,
)
data = {
'title': book.title,
'book': book,
'reviews': reviews_page,
'review_count': reviews.count(),
'ratings': reviews.filter(Q(content__isnull=True) | Q(content='')),
'rating': reviews.aggregate(Avg('rating'))['rating__avg'],
'tags': models.UserTag.objects.filter(book=book),
'user_tags': user_tags,
'user_shelves': user_shelves,
'other_edition_shelves': other_edition_shelves,
'readthroughs': readthroughs,
'show_progress': ('showprogress' in request.GET),
'path': '/book/%s' % book_id,
'next': next_page,
'prev': prev_page,
}
return TemplateResponse(request, 'book.html', data)
@login_required
@permission_required('bookwyrm.edit_book', raise_exception=True)
@require_GET
def edit_book_page(request, book_id):
''' info about a book '''
book = get_edition(book_id)
if not book.description:
book.description = book.parent_work.description
data = {
'title': 'Edit Book',
'book': book,
'form': forms.EditionForm(instance=book)
}
return TemplateResponse(request, 'edit_book.html', data)
@login_required
@permission_required('bookwyrm.edit_book', raise_exception=True)
@require_GET
def edit_author_page(request, author_id):
''' info about a book '''
author = get_object_or_404(models.Author, id=author_id)
data = {
'title': 'Edit Author',
'author': author,
'form': forms.AuthorForm(instance=author)
}
return TemplateResponse(request, 'edit_author.html', data)
@require_GET
def editions_page(request, book_id):
''' list of editions of a book '''
work = get_object_or_404(models.Work, id=book_id)
if is_api_request(request):
return ActivitypubResponse(work.to_edition_list(**request.GET))
data = {
'title': 'Editions of %s' % work.title,
'editions': work.editions.order_by('-edition_rank').all(),
'work': work,
}
return TemplateResponse(request, 'editions.html', data)
@require_GET
def author_page(request, author_id):
''' landing page for an author '''
author = get_object_or_404(models.Author, id=author_id)
if is_api_request(request):
return ActivitypubResponse(author.to_activity())
books = models.Work.objects.filter(
Q(authors=author) | Q(editions__authors=author)).distinct()
data = {
'title': author.name,
'author': author,
'books': [b.get_default_edition() for b in books],
}
return TemplateResponse(request, 'author.html', data)
@require_GET
def tag_page(request, tag_id):
''' books related to a tag '''
tag_obj = models.Tag.objects.filter(identifier=tag_id).first()
if not tag_obj:
return HttpResponseNotFound()
if is_api_request(request):
return ActivitypubResponse(tag_obj.to_activity(**request.GET))
books = models.Edition.objects.filter(
usertag__tag__identifier=tag_id
).distinct()
data = {
'title': tag_obj.name,
'books': books,
'tag': tag_obj,
}
return TemplateResponse(request, 'tag.html', data)
@csrf_exempt
@require_GET
def user_shelves_page(request, username):
''' list of followers '''
return shelf_page(request, username, None)
@require_GET
def shelf_page(request, username, shelf_identifier):
''' display a shelf '''
try:
user = get_user_from_username(username)
except models.User.DoesNotExist:
return HttpResponseNotFound()
if shelf_identifier:
shelf = user.shelf_set.get(identifier=shelf_identifier)
else:
shelf = user.shelf_set.first()
is_self = request.user == user
shelves = user.shelf_set
if not is_self:
follower = user.followers.filter(id=request.user.id).exists()
# make sure the user has permission to view the shelf
if shelf.privacy == 'direct' or \
(shelf.privacy == 'followers' and not follower):
return HttpResponseNotFound()
# only show other shelves that should be visible
if follower:
shelves = shelves.filter(privacy__in=['public', 'followers'])
else:
shelves = shelves.filter(privacy='public')
if is_api_request(request):
return ActivitypubResponse(shelf.to_activity(**request.GET))
books = models.ShelfBook.objects.filter(
added_by=user, shelf=shelf
).order_by('-updated_date').all()
data = {
'title': '%s\'s %s shelf' % (user.display_name, shelf.name),
'user': user,
'is_self': is_self,
'shelves': shelves.all(),
'shelf': shelf,
'books': [b.book for b in books],
}
return TemplateResponse(request, 'shelf.html', data)

View file

@ -0,0 +1,26 @@
''' make sure all our nice views are available '''
from .authentication import Login, Register, Logout
from .author import Author, EditAuthor
from .books import Book, EditBook, Editions
from .books import upload_cover, add_description, switch_edition, resolve_book
from .direct_message import DirectMessage
from .error import not_found_page, server_error_page
from .follow import follow, unfollow
from .follow import accept_follow_request, delete_follow_request, handle_accept
from .goal import Goal
from .import_data import Import, ImportStatus
from .interaction import Favorite, Unfavorite, Boost, Unboost
from .invite import ManageInvites, Invite
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 .password import PasswordResetRequest, PasswordReset, ChangePassword
from .tag import Tag, AddTag, RemoveTag
from .search import Search
from .shelf import Shelf
from .shelf import user_shelves_page, create_shelf, delete_shelf
from .shelf import shelve, unshelve
from .status import Status, Replies, CreateStatus, DeleteStatus
from .user import User, EditUser, Followers, Following

View file

@ -0,0 +1,113 @@
''' class views for login/register views '''
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from bookwyrm import forms, models
from bookwyrm.settings import DOMAIN
# pylint: disable= no-self-use
class Login(View):
''' authenticate an existing user '''
def get(self, request):
''' login page '''
if request.user.is_authenticated:
return redirect('/')
# sene user to the login page
data = {
'title': 'Login',
'login_form': forms.LoginForm(),
'register_form': forms.RegisterForm(),
}
return TemplateResponse(request, 'login.html', data)
def post(self, request):
''' authentication action '''
login_form = forms.LoginForm(request.POST)
localname = login_form.data['localname']
username = '%s@%s' % (localname, DOMAIN)
password = login_form.data['password']
user = authenticate(request, username=username, password=password)
if user is not None:
# successful login
login(request, user)
user.last_active_date = timezone.now()
return redirect(request.GET.get('next', '/'))
# login errors
login_form.non_field_errors = 'Username or password are incorrect'
register_form = forms.RegisterForm()
data = {
'login_form': login_form,
'register_form': register_form
}
return TemplateResponse(request, 'login.html', data)
class Register(View):
''' register a user '''
def post(self, request):
''' join the server '''
if not models.SiteSettings.get().allow_registration:
invite_code = request.POST.get('invite_code')
if not invite_code:
raise PermissionDenied
invite = get_object_or_404(models.SiteInvite, code=invite_code)
if not invite.valid():
raise PermissionDenied
else:
invite = None
form = forms.RegisterForm(request.POST)
errors = False
if not form.is_valid():
errors = True
localname = form.data['localname'].strip()
email = form.data['email']
password = form.data['password']
# check localname and email uniqueness
if models.User.objects.filter(localname=localname).first():
form.errors['localname'] = [
'User with this username already exists']
errors = True
if errors:
data = {
'login_form': forms.LoginForm(),
'register_form': form,
'invite': invite,
'valid': invite.valid() if invite else True,
}
if invite:
return TemplateResponse(request, 'invite.html', data)
return TemplateResponse(request, 'login.html', data)
username = '%s@%s' % (localname, DOMAIN)
user = models.User.objects.create_user(
username, email, password, localname=localname, local=True)
if invite:
invite.times_used += 1
invite.save()
login(request, user)
return redirect('/')
@method_decorator(login_required, name='dispatch')
class Logout(View):
''' log out '''
def get(self, request):
''' done with this place! outa here! '''
logout(request)
return redirect('/')

66
bookwyrm/views/author.py Normal file
View file

@ -0,0 +1,66 @@
''' the good people stuff! the authors! '''
from django.contrib.auth.decorators import login_required, permission_required
from django.db.models import Q
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 bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.broadcast import broadcast
from .helpers import is_api_request
# pylint: disable= no-self-use
class Author(View):
''' this person wrote a book '''
def get(self, request, author_id):
''' landing page for an author '''
author = get_object_or_404(models.Author, id=author_id)
if is_api_request(request):
return ActivitypubResponse(author.to_activity())
books = models.Work.objects.filter(
Q(authors=author) | Q(editions__authors=author)).distinct()
data = {
'title': author.name,
'author': author,
'books': [b.get_default_edition() for b in books],
}
return TemplateResponse(request, 'author.html', data)
@method_decorator(login_required, name='dispatch')
@method_decorator(
permission_required('bookwyrm.edit_book', raise_exception=True),
name='dispatch')
class EditAuthor(View):
''' edit author info '''
def get(self, request, author_id):
''' info about a book '''
author = get_object_or_404(models.Author, id=author_id)
data = {
'title': 'Edit Author',
'author': author,
'form': forms.AuthorForm(instance=author)
}
return TemplateResponse(request, 'edit_author.html', data)
def post(self, request, author_id):
''' edit a author cool '''
author = get_object_or_404(models.Author, id=author_id)
form = forms.AuthorForm(request.POST, request.FILES, instance=author)
if not form.is_valid():
data = {
'title': 'Edit Author',
'author': author,
'form': form
}
return TemplateResponse(request, 'edit_author.html', data)
author = form.save()
broadcast(request.user, author.to_update_activity(request.user))
return redirect('/author/%s' % author.id)

233
bookwyrm/views/books.py Normal file
View file

@ -0,0 +1,233 @@
''' the good stuff! the books! '''
from django.core.paginator import Paginator
from django.contrib.auth.decorators import login_required, permission_required
from django.db import transaction
from django.db.models import Avg, Q
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 forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.broadcast import broadcast
from bookwyrm.connectors import connector_manager
from bookwyrm.settings import PAGE_LENGTH
from .helpers import is_api_request, get_activity_feed, get_edition
# pylint: disable= no-self-use
class Book(View):
''' a book! this is the stuff '''
def get(self, request, book_id):
''' info about a book '''
try:
page = int(request.GET.get('page', 1))
except ValueError:
page = 1
try:
book = models.Book.objects.select_subclasses().get(id=book_id)
except models.Book.DoesNotExist:
return HttpResponseNotFound()
if is_api_request(request):
return ActivitypubResponse(book.to_activity())
if isinstance(book, models.Work):
book = book.get_default_edition()
if not book:
return HttpResponseNotFound()
work = book.parent_work
if not work:
return HttpResponseNotFound()
reviews = models.Review.objects.filter(
book__in=work.editions.all(),
)
# all reviews for the book
reviews = get_activity_feed(
request.user,
['public', 'unlisted', 'followers', 'direct'],
queryset=reviews
)
# the reviews to show
paginated = Paginator(reviews.exclude(
Q(content__isnull=True) | Q(content='')
), PAGE_LENGTH)
reviews_page = paginated.page(page)
user_tags = readthroughs = user_shelves = other_edition_shelves = []
if request.user.is_authenticated:
user_tags = models.UserTag.objects.filter(
book=book, user=request.user
).values_list('tag__identifier', flat=True)
readthroughs = models.ReadThrough.objects.filter(
user=request.user,
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
)
other_edition_shelves = models.ShelfBook.objects.filter(
~Q(book=book),
added_by=request.user,
book__parent_work=book.parent_work,
)
data = {
'title': book.title,
'book': book,
'reviews': reviews_page,
'review_count': reviews.count(),
'ratings': reviews.filter(Q(content__isnull=True) | Q(content='')),
'rating': reviews.aggregate(Avg('rating'))['rating__avg'],
'tags': models.UserTag.objects.filter(book=book),
'user_tags': user_tags,
'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)
@method_decorator(login_required, name='dispatch')
@method_decorator(
permission_required('bookwyrm.edit_book', raise_exception=True),
name='dispatch')
class EditBook(View):
''' edit a book '''
def get(self, request, book_id):
''' info about a book '''
book = get_edition(book_id)
if not book.description:
book.description = book.parent_work.description
data = {
'title': 'Edit Book',
'book': book,
'form': forms.EditionForm(instance=book)
}
return TemplateResponse(request, 'edit_book.html', data)
def post(self, request, book_id):
''' edit a book cool '''
book = get_object_or_404(models.Edition, id=book_id)
form = forms.EditionForm(request.POST, request.FILES, instance=book)
if not form.is_valid():
data = {
'title': 'Edit Book',
'book': book,
'form': form
}
return TemplateResponse(request, 'edit_book.html', data)
book = form.save()
broadcast(request.user, book.to_update_activity(request.user))
return redirect('/book/%s' % book.id)
class Editions(View):
''' list of editions '''
def get(self, request, book_id):
''' list of editions of a book '''
work = get_object_or_404(models.Work, id=book_id)
if is_api_request(request):
return ActivitypubResponse(work.to_edition_list(**request.GET))
data = {
'title': 'Editions of %s' % work.title,
'editions': work.editions.order_by('-edition_rank').all(),
'work': work,
}
return TemplateResponse(request, 'editions.html', data)
@login_required
@require_POST
def upload_cover(request, book_id):
''' upload a new cover '''
book = get_object_or_404(models.Edition, id=book_id)
form = forms.CoverForm(request.POST, request.FILES, instance=book)
if not form.is_valid():
return redirect('/book/%d' % book.id)
book.cover = form.files['cover']
book.save()
broadcast(request.user, book.to_update_activity(request.user))
return redirect('/book/%s' % book.id)
@login_required
@require_POST
@permission_required('bookwyrm.edit_book', raise_exception=True)
def add_description(request, book_id):
''' upload a new cover '''
if not request.method == 'POST':
return redirect('/')
book = get_object_or_404(models.Edition, id=book_id)
description = request.POST.get('description')
book.description = description
book.save()
broadcast(request.user, book.to_update_activity(request.user))
return redirect('/book/%s' % book.id)
@require_POST
def resolve_book(request):
''' figure out the local path to a book from a remote_id '''
remote_id = request.POST.get('remote_id')
connector = connector_manager.get_or_create_connector(remote_id)
book = connector.get_or_create_book(remote_id)
return redirect('/book/%d' % book.id)
@login_required
@require_POST
@transaction.atomic
def switch_edition(request):
''' switch your copy of a book to a different edition '''
edition_id = request.POST.get('edition')
new_edition = get_object_or_404(models.Edition, id=edition_id)
shelfbooks = models.ShelfBook.objects.filter(
book__parent_work=new_edition.parent_work,
shelf__user=request.user
)
for shelfbook in shelfbooks.all():
broadcast(request.user, shelfbook.to_remove_activity(request.user))
shelfbook.book = new_edition
shelfbook.save()
broadcast(request.user, shelfbook.to_add_activity(request.user))
readthroughs = models.ReadThrough.objects.filter(
book__parent_work=new_edition.parent_work,
user=request.user
)
for readthrough in readthroughs.all():
readthrough.book = new_edition
readthrough.save()
return redirect('/book/%d' % new_edition.id)

View file

@ -0,0 +1,26 @@
''' non-interactive pages '''
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views import View
from bookwyrm.settings import PAGE_LENGTH
from .helpers import get_activity_feed
# pylint: disable= no-self-use
@method_decorator(login_required, name='dispatch')
class DirectMessage(View):
''' dm view '''
def get(self, request, page=1):
''' like a feed but for dms only '''
activities = get_activity_feed(request.user, 'direct')
paginated = Paginator(activities, PAGE_LENGTH)
activity_page = paginated.page(page)
data = {
'title': 'Direct Messages',
'user': request.user,
'activities': activity_page,
}
return TemplateResponse(request, 'direct_messages.html', data)

13
bookwyrm/views/error.py Normal file
View file

@ -0,0 +1,13 @@
''' something has gone amiss '''
from django.template.response import TemplateResponse
def server_error_page(request):
''' 500 errors '''
return TemplateResponse(
request, 'error.html', {'title': 'Oops!'}, status=500)
def not_found_page(request, _):
''' 404s '''
return TemplateResponse(
request, 'notfound.html', {'title': 'Not found'}, status=404)

113
bookwyrm/views/follow.py Normal file
View file

@ -0,0 +1,113 @@
''' views for actions you can take in the application '''
from django.db import transaction
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseBadRequest
from django.shortcuts import redirect
from django.views.decorators.http import require_POST
from bookwyrm import models
from bookwyrm.broadcast import broadcast
from .helpers import get_user_from_username
@login_required
@require_POST
def follow(request):
''' follow another user, here or abroad '''
username = request.POST['user']
try:
to_follow = get_user_from_username(username)
except models.User.DoesNotExist:
return HttpResponseBadRequest()
relationship, _ = models.UserFollowRequest.objects.get_or_create(
user_subject=request.user,
user_object=to_follow,
)
activity = relationship.to_activity()
broadcast(
request.user, activity, privacy='direct', direct_recipients=[to_follow])
return redirect(to_follow.local_path)
@login_required
@require_POST
def unfollow(request):
''' unfollow a user '''
username = request.POST['user']
try:
to_unfollow = get_user_from_username(username)
except models.User.DoesNotExist:
return HttpResponseBadRequest()
relationship = models.UserFollows.objects.get(
user_subject=request.user,
user_object=to_unfollow
)
activity = relationship.to_undo_activity(request.user)
broadcast(
request.user, activity,
privacy='direct', direct_recipients=[to_unfollow])
to_unfollow.followers.remove(request.user)
return redirect(to_unfollow.local_path)
@login_required
@require_POST
def accept_follow_request(request):
''' a user accepts a follow request '''
username = request.POST['user']
try:
requester = get_user_from_username(username)
except models.User.DoesNotExist:
return HttpResponseBadRequest()
try:
follow_request = models.UserFollowRequest.objects.get(
user_subject=requester,
user_object=request.user
)
except models.UserFollowRequest.DoesNotExist:
# Request already dealt with.
return redirect(request.user.local_path)
handle_accept(follow_request)
return redirect(request.user.local_path)
def handle_accept(follow_request):
''' send an acceptance message to a follow request '''
user = follow_request.user_subject
to_follow = follow_request.user_object
with transaction.atomic():
relationship = models.UserFollows.from_request(follow_request)
follow_request.delete()
relationship.save()
activity = relationship.to_accept_activity()
broadcast(to_follow, activity, privacy='direct', direct_recipients=[user])
@login_required
@require_POST
def delete_follow_request(request):
''' a user rejects a follow request '''
username = request.POST['user']
try:
requester = get_user_from_username(username)
except models.User.DoesNotExist:
return HttpResponseBadRequest()
try:
follow_request = models.UserFollowRequest.objects.get(
user_subject=requester,
user_object=request.user
)
except models.UserFollowRequest.DoesNotExist:
return HttpResponseBadRequest()
activity = follow_request.to_reject_activity()
follow_request.delete()
broadcast(
request.user, activity, privacy='direct', direct_recipients=[requester])
return redirect('/user/%s' % request.user.localname)

79
bookwyrm/views/goal.py Normal file
View file

@ -0,0 +1,79 @@
''' non-interactive pages '''
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseNotFound
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views import View
from bookwyrm import forms, models
from bookwyrm.broadcast import broadcast
from bookwyrm.status import create_generated_note
from .helpers import get_user_from_username, object_visible_to_user
# pylint: disable= no-self-use
@method_decorator(login_required, name='dispatch')
class Goal(View):
''' track books for the year '''
def get(self, request, username, year):
''' reading goal page '''
user = get_user_from_username(username)
year = int(year)
goal = models.AnnualGoal.objects.filter(
year=year, user=user
).first()
if not goal and user != request.user:
return HttpResponseNotFound()
if goal and not object_visible_to_user(request.user, goal):
return HttpResponseNotFound()
data = {
'title': '%s\'s %d Reading' % (user.display_name, year),
'goal_form': forms.GoalForm(instance=goal),
'goal': goal,
'user': user,
'year': year,
}
return TemplateResponse(request, 'goal.html', data)
def post(self, request, username, year):
''' update or create an annual goal '''
user = get_user_from_username(username)
if user != request.user:
return HttpResponseNotFound()
year = int(year)
goal = models.AnnualGoal.objects.filter(
year=year, user=request.user
).first()
form = forms.GoalForm(request.POST, instance=goal)
if not form.is_valid():
data = {
'title': '%s\'s %d Reading' % (goal.user.display_name, year),
'goal_form': form,
'goal': goal,
'year': year,
}
return TemplateResponse(request, 'goal.html', data)
goal = form.save()
if request.POST.get('post-status'):
# create status, if appropraite
status = create_generated_note(
request.user,
'set a goal to read %d books in %d' % (goal.goal, goal.year),
privacy=goal.privacy
)
broadcast(
request.user,
status.to_create_activity(request.user),
software='bookwyrm')
# re-format the activity for non-bookwyrm servers
remote_activity = status.to_create_activity(request.user, pure=True)
broadcast(request.user, remote_activity, software='other')
return redirect(request.headers.get('Referer', '/'))

174
bookwyrm/views/helpers.py Normal file
View file

@ -0,0 +1,174 @@
''' helper functions used in various views '''
import re
from requests import HTTPError
from django.db.models import Q
from bookwyrm import activitypub, models
from bookwyrm.broadcast import broadcast
from bookwyrm.connectors import ConnectorException, get_data
from bookwyrm.status import create_generated_note
from bookwyrm.utils import regex
def get_user_from_username(username):
''' helper function to resolve a localname or a username to a user '''
# raises DoesNotExist if user is now found
try:
return models.User.objects.get(localname=username)
except models.User.DoesNotExist:
return models.User.objects.get(username=username)
def is_api_request(request):
''' check whether a request is asking for html or data '''
return 'json' in request.headers.get('Accept') or \
request.path[-5:] == '.json'
def is_bookworm_request(request):
''' check if the request is coming from another bookworm instance '''
user_agent = request.headers.get('User-Agent')
if user_agent is None or \
re.search(regex.bookwyrm_user_agent, user_agent) is None:
return False
return True
def object_visible_to_user(viewer, obj):
''' is a user authorized to view an object? '''
if viewer == obj.user or obj.privacy in ['public', 'unlisted']:
return True
if obj.privacy == 'followers' and \
obj.user.followers.filter(id=viewer.id).first():
return True
if isinstance(obj, models.Status):
if obj.privacy == 'direct' and \
obj.mention_users.filter(id=viewer.id).first():
return True
return False
def get_activity_feed(
user, privacy, local_only=False, following_only=False,
queryset=models.Status.objects):
''' get a filtered queryset of statuses '''
privacy = privacy if isinstance(privacy, list) else [privacy]
# if we're looking at Status, we need this. We don't if it's Comment
if hasattr(queryset, 'select_subclasses'):
queryset = queryset.select_subclasses()
# exclude deleted
queryset = queryset.exclude(deleted=True).order_by('-published_date')
# 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']]
# filter to only privided privacy levels
queryset = queryset.filter(privacy__in=privacy)
# only include statuses the user follows
if following_only:
queryset = queryset.exclude(
~Q(# remove everythign except
Q(user__in=user.following.all()) | # user follwoing
Q(user=user) |# is self
Q(mention_users=user)# mentions user
),
)
# exclude followers-only statuses the user doesn't follow
elif 'followers' in privacy:
queryset = queryset.exclude(
~Q(# user isn't following and it isn't their own status
Q(user__in=user.following.all()) | Q(user=user)
),
privacy='followers' # and the status is followers only
)
# exclude direct messages not intended for the user
if 'direct' in privacy:
queryset = queryset.exclude(
~Q(
Q(user=user) | Q(mention_users=user)
), privacy='direct'
)
# filter for only local status
if local_only:
queryset = queryset.filter(user__local=True)
# remove statuses that have boosts in the same queryset
try:
queryset = queryset.filter(~Q(boosters__in=queryset))
except ValueError:
pass
return queryset
def handle_remote_webfinger(query):
''' webfingerin' other servers '''
user = None
# usernames could be @user@domain or user@domain
if not query:
return None
if query[0] == '@':
query = query[1:]
try:
domain = query.split('@')[1]
except IndexError:
return None
try:
user = models.User.objects.get(username=query)
except models.User.DoesNotExist:
url = 'https://%s/.well-known/webfinger?resource=acct:%s' % \
(domain, query)
try:
data = get_data(url)
except (ConnectorException, HTTPError):
return None
for link in data.get('links'):
if link.get('rel') == 'self':
try:
user = activitypub.resolve_remote_id(
models.User, link['href']
)
except KeyError:
return None
return user
def get_edition(book_id):
''' look up a book in the db and return an edition '''
book = models.Book.objects.select_subclasses().get(id=book_id)
if isinstance(book, models.Work):
book = book.get_default_edition()
return book
def handle_reading_status(user, shelf, book, privacy):
''' post about a user reading a book '''
# tell the world about this cool thing that happened
try:
message = {
'to-read': 'wants to read',
'reading': 'started reading',
'read': 'finished reading'
}[shelf.identifier]
except KeyError:
# it's a non-standard shelf, don't worry about it
return
status = create_generated_note(
user,
message,
mention_books=[book],
privacy=privacy
)
status.save()
broadcast(user, status.to_create_activity(user))

View file

@ -0,0 +1,83 @@
''' import books from another app '''
from io import TextIOWrapper
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.http import HttpResponseBadRequest
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 bookwyrm import forms, goodreads_import, models
from bookwyrm.tasks import app
# pylint: disable= no-self-use
@method_decorator(login_required, name='dispatch')
class Import(View):
''' import view '''
def get(self, request):
''' load import page '''
return TemplateResponse(request, 'import.html', {
'title': 'Import Books',
'import_form': forms.ImportForm(),
'jobs': models.ImportJob.
objects.filter(user=request.user).order_by('-created_date'),
})
def post(self, request):
''' ingest a goodreads csv '''
form = forms.ImportForm(request.POST, request.FILES)
if form.is_valid():
include_reviews = request.POST.get('include_reviews') == 'on'
privacy = request.POST.get('privacy')
try:
job = goodreads_import.create_job(
request.user,
TextIOWrapper(
request.FILES['csv_file'],
encoding=request.encoding),
include_reviews,
privacy,
)
except (UnicodeDecodeError, ValueError):
return HttpResponseBadRequest('Not a valid csv file')
goodreads_import.start_import(job)
return redirect('/import-status/%d' % job.id)
return HttpResponseBadRequest()
@method_decorator(login_required, name='dispatch')
class ImportStatus(View):
''' status of an existing import '''
def get(self, request, job_id):
''' status of an import job '''
job = models.ImportJob.objects.get(id=job_id)
if job.user != request.user:
raise PermissionDenied
task = app.AsyncResult(job.task_id)
items = job.items.order_by('index').all()
failed_items = [i for i in items if i.fail_reason]
items = [i for i in items if not i.fail_reason]
return TemplateResponse(request, 'import_status.html', {
'title': 'Import Status',
'job': job,
'items': items,
'failed_items': failed_items,
'task': task
})
def post(self, request, job_id):
''' retry lines from an import '''
job = get_object_or_404(models.ImportJob, id=job_id)
items = []
for item in request.POST.getlist('import_item'):
items.append(get_object_or_404(models.ImportItem, id=item))
job = goodreads_import.create_retry_job(
request.user,
job,
items,
)
goodreads_import.start_import(job)
return redirect('/import-status/%d' % job.id)

View file

@ -0,0 +1,130 @@
''' boosts and favs '''
from django.db import IntegrityError
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.shortcuts import redirect
from django.utils.decorators import method_decorator
from django.views import View
from bookwyrm import models
from bookwyrm.broadcast import broadcast
from bookwyrm.status import create_notification
# pylint: disable= no-self-use
@method_decorator(login_required, name='dispatch')
class Favorite(View):
''' like a status '''
def post(self, request, status_id):
''' create a like '''
status = models.Status.objects.get(id=status_id)
try:
favorite = models.Favorite.objects.create(
status=status,
user=request.user
)
except IntegrityError:
# you already fav'ed that
return HttpResponseBadRequest()
fav_activity = favorite.to_activity()
broadcast(
request.user, fav_activity, privacy='direct',
direct_recipients=[status.user])
if status.user.local:
create_notification(
status.user,
'FAVORITE',
related_user=request.user,
related_status=status
)
return redirect(request.headers.get('Referer', '/'))
@method_decorator(login_required, name='dispatch')
class Unfavorite(View):
''' take back a fav '''
def post(self, request, status_id):
''' unlike a status '''
status = models.Status.objects.get(id=status_id)
try:
favorite = models.Favorite.objects.get(
status=status,
user=request.user
)
except models.Favorite.DoesNotExist:
# can't find that status, idk
return HttpResponseNotFound()
fav_activity = favorite.to_undo_activity(request.user)
favorite.delete()
broadcast(request.user, fav_activity, direct_recipients=[status.user])
# check for notification
if status.user.local:
notification = models.Notification.objects.filter(
user=status.user, related_user=request.user,
related_status=status, notification_type='FAVORITE'
).first()
if notification:
notification.delete()
return redirect(request.headers.get('Referer', '/'))
@method_decorator(login_required, name='dispatch')
class Boost(View):
''' boost a status '''
def post(self, request, status_id):
''' boost a status '''
status = models.Status.objects.get(id=status_id)
# is it boostable?
if not status.boostable:
return HttpResponseBadRequest()
if models.Boost.objects.filter(
boosted_status=status, user=request.user).exists():
# you already boosted that.
return redirect(request.headers.get('Referer', '/'))
boost = models.Boost.objects.create(
boosted_status=status,
privacy=status.privacy,
user=request.user,
)
boost_activity = boost.to_activity()
broadcast(request.user, boost_activity)
if status.user.local:
create_notification(
status.user,
'BOOST',
related_user=request.user,
related_status=status
)
return redirect(request.headers.get('Referer', '/'))
@method_decorator(login_required, name='dispatch')
class Unboost(View):
''' boost a status '''
def post(self, request, status_id):
''' boost a status '''
status = models.Status.objects.get(id=status_id)
boost = models.Boost.objects.filter(
boosted_status=status, user=request.user
).first()
activity = boost.to_undo_activity(request.user)
boost.delete()
broadcast(request.user, activity)
# delete related notification
if status.user.local:
notification = models.Notification.objects.filter(
user=status.user, related_user=request.user,
related_status=status, notification_type='BOOST'
).first()
if notification:
notification.delete()
return redirect(request.headers.get('Referer', '/'))

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

@ -0,0 +1,58 @@
''' invites when registration is closed '''
from django.contrib.auth.decorators import login_required, permission_required
from django.http import HttpResponseBadRequest
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 bookwyrm import forms, models
# pylint: disable= no-self-use
@method_decorator(login_required, name='dispatch')
@method_decorator(
permission_required('bookwyrm.create_invites', raise_exception=True),
name='dispatch')
class ManageInvites(View):
''' create invites '''
def get(self, request):
''' invite management page '''
data = {
'title': 'Invitations',
'invites': models.SiteInvite.objects.filter(
user=request.user).order_by('-created_date'),
'form': forms.CreateInviteForm(),
}
return TemplateResponse(request, 'manage_invites.html', data)
def post(self, request):
''' creates an invite database entry '''
form = forms.CreateInviteForm(request.POST)
if not form.is_valid():
return HttpResponseBadRequest("ERRORS : %s" % (form.errors,))
invite = form.save(commit=False)
invite.user = request.user
invite.save()
return redirect('/invite')
class Invite(View):
''' use an invite to register '''
def get(self, request, code):
''' endpoint for using an invites '''
if request.user.is_authenticated:
return redirect('/')
invite = get_object_or_404(models.SiteInvite, code=code)
data = {
'title': 'Join',
'register_form': forms.RegisterForm(),
'invite': invite,
'valid': invite.valid() if invite else True,
}
return TemplateResponse(request, 'invite.html', data)
# post handling is in views.authentication.Register

129
bookwyrm/views/landing.py Normal file
View file

@ -0,0 +1,129 @@
''' non-interactive pages '''
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Avg, Max
from django.template.response import TemplateResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from bookwyrm import forms, models
from bookwyrm.settings import PAGE_LENGTH
from .helpers import get_activity_feed
# pylint: disable= no-self-use
@method_decorator(login_required, name='dispatch')
class About(View):
''' create invites '''
def get(self, request):
''' more information about the instance '''
data = {
'title': 'About',
}
return TemplateResponse(request, 'about.html', data)
class Home(View):
''' discover page or home feed depending on auth '''
def get(self, request):
''' this is the same as the feed on the home tab '''
if request.user.is_authenticated:
feed_view = Feed.as_view()
return feed_view(request, 'home')
discover_view = Discover.as_view()
return discover_view(request)
class Discover(View):
''' preview of recently reviewed books '''
def get(self, request):
''' tiled book activity page '''
books = models.Edition.objects.filter(
review__published_date__isnull=False,
review__user__local=True,
review__privacy__in=['public', 'unlisted'],
).exclude(
cover__exact=''
).annotate(
Max('review__published_date')
).order_by('-review__published_date__max')[:6]
ratings = {}
for book in books:
reviews = models.Review.objects.filter(
book__in=book.parent_work.editions.all()
)
reviews = get_activity_feed(
request.user, ['public', 'unlisted'], queryset=reviews)
ratings[book.id] = reviews.aggregate(Avg('rating'))['rating__avg']
data = {
'title': 'Discover',
'register_form': forms.RegisterForm(),
'books': list(set(books)),
'ratings': ratings
}
return TemplateResponse(request, 'discover.html', data)
@method_decorator(login_required, name='dispatch')
class Feed(View):
''' activity stream '''
def get(self, request, tab):
''' user's homepage with activity feed '''
try:
page = int(request.GET.get('page', 1))
except ValueError:
page = 1
suggested_books = get_suggested_books(request.user)
if tab == 'home':
activities = get_activity_feed(
request.user, ['public', 'unlisted', 'followers'],
following_only=True)
elif tab == 'local':
activities = get_activity_feed(
request.user, ['public', 'followers'], local_only=True)
else:
activities = get_activity_feed(
request.user, ['public', 'followers'])
paginated = Paginator(activities, PAGE_LENGTH)
goal = models.AnnualGoal.objects.filter(
user=request.user, year=timezone.now().year
).first()
data = {
'title': 'Updates Feed',
'user': request.user,
'suggested_books': suggested_books,
'activities': paginated.page(page),
'tab': tab,
'goal': goal,
'goal_form': forms.GoalForm(),
}
return TemplateResponse(request, 'feed.html', data)
def get_suggested_books(user, max_books=5):
''' helper to get a user's recent books '''
book_count = 0
preset_shelves = [
('reading', max_books), ('read', 2), ('to-read', max_books)
]
suggested_books = []
for (preset, shelf_max) in preset_shelves:
limit = shelf_max if shelf_max < (max_books - book_count) \
else max_books - book_count
shelf = user.shelf_set.get(identifier=preset)
shelf_books = shelf.shelfbook_set.order_by(
'-updated_date'
).all()[:limit]
if not shelf_books:
continue
shelf_preview = {
'name': shelf.name,
'books': [s.book for s in shelf_books]
}
suggested_books.append(shelf_preview)
book_count += len(shelf_preview['books'])
return suggested_books

Some files were not shown because too many files have changed in this diff Show more