mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2025-04-26 20:14:43 +00:00
Merge branch 'main' into content-warnings
This commit is contained in:
commit
33c2f6c3b2
86 changed files with 1996 additions and 694 deletions
30
README.md
30
README.md
|
@ -60,8 +60,6 @@ cp .env.example .env
|
||||||
|
|
||||||
For most testing, you'll want to use ngrok. Remember to set the DOMAIN in `.env` to your ngrok domain.
|
For most testing, you'll want to use ngrok. Remember to set the DOMAIN in `.env` to your ngrok domain.
|
||||||
|
|
||||||
|
|
||||||
#### With Docker
|
|
||||||
You'll have to install the Docker and docker-compose. When you're ready, run:
|
You'll have to install the Docker and docker-compose. When you're ready, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
@ -70,33 +68,7 @@ docker-compose run --rm web python manage.py migrate
|
||||||
docker-compose run --rm web python manage.py initdb
|
docker-compose run --rm web python manage.py initdb
|
||||||
```
|
```
|
||||||
|
|
||||||
### Without Docker
|
Once the build is complete, you can access the instance at `localhost:1333`
|
||||||
You will need postgres installed and running on your computer.
|
|
||||||
|
|
||||||
``` bash
|
|
||||||
python3 -m venv venv
|
|
||||||
source venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
createdb bookwyrm
|
|
||||||
```
|
|
||||||
|
|
||||||
Create the psql user in `psql bookwyrm`:
|
|
||||||
``` psql
|
|
||||||
CREATE ROLE bookwyrm WITH LOGIN PASSWORD 'bookwyrm';
|
|
||||||
GRANT ALL PRIVILEGES ON DATABASE bookwyrm TO bookwyrm;
|
|
||||||
```
|
|
||||||
|
|
||||||
Initialize the database (or, more specifically, delete the existing database, run migrations, and start fresh):
|
|
||||||
``` bash
|
|
||||||
./rebuilddb.sh
|
|
||||||
```
|
|
||||||
This creates two users, `mouse` with password `password123` and `rat` with password `ratword`.
|
|
||||||
|
|
||||||
The application uses Celery and Redis for task management, which must also be installed and configured.
|
|
||||||
|
|
||||||
And go to the app at `localhost:8000`
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Installing in Production
|
## Installing in Production
|
||||||
|
|
||||||
|
|
|
@ -4,8 +4,6 @@ from json import JSONEncoder
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models.fields.files import ImageFileDescriptor
|
|
||||||
from django.db.models.fields.related_descriptors import ManyToManyDescriptor
|
|
||||||
|
|
||||||
from bookwyrm.connectors import ConnectorException, get_data
|
from bookwyrm.connectors import ConnectorException, get_data
|
||||||
from bookwyrm.tasks import app
|
from bookwyrm.tasks import app
|
||||||
|
@ -77,55 +75,30 @@ class ActivityObject:
|
||||||
)
|
)
|
||||||
|
|
||||||
# check for an existing instance, if we're not updating a known obj
|
# check for an existing instance, if we're not updating a known obj
|
||||||
if not instance:
|
instance = instance or model.find_existing(self.serialize()) or model()
|
||||||
instance = model.find_existing(self.serialize()) or model()
|
|
||||||
|
|
||||||
many_to_many_fields = {}
|
for field in instance.simple_fields:
|
||||||
image_fields = {}
|
field.set_field_from_activity(instance, self)
|
||||||
for field in model._meta.get_fields():
|
|
||||||
# check if it's an activitypub field
|
|
||||||
if not hasattr(field, 'field_to_activity'):
|
|
||||||
continue
|
|
||||||
# call the formatter associated with the model field class
|
|
||||||
value = field.field_from_activity(
|
|
||||||
getattr(self, field.get_activitypub_field())
|
|
||||||
)
|
|
||||||
if value is None or value is MISSING:
|
|
||||||
continue
|
|
||||||
|
|
||||||
model_field = getattr(model, field.name)
|
# image fields have to be set after other fields because they can save
|
||||||
|
# too early and jank up users
|
||||||
if isinstance(model_field, ManyToManyDescriptor):
|
for field in instance.image_fields:
|
||||||
# status mentions book/users for example, stash this for later
|
field.set_field_from_activity(instance, self, save=save)
|
||||||
many_to_many_fields[field.name] = value
|
|
||||||
elif isinstance(model_field, ImageFileDescriptor):
|
|
||||||
# image fields need custom handling
|
|
||||||
image_fields[field.name] = value
|
|
||||||
else:
|
|
||||||
# just a good old fashioned model.field = value
|
|
||||||
setattr(instance, field.name, value)
|
|
||||||
|
|
||||||
# if this isn't here, it messes up saving users. who even knows.
|
|
||||||
for (model_key, value) in image_fields.items():
|
|
||||||
getattr(instance, model_key).save(*value, save=save)
|
|
||||||
|
|
||||||
if not save:
|
if not save:
|
||||||
# we can't set many to many and reverse fields on an unsaved object
|
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
# we can't set many to many and reverse fields on an unsaved object
|
||||||
instance.save()
|
instance.save()
|
||||||
|
|
||||||
# add many to many fields, which have to be set post-save
|
# add many to many fields, which have to be set post-save
|
||||||
for (model_key, values) in many_to_many_fields.items():
|
for field in instance.many_to_many_fields:
|
||||||
# mention books/users, for example
|
# mention books/users, for example
|
||||||
getattr(instance, model_key).set(values)
|
field.set_field_from_activity(instance, self)
|
||||||
|
|
||||||
if not save or not hasattr(model, 'deserialize_reverse_fields'):
|
|
||||||
return instance
|
|
||||||
|
|
||||||
# reversed relationships in the models
|
# reversed relationships in the models
|
||||||
for (model_field_name, activity_field_name) in \
|
for (model_field_name, activity_field_name) in \
|
||||||
model.deserialize_reverse_fields:
|
instance.deserialize_reverse_fields:
|
||||||
# attachments on Status, for example
|
# attachments on Status, for example
|
||||||
values = getattr(self, activity_field_name)
|
values = getattr(self, activity_field_name)
|
||||||
if values is None or values is MISSING:
|
if values is None or values is MISSING:
|
||||||
|
|
|
@ -3,7 +3,7 @@ from dataclasses import dataclass
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from .base_activity import ActivityObject, Signature
|
from .base_activity import ActivityObject, Signature
|
||||||
from .book import Book
|
from .book import Edition
|
||||||
|
|
||||||
@dataclass(init=False)
|
@dataclass(init=False)
|
||||||
class Verb(ActivityObject):
|
class Verb(ActivityObject):
|
||||||
|
@ -73,7 +73,7 @@ class Add(Verb):
|
||||||
@dataclass(init=False)
|
@dataclass(init=False)
|
||||||
class AddBook(Verb):
|
class AddBook(Verb):
|
||||||
'''Add activity that's aware of the book obj '''
|
'''Add activity that's aware of the book obj '''
|
||||||
target: Book
|
target: Edition
|
||||||
type: str = 'Add'
|
type: str = 'Add'
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
''' customize the info available in context for rendering templates '''
|
''' customize the info available in context for rendering templates '''
|
||||||
from bookwyrm import models
|
from bookwyrm import models
|
||||||
|
|
||||||
def site_settings(request):
|
def site_settings(request):# pylint: disable=unused-argument
|
||||||
''' include the custom info about the site '''
|
''' include the custom info about the site '''
|
||||||
return {
|
return {
|
||||||
'site': models.SiteSettings.objects.get()
|
'site': models.SiteSettings.objects.get()
|
||||||
|
|
|
@ -30,7 +30,7 @@ class CustomForm(ModelForm):
|
||||||
visible.field.widget.attrs['rows'] = None
|
visible.field.widget.attrs['rows'] = None
|
||||||
visible.field.widget.attrs['class'] = css_classes[input_type]
|
visible.field.widget.attrs['class'] = css_classes[input_type]
|
||||||
|
|
||||||
|
# pylint: disable=missing-class-docstring
|
||||||
class LoginForm(CustomForm):
|
class LoginForm(CustomForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
|
@ -131,6 +131,7 @@ class ImportForm(forms.Form):
|
||||||
|
|
||||||
class ExpiryWidget(widgets.Select):
|
class ExpiryWidget(widgets.Select):
|
||||||
def value_from_datadict(self, data, files, name):
|
def value_from_datadict(self, data, files, name):
|
||||||
|
''' human-readable exiration time buckets '''
|
||||||
selected_string = super().value_from_datadict(data, files, name)
|
selected_string = super().value_from_datadict(data, files, name)
|
||||||
|
|
||||||
if selected_string == 'day':
|
if selected_string == 'day':
|
||||||
|
|
|
@ -53,7 +53,7 @@ def import_data(job_id):
|
||||||
for item in job.items.all():
|
for item in job.items.all():
|
||||||
try:
|
try:
|
||||||
item.resolve()
|
item.resolve()
|
||||||
except Exception as e:
|
except Exception as e:# pylint: disable=broad-except
|
||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
item.fail_reason = 'Error loading book'
|
item.fail_reason = 'Error loading book'
|
||||||
item.save()
|
item.save()
|
||||||
|
|
|
@ -6,6 +6,7 @@ import django.db.utils
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.http import HttpResponseBadRequest, HttpResponseNotFound
|
from django.http import HttpResponseBadRequest, HttpResponseNotFound
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from bookwyrm import activitypub, models, outgoing
|
from bookwyrm import activitypub, models, outgoing
|
||||||
|
@ -15,11 +16,9 @@ from bookwyrm.signatures import Signature
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
|
@require_POST
|
||||||
def inbox(request, username):
|
def inbox(request, username):
|
||||||
''' incoming activitypub events '''
|
''' incoming activitypub events '''
|
||||||
# TODO: should do some kind of checking if the user accepts
|
|
||||||
# this action from the sender probably? idk
|
|
||||||
# but this will just throw a 404 if the user doesn't exist
|
|
||||||
try:
|
try:
|
||||||
models.User.objects.get(localname=username)
|
models.User.objects.get(localname=username)
|
||||||
except models.User.DoesNotExist:
|
except models.User.DoesNotExist:
|
||||||
|
@ -29,11 +28,9 @@ def inbox(request, username):
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
|
@require_POST
|
||||||
def shared_inbox(request):
|
def shared_inbox(request):
|
||||||
''' incoming activitypub events '''
|
''' incoming activitypub events '''
|
||||||
if request.method == 'GET':
|
|
||||||
return HttpResponseNotFound()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = request.body
|
resp = request.body
|
||||||
activity = json.loads(resp)
|
activity = json.loads(resp)
|
||||||
|
@ -60,7 +57,6 @@ def shared_inbox(request):
|
||||||
'Announce': handle_boost,
|
'Announce': handle_boost,
|
||||||
'Add': {
|
'Add': {
|
||||||
'Edition': handle_add,
|
'Edition': handle_add,
|
||||||
'Work': handle_add,
|
|
||||||
},
|
},
|
||||||
'Undo': {
|
'Undo': {
|
||||||
'Follow': handle_unfollow,
|
'Follow': handle_unfollow,
|
||||||
|
@ -69,8 +65,8 @@ def shared_inbox(request):
|
||||||
},
|
},
|
||||||
'Update': {
|
'Update': {
|
||||||
'Person': handle_update_user,
|
'Person': handle_update_user,
|
||||||
'Edition': handle_update_book,
|
'Edition': handle_update_edition,
|
||||||
'Work': handle_update_book,
|
'Work': handle_update_work,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
activity_type = activity['type']
|
activity_type = activity['type']
|
||||||
|
@ -144,7 +140,7 @@ def handle_follow(activity):
|
||||||
def handle_unfollow(activity):
|
def handle_unfollow(activity):
|
||||||
''' unfollow a local user '''
|
''' unfollow a local user '''
|
||||||
obj = activity['object']
|
obj = activity['object']
|
||||||
requester = activitypub.resolve_remote_id(models.user, obj['actor'])
|
requester = activitypub.resolve_remote_id(models.User, obj['actor'])
|
||||||
to_unfollow = models.User.objects.get(remote_id=obj['object'])
|
to_unfollow = models.User.objects.get(remote_id=obj['object'])
|
||||||
# raises models.User.DoesNotExist
|
# raises models.User.DoesNotExist
|
||||||
|
|
||||||
|
@ -188,12 +184,13 @@ def handle_follow_reject(activity):
|
||||||
def handle_create(activity):
|
def handle_create(activity):
|
||||||
''' someone did something, good on them '''
|
''' someone did something, good on them '''
|
||||||
# deduplicate incoming activities
|
# deduplicate incoming activities
|
||||||
status_id = activity['object']['id']
|
activity = activity['object']
|
||||||
|
status_id = activity['id']
|
||||||
if models.Status.objects.filter(remote_id=status_id).count():
|
if models.Status.objects.filter(remote_id=status_id).count():
|
||||||
return
|
return
|
||||||
|
|
||||||
serializer = activitypub.activity_objects[activity['type']]
|
serializer = activitypub.activity_objects[activity['type']]
|
||||||
status = serializer(**activity)
|
activity = serializer(**activity)
|
||||||
try:
|
try:
|
||||||
model = models.activity_models[activity.type]
|
model = models.activity_models[activity.type]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
@ -201,21 +198,45 @@ def handle_create(activity):
|
||||||
return
|
return
|
||||||
|
|
||||||
if activity.type == 'Note':
|
if activity.type == 'Note':
|
||||||
|
# keep notes if they are replies to existing statuses
|
||||||
reply = models.Status.objects.filter(
|
reply = models.Status.objects.filter(
|
||||||
remote_id=activity.inReplyTo
|
remote_id=activity.inReplyTo
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not reply:
|
if not reply:
|
||||||
|
discard = True
|
||||||
|
# keep notes if they mention local users
|
||||||
|
tags = [l['href'] for l in activity.tag if l['type'] == 'Mention']
|
||||||
|
for tag in tags:
|
||||||
|
if models.User.objects.filter(
|
||||||
|
remote_id=tag, local=True).exists():
|
||||||
|
# we found a mention of a known use boost
|
||||||
|
discard = False
|
||||||
|
break
|
||||||
|
if discard:
|
||||||
return
|
return
|
||||||
|
|
||||||
activity.to_model(model)
|
status = activity.to_model(model)
|
||||||
# create a notification if this is a reply
|
# create a notification if this is a reply
|
||||||
|
notified = []
|
||||||
if status.reply_parent and status.reply_parent.user.local:
|
if status.reply_parent and status.reply_parent.user.local:
|
||||||
|
notified.append(status.reply_parent.user)
|
||||||
status_builder.create_notification(
|
status_builder.create_notification(
|
||||||
status.reply_parent.user,
|
status.reply_parent.user,
|
||||||
'REPLY',
|
'REPLY',
|
||||||
related_user=status.user,
|
related_user=status.user,
|
||||||
related_status=status,
|
related_status=status,
|
||||||
)
|
)
|
||||||
|
if status.mention_users.exists():
|
||||||
|
for mentioned_user in status.mention_users.all():
|
||||||
|
if not mentioned_user.local or mentioned_user in notified:
|
||||||
|
continue
|
||||||
|
status_builder.create_notification(
|
||||||
|
mentioned_user,
|
||||||
|
'MENTION',
|
||||||
|
related_user=status.user,
|
||||||
|
related_status=status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.task
|
@app.task
|
||||||
|
@ -228,11 +249,12 @@ def handle_delete_status(activity):
|
||||||
# is trying to delete a user.
|
# is trying to delete a user.
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
status = models.Status.objects.select_subclasses().get(
|
status = models.Status.objects.get(
|
||||||
remote_id=status_id
|
remote_id=status_id
|
||||||
)
|
)
|
||||||
except models.Status.DoesNotExist:
|
except models.Status.DoesNotExist:
|
||||||
return
|
return
|
||||||
|
models.Notification.objects.filter(related_status=status).all().delete()
|
||||||
status_builder.delete_status(status)
|
status_builder.delete_status(status)
|
||||||
|
|
||||||
|
|
||||||
|
@ -317,6 +339,12 @@ def handle_update_user(activity):
|
||||||
|
|
||||||
|
|
||||||
@app.task
|
@app.task
|
||||||
def handle_update_book(activity):
|
def handle_update_edition(activity):
|
||||||
''' a remote instance changed a book (Document) '''
|
''' a remote instance changed a book (Document) '''
|
||||||
activitypub.Edition(**activity['object']).to_model(models.Edition)
|
activitypub.Edition(**activity['object']).to_model(models.Edition)
|
||||||
|
|
||||||
|
|
||||||
|
@app.task
|
||||||
|
def handle_update_work(activity):
|
||||||
|
''' a remote instance changed a book (Document) '''
|
||||||
|
activitypub.Work(**activity['object']).to_model(models.Work)
|
||||||
|
|
|
@ -7,7 +7,7 @@ import django.core.validators
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import django.utils.timezone
|
import django.utils.timezone
|
||||||
import bookwyrm.utils.fields
|
from django.contrib.postgres.fields import JSONField
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
@ -62,7 +62,7 @@ class Migration(migrations.Migration):
|
||||||
('content', models.TextField(blank=True, null=True)),
|
('content', models.TextField(blank=True, null=True)),
|
||||||
('created_date', models.DateTimeField(auto_now_add=True)),
|
('created_date', models.DateTimeField(auto_now_add=True)),
|
||||||
('openlibrary_key', models.CharField(max_length=255)),
|
('openlibrary_key', models.CharField(max_length=255)),
|
||||||
('data', bookwyrm.utils.fields.JSONField()),
|
('data', JSONField()),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'abstract': False,
|
'abstract': False,
|
||||||
|
@ -75,7 +75,7 @@ class Migration(migrations.Migration):
|
||||||
('content', models.TextField(blank=True, null=True)),
|
('content', models.TextField(blank=True, null=True)),
|
||||||
('created_date', models.DateTimeField(auto_now_add=True)),
|
('created_date', models.DateTimeField(auto_now_add=True)),
|
||||||
('openlibrary_key', models.CharField(max_length=255, unique=True)),
|
('openlibrary_key', models.CharField(max_length=255, unique=True)),
|
||||||
('data', bookwyrm.utils.fields.JSONField()),
|
('data', JSONField()),
|
||||||
('cover', models.ImageField(blank=True, null=True, upload_to='covers/')),
|
('cover', models.ImageField(blank=True, null=True, upload_to='covers/')),
|
||||||
('added_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
('added_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||||
('authors', models.ManyToManyField(to='bookwyrm.Author')),
|
('authors', models.ManyToManyField(to='bookwyrm.Author')),
|
||||||
|
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import bookwyrm.models.connector
|
import bookwyrm.models.connector
|
||||||
import bookwyrm.models.site
|
import bookwyrm.models.site
|
||||||
import bookwyrm.utils.fields
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
import django.contrib.postgres.operations
|
import django.contrib.postgres.operations
|
||||||
import django.core.validators
|
import django.core.validators
|
||||||
|
@ -10,6 +9,7 @@ from django.db import migrations, models
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import django.db.models.expressions
|
import django.db.models.expressions
|
||||||
import django.utils.timezone
|
import django.utils.timezone
|
||||||
|
from django.contrib.postgres.fields import JSONField, ArrayField
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
@ -148,7 +148,7 @@ class Migration(migrations.Migration):
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='book',
|
model_name='book',
|
||||||
name='misc_identifiers',
|
name='misc_identifiers',
|
||||||
field=bookwyrm.utils.fields.JSONField(null=True),
|
field=JSONField(null=True),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='book',
|
model_name='book',
|
||||||
|
@ -226,7 +226,7 @@ class Migration(migrations.Migration):
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='author',
|
model_name='author',
|
||||||
name='aliases',
|
name='aliases',
|
||||||
field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
|
field=bookwyrm.models.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='user',
|
model_name='user',
|
||||||
|
@ -394,17 +394,17 @@ class Migration(migrations.Migration):
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='book',
|
model_name='book',
|
||||||
name='subject_places',
|
name='subject_places',
|
||||||
field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
|
field=ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='book',
|
model_name='book',
|
||||||
name='subjects',
|
name='subjects',
|
||||||
field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
|
field=ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='edition',
|
model_name='edition',
|
||||||
name='publishers',
|
name='publishers',
|
||||||
field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
|
field=ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='connector',
|
model_name='connector',
|
||||||
|
@ -578,7 +578,7 @@ class Migration(migrations.Migration):
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='book',
|
model_name='book',
|
||||||
name='languages',
|
name='languages',
|
||||||
field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
|
field=ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='edition',
|
model_name='edition',
|
||||||
|
@ -676,7 +676,7 @@ class Migration(migrations.Migration):
|
||||||
name='ImportItem',
|
name='ImportItem',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('data', bookwyrm.utils.fields.JSONField()),
|
('data', JSONField()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
# Generated by Django 3.0.7 on 2020-11-29 03:04
|
# Generated by Django 3.0.7 on 2020-11-29 03:04
|
||||||
|
|
||||||
import bookwyrm.utils.fields
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
from django.contrib.postgres.fields import ArrayField
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
@ -16,12 +15,12 @@ class Migration(migrations.Migration):
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='book',
|
model_name='book',
|
||||||
name='subject_places',
|
name='subject_places',
|
||||||
field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None),
|
field=ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='book',
|
model_name='book',
|
||||||
name='subjects',
|
name='subjects',
|
||||||
field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None),
|
field=ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='edition',
|
model_name='edition',
|
||||||
|
|
19
bookwyrm/migrations/0017_auto_20201212_0059.py
Normal file
19
bookwyrm/migrations/0017_auto_20201212_0059.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
# Generated by Django 3.0.7 on 2020-12-12 00:59
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('bookwyrm', '0016_auto_20201211_2026'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='readthrough',
|
||||||
|
name='book',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
|
||||||
|
),
|
||||||
|
]
|
19
bookwyrm/migrations/0023_auto_20201214_0511.py
Normal file
19
bookwyrm/migrations/0023_auto_20201214_0511.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
# Generated by Django 3.0.7 on 2020-12-14 05:11
|
||||||
|
|
||||||
|
import bookwyrm.models.fields
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('bookwyrm', '0022_auto_20201212_1744'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='status',
|
||||||
|
name='privacy',
|
||||||
|
field=bookwyrm.models.fields.PrivacyField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255),
|
||||||
|
),
|
||||||
|
]
|
14
bookwyrm/migrations/0023_merge_20201216_0112.py
Normal file
14
bookwyrm/migrations/0023_merge_20201216_0112.py
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
# Generated by Django 3.0.7 on 2020-12-16 01:12
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('bookwyrm', '0017_auto_20201212_0059'),
|
||||||
|
('bookwyrm', '0022_auto_20201212_1744'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
]
|
14
bookwyrm/migrations/0024_merge_20201216_1721.py
Normal file
14
bookwyrm/migrations/0024_merge_20201216_1721.py
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
# Generated by Django 3.0.7 on 2020-12-16 17:21
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('bookwyrm', '0023_auto_20201214_0511'),
|
||||||
|
('bookwyrm', '0023_merge_20201216_0112'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
]
|
39
bookwyrm/migrations/0025_auto_20201217_0046.py
Normal file
39
bookwyrm/migrations/0025_auto_20201217_0046.py
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
# Generated by Django 3.0.7 on 2020-12-17 00:46
|
||||||
|
|
||||||
|
import bookwyrm.models.fields
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('bookwyrm', '0024_merge_20201216_1721'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='author',
|
||||||
|
name='bio',
|
||||||
|
field=bookwyrm.models.fields.HtmlField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='book',
|
||||||
|
name='description',
|
||||||
|
field=bookwyrm.models.fields.HtmlField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='quotation',
|
||||||
|
name='quote',
|
||||||
|
field=bookwyrm.models.fields.HtmlField(),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='status',
|
||||||
|
name='content',
|
||||||
|
field=bookwyrm.models.fields.HtmlField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='user',
|
||||||
|
name='summary',
|
||||||
|
field=bookwyrm.models.fields.HtmlField(default=''),
|
||||||
|
),
|
||||||
|
]
|
|
@ -25,8 +25,3 @@ from .site import SiteSettings, SiteInvite, PasswordReset
|
||||||
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
|
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
|
||||||
activity_models = {c[1].activity_serializer.__name__: c[1] \
|
activity_models = {c[1].activity_serializer.__name__: c[1] \
|
||||||
for c in cls_members if hasattr(c[1], 'activity_serializer')}
|
for c in cls_members if hasattr(c[1], 'activity_serializer')}
|
||||||
|
|
||||||
def to_activity(activity_json):
|
|
||||||
''' link up models and activities '''
|
|
||||||
activity_type = activity_json.get('type')
|
|
||||||
return activity_models[activity_type].to_activity(activity_json)
|
|
||||||
|
|
|
@ -16,7 +16,8 @@ class Author(ActivitypubMixin, BookWyrmModel):
|
||||||
max_length=255, blank=True, null=True, deduplication_field=True)
|
max_length=255, blank=True, null=True, deduplication_field=True)
|
||||||
sync = models.BooleanField(default=True)
|
sync = models.BooleanField(default=True)
|
||||||
last_sync_date = models.DateTimeField(default=timezone.now)
|
last_sync_date = models.DateTimeField(default=timezone.now)
|
||||||
wikipedia_link = fields.CharField(max_length=255, blank=True, null=True, deduplication_field=True)
|
wikipedia_link = fields.CharField(
|
||||||
|
max_length=255, blank=True, null=True, deduplication_field=True)
|
||||||
# idk probably other keys would be useful here?
|
# idk probably other keys would be useful here?
|
||||||
born = fields.DateTimeField(blank=True, null=True)
|
born = fields.DateTimeField(blank=True, null=True)
|
||||||
died = fields.DateTimeField(blank=True, null=True)
|
died = fields.DateTimeField(blank=True, null=True)
|
||||||
|
@ -24,7 +25,7 @@ class Author(ActivitypubMixin, BookWyrmModel):
|
||||||
aliases = fields.ArrayField(
|
aliases = fields.ArrayField(
|
||||||
models.CharField(max_length=255), blank=True, default=list
|
models.CharField(max_length=255), blank=True, default=list
|
||||||
)
|
)
|
||||||
bio = fields.TextField(null=True, blank=True)
|
bio = fields.HtmlField(null=True, blank=True)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
''' can't be abstract for query reasons, but you shouldn't USE it '''
|
''' can't be abstract for query reasons, but you shouldn't USE it '''
|
||||||
|
|
|
@ -14,16 +14,9 @@ from django.dispatch import receiver
|
||||||
|
|
||||||
from bookwyrm import activitypub
|
from bookwyrm import activitypub
|
||||||
from bookwyrm.settings import DOMAIN, PAGE_LENGTH
|
from bookwyrm.settings import DOMAIN, PAGE_LENGTH
|
||||||
from .fields import RemoteIdField
|
from .fields import ImageField, ManyToManyField, RemoteIdField
|
||||||
|
|
||||||
|
|
||||||
PrivacyLevels = models.TextChoices('Privacy', [
|
|
||||||
'public',
|
|
||||||
'unlisted',
|
|
||||||
'followers',
|
|
||||||
'direct'
|
|
||||||
])
|
|
||||||
|
|
||||||
class BookWyrmModel(models.Model):
|
class BookWyrmModel(models.Model):
|
||||||
''' shared fields '''
|
''' shared fields '''
|
||||||
created_date = models.DateTimeField(auto_now_add=True)
|
created_date = models.DateTimeField(auto_now_add=True)
|
||||||
|
@ -44,6 +37,7 @@ class BookWyrmModel(models.Model):
|
||||||
|
|
||||||
|
|
||||||
@receiver(models.signals.post_save)
|
@receiver(models.signals.post_save)
|
||||||
|
#pylint: disable=unused-argument
|
||||||
def execute_after_save(sender, instance, created, *args, **kwargs):
|
def execute_after_save(sender, instance, created, *args, **kwargs):
|
||||||
''' set the remote_id after save (when the id is available) '''
|
''' set the remote_id after save (when the id is available) '''
|
||||||
if not created or not hasattr(instance, 'get_remote_id'):
|
if not created or not hasattr(instance, 'get_remote_id'):
|
||||||
|
@ -67,6 +61,33 @@ class ActivitypubMixin:
|
||||||
activity_serializer = lambda: {}
|
activity_serializer = lambda: {}
|
||||||
reverse_unfurl = False
|
reverse_unfurl = False
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
''' collect some info on model fields '''
|
||||||
|
self.image_fields = []
|
||||||
|
self.many_to_many_fields = []
|
||||||
|
self.simple_fields = [] # "simple"
|
||||||
|
for field in self._meta.get_fields():
|
||||||
|
if not hasattr(field, 'field_to_activity'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(field, ImageField):
|
||||||
|
self.image_fields.append(field)
|
||||||
|
elif isinstance(field, ManyToManyField):
|
||||||
|
self.many_to_many_fields.append(field)
|
||||||
|
else:
|
||||||
|
self.simple_fields.append(field)
|
||||||
|
|
||||||
|
self.activity_fields = self.image_fields + \
|
||||||
|
self.many_to_many_fields + self.simple_fields
|
||||||
|
|
||||||
|
self.deserialize_reverse_fields = self.deserialize_reverse_fields \
|
||||||
|
if hasattr(self, 'deserialize_reverse_fields') else []
|
||||||
|
self.serialize_reverse_fields = self.serialize_reverse_fields \
|
||||||
|
if hasattr(self, 'serialize_reverse_fields') else []
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_existing_by_remote_id(cls, remote_id):
|
def find_existing_by_remote_id(cls, remote_id):
|
||||||
''' look up a remote id in the db '''
|
''' look up a remote id in the db '''
|
||||||
|
@ -114,19 +135,8 @@ class ActivitypubMixin:
|
||||||
def to_activity(self):
|
def to_activity(self):
|
||||||
''' convert from a model to an activity '''
|
''' convert from a model to an activity '''
|
||||||
activity = {}
|
activity = {}
|
||||||
for field in self._meta.get_fields():
|
for field in self.activity_fields:
|
||||||
if not hasattr(field, 'field_to_activity'):
|
field.set_activity_from_field(activity, self)
|
||||||
continue
|
|
||||||
value = field.field_to_activity(getattr(self, field.name))
|
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
key = field.get_activitypub_field()
|
|
||||||
if key in activity and isinstance(activity[key], list):
|
|
||||||
# handles tags on status, which accumulate across fields
|
|
||||||
activity[key] += value
|
|
||||||
else:
|
|
||||||
activity[key] = value
|
|
||||||
|
|
||||||
if hasattr(self, 'serialize_reverse_fields'):
|
if hasattr(self, 'serialize_reverse_fields'):
|
||||||
# for example, editions of a work
|
# for example, editions of a work
|
||||||
|
@ -141,9 +151,9 @@ class ActivitypubMixin:
|
||||||
return self.activity_serializer(**activity).serialize()
|
return self.activity_serializer(**activity).serialize()
|
||||||
|
|
||||||
|
|
||||||
def to_create_activity(self, user):
|
def to_create_activity(self, user, **kwargs):
|
||||||
''' returns the object wrapped in a Create activity '''
|
''' returns the object wrapped in a Create activity '''
|
||||||
activity_object = self.to_activity()
|
activity_object = self.to_activity(**kwargs)
|
||||||
|
|
||||||
signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key))
|
signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key))
|
||||||
content = activity_object['content']
|
content = activity_object['content']
|
||||||
|
|
|
@ -36,7 +36,7 @@ class Book(ActivitypubMixin, BookWyrmModel):
|
||||||
title = fields.CharField(max_length=255)
|
title = fields.CharField(max_length=255)
|
||||||
sort_title = fields.CharField(max_length=255, blank=True, null=True)
|
sort_title = fields.CharField(max_length=255, blank=True, null=True)
|
||||||
subtitle = fields.CharField(max_length=255, blank=True, null=True)
|
subtitle = fields.CharField(max_length=255, blank=True, null=True)
|
||||||
description = fields.TextField(blank=True, null=True)
|
description = fields.HtmlField(blank=True, null=True)
|
||||||
languages = fields.ArrayField(
|
languages = fields.ArrayField(
|
||||||
models.CharField(max_length=255), blank=True, default=list
|
models.CharField(max_length=255), blank=True, default=list
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
''' activitypub-aware django model fields '''
|
''' activitypub-aware django model fields '''
|
||||||
|
from dataclasses import MISSING
|
||||||
import re
|
import re
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import dateutil.parser
|
import dateutil.parser
|
||||||
from dateutil.parser import ParserError
|
from dateutil.parser import ParserError
|
||||||
from django.contrib.auth.models import AbstractUser
|
|
||||||
from django.contrib.postgres.fields import ArrayField as DjangoArrayField
|
from django.contrib.postgres.fields import ArrayField as DjangoArrayField
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
|
@ -12,6 +12,7 @@ from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from bookwyrm import activitypub
|
from bookwyrm import activitypub
|
||||||
|
from bookwyrm.sanitize_html import InputHtmlParser
|
||||||
from bookwyrm.settings import DOMAIN
|
from bookwyrm.settings import DOMAIN
|
||||||
from bookwyrm.connectors import get_image
|
from bookwyrm.connectors import get_image
|
||||||
|
|
||||||
|
@ -24,6 +25,14 @@ def validate_remote_id(value):
|
||||||
params={'value': value},
|
params={'value': value},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def validate_username(value):
|
||||||
|
''' make sure usernames look okay '''
|
||||||
|
if not re.match(r'^[A-Za-z\-_\.]+$', value):
|
||||||
|
raise ValidationError(
|
||||||
|
_('%(value)s is not a valid remote_id'),
|
||||||
|
params={'value': value},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ActivitypubFieldMixin:
|
class ActivitypubFieldMixin:
|
||||||
''' make a database field serializable '''
|
''' make a database field serializable '''
|
||||||
|
@ -38,6 +47,36 @@ class ActivitypubFieldMixin:
|
||||||
self.activitypub_field = activitypub_field
|
self.activitypub_field = activitypub_field
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def set_field_from_activity(self, instance, data):
|
||||||
|
''' helper function for assinging a value to the field '''
|
||||||
|
try:
|
||||||
|
value = getattr(data, self.get_activitypub_field())
|
||||||
|
except AttributeError:
|
||||||
|
# masssively hack-y workaround for boosts
|
||||||
|
if self.get_activitypub_field() != 'attributedTo':
|
||||||
|
raise
|
||||||
|
value = getattr(data, 'actor')
|
||||||
|
formatted = self.field_from_activity(value)
|
||||||
|
if formatted is None or formatted is MISSING:
|
||||||
|
return
|
||||||
|
setattr(instance, self.name, formatted)
|
||||||
|
|
||||||
|
|
||||||
|
def set_activity_from_field(self, activity, instance):
|
||||||
|
''' update the json object '''
|
||||||
|
value = getattr(instance, self.name)
|
||||||
|
formatted = self.field_to_activity(value)
|
||||||
|
if formatted is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
key = self.get_activitypub_field()
|
||||||
|
if isinstance(activity.get(key), list):
|
||||||
|
activity[key] += formatted
|
||||||
|
else:
|
||||||
|
activity[key] = formatted
|
||||||
|
|
||||||
|
|
||||||
def field_to_activity(self, value):
|
def field_to_activity(self, value):
|
||||||
''' formatter to convert a model value into activitypub '''
|
''' formatter to convert a model value into activitypub '''
|
||||||
if hasattr(self, 'activitypub_wrapper'):
|
if hasattr(self, 'activitypub_wrapper'):
|
||||||
|
@ -103,7 +142,7 @@ class UsernameField(ActivitypubFieldMixin, models.CharField):
|
||||||
_('username'),
|
_('username'),
|
||||||
max_length=150,
|
max_length=150,
|
||||||
unique=True,
|
unique=True,
|
||||||
validators=[AbstractUser.username_validator],
|
validators=[validate_username],
|
||||||
error_messages={
|
error_messages={
|
||||||
'unique': _('A user with that username already exists.'),
|
'unique': _('A user with that username already exists.'),
|
||||||
},
|
},
|
||||||
|
@ -123,6 +162,52 @@ class UsernameField(ActivitypubFieldMixin, models.CharField):
|
||||||
return value.split('@')[0]
|
return value.split('@')[0]
|
||||||
|
|
||||||
|
|
||||||
|
PrivacyLevels = models.TextChoices('Privacy', [
|
||||||
|
'public',
|
||||||
|
'unlisted',
|
||||||
|
'followers',
|
||||||
|
'direct'
|
||||||
|
])
|
||||||
|
|
||||||
|
class PrivacyField(ActivitypubFieldMixin, models.CharField):
|
||||||
|
''' this maps to two differente activitypub fields '''
|
||||||
|
public = 'https://www.w3.org/ns/activitystreams#Public'
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(
|
||||||
|
*args, max_length=255,
|
||||||
|
choices=PrivacyLevels.choices, default='public')
|
||||||
|
|
||||||
|
def set_field_from_activity(self, instance, data):
|
||||||
|
to = data.to
|
||||||
|
cc = data.cc
|
||||||
|
if to == [self.public]:
|
||||||
|
setattr(instance, self.name, 'public')
|
||||||
|
elif cc == []:
|
||||||
|
setattr(instance, self.name, 'direct')
|
||||||
|
elif self.public in cc:
|
||||||
|
setattr(instance, self.name, 'unlisted')
|
||||||
|
else:
|
||||||
|
setattr(instance, self.name, 'followers')
|
||||||
|
|
||||||
|
def set_activity_from_field(self, activity, instance):
|
||||||
|
mentions = [u.remote_id for u in instance.mention_users.all()]
|
||||||
|
# this is a link to the followers list
|
||||||
|
followers = instance.user.__class__._meta.get_field('followers')\
|
||||||
|
.field_to_activity(instance.user.followers)
|
||||||
|
if instance.privacy == 'public':
|
||||||
|
activity['to'] = [self.public]
|
||||||
|
activity['cc'] = [followers] + mentions
|
||||||
|
elif instance.privacy == 'unlisted':
|
||||||
|
activity['to'] = [followers]
|
||||||
|
activity['cc'] = [self.public] + mentions
|
||||||
|
elif instance.privacy == 'followers':
|
||||||
|
activity['to'] = [followers]
|
||||||
|
activity['cc'] = mentions
|
||||||
|
if instance.privacy == 'direct':
|
||||||
|
activity['to'] = mentions
|
||||||
|
activity['cc'] = []
|
||||||
|
|
||||||
|
|
||||||
class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey):
|
class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey):
|
||||||
''' activitypub-aware foreign key field '''
|
''' activitypub-aware foreign key field '''
|
||||||
def field_to_activity(self, value):
|
def field_to_activity(self, value):
|
||||||
|
@ -145,6 +230,14 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
|
||||||
self.link_only = link_only
|
self.link_only = link_only
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def set_field_from_activity(self, instance, data):
|
||||||
|
''' helper function for assinging a value to the field '''
|
||||||
|
value = getattr(data, self.get_activitypub_field())
|
||||||
|
formatted = self.field_from_activity(value)
|
||||||
|
if formatted is None or formatted is MISSING:
|
||||||
|
return
|
||||||
|
getattr(instance, self.name).set(formatted)
|
||||||
|
|
||||||
def field_to_activity(self, value):
|
def field_to_activity(self, value):
|
||||||
if self.link_only:
|
if self.link_only:
|
||||||
return '%s/%s' % (value.instance.remote_id, self.name)
|
return '%s/%s' % (value.instance.remote_id, self.name)
|
||||||
|
@ -152,6 +245,8 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
|
||||||
|
|
||||||
def field_from_activity(self, value):
|
def field_from_activity(self, value):
|
||||||
items = []
|
items = []
|
||||||
|
if value is None or value is MISSING:
|
||||||
|
return []
|
||||||
for remote_id in value:
|
for remote_id in value:
|
||||||
try:
|
try:
|
||||||
validate_remote_id(remote_id)
|
validate_remote_id(remote_id)
|
||||||
|
@ -189,6 +284,8 @@ class TagField(ManyToManyField):
|
||||||
for link_json in value:
|
for link_json in value:
|
||||||
link = activitypub.Link(**link_json)
|
link = activitypub.Link(**link_json)
|
||||||
tag_type = link.type if link.type != 'Mention' else 'Person'
|
tag_type = link.type if link.type != 'Mention' else 'Person'
|
||||||
|
if tag_type == 'Book':
|
||||||
|
tag_type = 'Edition'
|
||||||
if tag_type != self.related_model.activity_serializer.type:
|
if tag_type != self.related_model.activity_serializer.type:
|
||||||
# tags can contain multiple types
|
# tags can contain multiple types
|
||||||
continue
|
continue
|
||||||
|
@ -210,9 +307,20 @@ def image_serializer(value):
|
||||||
|
|
||||||
class ImageField(ActivitypubFieldMixin, models.ImageField):
|
class ImageField(ActivitypubFieldMixin, models.ImageField):
|
||||||
''' activitypub-aware image field '''
|
''' activitypub-aware image field '''
|
||||||
|
# pylint: disable=arguments-differ
|
||||||
|
def set_field_from_activity(self, instance, data, save=True):
|
||||||
|
''' helper function for assinging a value to the field '''
|
||||||
|
value = getattr(data, self.get_activitypub_field())
|
||||||
|
formatted = self.field_from_activity(value)
|
||||||
|
if formatted is None or formatted is MISSING:
|
||||||
|
return
|
||||||
|
getattr(instance, self.name).save(*formatted, save=save)
|
||||||
|
|
||||||
|
|
||||||
def field_to_activity(self, value):
|
def field_to_activity(self, value):
|
||||||
return image_serializer(value)
|
return image_serializer(value)
|
||||||
|
|
||||||
|
|
||||||
def field_from_activity(self, value):
|
def field_from_activity(self, value):
|
||||||
image_slug = value
|
image_slug = value
|
||||||
# when it's an inline image (User avatar/icon, Book cover), it's a json
|
# when it's an inline image (User avatar/icon, Book cover), it's a json
|
||||||
|
@ -255,6 +363,15 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
|
||||||
except (ParserError, TypeError):
|
except (ParserError, TypeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
class HtmlField(ActivitypubFieldMixin, models.TextField):
|
||||||
|
''' a text field for storing html '''
|
||||||
|
def field_from_activity(self, value):
|
||||||
|
if not value or value == MISSING:
|
||||||
|
return None
|
||||||
|
sanitizer = InputHtmlParser()
|
||||||
|
sanitizer.feed(value)
|
||||||
|
return sanitizer.get_output()
|
||||||
|
|
||||||
class ArrayField(ActivitypubFieldMixin, DjangoArrayField):
|
class ArrayField(ActivitypubFieldMixin, DjangoArrayField):
|
||||||
''' activitypub-aware array field '''
|
''' activitypub-aware array field '''
|
||||||
def field_to_activity(self, value):
|
def field_to_activity(self, value):
|
||||||
|
|
|
@ -2,14 +2,13 @@
|
||||||
import re
|
import re
|
||||||
import dateutil.parser
|
import dateutil.parser
|
||||||
|
|
||||||
|
from django.contrib.postgres.fields import JSONField
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from bookwyrm import books_manager
|
from bookwyrm import books_manager
|
||||||
from bookwyrm.connectors import ConnectorException
|
|
||||||
from bookwyrm.models import ReadThrough, User, Book
|
from bookwyrm.models import ReadThrough, User, Book
|
||||||
from bookwyrm.utils.fields import JSONField
|
from .fields import PrivacyLevels
|
||||||
from .base_model import PrivacyLevels
|
|
||||||
|
|
||||||
|
|
||||||
# Mapping goodreads -> bookwyrm shelf titles.
|
# Mapping goodreads -> bookwyrm shelf titles.
|
||||||
|
|
|
@ -37,7 +37,7 @@ class UserRelationship(ActivitypubMixin, BookWyrmModel):
|
||||||
|
|
||||||
activity_serializer = activitypub.Follow
|
activity_serializer = activitypub.Follow
|
||||||
|
|
||||||
def get_remote_id(self, status=None):
|
def get_remote_id(self, status=None):# pylint: disable=arguments-differ
|
||||||
''' use shelf identifier in remote_id '''
|
''' use shelf identifier in remote_id '''
|
||||||
status = status or 'follows'
|
status = status or 'follows'
|
||||||
base_path = self.user_subject.remote_id
|
base_path = self.user_subject.remote_id
|
||||||
|
|
|
@ -3,8 +3,8 @@ import re
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from bookwyrm import activitypub
|
from bookwyrm import activitypub
|
||||||
from .base_model import BookWyrmModel
|
from .base_model import ActivitypubMixin, BookWyrmModel
|
||||||
from .base_model import OrderedCollectionMixin, PrivacyLevels
|
from .base_model import OrderedCollectionMixin
|
||||||
from . import fields
|
from . import fields
|
||||||
|
|
||||||
|
|
||||||
|
@ -18,7 +18,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
||||||
privacy = fields.CharField(
|
privacy = fields.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
default='public',
|
default='public',
|
||||||
choices=PrivacyLevels.choices
|
choices=fields.PrivacyLevels.choices
|
||||||
)
|
)
|
||||||
books = models.ManyToManyField(
|
books = models.ManyToManyField(
|
||||||
'Edition',
|
'Edition',
|
||||||
|
@ -51,7 +51,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
||||||
unique_together = ('user', 'identifier')
|
unique_together = ('user', 'identifier')
|
||||||
|
|
||||||
|
|
||||||
class ShelfBook(BookWyrmModel):
|
class ShelfBook(ActivitypubMixin, BookWyrmModel):
|
||||||
''' many to many join table for books and shelves '''
|
''' many to many join table for books and shelves '''
|
||||||
book = fields.ForeignKey(
|
book = fields.ForeignKey(
|
||||||
'Edition', on_delete=models.PROTECT, activitypub_field='object')
|
'Edition', on_delete=models.PROTECT, activitypub_field='object')
|
||||||
|
|
|
@ -6,7 +6,7 @@ from model_utils.managers import InheritanceManager
|
||||||
|
|
||||||
from bookwyrm import activitypub
|
from bookwyrm import activitypub
|
||||||
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
|
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
|
||||||
from .base_model import BookWyrmModel, PrivacyLevels
|
from .base_model import BookWyrmModel
|
||||||
from . import fields
|
from . import fields
|
||||||
from .fields import image_serializer
|
from .fields import image_serializer
|
||||||
|
|
||||||
|
@ -14,19 +14,15 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
''' any post, like a reply to a review, etc '''
|
''' any post, like a reply to a review, etc '''
|
||||||
user = fields.ForeignKey(
|
user = fields.ForeignKey(
|
||||||
'User', on_delete=models.PROTECT, activitypub_field='attributedTo')
|
'User', on_delete=models.PROTECT, activitypub_field='attributedTo')
|
||||||
content = fields.TextField(blank=True, null=True)
|
content = fields.HtmlField(blank=True, null=True)
|
||||||
mention_users = fields.TagField('User', related_name='mention_user')
|
mention_users = fields.TagField('User', related_name='mention_user')
|
||||||
mention_books = fields.TagField('Edition', related_name='mention_book')
|
mention_books = fields.TagField('Edition', related_name='mention_book')
|
||||||
local = models.BooleanField(default=True)
|
local = models.BooleanField(default=True)
|
||||||
privacy = models.CharField(
|
|
||||||
max_length=255,
|
|
||||||
default='public',
|
|
||||||
choices=PrivacyLevels.choices
|
|
||||||
)
|
|
||||||
content_warning = fields.CharField(
|
content_warning = fields.CharField(
|
||||||
max_length=150, blank=True, null=True, activitypub_field='summary')
|
max_length=150, blank=True, null=True, activitypub_field='summary')
|
||||||
|
privacy = fields.PrivacyField(max_length=255)
|
||||||
sensitive = fields.BooleanField(default=False)
|
sensitive = fields.BooleanField(default=False)
|
||||||
# the created date can't be this, because of receiving federated posts
|
# created date is different than publish date because of federated posts
|
||||||
published_date = fields.DateTimeField(
|
published_date = fields.DateTimeField(
|
||||||
default=timezone.now, activitypub_field='published')
|
default=timezone.now, activitypub_field='published')
|
||||||
deleted = models.BooleanField(default=False)
|
deleted = models.BooleanField(default=False)
|
||||||
|
@ -50,12 +46,13 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
serialize_reverse_fields = [('attachments', 'attachment')]
|
serialize_reverse_fields = [('attachments', 'attachment')]
|
||||||
deserialize_reverse_fields = [('attachments', 'attachment')]
|
deserialize_reverse_fields = [('attachments', 'attachment')]
|
||||||
|
|
||||||
#----- replies collection activitypub ----#
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def replies(cls, status):
|
def replies(cls, status):
|
||||||
''' load all replies to a status. idk if there's a better way
|
''' load all replies to a status. idk if there's a better way
|
||||||
to write this so it's just a property '''
|
to write this so it's just a property '''
|
||||||
return cls.objects.filter(reply_parent=status).select_subclasses()
|
return cls.objects.filter(
|
||||||
|
reply_parent=status
|
||||||
|
).select_subclasses().order_by('published_date')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status_type(self):
|
def status_type(self):
|
||||||
|
@ -82,27 +79,8 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
activity = ActivitypubMixin.to_activity(self)
|
activity = ActivitypubMixin.to_activity(self)
|
||||||
activity['replies'] = self.to_replies()
|
activity['replies'] = self.to_replies()
|
||||||
|
|
||||||
# privacy controls
|
|
||||||
public = 'https://www.w3.org/ns/activitystreams#Public'
|
|
||||||
mentions = [u.remote_id for u in self.mention_users.all()]
|
|
||||||
# this is a link to the followers list:
|
|
||||||
followers = self.user.__class__._meta.get_field('followers')\
|
|
||||||
.field_to_activity(self.user.followers)
|
|
||||||
if self.privacy == 'public':
|
|
||||||
activity['to'] = [public]
|
|
||||||
activity['cc'] = [followers] + mentions
|
|
||||||
elif self.privacy == 'unlisted':
|
|
||||||
activity['to'] = [followers]
|
|
||||||
activity['cc'] = [public] + mentions
|
|
||||||
elif self.privacy == 'followers':
|
|
||||||
activity['to'] = [followers]
|
|
||||||
activity['cc'] = mentions
|
|
||||||
if self.privacy == 'direct':
|
|
||||||
activity['to'] = mentions
|
|
||||||
activity['cc'] = []
|
|
||||||
|
|
||||||
# "pure" serialization for non-bookwyrm instances
|
# "pure" serialization for non-bookwyrm instances
|
||||||
if pure:
|
if pure and hasattr(self, 'pure_content'):
|
||||||
activity['content'] = self.pure_content
|
activity['content'] = self.pure_content
|
||||||
if 'name' in activity:
|
if 'name' in activity:
|
||||||
activity['name'] = self.pure_name
|
activity['name'] = self.pure_name
|
||||||
|
@ -158,7 +136,7 @@ class Comment(Status):
|
||||||
|
|
||||||
class Quotation(Status):
|
class Quotation(Status):
|
||||||
''' like a review but without a rating and transient '''
|
''' like a review but without a rating and transient '''
|
||||||
quote = fields.TextField()
|
quote = fields.HtmlField()
|
||||||
book = fields.ForeignKey(
|
book = fields.ForeignKey(
|
||||||
'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook')
|
'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook')
|
||||||
|
|
||||||
|
@ -192,6 +170,7 @@ class Review(Status):
|
||||||
def pure_name(self):
|
def pure_name(self):
|
||||||
''' clarify review names for mastodon serialization '''
|
''' clarify review names for mastodon serialization '''
|
||||||
if self.rating:
|
if self.rating:
|
||||||
|
#pylint: disable=bad-string-format-type
|
||||||
return 'Review of "%s" (%d stars): %s' % (
|
return 'Review of "%s" (%d stars): %s' % (
|
||||||
self.book.title,
|
self.book.title,
|
||||||
self.rating,
|
self.rating,
|
||||||
|
@ -241,6 +220,18 @@ class Boost(Status):
|
||||||
activitypub_field='object',
|
activitypub_field='object',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
''' the user field is "actor" here instead of "attributedTo" '''
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
reserve_fields = ['user', 'boosted_status']
|
||||||
|
self.simple_fields = [f for f in self.simple_fields if \
|
||||||
|
f.name in reserve_fields]
|
||||||
|
self.activity_fields = self.simple_fields
|
||||||
|
self.many_to_many_fields = []
|
||||||
|
self.image_fields = []
|
||||||
|
self.deserialize_reverse_fields = []
|
||||||
|
|
||||||
activity_serializer = activitypub.Boost
|
activity_serializer = activitypub.Boost
|
||||||
|
|
||||||
# This constraint can't work as it would cross tables.
|
# This constraint can't work as it would cross tables.
|
||||||
|
@ -251,7 +242,7 @@ class Boost(Status):
|
||||||
class ReadThrough(BookWyrmModel):
|
class ReadThrough(BookWyrmModel):
|
||||||
''' Store progress through a book in the database. '''
|
''' Store progress through a book in the database. '''
|
||||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||||
book = models.ForeignKey('Book', on_delete=models.PROTECT)
|
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
|
||||||
pages_read = models.IntegerField(
|
pages_read = models.IntegerField(
|
||||||
null=True,
|
null=True,
|
||||||
blank=True)
|
blank=True)
|
||||||
|
|
|
@ -42,7 +42,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
outbox = fields.RemoteIdField(unique=True)
|
outbox = fields.RemoteIdField(unique=True)
|
||||||
summary = fields.TextField(default='')
|
summary = fields.HtmlField(default='')
|
||||||
local = models.BooleanField(default=False)
|
local = models.BooleanField(default=False)
|
||||||
bookwyrm_user = fields.BooleanField(default=True)
|
bookwyrm_user = fields.BooleanField(default=True)
|
||||||
localname = models.CharField(
|
localname = models.CharField(
|
||||||
|
|
|
@ -252,7 +252,6 @@ def handle_status(user, form):
|
||||||
broadcast(user, status.to_create_activity(user), software='bookwyrm')
|
broadcast(user, status.to_create_activity(user), software='bookwyrm')
|
||||||
|
|
||||||
# re-format the activity for non-bookwyrm servers
|
# re-format the activity for non-bookwyrm servers
|
||||||
if hasattr(status, 'pure_activity_serializer'):
|
|
||||||
remote_activity = status.to_create_activity(user, pure=True)
|
remote_activity = status.to_create_activity(user, pure=True)
|
||||||
broadcast(user, remote_activity, software='other')
|
broadcast(user, remote_activity, software='other')
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
''' html parser to clean up incoming text from unknown sources '''
|
''' html parser to clean up incoming text from unknown sources '''
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
class InputHtmlParser(HTMLParser):
|
class InputHtmlParser(HTMLParser):#pylint: disable=abstract-method
|
||||||
''' Removes any html that isn't allowed_tagsed from a block '''
|
''' Removes any html that isn't allowed_tagsed from a block '''
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|
|
@ -99,10 +99,6 @@ BOOKWYRM_DBS = {
|
||||||
'HOST': env('POSTGRES_HOST', ''),
|
'HOST': env('POSTGRES_HOST', ''),
|
||||||
'PORT': 5432
|
'PORT': 5432
|
||||||
},
|
},
|
||||||
'sqlite': {
|
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
|
||||||
'NAME': os.path.join(BASE_DIR, 'fedireads.db')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
''' Handle user activity '''
|
''' Handle user activity '''
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from bookwyrm import activitypub, books_manager, models
|
from bookwyrm import models
|
||||||
from bookwyrm.sanitize_html import InputHtmlParser
|
from bookwyrm.sanitize_html import InputHtmlParser
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{% extends 'layout.html' %}
|
{% extends 'layout.html' %}
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<h1 class="title">{{ author.name }}</h1>
|
<h1 class="title">{{ author.name }}</h1>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{% extends 'layout.html' %}
|
{% extends 'layout.html' %}
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
{% load humanize %}
|
{% load humanize %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
|
@ -91,88 +91,27 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% for readthrough in readthroughs %}
|
{# user's relationship to the book #}
|
||||||
<div class="content block">
|
|
||||||
<input class="toggle-control" type="radio" name="show-edit-readthrough" id="show-readthrough-{{ readthrough.id }}" checked>
|
|
||||||
<div class="toggle-content hidden">
|
|
||||||
<dl>
|
|
||||||
{% if readthrough.start_date %}
|
|
||||||
<dt>Started reading:</dt>
|
|
||||||
<dd>{{ readthrough.start_date | naturalday }}</dd>
|
|
||||||
{% endif %}
|
|
||||||
{% if readthrough.finish_date %}
|
|
||||||
<dt>Finished reading:</dt>
|
|
||||||
<dd>{{ readthrough.finish_date | naturalday }}</dd>
|
|
||||||
{% 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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="block">
|
|
||||||
<input class="toggle-control" type="radio" name="show-edit-readthrough" id="edit-readthrough-{{ readthrough.id }}">
|
|
||||||
<div class="toggle-content hidden">
|
|
||||||
<div class="box">
|
|
||||||
<form name="edit-readthrough" action="/edit-readthrough" method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="id" value="{{ readthrough.id }}">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">
|
|
||||||
Started reading
|
|
||||||
<input type="date" name="start_date" class="input" id="id_start_date-{{ readthrough.id }}" value="{{ readthrough.start_date | date:"Y-m-d" }}">
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">
|
|
||||||
Finished reading
|
|
||||||
<input type="date" name="finish_date" class="input" id="id_finish_date-{{ readthrough.id }}" value="{{ readthrough.finish_date | date:"Y-m-d" }}">
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<input class="toggle-control" type="checkbox" name="delete-readthrough-{{ readthrough.id }}" id="delete-readthrough-{{ readthrough.id }}">
|
{% for shelf in user_shelves %}
|
||||||
<div class="modal toggle-content hidden">
|
<p>
|
||||||
<div class="modal-background"></div>
|
This edition is on your <a href="/user/{{ user.localname }}/shelves/{{ shelf.shelf.identifier }}">{{ shelf.shelf.name }}</a> shelf.
|
||||||
<div class="modal-card">
|
{% include 'snippets/shelf_selector.html' with current=shelf.shelf %}
|
||||||
<header class="modal-card-head">
|
</p>
|
||||||
<p class="modal-card-title">Delete this read-though?</p>
|
|
||||||
<label class="delete" for="delete-readthrough-{{ readthrough.id }}" aria-label="close"></label>
|
|
||||||
</header>
|
|
||||||
<footer class="modal-card-foot">
|
|
||||||
<form name="delete-readthrough-{{ readthrough.id }}" action="/delete-readthrough" method="POST">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="id" value="{{ readthrough.id }}">
|
|
||||||
<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>
|
|
||||||
</form>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
<label class="modal-close is-large" for="delete-readthrough-{{ readthrough.id }}" aria-label="close"></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for shelf in other_edition_shelves %}
|
||||||
|
<p>
|
||||||
|
A <a href="/book/{{ shelf.book.id }}">different edition</a> of this book is on your <a href="/user/{{ user.localname }}/shelves/{{ shelf.shelf.identifier }}">{{ shelf.shelf.name }}</a> shelf.
|
||||||
|
{% include 'snippets/switch_edition_button.html' with edition=book %}
|
||||||
|
</p>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for readthrough in readthroughs %}
|
||||||
|
{% include 'snippets/readthrough.html' with readthrough=readthrough %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if request.user.is_authenticated %}
|
{% if request.user.is_authenticated %}
|
||||||
<div class="box">
|
<div class="box">
|
||||||
{% include 'snippets/create_status.html' with book=book hide_cover=True %}
|
{% include 'snippets/create_status.html' with book=book hide_cover=True %}
|
||||||
|
|
37
bookwyrm/templates/direct_messages.html
Normal file
37
bookwyrm/templates/direct_messages.html
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
{% extends 'layout.html' %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="block">
|
||||||
|
<h1 class="title">Direct Messages</h1>
|
||||||
|
|
||||||
|
{% if not activities %}
|
||||||
|
<p>You have no messages right now.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% for activity in activities %}
|
||||||
|
<div class="block">
|
||||||
|
{% include 'snippets/status.html' with status=activity %}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
|
@ -1,5 +1,5 @@
|
||||||
{% extends 'layout.html' %}
|
{% extends 'layout.html' %}
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<h1 class="title">Editions of <a href="/book/{{ work.id }}">"{{ work.title }}"</a></h1>
|
<h1 class="title">Editions of <a href="/book/{{ work.id }}">"{{ work.title }}"</a></h1>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{% extends 'layout.html' %}
|
{% extends 'layout.html' %}
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{% extends 'layout.html' %}
|
{% extends 'layout.html' %}
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<h1 class="title">
|
<h1 class="title">
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{% extends 'layout.html' %}
|
{% extends 'layout.html' %}
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<h1 class="title">
|
<h1 class="title">
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{% extends 'layout.html' %}
|
{% extends 'layout.html' %}
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
{% load humanize %}
|
{% load humanize %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="block">
|
<div class="block">
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
@ -68,6 +68,9 @@
|
||||||
{% include 'snippets/username.html' with user=request.user %}
|
{% include 'snippets/username.html' with user=request.user %}
|
||||||
</p></div>
|
</p></div>
|
||||||
<div class="navbar-dropdown">
|
<div class="navbar-dropdown">
|
||||||
|
<a href="/direct-messages" class="navbar-item">
|
||||||
|
Direct messages
|
||||||
|
</a>
|
||||||
<a href="/user/{{request.user.localname}}" class="navbar-item">
|
<a href="/user/{{request.user.localname}}" class="navbar-item">
|
||||||
Profile
|
Profile
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{% extends 'layout.html' %}
|
{% extends 'layout.html' %}
|
||||||
{% load humanize %}
|
{% load humanize %}
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<h1 class="title">Notifications</h1>
|
<h1 class="title">Notifications</h1>
|
||||||
|
@ -54,7 +54,7 @@
|
||||||
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-white{% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %} has-text-black{% else %}-bis has-text-grey-dark{% endif %}{% endif %}">
|
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-white{% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %} has-text-black{% else %}-bis has-text-grey-dark{% endif %}{% endif %}">
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<a href="{{ notification.related_status.remote_id }}">{{ notification.related_status.content | truncatewords_html:10 }}</a>
|
<a href="{{ notification.related_status.remote_id }}">{{ notification.related_status.content | safe | truncatewords_html:10 }}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-narrow {% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %}has-text-black{% else %}has-text-grey-dark{% endif %}">
|
<div class="column is-narrow {% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %}has-text-black{% else %}has-text-grey-dark{% endif %}">
|
||||||
{{ notification.related_status.published_date | post_date }}
|
{{ notification.related_status.published_date | post_date }}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{% extends 'layout.html' %}
|
{% extends 'layout.html' %}
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
<img class="avatar image {% if large %}is-96x96{% else %}is-32x32{% endif %}" src="{% if user.avatar %}/images/{{ user.avatar }}{% else %}/static/images/default_avi.jpg{% endif %}" alt="avatar for {{ user|username }}">
|
<img class="avatar image {% if large %}is-96x96{% else %}is-32x32{% endif %}" src="{% if user.avatar %}/images/{{ user.avatar }}{% else %}/static/images/default_avi.jpg{% endif %}" alt="avatar for {{ user|username }}">
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
<div class="cover-container is-{{ size }}">
|
<div class="cover-container is-{{ size }}">
|
||||||
{% if book.cover %}
|
{% if book.cover %}
|
||||||
<img class="book-cover" src="/images/{{ book.cover }}" alt="{% include 'snippets/cover_alt.html' with book=book %}">
|
<img class="book-cover" src="/images/{{ book.cover }}" alt="{% include 'snippets/cover_alt.html' with book=book %}">
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
@ -1,16 +1,11 @@
|
||||||
<div class="columns">
|
<div class="columns is-multiline">
|
||||||
{% for book in books %}
|
{% for book in books %}
|
||||||
{% if forloop.counter0|divisibleby:"4" %}
|
|
||||||
</div>
|
|
||||||
<div class="columns">
|
|
||||||
{% endif %}
|
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<a href="/book/{{ book.id }}">
|
<a href="/book/{{ book.id }}">
|
||||||
{% include 'snippets/book_cover.html' with book=book %}
|
{% include 'snippets/book_cover.html' with book=book %}
|
||||||
</a>
|
</a>
|
||||||
{% include 'snippets/rate_action.html' with user=request.user book=book %}
|
{% include 'snippets/shelve_button.html' with book=book switch_mode=True %}
|
||||||
{% include 'snippets/shelve_button.html' with book=book %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
{% with status.id|uuid as uuid %}
|
{% 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" onsubmit="return interact(event)" class="boost-{{ status.id }}-{{ uuid }} {% if request.user|boosted:status %}hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
'{{ book.title }}' Cover ({{ book|edition_info }})
|
'{{ book.title }}' Cover ({{ book|edition_info }})
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{% load humanize %}
|
{% load humanize %}
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
|
|
||||||
<div class="tabs is-boxed">
|
<div class="tabs is-boxed">
|
||||||
<ul role="tablist">
|
<ul role="tablist">
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
{% with status.id|uuid as uuid %}
|
{% 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" onsubmit="return interact(event)" class="fav-{{ status.id }}-{{ uuid }} {% if request.user|liked:status %}hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
<div>
|
<div>
|
||||||
<input class="toggle-control" type="checkbox" name="finish-reading-{{ uuid }}" id="finish-reading-{{ uuid }}">
|
<input class="toggle-control" type="checkbox" name="finish-reading-{{ uuid }}" id="finish-reading-{{ uuid }}">
|
||||||
<div class="modal toggle-content hidden">
|
<div class="modal toggle-content hidden">
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
{% if request.user|follow_request_exists:user %}
|
{% if request.user|follow_request_exists:user %}
|
||||||
<form action="/accept-follow-request/" method="POST">
|
<form action="/accept-follow-request/" method="POST">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
<div class="select">
|
<div class="select">
|
||||||
{% with 0|uuid as uuid %}
|
{% with 0|uuid as uuid %}
|
||||||
{% if not no_label %}
|
{% if not no_label %}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
<span class="is-sr-only">Leave a rating</span>
|
<span class="is-sr-only">Leave a rating</span>
|
||||||
<div class="field is-grouped stars rate-stars">
|
<div class="field is-grouped stars rate-stars">
|
||||||
{% for i in '12345'|make_list %}
|
{% for i in '12345'|make_list %}
|
||||||
|
|
80
bookwyrm/templates/snippets/readthrough.html
Normal file
80
bookwyrm/templates/snippets/readthrough.html
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
{% load humanize %}
|
||||||
|
<div class="content block">
|
||||||
|
<input class="toggle-control" type="radio" name="show-edit-readthrough" id="show-readthrough-{{ readthrough.id }}" checked>
|
||||||
|
<div class="toggle-content hidden">
|
||||||
|
<dl>
|
||||||
|
{% if readthrough.start_date %}
|
||||||
|
<dt>Started reading:</dt>
|
||||||
|
<dd>{{ readthrough.start_date | naturalday }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if readthrough.finish_date %}
|
||||||
|
<dt>Finished reading:</dt>
|
||||||
|
<dd>{{ readthrough.finish_date | naturalday }}</dd>
|
||||||
|
{% 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="block">
|
||||||
|
<input class="toggle-control" type="radio" name="show-edit-readthrough" id="edit-readthrough-{{ readthrough.id }}">
|
||||||
|
<div class="toggle-content hidden">
|
||||||
|
<div class="box">
|
||||||
|
<form name="edit-readthrough" action="/edit-readthrough" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="id" value="{{ readthrough.id }}">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">
|
||||||
|
Started reading
|
||||||
|
<input type="date" name="start_date" class="input" id="id_start_date-{{ readthrough.id }}" value="{{ readthrough.start_date | date:"Y-m-d" }}">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">
|
||||||
|
Finished reading
|
||||||
|
<input type="date" name="finish_date" class="input" id="id_finish_date-{{ readthrough.id }}" value="{{ readthrough.finish_date | date:"Y-m-d" }}">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input class="toggle-control" type="checkbox" name="delete-readthrough-{{ readthrough.id }}" id="delete-readthrough-{{ readthrough.id }}">
|
||||||
|
<div class="modal toggle-content hidden">
|
||||||
|
<div class="modal-background"></div>
|
||||||
|
<div class="modal-card">
|
||||||
|
<header class="modal-card-head">
|
||||||
|
<p class="modal-card-title">Delete this read-though?</p>
|
||||||
|
<label class="delete" for="delete-readthrough-{{ readthrough.id }}" aria-label="close"></label>
|
||||||
|
</header>
|
||||||
|
<footer class="modal-card-foot">
|
||||||
|
<form name="delete-readthrough-{{ readthrough.id }}" action="/delete-readthrough" method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="id" value="{{ readthrough.id }}">
|
||||||
|
<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>
|
||||||
|
</form>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<label class="modal-close is-large" for="delete-readthrough-{{ readthrough.id }}" aria-label="close"></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -1,4 +1,4 @@
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
{% with activity.id|uuid as uuid %}
|
{% with activity.id|uuid as uuid %}
|
||||||
<form class="is-flex-grow-1" name="reply" action="/reply" method="post" onsubmit="return reply(event)">
|
<form class="is-flex-grow-1" name="reply" action="/reply" method="post" onsubmit="return reply(event)">
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
|
@ -13,7 +13,7 @@
|
||||||
|
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
{% include 'snippets/privacy_select.html' %}
|
{% include 'snippets/privacy_select.html' with current=activity.privacy %}
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<button class="button is-primary" type="submit">
|
<button class="button is-primary" type="submit">
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{% load humanize %}
|
{% load humanize %}
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
{% if shelf.books.all|length > 0 %}
|
{% if shelf.books.all|length > 0 %}
|
||||||
<table class="table is-striped is-fullwidth">
|
<table class="table is-striped is-fullwidth">
|
||||||
|
|
||||||
|
|
|
@ -1,27 +1,31 @@
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
{% if request.user.is_authenticated %}
|
{% if request.user.is_authenticated %}
|
||||||
|
|
||||||
{% with book.id|uuid as uuid %}
|
{% with book.id|uuid as uuid %}
|
||||||
{% active_shelf book as active_shelf %}
|
{% active_shelf book as active_shelf %}
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
{% if active_shelf.identifier == 'read' %}
|
{% if switch_mode and active_shelf.book != book %}
|
||||||
|
{% include 'snippets/switch_edition_button.html' with edition=book size='is-small' %}
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
{% if active_shelf.shelf.identifier == 'read' %}
|
||||||
<button class="button is-small" disabled>
|
<button class="button is-small" disabled>
|
||||||
<span>Read</span> <span class="icon icon-check"></span>
|
<span>Read</span> <span class="icon icon-check"></span>
|
||||||
</button>
|
</button>
|
||||||
{% elif active_shelf.identifier == 'reading' %}
|
{% elif active_shelf.shelf.identifier == 'reading' %}
|
||||||
<label class="button is-small" for="finish-reading-{{ uuid }}" role="button" tabindex="0">
|
<label class="button is-small" for="finish-reading-{{ uuid }}" role="button" tabindex="0">
|
||||||
I'm done!
|
I'm done!
|
||||||
</label>
|
</label>
|
||||||
{% include 'snippets/finish_reading_modal.html' %}
|
{% include 'snippets/finish_reading_modal.html' with book=active_shelf.book %}
|
||||||
{% elif active_shelf.identifier == 'to-read' %}
|
{% elif active_shelf.shelf.identifier == 'to-read' %}
|
||||||
<label class="button is-small" for="start-reading-{{ uuid }}" role="button" tabindex="0">
|
<label class="button is-small" for="start-reading-{{ uuid }}" role="button" tabindex="0">
|
||||||
Start reading
|
Start reading
|
||||||
</label>
|
</label>
|
||||||
{% include 'snippets/start_reading_modal.html' %}
|
{% include 'snippets/start_reading_modal.html' with book=active_shelf.book %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<form name="shelve" action="/shelve/" method="post">
|
<form name="shelve" action="/shelve/" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="book" value="{{ book.id }}">
|
<input type="hidden" name="book" value="{{ active_shelf.book.id }}">
|
||||||
<input type="hidden" name="shelf" value="to-read">
|
<input type="hidden" name="shelf" value="to-read">
|
||||||
<button class="button is-small" type="submit">Want to read</button>
|
<button class="button is-small" type="submit">Want to read</button>
|
||||||
</form>
|
</form>
|
||||||
|
@ -40,17 +44,17 @@
|
||||||
<ul class="dropdown-content">
|
<ul class="dropdown-content">
|
||||||
{% for shelf in request.user.shelf_set.all %}
|
{% for shelf in request.user.shelf_set.all %}
|
||||||
<li role="menuitem">
|
<li role="menuitem">
|
||||||
{% if shelf.identifier == 'to-read' %}
|
{% if active_shelf.shelf.identifier == 'to-read' and shelf.identifier == 'reading' %}
|
||||||
<div class="dropdown-item pt-0 pb-0">
|
<div class="dropdown-item pt-0 pb-0">
|
||||||
<label class="button is-small" for="start-reading-{{ uuid }}" role="button" tabindex="0">
|
<label class="button is-small" for="start-reading-{{ uuid }}" role="button" tabindex="0">
|
||||||
Start reading
|
Start reading
|
||||||
</label>
|
</label>
|
||||||
{% include 'snippets/start_reading_modal.html' %}
|
{% include 'snippets/start_reading_modal.html' with book=active_shelf.book %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form class="dropdown-item pt-0 pb-0" name="shelve" action="/shelve/" method="post">
|
<form class="dropdown-item pt-0 pb-0" name="shelve" action="/shelve/" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="book" value="{{ book.id }}">
|
<input type="hidden" name="book" value="{{ active_shelf.book.id }}">
|
||||||
<button class="button is-small" name="shelf" type="submit" value="{{ shelf.identifier }}" {% if shelf in book.shelf_set.all %} disabled {% endif %}>
|
<button class="button is-small" name="shelf" type="submit" value="{{ shelf.identifier }}" {% if shelf in book.shelf_set.all %} disabled {% endif %}>
|
||||||
<span>{{ shelf.name }}</span>
|
<span>{{ shelf.name }}</span>
|
||||||
{% if shelf in book.shelf_set.all %}<span class="icon icon-check"></span>{% endif %}
|
{% if shelf in book.shelf_set.all %}<span class="icon icon-check"></span>{% endif %}
|
||||||
|
@ -62,6 +66,7 @@
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
{% if not status.deleted %}
|
{% if not status.deleted %}
|
||||||
{% if status.status_type == 'Boost' %}
|
{% if status.status_type == 'Boost' %}
|
||||||
{% include 'snippets/avatar.html' with user=status.user %}
|
{% include 'snippets/avatar.html' with user=status.user %}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
{% load humanize %}
|
{% load humanize %}
|
||||||
|
|
||||||
{% if not status.deleted %}
|
{% if not status.deleted %}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
<div class="block">
|
<div class="block">
|
||||||
{% if status.status_type == 'Review' %}
|
{% if status.status_type == 'Review' %}
|
||||||
<h3>
|
<h3>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
{% include 'snippets/avatar.html' with user=status.user %}
|
{% include 'snippets/avatar.html' with user=status.user %}
|
||||||
{% include 'snippets/username.html' with user=status.user %}
|
{% include 'snippets/username.html' with user=status.user %}
|
||||||
|
|
||||||
|
|
5
bookwyrm/templates/snippets/switch_edition_button.html
Normal file
5
bookwyrm/templates/snippets/switch_edition_button.html
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<form name="switch-edition" action="/switch-edition" method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="edition" value="{{ edition.id }}">
|
||||||
|
<button class="button {{ size }}">Switch to this edition</button>
|
||||||
|
</form>
|
|
@ -1,4 +1,4 @@
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
<div class="block">
|
<div class="block">
|
||||||
|
|
||||||
{% with depth=depth|add:1 %}
|
{% with depth=depth|add:1 %}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
{% with 0|uuid as uuid %}
|
{% with 0|uuid as uuid %}
|
||||||
{% if full %}
|
{% if full %}
|
||||||
|
|
||||||
{% with full|text_overflow as trimmed %}
|
{% with full|truncatewords_html:60 as trimmed %}
|
||||||
{% if trimmed != full %}
|
{% if trimmed != full %}
|
||||||
<div>
|
<div>
|
||||||
<input type="radio" name="show-hide-{{ uuid }}" id="show-{{ uuid }}" class="toggle-control" checked>
|
<input type="radio" name="show-hide-{{ uuid }}" id="show-{{ uuid }}" class="toggle-control" checked>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{% load humanize %}
|
{% load humanize %}
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
<a href="/user/{{ user | username }}" class="user">{% if user.name %}{{ user.name }}{% else %}{{ user | username }}{% endif %}</a>{% if possessive %}'s{% endif %}{% if show_full and user.name or show_full and user.localname %} ({{ user.username }}){% endif %}
|
<a href="/user/{{ user | username }}" class="user">{% if user.name %}{{ user.name }}{% else %}{{ user | username }}{% endif %}</a>{% if possessive %}'s{% endif %}{% if show_full and user.name or show_full and user.localname %} ({{ user.username }}){% endif %}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{% extends 'layout.html' %}
|
{% extends 'layout.html' %}
|
||||||
{% load fr_display %}
|
{% load bookwyrm_tags %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="block">
|
<div class="block">
|
||||||
|
|
|
@ -116,20 +116,6 @@ def get_book_description(book):
|
||||||
''' use the work's text if the book doesn't have it '''
|
''' use the work's text if the book doesn't have it '''
|
||||||
return book.description or book.parent_work.description
|
return book.description or book.parent_work.description
|
||||||
|
|
||||||
@register.filter(name='text_overflow')
|
|
||||||
def text_overflow(text):
|
|
||||||
''' dont' let book descriptions run for ages '''
|
|
||||||
if not text:
|
|
||||||
return ''
|
|
||||||
char_max = 400
|
|
||||||
if text and len(text) < char_max:
|
|
||||||
return text
|
|
||||||
|
|
||||||
trimmed = text[:char_max]
|
|
||||||
# go back to the last space
|
|
||||||
trimmed = ' '.join(trimmed.split(' ')[:-1])
|
|
||||||
return trimmed + '...'
|
|
||||||
|
|
||||||
|
|
||||||
@register.filter(name='uuid')
|
@register.filter(name='uuid')
|
||||||
def get_uuid(identifier):
|
def get_uuid(identifier):
|
||||||
|
@ -146,7 +132,10 @@ def time_since(date):
|
||||||
delta = now - date
|
delta = now - date
|
||||||
|
|
||||||
if date < (now - relativedelta(weeks=1)):
|
if date < (now - relativedelta(weeks=1)):
|
||||||
return date.strftime('%b %-d')
|
formatter = '%b %-d'
|
||||||
|
if date.year != now.year:
|
||||||
|
formatter += ' %Y'
|
||||||
|
return date.strftime(formatter)
|
||||||
delta = relativedelta(now, date)
|
delta = relativedelta(now, date)
|
||||||
if delta.days:
|
if delta.days:
|
||||||
return '%dd' % delta.days
|
return '%dd' % delta.days
|
||||||
|
@ -160,12 +149,11 @@ def time_since(date):
|
||||||
@register.simple_tag(takes_context=True)
|
@register.simple_tag(takes_context=True)
|
||||||
def active_shelf(context, book):
|
def active_shelf(context, book):
|
||||||
''' check what shelf a user has a book on, if any '''
|
''' check what shelf a user has a book on, if any '''
|
||||||
#TODO: books can be on multiple shelves, handle that better
|
|
||||||
shelf = models.ShelfBook.objects.filter(
|
shelf = models.ShelfBook.objects.filter(
|
||||||
shelf__user=context['request'].user,
|
shelf__user=context['request'].user,
|
||||||
book=book
|
book__in=book.parent_work.editions.all()
|
||||||
).first()
|
).first()
|
||||||
return shelf.shelf if shelf else None
|
return shelf if shelf else {'book': book}
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag(takes_context=False)
|
@register.simple_tag(takes_context=False)
|
|
@ -95,34 +95,67 @@ class BaseActivity(TestCase):
|
||||||
self.assertEqual(result.remote_id, 'https://example.com/user/mouse')
|
self.assertEqual(result.remote_id, 'https://example.com/user/mouse')
|
||||||
self.assertEqual(result.name, 'MOUSE?? MOUSE!!')
|
self.assertEqual(result.name, 'MOUSE?? MOUSE!!')
|
||||||
|
|
||||||
def test_to_model(self):
|
def test_to_model_invalid_model(self):
|
||||||
''' the big boy of this module. it feels janky to test this with actual
|
''' catch mismatch between activity type and model type '''
|
||||||
models rather than a test model, but I don't know how to make a test
|
|
||||||
model so here we are. '''
|
|
||||||
instance = ActivityObject(id='a', type='b')
|
instance = ActivityObject(id='a', type='b')
|
||||||
with self.assertRaises(ActivitySerializerError):
|
with self.assertRaises(ActivitySerializerError):
|
||||||
instance.to_model(models.User)
|
instance.to_model(models.User)
|
||||||
|
|
||||||
# test setting simple fields
|
def test_to_model_simple_fields(self):
|
||||||
|
''' test setting simple fields '''
|
||||||
self.assertEqual(self.user.name, '')
|
self.assertEqual(self.user.name, '')
|
||||||
update_data = activitypub.Person(**self.user.to_activity())
|
|
||||||
update_data.name = 'New Name'
|
activity = activitypub.Person(
|
||||||
update_data.to_model(models.User, self.user)
|
id=self.user.remote_id,
|
||||||
|
name='New Name',
|
||||||
|
preferredUsername='mouse',
|
||||||
|
inbox='http://www.com/',
|
||||||
|
outbox='http://www.com/',
|
||||||
|
followers='',
|
||||||
|
summary='',
|
||||||
|
publicKey=None,
|
||||||
|
endpoints={},
|
||||||
|
)
|
||||||
|
|
||||||
|
activity.to_model(models.User, self.user)
|
||||||
|
|
||||||
self.assertEqual(self.user.name, 'New Name')
|
self.assertEqual(self.user.name, 'New Name')
|
||||||
|
|
||||||
def test_to_model_foreign_key(self):
|
def test_to_model_foreign_key(self):
|
||||||
''' test setting one to one/foreign key '''
|
''' test setting one to one/foreign key '''
|
||||||
update_data = activitypub.Person(**self.user.to_activity())
|
activity = activitypub.Person(
|
||||||
update_data.publicKey['publicKeyPem'] = 'hi im secure'
|
id=self.user.remote_id,
|
||||||
update_data.to_model(models.User, self.user)
|
name='New Name',
|
||||||
|
preferredUsername='mouse',
|
||||||
|
inbox='http://www.com/',
|
||||||
|
outbox='http://www.com/',
|
||||||
|
followers='',
|
||||||
|
summary='',
|
||||||
|
publicKey=self.user.key_pair.to_activity(),
|
||||||
|
endpoints={},
|
||||||
|
)
|
||||||
|
|
||||||
|
activity.publicKey['publicKeyPem'] = 'hi im secure'
|
||||||
|
|
||||||
|
activity.to_model(models.User, self.user)
|
||||||
self.assertEqual(self.user.key_pair.public_key, 'hi im secure')
|
self.assertEqual(self.user.key_pair.public_key, 'hi im secure')
|
||||||
|
|
||||||
@responses.activate
|
@responses.activate
|
||||||
def test_to_model_image(self):
|
def test_to_model_image(self):
|
||||||
''' update an image field '''
|
''' update an image field '''
|
||||||
update_data = activitypub.Person(**self.user.to_activity())
|
activity = activitypub.Person(
|
||||||
update_data.icon = {'url': 'http://www.example.com/image.jpg'}
|
id=self.user.remote_id,
|
||||||
|
name='New Name',
|
||||||
|
preferredUsername='mouse',
|
||||||
|
inbox='http://www.com/',
|
||||||
|
outbox='http://www.com/',
|
||||||
|
followers='',
|
||||||
|
summary='',
|
||||||
|
publicKey=None,
|
||||||
|
endpoints={},
|
||||||
|
icon={'url': 'http://www.example.com/image.jpg'}
|
||||||
|
)
|
||||||
|
|
||||||
responses.add(
|
responses.add(
|
||||||
responses.GET,
|
responses.GET,
|
||||||
'http://www.example.com/image.jpg',
|
'http://www.example.com/image.jpg',
|
||||||
|
@ -133,7 +166,7 @@ class BaseActivity(TestCase):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
self.user.avatar.file #pylint: disable=pointless-statement
|
self.user.avatar.file #pylint: disable=pointless-statement
|
||||||
|
|
||||||
update_data.to_model(models.User, self.user)
|
activity.to_model(models.User, self.user)
|
||||||
self.assertIsNotNone(self.user.avatar.name)
|
self.assertIsNotNone(self.user.avatar.name)
|
||||||
self.assertIsNotNone(self.user.avatar.file)
|
self.assertIsNotNone(self.user.avatar.file)
|
||||||
|
|
||||||
|
@ -145,8 +178,14 @@ class BaseActivity(TestCase):
|
||||||
)
|
)
|
||||||
book = models.Edition.objects.create(
|
book = models.Edition.objects.create(
|
||||||
title='Test Edition', remote_id='http://book.com/book')
|
title='Test Edition', remote_id='http://book.com/book')
|
||||||
update_data = activitypub.Note(**status.to_activity())
|
update_data = activitypub.Note(
|
||||||
update_data.tag = [
|
id=status.remote_id,
|
||||||
|
content=status.content,
|
||||||
|
attributedTo=self.user.remote_id,
|
||||||
|
published='hi',
|
||||||
|
to=[],
|
||||||
|
cc=[],
|
||||||
|
tag=[
|
||||||
{
|
{
|
||||||
'type': 'Mention',
|
'type': 'Mention',
|
||||||
'name': 'gerald',
|
'name': 'gerald',
|
||||||
|
@ -158,6 +197,7 @@ class BaseActivity(TestCase):
|
||||||
'href': 'http://book.com/book'
|
'href': 'http://book.com/book'
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
)
|
||||||
update_data.to_model(models.Status, instance=status)
|
update_data.to_model(models.Status, instance=status)
|
||||||
self.assertEqual(status.mention_users.first(), self.user)
|
self.assertEqual(status.mention_users.first(), self.user)
|
||||||
self.assertEqual(status.mention_books.first(), book)
|
self.assertEqual(status.mention_books.first(), book)
|
||||||
|
@ -171,12 +211,19 @@ class BaseActivity(TestCase):
|
||||||
content='test status',
|
content='test status',
|
||||||
user=self.user,
|
user=self.user,
|
||||||
)
|
)
|
||||||
update_data = activitypub.Note(**status.to_activity())
|
update_data = activitypub.Note(
|
||||||
update_data.attachment = [{
|
id=status.remote_id,
|
||||||
|
content=status.content,
|
||||||
|
attributedTo=self.user.remote_id,
|
||||||
|
published='hi',
|
||||||
|
to=[],
|
||||||
|
cc=[],
|
||||||
|
attachment=[{
|
||||||
'url': 'http://www.example.com/image.jpg',
|
'url': 'http://www.example.com/image.jpg',
|
||||||
'name': 'alt text',
|
'name': 'alt text',
|
||||||
'type': 'Image',
|
'type': 'Image',
|
||||||
}]
|
}],
|
||||||
|
)
|
||||||
|
|
||||||
responses.add(
|
responses.add(
|
||||||
responses.GET,
|
responses.GET,
|
||||||
|
|
51
bookwyrm/tests/data/ap_note.json
Normal file
51
bookwyrm/tests/data/ap_note.json
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
{
|
||||||
|
"@context": [
|
||||||
|
"https://www.w3.org/ns/activitystreams",
|
||||||
|
{
|
||||||
|
"ostatus": "http://ostatus.org#",
|
||||||
|
"atomUri": "ostatus:atomUri",
|
||||||
|
"inReplyToAtomUri": "ostatus:inReplyToAtomUri",
|
||||||
|
"conversation": "ostatus:conversation",
|
||||||
|
"sensitive": "as:sensitive",
|
||||||
|
"toot": "http://joinmastodon.org/ns#",
|
||||||
|
"votersCount": "toot:votersCount"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "https://example.com/users/rat/statuses/1234567",
|
||||||
|
"type": "Note",
|
||||||
|
"summary": null,
|
||||||
|
"inReplyTo": null,
|
||||||
|
"published": "2020-12-13T05:09:29Z",
|
||||||
|
"url": "https://example.com/@rat/1234567",
|
||||||
|
"attributedTo": "https://example.com/users/rat",
|
||||||
|
"to": [
|
||||||
|
"https://example.com/user/mouse"
|
||||||
|
],
|
||||||
|
"cc": [],
|
||||||
|
"sensitive": false,
|
||||||
|
"atomUri": "https://example.com/users/rat/statuses/1234567",
|
||||||
|
"inReplyToAtomUri": null,
|
||||||
|
"conversation": "tag:example.com,2020-12-13:objectId=7309346:objectType=Conversation",
|
||||||
|
"content": "test content in note",
|
||||||
|
"contentMap": {
|
||||||
|
"en": "<p><span class=\"h-card\"><a href=\"https://5ebd724a6abd.ngrok.io/user/mouse\" class=\"u-url mention\">@<span>mouse</span></a></span> hi</p>"
|
||||||
|
},
|
||||||
|
"attachment": [],
|
||||||
|
"tag": [
|
||||||
|
{
|
||||||
|
"type": "Mention",
|
||||||
|
"href": "https://example.com/user/mouse",
|
||||||
|
"name": "@mouse@example.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"replies": {
|
||||||
|
"id": "https://example.com/users/rat/statuses/105371151200548049/replies",
|
||||||
|
"type": "Collection",
|
||||||
|
"first": {
|
||||||
|
"type": "CollectionPage",
|
||||||
|
"next": "https://example.com/users/rat/statuses/105371151200548049/replies?only_other_accounts=true&page=true",
|
||||||
|
"partOf": "https://example.com/users/rat/statuses/105371151200548049/replies",
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1 +0,0 @@
|
||||||
from . import *
|
|
|
@ -1,51 +0,0 @@
|
||||||
import json
|
|
||||||
import pathlib
|
|
||||||
from unittest.mock import patch
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from bookwyrm import models, incoming
|
|
||||||
|
|
||||||
|
|
||||||
class Favorite(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
with patch('bookwyrm.models.user.set_remote_server.delay'):
|
|
||||||
with patch('bookwyrm.models.user.get_remote_reviews.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.local_user = models.User.objects.create_user(
|
|
||||||
'mouse', 'mouse@mouse.com', 'mouseword', local=True,
|
|
||||||
remote_id='http://local.com/user/mouse')
|
|
||||||
|
|
||||||
self.status = models.Status.objects.create(
|
|
||||||
user=self.local_user,
|
|
||||||
content='Test status',
|
|
||||||
remote_id='http://local.com/status/1',
|
|
||||||
)
|
|
||||||
|
|
||||||
datafile = pathlib.Path(__file__).parent.joinpath(
|
|
||||||
'../data/ap_user.json'
|
|
||||||
)
|
|
||||||
self.user_data = json.loads(datafile.read_bytes())
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def test_handle_favorite(self):
|
|
||||||
activity = {
|
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
||||||
'id': 'http://example.com/fav/1',
|
|
||||||
'actor': 'https://example.com/users/rat',
|
|
||||||
'published': 'Mon, 25 May 2020 19:31:20 GMT',
|
|
||||||
'object': 'http://local.com/status/1',
|
|
||||||
}
|
|
||||||
|
|
||||||
incoming.handle_favorite(activity)
|
|
||||||
|
|
||||||
fav = models.Favorite.objects.get(remote_id='http://example.com/fav/1')
|
|
||||||
self.assertEqual(fav.status, self.status)
|
|
||||||
self.assertEqual(fav.remote_id, 'http://example.com/fav/1')
|
|
||||||
self.assertEqual(fav.user, self.remote_user)
|
|
|
@ -1,77 +0,0 @@
|
||||||
from unittest.mock import patch
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from bookwyrm import models, incoming
|
|
||||||
|
|
||||||
|
|
||||||
class IncomingFollow(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
with patch('bookwyrm.models.user.set_remote_server.delay'):
|
|
||||||
with patch('bookwyrm.models.user.get_remote_reviews.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.local_user = models.User.objects.create_user(
|
|
||||||
'mouse', 'mouse@mouse.com', 'mouseword', local=True)
|
|
||||||
self.local_user.remote_id = 'http://local.com/user/mouse'
|
|
||||||
self.local_user.save()
|
|
||||||
|
|
||||||
|
|
||||||
def test_handle_follow(self):
|
|
||||||
activity = {
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"id": "https://example.com/users/rat/follows/123",
|
|
||||||
"type": "Follow",
|
|
||||||
"actor": "https://example.com/users/rat",
|
|
||||||
"object": "http://local.com/user/mouse"
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch('bookwyrm.broadcast.broadcast_task.delay') as _:
|
|
||||||
incoming.handle_follow(activity)
|
|
||||||
|
|
||||||
# notification created
|
|
||||||
notification = models.Notification.objects.get()
|
|
||||||
self.assertEqual(notification.user, self.local_user)
|
|
||||||
self.assertEqual(notification.notification_type, 'FOLLOW')
|
|
||||||
|
|
||||||
# the request should have been deleted
|
|
||||||
requests = models.UserFollowRequest.objects.all()
|
|
||||||
self.assertEqual(list(requests), [])
|
|
||||||
|
|
||||||
# the follow relationship should exist
|
|
||||||
follow = models.UserFollows.objects.get(user_object=self.local_user)
|
|
||||||
self.assertEqual(follow.user_subject, self.remote_user)
|
|
||||||
|
|
||||||
|
|
||||||
def test_handle_follow_manually_approved(self):
|
|
||||||
activity = {
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"id": "https://example.com/users/rat/follows/123",
|
|
||||||
"type": "Follow",
|
|
||||||
"actor": "https://example.com/users/rat",
|
|
||||||
"object": "http://local.com/user/mouse"
|
|
||||||
}
|
|
||||||
|
|
||||||
self.local_user.manually_approves_followers = True
|
|
||||||
self.local_user.save()
|
|
||||||
|
|
||||||
with patch('bookwyrm.broadcast.broadcast_task.delay') as _:
|
|
||||||
incoming.handle_follow(activity)
|
|
||||||
|
|
||||||
# notification created
|
|
||||||
notification = models.Notification.objects.get()
|
|
||||||
self.assertEqual(notification.user, self.local_user)
|
|
||||||
self.assertEqual(notification.notification_type, 'FOLLOW_REQUEST')
|
|
||||||
|
|
||||||
# the request should exist
|
|
||||||
request = models.UserFollowRequest.objects.get()
|
|
||||||
self.assertEqual(request.user_subject, self.remote_user)
|
|
||||||
self.assertEqual(request.user_object, self.local_user)
|
|
||||||
|
|
||||||
# the follow relationship should not exist
|
|
||||||
follow = models.UserFollows.objects.all()
|
|
||||||
self.assertEqual(list(follow), [])
|
|
|
@ -1,52 +0,0 @@
|
||||||
from unittest.mock import patch
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from bookwyrm import models, incoming
|
|
||||||
|
|
||||||
|
|
||||||
class IncomingFollowAccept(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
with patch('bookwyrm.models.user.set_remote_server.delay'):
|
|
||||||
with patch('bookwyrm.models.user.get_remote_reviews.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.local_user = models.User.objects.create_user(
|
|
||||||
'mouse', 'mouse@mouse.com', 'mouseword', local=True)
|
|
||||||
self.local_user.remote_id = 'http://local.com/user/mouse'
|
|
||||||
self.local_user.save()
|
|
||||||
|
|
||||||
|
|
||||||
def test_handle_follow_accept(self):
|
|
||||||
activity = {
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"id": "https://example.com/users/rat/follows/123#accepts",
|
|
||||||
"type": "Accept",
|
|
||||||
"actor": "https://example.com/users/rat",
|
|
||||||
"object": {
|
|
||||||
"id": "https://example.com/users/rat/follows/123",
|
|
||||||
"type": "Follow",
|
|
||||||
"actor": "http://local.com/user/mouse",
|
|
||||||
"object": "https://example.com/users/rat"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
models.UserFollowRequest.objects.create(
|
|
||||||
user_subject=self.local_user,
|
|
||||||
user_object=self.remote_user
|
|
||||||
)
|
|
||||||
self.assertEqual(models.UserFollowRequest.objects.count(), 1)
|
|
||||||
|
|
||||||
incoming.handle_follow_accept(activity)
|
|
||||||
|
|
||||||
# request should be deleted
|
|
||||||
self.assertEqual(models.UserFollowRequest.objects.count(), 0)
|
|
||||||
|
|
||||||
# relationship should be created
|
|
||||||
follows = self.remote_user.followers
|
|
||||||
self.assertEqual(follows.count(), 1)
|
|
||||||
self.assertEqual(follows.first(), self.local_user)
|
|
|
@ -8,6 +8,7 @@ from bookwyrm.models.book import isbn_10_to_13, isbn_13_to_10
|
||||||
class Book(TestCase):
|
class Book(TestCase):
|
||||||
''' not too much going on in the books model but here we are '''
|
''' not too much going on in the books model but here we are '''
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
''' we'll need some books '''
|
||||||
self.work = models.Work.objects.create(
|
self.work = models.Work.objects.create(
|
||||||
title='Example Work',
|
title='Example Work',
|
||||||
remote_id='https://example.com/book/1'
|
remote_id='https://example.com/book/1'
|
||||||
|
@ -22,6 +23,7 @@ class Book(TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_remote_id(self):
|
def test_remote_id(self):
|
||||||
|
''' fanciness with remote/origin ids '''
|
||||||
remote_id = 'https://%s/book/%d' % (settings.DOMAIN, self.work.id)
|
remote_id = 'https://%s/book/%d' % (settings.DOMAIN, self.work.id)
|
||||||
self.assertEqual(self.work.get_remote_id(), remote_id)
|
self.assertEqual(self.work.get_remote_id(), remote_id)
|
||||||
self.assertEqual(self.work.remote_id, remote_id)
|
self.assertEqual(self.work.remote_id, remote_id)
|
||||||
|
@ -54,17 +56,3 @@ class Book(TestCase):
|
||||||
isbn_13 = '978-1788-16167-1'
|
isbn_13 = '978-1788-16167-1'
|
||||||
isbn_10 = isbn_13_to_10(isbn_13)
|
isbn_10 = isbn_13_to_10(isbn_13)
|
||||||
self.assertEqual(isbn_10, '178816167X')
|
self.assertEqual(isbn_10, '178816167X')
|
||||||
|
|
||||||
|
|
||||||
class Shelf(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
user = models.User.objects.create_user(
|
|
||||||
'mouse', 'mouse@mouse.mouse', 'mouseword', local=True)
|
|
||||||
models.Shelf.objects.create(
|
|
||||||
name='Test Shelf', identifier='test-shelf', user=user)
|
|
||||||
|
|
||||||
def test_remote_id(self):
|
|
||||||
''' editions and works use the same absolute id syntax '''
|
|
||||||
shelf = models.Shelf.objects.get(identifier='test-shelf')
|
|
||||||
expected_id = 'https://%s/user/mouse/shelf/test-shelf' % settings.DOMAIN
|
|
||||||
self.assertEqual(shelf.get_remote_id(), expected_id)
|
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
''' testing models '''
|
''' testing models '''
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
from dataclasses import dataclass
|
||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
|
from typing import List
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
@ -15,33 +17,27 @@ from django.db import models
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from bookwyrm.models import fields, User
|
from bookwyrm.activitypub.base_activity import ActivityObject
|
||||||
|
from bookwyrm.models import fields, User, Status
|
||||||
|
from bookwyrm.models.base_model import ActivitypubMixin, BookWyrmModel
|
||||||
|
|
||||||
|
#pylint: disable=too-many-public-methods
|
||||||
class ActivitypubFields(TestCase):
|
class ActivitypubFields(TestCase):
|
||||||
''' overwrites standard model feilds to work with activitypub '''
|
''' overwrites standard model feilds to work with activitypub '''
|
||||||
def test_validate_remote_id(self):
|
def test_validate_remote_id(self):
|
||||||
''' should look like a url '''
|
''' should look like a url '''
|
||||||
self.assertIsNone(fields.validate_remote_id(
|
self.assertIsNone(fields.validate_remote_id('http://www.example.com'))
|
||||||
'http://www.example.com'
|
self.assertIsNone(fields.validate_remote_id('https://www.example.com'))
|
||||||
))
|
self.assertIsNone(fields.validate_remote_id('http://exle.com/dlg-23/x'))
|
||||||
self.assertIsNone(fields.validate_remote_id(
|
|
||||||
'https://www.example.com'
|
|
||||||
))
|
|
||||||
self.assertIsNone(fields.validate_remote_id(
|
|
||||||
'http://example.com/dlfjg-23/x'
|
|
||||||
))
|
|
||||||
self.assertRaises(
|
self.assertRaises(
|
||||||
ValidationError, fields.validate_remote_id,
|
ValidationError, fields.validate_remote_id,
|
||||||
'http:/example.com/dlfjg-23/x'
|
'http:/example.com/dlfjg-23/x')
|
||||||
)
|
|
||||||
self.assertRaises(
|
self.assertRaises(
|
||||||
ValidationError, fields.validate_remote_id,
|
ValidationError, fields.validate_remote_id,
|
||||||
'www.example.com/dlfjg-23/x'
|
'www.example.com/dlfjg-23/x')
|
||||||
)
|
|
||||||
self.assertRaises(
|
self.assertRaises(
|
||||||
ValidationError, fields.validate_remote_id,
|
ValidationError, fields.validate_remote_id,
|
||||||
'http://www.example.com/dlfjg 23/x'
|
'http://www.example.com/dlfjg 23/x')
|
||||||
)
|
|
||||||
|
|
||||||
def test_activitypub_field_mixin(self):
|
def test_activitypub_field_mixin(self):
|
||||||
''' generic mixin with super basic to and from functionality '''
|
''' generic mixin with super basic to and from functionality '''
|
||||||
|
@ -67,6 +63,38 @@ class ActivitypubFields(TestCase):
|
||||||
instance.name = 'snake_case_name'
|
instance.name = 'snake_case_name'
|
||||||
self.assertEqual(instance.get_activitypub_field(), 'snakeCaseName')
|
self.assertEqual(instance.get_activitypub_field(), 'snakeCaseName')
|
||||||
|
|
||||||
|
def test_set_field_from_activity(self):
|
||||||
|
''' setter from entire json blob '''
|
||||||
|
@dataclass
|
||||||
|
class TestModel:
|
||||||
|
''' real simple mock '''
|
||||||
|
field_name: str
|
||||||
|
|
||||||
|
mock_model = TestModel(field_name='bip')
|
||||||
|
TestActivity = namedtuple('test', ('fieldName', 'unrelated'))
|
||||||
|
data = TestActivity(fieldName='hi', unrelated='bfkjh')
|
||||||
|
|
||||||
|
instance = fields.ActivitypubFieldMixin()
|
||||||
|
instance.name = 'field_name'
|
||||||
|
|
||||||
|
instance.set_field_from_activity(mock_model, data)
|
||||||
|
self.assertEqual(mock_model.field_name, 'hi')
|
||||||
|
|
||||||
|
def test_set_activity_from_field(self):
|
||||||
|
''' set json field given entire model '''
|
||||||
|
@dataclass
|
||||||
|
class TestModel:
|
||||||
|
''' real simple mock '''
|
||||||
|
field_name: str
|
||||||
|
unrelated: str
|
||||||
|
mock_model = TestModel(field_name='bip', unrelated='field')
|
||||||
|
instance = fields.ActivitypubFieldMixin()
|
||||||
|
instance.name = 'field_name'
|
||||||
|
|
||||||
|
data = {}
|
||||||
|
instance.set_activity_from_field(data, mock_model)
|
||||||
|
self.assertEqual(data['fieldName'], 'bip')
|
||||||
|
|
||||||
def test_remote_id_field(self):
|
def test_remote_id_field(self):
|
||||||
''' just sets some defaults on charfield '''
|
''' just sets some defaults on charfield '''
|
||||||
instance = fields.RemoteIdField()
|
instance = fields.RemoteIdField()
|
||||||
|
@ -90,6 +118,97 @@ class ActivitypubFields(TestCase):
|
||||||
|
|
||||||
self.assertEqual(instance.field_to_activity('test@example.com'), 'test')
|
self.assertEqual(instance.field_to_activity('test@example.com'), 'test')
|
||||||
|
|
||||||
|
|
||||||
|
def test_privacy_field_defaults(self):
|
||||||
|
''' post privacy field's many default values '''
|
||||||
|
instance = fields.PrivacyField()
|
||||||
|
self.assertEqual(instance.max_length, 255)
|
||||||
|
self.assertEqual(
|
||||||
|
[c[0] for c in instance.choices],
|
||||||
|
['public', 'unlisted', 'followers', 'direct'])
|
||||||
|
self.assertEqual(instance.default, 'public')
|
||||||
|
self.assertEqual(
|
||||||
|
instance.public, 'https://www.w3.org/ns/activitystreams#Public')
|
||||||
|
|
||||||
|
def test_privacy_field_set_field_from_activity(self):
|
||||||
|
''' translate between to/cc fields and privacy '''
|
||||||
|
@dataclass(init=False)
|
||||||
|
class TestActivity(ActivityObject):
|
||||||
|
''' real simple mock '''
|
||||||
|
to: List[str]
|
||||||
|
cc: List[str]
|
||||||
|
id: str = 'http://hi.com'
|
||||||
|
type: str = 'Test'
|
||||||
|
|
||||||
|
class TestPrivacyModel(ActivitypubMixin, BookWyrmModel):
|
||||||
|
''' real simple mock model because BookWyrmModel is abstract '''
|
||||||
|
privacy_field = fields.PrivacyField()
|
||||||
|
mention_users = fields.TagField(User)
|
||||||
|
user = fields.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
public = 'https://www.w3.org/ns/activitystreams#Public'
|
||||||
|
data = TestActivity(
|
||||||
|
to=[public],
|
||||||
|
cc=['bleh'],
|
||||||
|
)
|
||||||
|
model_instance = TestPrivacyModel(privacy_field='direct')
|
||||||
|
self.assertEqual(model_instance.privacy_field, 'direct')
|
||||||
|
|
||||||
|
instance = fields.PrivacyField()
|
||||||
|
instance.name = 'privacy_field'
|
||||||
|
instance.set_field_from_activity(model_instance, data)
|
||||||
|
self.assertEqual(model_instance.privacy_field, 'public')
|
||||||
|
|
||||||
|
data.to = ['bleh']
|
||||||
|
data.cc = []
|
||||||
|
instance.set_field_from_activity(model_instance, data)
|
||||||
|
self.assertEqual(model_instance.privacy_field, 'direct')
|
||||||
|
|
||||||
|
data.to = ['bleh']
|
||||||
|
data.cc = [public, 'waah']
|
||||||
|
instance.set_field_from_activity(model_instance, data)
|
||||||
|
self.assertEqual(model_instance.privacy_field, 'unlisted')
|
||||||
|
|
||||||
|
|
||||||
|
def test_privacy_field_set_activity_from_field(self):
|
||||||
|
''' translate between to/cc fields and privacy '''
|
||||||
|
user = User.objects.create_user(
|
||||||
|
'rat', 'rat@rat.rat', 'ratword', local=True)
|
||||||
|
public = 'https://www.w3.org/ns/activitystreams#Public'
|
||||||
|
followers = '%s/followers' % user.remote_id
|
||||||
|
|
||||||
|
instance = fields.PrivacyField()
|
||||||
|
instance.name = 'privacy_field'
|
||||||
|
|
||||||
|
model_instance = Status.objects.create(user=user, content='hi')
|
||||||
|
activity = {}
|
||||||
|
instance.set_activity_from_field(activity, model_instance)
|
||||||
|
self.assertEqual(activity['to'], [public])
|
||||||
|
self.assertEqual(activity['cc'], [followers])
|
||||||
|
|
||||||
|
model_instance = Status.objects.create(user=user, privacy='unlisted')
|
||||||
|
activity = {}
|
||||||
|
instance.set_activity_from_field(activity, model_instance)
|
||||||
|
self.assertEqual(activity['to'], [followers])
|
||||||
|
self.assertEqual(activity['cc'], [public])
|
||||||
|
|
||||||
|
model_instance = Status.objects.create(user=user, privacy='followers')
|
||||||
|
activity = {}
|
||||||
|
instance.set_activity_from_field(activity, model_instance)
|
||||||
|
self.assertEqual(activity['to'], [followers])
|
||||||
|
self.assertEqual(activity['cc'], [])
|
||||||
|
|
||||||
|
model_instance = Status.objects.create(
|
||||||
|
user=user,
|
||||||
|
privacy='direct',
|
||||||
|
)
|
||||||
|
model_instance.mention_users.set([user])
|
||||||
|
activity = {}
|
||||||
|
instance.set_activity_from_field(activity, model_instance)
|
||||||
|
self.assertEqual(activity['to'], [user.remote_id])
|
||||||
|
self.assertEqual(activity['cc'], [])
|
||||||
|
|
||||||
|
|
||||||
def test_foreign_key(self):
|
def test_foreign_key(self):
|
||||||
''' should be able to format a related model '''
|
''' should be able to format a related model '''
|
||||||
instance = fields.ForeignKey('User', on_delete=models.CASCADE)
|
instance = fields.ForeignKey('User', on_delete=models.CASCADE)
|
||||||
|
@ -98,6 +217,7 @@ class ActivitypubFields(TestCase):
|
||||||
# returns the remote_id field of the related object
|
# returns the remote_id field of the related object
|
||||||
self.assertEqual(instance.field_to_activity(item), 'https://e.b/c')
|
self.assertEqual(instance.field_to_activity(item), 'https://e.b/c')
|
||||||
|
|
||||||
|
|
||||||
@responses.activate
|
@responses.activate
|
||||||
def test_foreign_key_from_activity_str(self):
|
def test_foreign_key_from_activity_str(self):
|
||||||
''' create a new object from a foreign key '''
|
''' create a new object from a foreign key '''
|
||||||
|
@ -312,3 +432,12 @@ class ActivitypubFields(TestCase):
|
||||||
''' idk why it makes them strings but probably for a good reason '''
|
''' idk why it makes them strings but probably for a good reason '''
|
||||||
instance = fields.ArrayField(fields.IntegerField)
|
instance = fields.ArrayField(fields.IntegerField)
|
||||||
self.assertEqual(instance.field_to_activity([0, 1]), ['0', '1'])
|
self.assertEqual(instance.field_to_activity([0, 1]), ['0', '1'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_field(self):
|
||||||
|
''' sanitizes html, the sanitizer has its own tests '''
|
||||||
|
instance = fields.HtmlField()
|
||||||
|
self.assertEqual(
|
||||||
|
instance.field_from_activity('<marquee><p>hi</p></marquee>'),
|
||||||
|
'<p>hi</p>'
|
||||||
|
)
|
||||||
|
|
30
bookwyrm/tests/models/test_shelf_model.py
Normal file
30
bookwyrm/tests/models/test_shelf_model.py
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
''' testing models '''
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from bookwyrm import models, settings
|
||||||
|
|
||||||
|
|
||||||
|
class Shelf(TestCase):
|
||||||
|
''' some activitypub oddness ahead '''
|
||||||
|
def setUp(self):
|
||||||
|
''' look, a shelf '''
|
||||||
|
self.user = models.User.objects.create_user(
|
||||||
|
'mouse', 'mouse@mouse.mouse', 'mouseword', local=True)
|
||||||
|
self.shelf = models.Shelf.objects.create(
|
||||||
|
name='Test Shelf', identifier='test-shelf', user=self.user)
|
||||||
|
|
||||||
|
def test_remote_id(self):
|
||||||
|
''' shelves use custom remote ids '''
|
||||||
|
expected_id = 'https://%s/user/mouse/shelf/test-shelf' % settings.DOMAIN
|
||||||
|
self.assertEqual(self.shelf.get_remote_id(), expected_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_activity(self):
|
||||||
|
''' jsonify it '''
|
||||||
|
activity_json = self.shelf.to_activity()
|
||||||
|
self.assertIsInstance(activity_json, dict)
|
||||||
|
self.assertEqual(activity_json['id'], self.shelf.remote_id)
|
||||||
|
self.assertEqual(activity_json['totalItems'], 0)
|
||||||
|
self.assertEqual(activity_json['type'], 'OrderedCollection')
|
||||||
|
self.assertEqual(activity_json['name'], 'Test Shelf')
|
||||||
|
self.assertEqual(activity_json['owner'], self.user.remote_id)
|
503
bookwyrm/tests/test_incoming.py
Normal file
503
bookwyrm/tests/test_incoming.py
Normal file
|
@ -0,0 +1,503 @@
|
||||||
|
''' test incoming activities '''
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.http import HttpResponseBadRequest, HttpResponseNotAllowed, \
|
||||||
|
HttpResponseNotFound
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.test.client import RequestFactory
|
||||||
|
|
||||||
|
from bookwyrm import models, incoming
|
||||||
|
|
||||||
|
|
||||||
|
#pylint: disable=too-many-public-methods
|
||||||
|
class Incoming(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)
|
||||||
|
self.local_user.remote_id = 'https://example.com/user/mouse'
|
||||||
|
self.local_user.save()
|
||||||
|
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.factory = RequestFactory()
|
||||||
|
|
||||||
|
|
||||||
|
def test_inbox_invalid_get(self):
|
||||||
|
''' shouldn't try to handle if the user is not found '''
|
||||||
|
request = self.factory.get('https://www.example.com/')
|
||||||
|
self.assertIsInstance(
|
||||||
|
incoming.inbox(request, 'anything'), HttpResponseNotAllowed)
|
||||||
|
self.assertIsInstance(
|
||||||
|
incoming.shared_inbox(request), HttpResponseNotAllowed)
|
||||||
|
|
||||||
|
def test_inbox_invalid_user(self):
|
||||||
|
''' shouldn't try to handle if the user is not found '''
|
||||||
|
request = self.factory.post('https://www.example.com/')
|
||||||
|
self.assertIsInstance(
|
||||||
|
incoming.inbox(request, 'fish@tomato.com'), HttpResponseNotFound)
|
||||||
|
|
||||||
|
def test_inbox_invalid_no_object(self):
|
||||||
|
''' json is missing "object" field '''
|
||||||
|
request = self.factory.post(
|
||||||
|
self.local_user.shared_inbox, data={})
|
||||||
|
self.assertIsInstance(
|
||||||
|
incoming.shared_inbox(request), HttpResponseBadRequest)
|
||||||
|
|
||||||
|
def test_inbox_invalid_bad_signature(self):
|
||||||
|
''' bad request for invalid signature '''
|
||||||
|
request = self.factory.post(
|
||||||
|
self.local_user.shared_inbox,
|
||||||
|
'{"type": "Test", "object": "exists"}',
|
||||||
|
content_type='application/json')
|
||||||
|
with patch('bookwyrm.incoming.has_valid_signature') as mock_has_valid:
|
||||||
|
mock_has_valid.return_value = False
|
||||||
|
self.assertEqual(
|
||||||
|
incoming.shared_inbox(request).status_code, 401)
|
||||||
|
|
||||||
|
def test_inbox_invalid_bad_signature_delete(self):
|
||||||
|
''' invalid signature for Delete is okay though '''
|
||||||
|
request = self.factory.post(
|
||||||
|
self.local_user.shared_inbox,
|
||||||
|
'{"type": "Delete", "object": "exists"}',
|
||||||
|
content_type='application/json')
|
||||||
|
with patch('bookwyrm.incoming.has_valid_signature') as mock_has_valid:
|
||||||
|
mock_has_valid.return_value = False
|
||||||
|
self.assertEqual(
|
||||||
|
incoming.shared_inbox(request).status_code, 200)
|
||||||
|
|
||||||
|
def test_inbox_unknown_type(self):
|
||||||
|
''' never heard of that activity type, don't have a handler for it '''
|
||||||
|
request = self.factory.post(
|
||||||
|
self.local_user.shared_inbox,
|
||||||
|
'{"type": "Fish", "object": "exists"}',
|
||||||
|
content_type='application/json')
|
||||||
|
with patch('bookwyrm.incoming.has_valid_signature') as mock_has_valid:
|
||||||
|
mock_has_valid.return_value = True
|
||||||
|
self.assertIsInstance(
|
||||||
|
incoming.shared_inbox(request), HttpResponseNotFound)
|
||||||
|
|
||||||
|
def test_inbox_success(self):
|
||||||
|
''' a known type, for which we start a task '''
|
||||||
|
request = self.factory.post(
|
||||||
|
self.local_user.shared_inbox,
|
||||||
|
'{"type": "Accept", "object": "exists"}',
|
||||||
|
content_type='application/json')
|
||||||
|
with patch('bookwyrm.incoming.has_valid_signature') as mock_has_valid:
|
||||||
|
mock_has_valid.return_value = True
|
||||||
|
|
||||||
|
with patch('bookwyrm.incoming.handle_follow_accept.delay'):
|
||||||
|
self.assertEqual(
|
||||||
|
incoming.shared_inbox(request).status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_follow(self):
|
||||||
|
''' remote user wants to follow local user '''
|
||||||
|
activity = {
|
||||||
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
"id": "https://example.com/users/rat/follows/123",
|
||||||
|
"type": "Follow",
|
||||||
|
"actor": "https://example.com/users/rat",
|
||||||
|
"object": "https://example.com/user/mouse"
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch('bookwyrm.broadcast.broadcast_task.delay'):
|
||||||
|
incoming.handle_follow(activity)
|
||||||
|
|
||||||
|
# notification created
|
||||||
|
notification = models.Notification.objects.get()
|
||||||
|
self.assertEqual(notification.user, self.local_user)
|
||||||
|
self.assertEqual(notification.notification_type, 'FOLLOW')
|
||||||
|
|
||||||
|
# the request should have been deleted
|
||||||
|
requests = models.UserFollowRequest.objects.all()
|
||||||
|
self.assertEqual(list(requests), [])
|
||||||
|
|
||||||
|
# the follow relationship should exist
|
||||||
|
follow = models.UserFollows.objects.get(user_object=self.local_user)
|
||||||
|
self.assertEqual(follow.user_subject, self.remote_user)
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_follow_manually_approved(self):
|
||||||
|
''' needs approval before following '''
|
||||||
|
activity = {
|
||||||
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
"id": "https://example.com/users/rat/follows/123",
|
||||||
|
"type": "Follow",
|
||||||
|
"actor": "https://example.com/users/rat",
|
||||||
|
"object": "https://example.com/user/mouse"
|
||||||
|
}
|
||||||
|
|
||||||
|
self.local_user.manually_approves_followers = True
|
||||||
|
self.local_user.save()
|
||||||
|
|
||||||
|
with patch('bookwyrm.broadcast.broadcast_task.delay'):
|
||||||
|
incoming.handle_follow(activity)
|
||||||
|
|
||||||
|
# notification created
|
||||||
|
notification = models.Notification.objects.get()
|
||||||
|
self.assertEqual(notification.user, self.local_user)
|
||||||
|
self.assertEqual(notification.notification_type, 'FOLLOW_REQUEST')
|
||||||
|
|
||||||
|
# the request should exist
|
||||||
|
request = models.UserFollowRequest.objects.get()
|
||||||
|
self.assertEqual(request.user_subject, self.remote_user)
|
||||||
|
self.assertEqual(request.user_object, self.local_user)
|
||||||
|
|
||||||
|
# the follow relationship should not exist
|
||||||
|
follow = models.UserFollows.objects.all()
|
||||||
|
self.assertEqual(list(follow), [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_unfollow(self):
|
||||||
|
''' remove a relationship '''
|
||||||
|
activity = {
|
||||||
|
"type": "Undo",
|
||||||
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
"object": {
|
||||||
|
"id": "https://example.com/users/rat/follows/123",
|
||||||
|
"type": "Follow",
|
||||||
|
"actor": "https://example.com/users/rat",
|
||||||
|
"object": "https://example.com/user/mouse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
models.UserFollows.objects.create(
|
||||||
|
user_subject=self.remote_user, user_object=self.local_user)
|
||||||
|
self.assertEqual(self.remote_user, self.local_user.followers.first())
|
||||||
|
|
||||||
|
incoming.handle_unfollow(activity)
|
||||||
|
self.assertIsNone(self.local_user.followers.first())
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_follow_accept(self):
|
||||||
|
''' a remote user approved a follow request from local '''
|
||||||
|
activity = {
|
||||||
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
"id": "https://example.com/users/rat/follows/123#accepts",
|
||||||
|
"type": "Accept",
|
||||||
|
"actor": "https://example.com/users/rat",
|
||||||
|
"object": {
|
||||||
|
"id": "https://example.com/users/rat/follows/123",
|
||||||
|
"type": "Follow",
|
||||||
|
"actor": "https://example.com/user/mouse",
|
||||||
|
"object": "https://example.com/users/rat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
models.UserFollowRequest.objects.create(
|
||||||
|
user_subject=self.local_user,
|
||||||
|
user_object=self.remote_user
|
||||||
|
)
|
||||||
|
self.assertEqual(models.UserFollowRequest.objects.count(), 1)
|
||||||
|
|
||||||
|
incoming.handle_follow_accept(activity)
|
||||||
|
|
||||||
|
# request should be deleted
|
||||||
|
self.assertEqual(models.UserFollowRequest.objects.count(), 0)
|
||||||
|
|
||||||
|
# relationship should be created
|
||||||
|
follows = self.remote_user.followers
|
||||||
|
self.assertEqual(follows.count(), 1)
|
||||||
|
self.assertEqual(follows.first(), self.local_user)
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_follow_reject(self):
|
||||||
|
''' turn down a follow request '''
|
||||||
|
activity = {
|
||||||
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
"id": "https://example.com/users/rat/follows/123#accepts",
|
||||||
|
"type": "Reject",
|
||||||
|
"actor": "https://example.com/users/rat",
|
||||||
|
"object": {
|
||||||
|
"id": "https://example.com/users/rat/follows/123",
|
||||||
|
"type": "Follow",
|
||||||
|
"actor": "https://example.com/user/mouse",
|
||||||
|
"object": "https://example.com/users/rat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
models.UserFollowRequest.objects.create(
|
||||||
|
user_subject=self.local_user,
|
||||||
|
user_object=self.remote_user
|
||||||
|
)
|
||||||
|
self.assertEqual(models.UserFollowRequest.objects.count(), 1)
|
||||||
|
|
||||||
|
incoming.handle_follow_reject(activity)
|
||||||
|
|
||||||
|
# request should be deleted
|
||||||
|
self.assertEqual(models.UserFollowRequest.objects.count(), 0)
|
||||||
|
|
||||||
|
# relationship should be created
|
||||||
|
follows = self.remote_user.followers
|
||||||
|
self.assertEqual(follows.count(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_create(self):
|
||||||
|
''' the "it justs works" mode '''
|
||||||
|
self.assertEqual(models.Status.objects.count(), 1)
|
||||||
|
|
||||||
|
datafile = pathlib.Path(__file__).parent.joinpath(
|
||||||
|
'data/ap_quotation.json')
|
||||||
|
status_data = json.loads(datafile.read_bytes())
|
||||||
|
models.Edition.objects.create(
|
||||||
|
title='Test Book', remote_id='https://example.com/book/1')
|
||||||
|
activity = {'object': status_data, 'type': 'Create'}
|
||||||
|
|
||||||
|
incoming.handle_create(activity)
|
||||||
|
|
||||||
|
status = models.Quotation.objects.get()
|
||||||
|
self.assertEqual(
|
||||||
|
status.remote_id, 'https://example.com/user/mouse/quotation/13')
|
||||||
|
self.assertEqual(status.quote, 'quote body')
|
||||||
|
self.assertEqual(status.content, 'commentary')
|
||||||
|
self.assertEqual(status.user, self.local_user)
|
||||||
|
self.assertEqual(models.Status.objects.count(), 2)
|
||||||
|
|
||||||
|
# while we're here, lets ensure we avoid dupes
|
||||||
|
incoming.handle_create(activity)
|
||||||
|
self.assertEqual(models.Status.objects.count(), 2)
|
||||||
|
|
||||||
|
def test_handle_create_remote_note_with_mention(self):
|
||||||
|
''' should only create it under the right circumstances '''
|
||||||
|
self.assertEqual(models.Status.objects.count(), 1)
|
||||||
|
self.assertFalse(
|
||||||
|
models.Notification.objects.filter(user=self.local_user).exists())
|
||||||
|
|
||||||
|
datafile = pathlib.Path(__file__).parent.joinpath(
|
||||||
|
'data/ap_note.json')
|
||||||
|
status_data = json.loads(datafile.read_bytes())
|
||||||
|
activity = {'object': status_data, 'type': 'Create'}
|
||||||
|
|
||||||
|
incoming.handle_create(activity)
|
||||||
|
status = models.Status.objects.last()
|
||||||
|
self.assertEqual(status.content, 'test content in note')
|
||||||
|
self.assertEqual(status.mention_users.first(), self.local_user)
|
||||||
|
self.assertTrue(
|
||||||
|
models.Notification.objects.filter(user=self.local_user).exists())
|
||||||
|
self.assertEqual(
|
||||||
|
models.Notification.objects.get().notification_type, 'MENTION')
|
||||||
|
|
||||||
|
def test_handle_create_remote_note_with_reply(self):
|
||||||
|
''' should only create it under the right circumstances '''
|
||||||
|
self.assertEqual(models.Status.objects.count(), 1)
|
||||||
|
self.assertFalse(
|
||||||
|
models.Notification.objects.filter(user=self.local_user))
|
||||||
|
|
||||||
|
datafile = pathlib.Path(__file__).parent.joinpath(
|
||||||
|
'data/ap_note.json')
|
||||||
|
status_data = json.loads(datafile.read_bytes())
|
||||||
|
del status_data['tag']
|
||||||
|
status_data['inReplyTo'] = self.status.remote_id
|
||||||
|
activity = {'object': status_data, 'type': 'Create'}
|
||||||
|
|
||||||
|
incoming.handle_create(activity)
|
||||||
|
status = models.Status.objects.last()
|
||||||
|
self.assertEqual(status.content, 'test content in note')
|
||||||
|
self.assertEqual(status.reply_parent, self.status)
|
||||||
|
self.assertTrue(
|
||||||
|
models.Notification.objects.filter(user=self.local_user))
|
||||||
|
self.assertEqual(
|
||||||
|
models.Notification.objects.get().notification_type, 'REPLY')
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_delete_status(self):
|
||||||
|
''' remove a status '''
|
||||||
|
self.assertFalse(self.status.deleted)
|
||||||
|
activity = {
|
||||||
|
'type': 'Delete',
|
||||||
|
'id': '%s/activity' % self.status.remote_id,
|
||||||
|
'actor': self.local_user.remote_id,
|
||||||
|
'object': {'id': self.status.remote_id},
|
||||||
|
}
|
||||||
|
incoming.handle_delete_status(activity)
|
||||||
|
# deletion doens't remove the status, it turns it into a tombstone
|
||||||
|
status = models.Status.objects.get()
|
||||||
|
self.assertTrue(status.deleted)
|
||||||
|
self.assertIsInstance(status.deleted_date, datetime)
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_delete_status_notifications(self):
|
||||||
|
''' remove a status with related notifications '''
|
||||||
|
models.Notification.objects.create(
|
||||||
|
related_status=self.status,
|
||||||
|
user=self.local_user,
|
||||||
|
notification_type='MENTION'
|
||||||
|
)
|
||||||
|
# this one is innocent, don't delete it
|
||||||
|
notif = models.Notification.objects.create(
|
||||||
|
user=self.local_user,
|
||||||
|
notification_type='MENTION'
|
||||||
|
)
|
||||||
|
self.assertFalse(self.status.deleted)
|
||||||
|
self.assertEqual(models.Notification.objects.count(), 2)
|
||||||
|
activity = {
|
||||||
|
'type': 'Delete',
|
||||||
|
'id': '%s/activity' % self.status.remote_id,
|
||||||
|
'actor': self.local_user.remote_id,
|
||||||
|
'object': {'id': self.status.remote_id},
|
||||||
|
}
|
||||||
|
incoming.handle_delete_status(activity)
|
||||||
|
# deletion doens't remove the status, it turns it into a tombstone
|
||||||
|
status = models.Status.objects.get()
|
||||||
|
self.assertTrue(status.deleted)
|
||||||
|
self.assertIsInstance(status.deleted_date, datetime)
|
||||||
|
|
||||||
|
# notifications should be truly deleted
|
||||||
|
self.assertEqual(models.Notification.objects.count(), 1)
|
||||||
|
self.assertEqual(models.Notification.objects.get(), notif)
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_favorite(self):
|
||||||
|
''' fav a status '''
|
||||||
|
activity = {
|
||||||
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
|
'id': 'https://example.com/fav/1',
|
||||||
|
'actor': 'https://example.com/users/rat',
|
||||||
|
'published': 'Mon, 25 May 2020 19:31:20 GMT',
|
||||||
|
'object': 'https://example.com/status/1',
|
||||||
|
}
|
||||||
|
|
||||||
|
incoming.handle_favorite(activity)
|
||||||
|
|
||||||
|
fav = models.Favorite.objects.get(remote_id='https://example.com/fav/1')
|
||||||
|
self.assertEqual(fav.status, self.status)
|
||||||
|
self.assertEqual(fav.remote_id, 'https://example.com/fav/1')
|
||||||
|
self.assertEqual(fav.user, self.remote_user)
|
||||||
|
|
||||||
|
def test_handle_unfavorite(self):
|
||||||
|
''' fav a status '''
|
||||||
|
activity = {
|
||||||
|
'id': 'https://example.com/fav/1#undo',
|
||||||
|
'type': 'Undo',
|
||||||
|
'object': {
|
||||||
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
|
'id': 'https://example.com/fav/1',
|
||||||
|
'actor': 'https://example.com/users/rat',
|
||||||
|
'published': 'Mon, 25 May 2020 19:31:20 GMT',
|
||||||
|
'object': 'https://example.com/fav/1',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
models.Favorite.objects.create(
|
||||||
|
status=self.status,
|
||||||
|
user=self.remote_user,
|
||||||
|
remote_id='https://example.com/fav/1')
|
||||||
|
self.assertEqual(models.Favorite.objects.count(), 1)
|
||||||
|
|
||||||
|
incoming.handle_unfavorite(activity)
|
||||||
|
self.assertEqual(models.Favorite.objects.count(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_boost(self):
|
||||||
|
''' boost a status '''
|
||||||
|
self.assertEqual(models.Notification.objects.count(), 0)
|
||||||
|
activity = {
|
||||||
|
'type': 'Announce',
|
||||||
|
'id': '%s/boost' % self.status.remote_id,
|
||||||
|
'actor': self.remote_user.remote_id,
|
||||||
|
'object': self.status.to_activity(),
|
||||||
|
}
|
||||||
|
incoming.handle_boost(activity)
|
||||||
|
boost = models.Boost.objects.get()
|
||||||
|
self.assertEqual(boost.boosted_status, self.status)
|
||||||
|
notification = models.Notification.objects.get()
|
||||||
|
self.assertEqual(notification.user, self.local_user)
|
||||||
|
self.assertEqual(notification.related_status, self.status)
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_unboost(self):
|
||||||
|
''' undo a boost '''
|
||||||
|
activity = {
|
||||||
|
'type': 'Undo',
|
||||||
|
'object': {
|
||||||
|
'type': 'Announce',
|
||||||
|
'id': '%s/boost' % self.status.remote_id,
|
||||||
|
'actor': self.local_user.remote_id,
|
||||||
|
'object': self.status.to_activity(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
models.Boost.objects.create(
|
||||||
|
boosted_status=self.status, user=self.remote_user)
|
||||||
|
incoming.handle_unboost(activity)
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_add_book(self):
|
||||||
|
''' shelving a book '''
|
||||||
|
book = models.Edition.objects.create(
|
||||||
|
title='Test', remote_id='https://bookwyrm.social/book/37292')
|
||||||
|
shelf = models.Shelf.objects.create(
|
||||||
|
user=self.remote_user, name='Test Shelf')
|
||||||
|
shelf.remote_id = 'https://bookwyrm.social/user/mouse/shelf/to-read'
|
||||||
|
shelf.save()
|
||||||
|
|
||||||
|
activity = {
|
||||||
|
"id": "https://bookwyrm.social/shelfbook/6189#add",
|
||||||
|
"type": "Add",
|
||||||
|
"actor": "hhttps://example.com/users/rat",
|
||||||
|
"object": "https://bookwyrm.social/book/37292",
|
||||||
|
"target": "https://bookwyrm.social/user/mouse/shelf/to-read",
|
||||||
|
"@context": "https://www.w3.org/ns/activitystreams"
|
||||||
|
}
|
||||||
|
incoming.handle_add(activity)
|
||||||
|
self.assertEqual(shelf.books.first(), book)
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_update_user(self):
|
||||||
|
''' update an existing user '''
|
||||||
|
datafile = pathlib.Path(__file__).parent.joinpath(
|
||||||
|
'data/ap_user.json')
|
||||||
|
userdata = json.loads(datafile.read_bytes())
|
||||||
|
del userdata['icon']
|
||||||
|
self.assertEqual(self.local_user.name, '')
|
||||||
|
incoming.handle_update_user({'object': userdata})
|
||||||
|
user = models.User.objects.get(id=self.local_user.id)
|
||||||
|
self.assertEqual(user.name, 'MOUSE?? MOUSE!!')
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_update_edition(self):
|
||||||
|
''' update an existing edition '''
|
||||||
|
datafile = pathlib.Path(__file__).parent.joinpath(
|
||||||
|
'data/fr_edition.json')
|
||||||
|
bookdata = json.loads(datafile.read_bytes())
|
||||||
|
|
||||||
|
book = models.Edition.objects.create(
|
||||||
|
title='Test Book', remote_id='https://bookwyrm.social/book/5989')
|
||||||
|
|
||||||
|
del bookdata['authors']
|
||||||
|
self.assertEqual(book.title, 'Test Book')
|
||||||
|
with patch(
|
||||||
|
'bookwyrm.activitypub.base_activity.set_related_field.delay'):
|
||||||
|
incoming.handle_update_edition({'object': bookdata})
|
||||||
|
book = models.Edition.objects.get(id=book.id)
|
||||||
|
self.assertEqual(book.title, 'Piranesi')
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_update_work(self):
|
||||||
|
''' update an existing edition '''
|
||||||
|
datafile = pathlib.Path(__file__).parent.joinpath(
|
||||||
|
'data/fr_work.json')
|
||||||
|
bookdata = json.loads(datafile.read_bytes())
|
||||||
|
|
||||||
|
book = models.Work.objects.create(
|
||||||
|
title='Test Book', remote_id='https://bookwyrm.social/book/5988')
|
||||||
|
|
||||||
|
del bookdata['authors']
|
||||||
|
self.assertEqual(book.title, 'Test Book')
|
||||||
|
with patch(
|
||||||
|
'bookwyrm.activitypub.base_activity.set_related_field.delay'):
|
||||||
|
incoming.handle_update_work({'object': bookdata})
|
||||||
|
book = models.Work.objects.get(id=book.id)
|
||||||
|
self.assertEqual(book.title, 'Piranesi')
|
|
@ -1,34 +1,36 @@
|
||||||
|
''' make sure only valid html gets to the app '''
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from bookwyrm.sanitize_html import InputHtmlParser
|
from bookwyrm.sanitize_html import InputHtmlParser
|
||||||
|
|
||||||
|
|
||||||
class Sanitizer(TestCase):
|
class Sanitizer(TestCase):
|
||||||
|
''' sanitizer tests '''
|
||||||
def test_no_html(self):
|
def test_no_html(self):
|
||||||
|
''' just text '''
|
||||||
input_text = 'no html '
|
input_text = 'no html '
|
||||||
parser = InputHtmlParser()
|
parser = InputHtmlParser()
|
||||||
parser.feed(input_text)
|
parser.feed(input_text)
|
||||||
output = parser.get_output()
|
output = parser.get_output()
|
||||||
self.assertEqual(input_text, output)
|
self.assertEqual(input_text, output)
|
||||||
|
|
||||||
|
|
||||||
def test_valid_html(self):
|
def test_valid_html(self):
|
||||||
|
''' leave the html untouched '''
|
||||||
input_text = '<b>yes </b> <i>html</i>'
|
input_text = '<b>yes </b> <i>html</i>'
|
||||||
parser = InputHtmlParser()
|
parser = InputHtmlParser()
|
||||||
parser.feed(input_text)
|
parser.feed(input_text)
|
||||||
output = parser.get_output()
|
output = parser.get_output()
|
||||||
self.assertEqual(input_text, output)
|
self.assertEqual(input_text, output)
|
||||||
|
|
||||||
|
|
||||||
def test_valid_html_attrs(self):
|
def test_valid_html_attrs(self):
|
||||||
|
''' and don't remove attributes '''
|
||||||
input_text = '<a href="fish.com">yes </a> <i>html</i>'
|
input_text = '<a href="fish.com">yes </a> <i>html</i>'
|
||||||
parser = InputHtmlParser()
|
parser = InputHtmlParser()
|
||||||
parser.feed(input_text)
|
parser.feed(input_text)
|
||||||
output = parser.get_output()
|
output = parser.get_output()
|
||||||
self.assertEqual(input_text, output)
|
self.assertEqual(input_text, output)
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_html(self):
|
def test_invalid_html(self):
|
||||||
|
''' remove all html when the html is malformed '''
|
||||||
input_text = '<b>yes <i>html</i>'
|
input_text = '<b>yes <i>html</i>'
|
||||||
parser = InputHtmlParser()
|
parser = InputHtmlParser()
|
||||||
parser.feed(input_text)
|
parser.feed(input_text)
|
||||||
|
@ -41,8 +43,8 @@ class Sanitizer(TestCase):
|
||||||
output = parser.get_output()
|
output = parser.get_output()
|
||||||
self.assertEqual('yes html ', output)
|
self.assertEqual('yes html ', output)
|
||||||
|
|
||||||
|
|
||||||
def test_disallowed_html(self):
|
def test_disallowed_html(self):
|
||||||
|
''' remove disallowed html but keep allowed html '''
|
||||||
input_text = '<div> yes <i>html</i></div>'
|
input_text = '<div> yes <i>html</i></div>'
|
||||||
parser = InputHtmlParser()
|
parser = InputHtmlParser()
|
||||||
parser.feed(input_text)
|
parser.feed(input_text)
|
||||||
|
|
242
bookwyrm/tests/test_templatetags.py
Normal file
242
bookwyrm/tests/test_templatetags.py
Normal file
|
@ -0,0 +1,242 @@
|
||||||
|
''' style fixes and lookups for templates '''
|
||||||
|
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
|
||||||
|
|
||||||
|
from bookwyrm import models
|
||||||
|
from bookwyrm.templatetags import bookwyrm_tags
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateTags(TestCase):
|
||||||
|
''' lotta different things here '''
|
||||||
|
def setUp(self):
|
||||||
|
''' create some filler objects '''
|
||||||
|
self.user = models.User.objects.create_user(
|
||||||
|
'mouse', 'mouse@mouse.mouse', 'mouseword', local=True)
|
||||||
|
with patch('bookwyrm.models.user.set_remote_server.delay'):
|
||||||
|
self.remote_user = models.User.objects.create_user(
|
||||||
|
'rat', 'rat@rat.rat', 'ratword',
|
||||||
|
remote_id='http://example.com/rat', local=False)
|
||||||
|
self.book = models.Edition.objects.create(title='Test Book')
|
||||||
|
|
||||||
|
|
||||||
|
def test_dict_key(self):
|
||||||
|
''' just getting a value out of a dict '''
|
||||||
|
test_dict = {'a': 1, 'b': 3}
|
||||||
|
self.assertEqual(
|
||||||
|
bookwyrm_tags.dict_key(test_dict, 'a'), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
bookwyrm_tags.dict_key(test_dict, 'c'), 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_rating(self):
|
||||||
|
''' get a user's most recent rating of a book '''
|
||||||
|
models.Review.objects.create(
|
||||||
|
user=self.user, book=self.book, rating=3)
|
||||||
|
self.assertEqual(
|
||||||
|
bookwyrm_tags.get_rating(self.book, self.user), 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_rating_doesnt_exist(self):
|
||||||
|
''' there is no rating available '''
|
||||||
|
self.assertEqual(
|
||||||
|
bookwyrm_tags.get_rating(self.book, self.user), 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_user_identifer_local(self):
|
||||||
|
''' fall back to the simplest uid available '''
|
||||||
|
self.assertNotEqual(self.user.username, self.user.localname)
|
||||||
|
self.assertEqual(
|
||||||
|
bookwyrm_tags.get_user_identifier(self.user), 'mouse')
|
||||||
|
|
||||||
|
def test_get_user_identifer_remote(self):
|
||||||
|
''' for a remote user, should be their full username '''
|
||||||
|
self.assertEqual(
|
||||||
|
bookwyrm_tags.get_user_identifier(self.remote_user),
|
||||||
|
'rat@example.com')
|
||||||
|
|
||||||
|
def test_get_notification_count(self):
|
||||||
|
''' just countin' '''
|
||||||
|
self.assertEqual(bookwyrm_tags.get_notification_count(self.user), 0)
|
||||||
|
|
||||||
|
models.Notification.objects.create(
|
||||||
|
user=self.user, notification_type='FOLLOW')
|
||||||
|
models.Notification.objects.create(
|
||||||
|
user=self.user, notification_type='FOLLOW')
|
||||||
|
|
||||||
|
models.Notification.objects.create(
|
||||||
|
user=self.remote_user, notification_type='FOLLOW')
|
||||||
|
|
||||||
|
self.assertEqual(bookwyrm_tags.get_notification_count(self.user), 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_replies(self):
|
||||||
|
''' direct replies to a status '''
|
||||||
|
parent = models.Review.objects.create(
|
||||||
|
user=self.user, book=self.book)
|
||||||
|
first_child = models.Status.objects.create(
|
||||||
|
reply_parent=parent, user=self.user)
|
||||||
|
second_child = models.Status.objects.create(
|
||||||
|
reply_parent=parent, user=self.user)
|
||||||
|
third_child = models.Status.objects.create(
|
||||||
|
reply_parent=parent, user=self.user, deleted=True)
|
||||||
|
|
||||||
|
replies = bookwyrm_tags.get_replies(parent)
|
||||||
|
self.assertEqual(len(replies), 2)
|
||||||
|
self.assertTrue(first_child in replies)
|
||||||
|
self.assertTrue(second_child in replies)
|
||||||
|
self.assertFalse(third_child in replies)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_parent(self):
|
||||||
|
''' get the reply parent of a status '''
|
||||||
|
parent = models.Review.objects.create(
|
||||||
|
user=self.user, book=self.book)
|
||||||
|
child = models.Status.objects.create(
|
||||||
|
reply_parent=parent, user=self.user)
|
||||||
|
|
||||||
|
result = bookwyrm_tags.get_parent(child)
|
||||||
|
self.assertEqual(result, parent)
|
||||||
|
self.assertIsInstance(result, models.Review)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_user_liked(self):
|
||||||
|
''' did a user like a status '''
|
||||||
|
status = models.Review.objects.create(
|
||||||
|
user=self.remote_user, book=self.book)
|
||||||
|
|
||||||
|
self.assertFalse(bookwyrm_tags.get_user_liked(self.user, status))
|
||||||
|
models.Favorite.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
status=status
|
||||||
|
)
|
||||||
|
self.assertTrue(bookwyrm_tags.get_user_liked(self.user, status))
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_user_boosted(self):
|
||||||
|
''' did a user boost a status '''
|
||||||
|
status = models.Review.objects.create(
|
||||||
|
user=self.remote_user, book=self.book)
|
||||||
|
|
||||||
|
self.assertFalse(bookwyrm_tags.get_user_boosted(self.user, status))
|
||||||
|
models.Boost.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
boosted_status=status
|
||||||
|
)
|
||||||
|
self.assertTrue(bookwyrm_tags.get_user_boosted(self.user, status))
|
||||||
|
|
||||||
|
|
||||||
|
def test_follow_request_exists(self):
|
||||||
|
''' does a user want to follow '''
|
||||||
|
self.assertFalse(
|
||||||
|
bookwyrm_tags.follow_request_exists(self.user, self.remote_user))
|
||||||
|
|
||||||
|
models.UserFollowRequest.objects.create(
|
||||||
|
user_subject=self.user,
|
||||||
|
user_object=self.remote_user)
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
bookwyrm_tags.follow_request_exists(self.user, self.remote_user))
|
||||||
|
self.assertTrue(
|
||||||
|
bookwyrm_tags.follow_request_exists(self.remote_user, self.user))
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_boosted(self):
|
||||||
|
''' load a boosted status '''
|
||||||
|
status = models.Review.objects.create(
|
||||||
|
user=self.remote_user, book=self.book)
|
||||||
|
boost = models.Boost.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
boosted_status=status
|
||||||
|
)
|
||||||
|
boosted = bookwyrm_tags.get_boosted(boost)
|
||||||
|
self.assertIsInstance(boosted, models.Review)
|
||||||
|
self.assertEqual(boosted, status)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_edition_info(self):
|
||||||
|
''' text slug about an edition '''
|
||||||
|
self.assertEqual(
|
||||||
|
bookwyrm_tags.get_edition_info(self.book), '')
|
||||||
|
|
||||||
|
self.book.physical_format = 'worm'
|
||||||
|
self.book.save()
|
||||||
|
self.assertEqual(
|
||||||
|
bookwyrm_tags.get_edition_info(self.book), 'worm')
|
||||||
|
|
||||||
|
self.book.languages = ['English']
|
||||||
|
self.book.save()
|
||||||
|
self.assertEqual(
|
||||||
|
bookwyrm_tags.get_edition_info(self.book), 'worm')
|
||||||
|
|
||||||
|
self.book.languages = ['Glorbish', 'English']
|
||||||
|
self.book.save()
|
||||||
|
self.assertEqual(
|
||||||
|
bookwyrm_tags.get_edition_info(self.book),
|
||||||
|
'worm, Glorbish language')
|
||||||
|
|
||||||
|
self.book.published_date = timezone.make_aware(parse('2020'))
|
||||||
|
self.book.save()
|
||||||
|
self.assertEqual(
|
||||||
|
bookwyrm_tags.get_edition_info(self.book),
|
||||||
|
'worm, Glorbish language, 2020')
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_book_description(self):
|
||||||
|
''' grab it from the edition or the parent '''
|
||||||
|
work = models.Work.objects.create(title='Test Work')
|
||||||
|
self.book.parent_work = work
|
||||||
|
self.book.save()
|
||||||
|
|
||||||
|
self.assertIsNone(bookwyrm_tags.get_book_description(self.book))
|
||||||
|
|
||||||
|
work.description = 'hi'
|
||||||
|
work.save()
|
||||||
|
self.assertEqual(bookwyrm_tags.get_book_description(self.book), 'hi')
|
||||||
|
|
||||||
|
self.book.description = 'hello'
|
||||||
|
self.book.save()
|
||||||
|
self.assertEqual(bookwyrm_tags.get_book_description(self.book), 'hello')
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_uuid(self):
|
||||||
|
''' uuid functionality '''
|
||||||
|
uuid = bookwyrm_tags.get_uuid('hi')
|
||||||
|
self.assertTrue(re.match(r'hi[A-Za-z0-9\-]', uuid))
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_since(self):
|
||||||
|
''' ultraconcise timestamps '''
|
||||||
|
self.assertEqual(bookwyrm_tags.time_since('bleh'), '')
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
self.assertEqual(bookwyrm_tags.time_since(now), '0s')
|
||||||
|
|
||||||
|
seconds_ago = now - relativedelta(seconds=4)
|
||||||
|
self.assertEqual(bookwyrm_tags.time_since(seconds_ago), '4s')
|
||||||
|
|
||||||
|
minutes_ago = now - relativedelta(minutes=8)
|
||||||
|
self.assertEqual(bookwyrm_tags.time_since(minutes_ago), '8m')
|
||||||
|
|
||||||
|
hours_ago = now - relativedelta(hours=9)
|
||||||
|
self.assertEqual(bookwyrm_tags.time_since(hours_ago), '9h')
|
||||||
|
|
||||||
|
days_ago = now - relativedelta(days=3)
|
||||||
|
self.assertEqual(bookwyrm_tags.time_since(days_ago), '3d')
|
||||||
|
|
||||||
|
# I am not going to figure out how to mock dates tonight.
|
||||||
|
months_ago = now - relativedelta(months=5)
|
||||||
|
self.assertTrue(re.match(
|
||||||
|
r'[A-Z][a-z]{2} \d?\d',
|
||||||
|
bookwyrm_tags.time_since(months_ago)
|
||||||
|
))
|
||||||
|
|
||||||
|
years_ago = now - relativedelta(years=10)
|
||||||
|
self.assertTrue(re.match(
|
||||||
|
r'[A-Z][a-z]{2} \d?\d \d{4}',
|
||||||
|
bookwyrm_tags.time_since(years_ago)
|
||||||
|
))
|
264
bookwyrm/tests/test_view_actions.py
Normal file
264
bookwyrm/tests/test_view_actions.py
Normal file
|
@ -0,0 +1,264 @@
|
||||||
|
''' test for app action functionality '''
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.http.response import Http404
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.test.client import RequestFactory
|
||||||
|
|
||||||
|
from bookwyrm import view_actions as actions, models
|
||||||
|
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)
|
||||||
|
self.local_user.remote_id = 'https://example.com/user/mouse'
|
||||||
|
self.local_user.save()
|
||||||
|
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.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/',
|
||||||
|
{
|
||||||
|
'username': '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/',
|
||||||
|
{
|
||||||
|
'username': '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/',
|
||||||
|
{
|
||||||
|
'username': '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/',
|
||||||
|
{
|
||||||
|
'username': '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/',
|
||||||
|
{
|
||||||
|
'username': '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/',
|
||||||
|
{
|
||||||
|
'username': '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/',
|
||||||
|
{
|
||||||
|
'username': '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/',
|
||||||
|
{
|
||||||
|
'username': '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)
|
||||||
|
|
||||||
|
# invalid invite
|
||||||
|
request = self.factory.post(
|
||||||
|
'register/',
|
||||||
|
{
|
||||||
|
'username': 'nutria2',
|
||||||
|
'password': 'mouseword',
|
||||||
|
'email': 'aa@bb.ccc',
|
||||||
|
'invite_code': 'testcode'
|
||||||
|
})
|
||||||
|
response = actions.register(request)
|
||||||
|
self.assertEqual(models.User.objects.count(), 3)
|
||||||
|
|
||||||
|
# bad invite code
|
||||||
|
request = self.factory.post(
|
||||||
|
'register/',
|
||||||
|
{
|
||||||
|
'username': '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_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)
|
|
@ -53,7 +53,8 @@ urlpatterns = [
|
||||||
|
|
||||||
path('', views.home),
|
path('', views.home),
|
||||||
re_path(r'^(?P<tab>home|local|federated)/?$', views.home_tab),
|
re_path(r'^(?P<tab>home|local|federated)/?$', views.home_tab),
|
||||||
re_path(r'^notifications/?', views.notifications_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/?$', views.import_page),
|
||||||
re_path(r'^import-status/(\d+)/?$', views.import_status),
|
re_path(r'^import-status/(\d+)/?$', views.import_status),
|
||||||
re_path(r'^user-edit/?$', views.edit_profile_page),
|
re_path(r'^user-edit/?$', views.edit_profile_page),
|
||||||
|
@ -97,15 +98,16 @@ urlpatterns = [
|
||||||
|
|
||||||
re_path(r'^edit-profile/?$', actions.edit_profile),
|
re_path(r'^edit-profile/?$', actions.edit_profile),
|
||||||
|
|
||||||
re_path(r'^import-data/?', actions.import_data),
|
re_path(r'^import-data/?$', actions.import_data),
|
||||||
re_path(r'^retry-import/?', actions.retry_import),
|
re_path(r'^retry-import/?$', actions.retry_import),
|
||||||
re_path(r'^resolve-book/?', actions.resolve_book),
|
re_path(r'^resolve-book/?$', actions.resolve_book),
|
||||||
re_path(r'^edit-book/(?P<book_id>\d+)/?', actions.edit_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'^upload-cover/(?P<book_id>\d+)/?$', actions.upload_cover),
|
||||||
re_path(r'^add-description/(?P<book_id>\d+)/?', actions.add_description),
|
re_path(r'^add-description/(?P<book_id>\d+)/?$', actions.add_description),
|
||||||
|
|
||||||
re_path(r'^edit-readthrough/?', actions.edit_readthrough),
|
re_path(r'^switch-edition/?$', actions.switch_edition),
|
||||||
re_path(r'^delete-readthrough/?', actions.delete_readthrough),
|
re_path(r'^edit-readthrough/?$', actions.edit_readthrough),
|
||||||
|
re_path(r'^delete-readthrough/?$', actions.delete_readthrough),
|
||||||
|
|
||||||
re_path(r'^rate/?$', actions.rate),
|
re_path(r'^rate/?$', actions.rate),
|
||||||
re_path(r'^review/?$', actions.review),
|
re_path(r'^review/?$', actions.review),
|
||||||
|
|
|
@ -1,67 +0,0 @@
|
||||||
'''Quick and dirty shim for JSONField and ArrayField compatibility on sqlite.
|
|
||||||
|
|
||||||
For more info and original code, see:
|
|
||||||
- https://medium.com/@philamersune/using-postgresql-jsonfield-in-sqlite-95ad4ad2e5f1
|
|
||||||
- https://gist.github.com/pvsune/2e5f9f9ae356d0bff633d896bc7d168b#file-django-sqlite-fields-py
|
|
||||||
'''
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.postgres.fields import (
|
|
||||||
JSONField as DjangoJSONField,
|
|
||||||
ArrayField as DjangoArrayField,
|
|
||||||
)
|
|
||||||
from django.db.models import Field
|
|
||||||
|
|
||||||
|
|
||||||
class JSONField(DjangoJSONField):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ArrayField(DjangoArrayField):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
if 'sqlite' in settings.DATABASES['default']['ENGINE']:
|
|
||||||
class JSONField(Field):
|
|
||||||
def db_type(self, connection):
|
|
||||||
return 'text'
|
|
||||||
|
|
||||||
def from_db_value(self, value, expression, connection):
|
|
||||||
if value is not None:
|
|
||||||
return self.to_python(value)
|
|
||||||
return value
|
|
||||||
|
|
||||||
def to_python(self, value):
|
|
||||||
if value is not None:
|
|
||||||
try:
|
|
||||||
return json.loads(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return value
|
|
||||||
return value
|
|
||||||
|
|
||||||
def get_prep_value(self, value):
|
|
||||||
if value is not None:
|
|
||||||
return str(json.dumps(value))
|
|
||||||
return value
|
|
||||||
|
|
||||||
def value_to_string(self, obj):
|
|
||||||
return self.value_from_object(obj)
|
|
||||||
|
|
||||||
|
|
||||||
class ArrayField(JSONField):
|
|
||||||
def __init__(self, base_field, size=None, **kwargs):
|
|
||||||
"""Care for DjangoArrayField's kwargs."""
|
|
||||||
self.base_field = base_field
|
|
||||||
self.size = size
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
|
|
||||||
def deconstruct(self):
|
|
||||||
"""Need to create migrations properly."""
|
|
||||||
name, path, args, kwargs = super().deconstruct()
|
|
||||||
kwargs.update({
|
|
||||||
'base_field': self.base_field.clone(),
|
|
||||||
'size': self.size,
|
|
||||||
})
|
|
||||||
return name, path, args, kwargs
|
|
|
@ -10,6 +10,7 @@ from django.contrib.auth import authenticate, login, logout
|
||||||
from django.contrib.auth.decorators import login_required, permission_required
|
from django.contrib.auth.decorators import login_required, permission_required
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
|
from django.db import transaction
|
||||||
from django.http import HttpResponseBadRequest, HttpResponseNotFound
|
from django.http import HttpResponseBadRequest, HttpResponseNotFound
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
|
@ -17,6 +18,7 @@ from django.utils import timezone
|
||||||
from django.views.decorators.http import require_GET, require_POST
|
from django.views.decorators.http import require_GET, require_POST
|
||||||
|
|
||||||
from bookwyrm import books_manager
|
from bookwyrm import books_manager
|
||||||
|
from bookwyrm.broadcast import broadcast
|
||||||
from bookwyrm import forms, models, outgoing
|
from bookwyrm import forms, models, outgoing
|
||||||
from bookwyrm import goodreads_import
|
from bookwyrm import goodreads_import
|
||||||
from bookwyrm.emailing import password_reset_email
|
from bookwyrm.emailing import password_reset_email
|
||||||
|
@ -66,7 +68,7 @@ def register(request):
|
||||||
if not form.is_valid():
|
if not form.is_valid():
|
||||||
errors = True
|
errors = True
|
||||||
|
|
||||||
username = form.data['username']
|
username = form.data['username'].strip()
|
||||||
email = form.data['email']
|
email = form.data['email']
|
||||||
password = form.data['password']
|
password = form.data['password']
|
||||||
|
|
||||||
|
@ -215,6 +217,7 @@ def edit_profile(request):
|
||||||
return redirect('/user/%s' % request.user.localname)
|
return redirect('/user/%s' % request.user.localname)
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
def resolve_book(request):
|
def resolve_book(request):
|
||||||
''' figure out the local path to a book from a remote_id '''
|
''' figure out the local path to a book from a remote_id '''
|
||||||
remote_id = request.POST.get('remote_id')
|
remote_id = request.POST.get('remote_id')
|
||||||
|
@ -245,6 +248,36 @@ def edit_book(request, book_id):
|
||||||
return redirect('/book/%s' % book.id)
|
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
|
@login_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def upload_cover(request, book_id):
|
def upload_cover(request, book_id):
|
||||||
|
|
|
@ -113,11 +113,36 @@ def get_suggested_books(user, max_books=5):
|
||||||
return suggested_books
|
return suggested_books
|
||||||
|
|
||||||
|
|
||||||
|
@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, filter_level, model=models.Status):
|
def get_activity_feed(user, filter_level, model=models.Status):
|
||||||
''' get a filtered queryset of statuses '''
|
''' get a filtered queryset of statuses '''
|
||||||
# status updates for your follow network
|
|
||||||
if user.is_anonymous:
|
if user.is_anonymous:
|
||||||
user = None
|
user = None
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
following = models.User.objects.filter(
|
following = models.User.objects.filter(
|
||||||
Q(followers=user) | Q(id=user.id)
|
Q(followers=user) | Q(id=user.id)
|
||||||
|
@ -135,6 +160,16 @@ def get_activity_feed(user, filter_level, model=models.Status):
|
||||||
'-published_date'
|
'-published_date'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if filter_level == 'direct':
|
||||||
|
return activities.filter(
|
||||||
|
Q(user=user) | Q(mention_users=user),
|
||||||
|
privacy='direct'
|
||||||
|
)
|
||||||
|
|
||||||
|
# never show DMs in the regular feed
|
||||||
|
activities = activities.filter(~Q(privacy='direct'))
|
||||||
|
|
||||||
|
|
||||||
if hasattr(activities, 'select_subclasses'):
|
if hasattr(activities, 'select_subclasses'):
|
||||||
activities = activities.select_subclasses()
|
activities = activities.select_subclasses()
|
||||||
|
|
||||||
|
@ -559,8 +594,7 @@ def book_page(request, book_id):
|
||||||
prev_page = '/book/%s/?page=%d' % \
|
prev_page = '/book/%s/?page=%d' % \
|
||||||
(book_id, reviews_page.previous_page_number())
|
(book_id, reviews_page.previous_page_number())
|
||||||
|
|
||||||
user_tags = []
|
user_tags = readthroughs = user_shelves = other_edition_shelves = []
|
||||||
readthroughs = []
|
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
user_tags = models.UserTag.objects.filter(
|
user_tags = models.UserTag.objects.filter(
|
||||||
book=book, user=request.user
|
book=book, user=request.user
|
||||||
|
@ -571,6 +605,16 @@ def book_page(request, book_id):
|
||||||
book=book,
|
book=book,
|
||||||
).order_by('start_date')
|
).order_by('start_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,
|
||||||
|
)
|
||||||
|
|
||||||
rating = reviews.aggregate(Avg('rating'))
|
rating = reviews.aggregate(Avg('rating'))
|
||||||
tags = models.UserTag.objects.filter(
|
tags = models.UserTag.objects.filter(
|
||||||
book=book,
|
book=book,
|
||||||
|
@ -584,6 +628,8 @@ def book_page(request, book_id):
|
||||||
'rating': rating['rating__avg'],
|
'rating': rating['rating__avg'],
|
||||||
'tags': tags,
|
'tags': tags,
|
||||||
'user_tags': user_tags,
|
'user_tags': user_tags,
|
||||||
|
'user_shelves': user_shelves,
|
||||||
|
'other_edition_shelves': other_edition_shelves,
|
||||||
'readthroughs': readthroughs,
|
'readthroughs': readthroughs,
|
||||||
'path': '/book/%s' % book_id,
|
'path': '/book/%s' % book_id,
|
||||||
'info_fields': [
|
'info_fields': [
|
||||||
|
@ -627,10 +673,9 @@ def editions_page(request, book_id):
|
||||||
encoder=ActivityEncoder
|
encoder=ActivityEncoder
|
||||||
)
|
)
|
||||||
|
|
||||||
editions = models.Edition.objects.filter(parent_work=work).all()
|
|
||||||
data = {
|
data = {
|
||||||
'title': 'Editions of %s' % work.title,
|
'title': 'Editions of %s' % work.title,
|
||||||
'editions': editions,
|
'editions': work.editions.all(),
|
||||||
'work': work,
|
'work': work,
|
||||||
}
|
}
|
||||||
return TemplateResponse(request, 'editions.html', data)
|
return TemplateResponse(request, 'editions.html', data)
|
||||||
|
|
3
bw-dev
3
bw-dev
|
@ -57,7 +57,8 @@ case "$1" in
|
||||||
clean
|
clean
|
||||||
;;
|
;;
|
||||||
makemigrations)
|
makemigrations)
|
||||||
execweb python manage.py makemigrations
|
shift 1
|
||||||
|
execweb python manage.py makemigrations "$@"
|
||||||
;;
|
;;
|
||||||
migrate)
|
migrate)
|
||||||
execweb python manage.py rename_app fedireads bookwyrm
|
execweb python manage.py rename_app fedireads bookwyrm
|
||||||
|
|
25
rebuilddb.sh
25
rebuilddb.sh
|
@ -1,25 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
if [ ! -f .env ]; then
|
|
||||||
echo "No .env found -- copying .example.env to .env!"
|
|
||||||
cp .env.example .env
|
|
||||||
fi
|
|
||||||
|
|
||||||
source .env
|
|
||||||
|
|
||||||
if [ $BOOKWYRM_DATABASE_BACKEND = 'sqlite' ]; then
|
|
||||||
if [ -f fedireads.db ]; then
|
|
||||||
rm fedireads.db
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
# assume postgres
|
|
||||||
dropdb fedireads
|
|
||||||
createdb fedireads
|
|
||||||
fi
|
|
||||||
|
|
||||||
python manage.py makemigrations fedireads
|
|
||||||
python manage.py migrate
|
|
||||||
|
|
||||||
python manage.py initdb
|
|
||||||
python manage.py runserver
|
|
Loading…
Reference in a new issue