Merge branch 'main' into production

This commit is contained in:
Mouse Reeve 2020-12-02 15:28:01 -08:00
commit 4171829626
43 changed files with 592 additions and 461 deletions

68
.github/workflows/django-tests.yml vendored Normal file
View file

@ -0,0 +1,68 @@
name: Run Python Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-20.04
strategy:
max-parallel: 4
matrix:
db: [postgres]
python-version: [3.9]
include:
- db: postgres
db_port: 5432
services:
postgres:
image: postgres:10
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: hunter2
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run Tests
env:
DB: ${{ matrix.db }}
DB_HOST: 127.0.0.1
DB_PORT: ${{ matrix.db_port }}
DB_PASSWORD: hunter2
SECRET_KEY: beepbeep
DEBUG: true
DOMAIN: your.domain.here
OL_URL: https://openlibrary.org
BOOKWYRM_DATABASE_BACKEND: postgres
MEDIA_ROOT: images/
POSTGRES_PASSWORD: hunter2
POSTGRES_USER: postgres
POSTGRES_DB: github_actions
POSTGRES_HOST: 127.0.0.1
CELERY_BROKER: ""
CELERY_RESULT_BACKEND: ""
EMAIL_HOST: "smtp.mailgun.org"
EMAIL_PORT: 587
EMAIL_HOST_USER: ""
EMAIL_HOST_PASSWORD: ""
EMAIL_USE_TLS: true
run: |
python manage.py test

View file

@ -2,11 +2,11 @@
import inspect import inspect
import sys import sys
from .base_activity import ActivityEncoder, Image, PublicKey, Signature from .base_activity import ActivityEncoder, PublicKey, Signature
from .base_activity import Link, Mention from .base_activity import Link, Mention
from .base_activity import ActivitySerializerError from .base_activity import ActivitySerializerError
from .base_activity import tag_formatter from .base_activity import tag_formatter
from .base_activity import image_formatter, image_attachments_formatter from .image import Image
from .note import Note, GeneratedNote, Article, Comment, Review, Quotation from .note import Note, GeneratedNote, Article, Comment, Review, Quotation
from .note import Tombstone from .note import Tombstone
from .interaction import Boost, Like from .interaction import Boost, Like

View file

@ -4,6 +4,7 @@ from json import JSONEncoder
from uuid import uuid4 from uuid import uuid4
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.db import transaction
from django.db.models.fields.related_descriptors \ from django.db.models.fields.related_descriptors \
import ForwardManyToOneDescriptor, ManyToManyDescriptor, \ import ForwardManyToOneDescriptor, ManyToManyDescriptor, \
ReverseManyToOneDescriptor ReverseManyToOneDescriptor
@ -23,13 +24,6 @@ class ActivityEncoder(JSONEncoder):
return o.__dict__ return o.__dict__
@dataclass
class Image:
''' image block '''
url: str
type: str = 'Image'
@dataclass @dataclass
class Link(): class Link():
''' for tagging a book in a status ''' ''' for tagging a book in a status '''
@ -74,7 +68,8 @@ class ActivityObject:
try: try:
value = kwargs[field.name] value = kwargs[field.name]
except KeyError: except KeyError:
if field.default == MISSING: if field.default == MISSING and \
field.default_factory == MISSING:
raise ActivitySerializerError(\ raise ActivitySerializerError(\
'Missing required field: %s' % field.name) 'Missing required field: %s' % field.name)
value = field.default value = field.default
@ -112,14 +107,15 @@ class ActivityObject:
formatted_value = mapping.model_formatter(value) formatted_value = mapping.model_formatter(value)
if isinstance(model_field, ForwardManyToOneDescriptor) and \ if isinstance(model_field, ForwardManyToOneDescriptor) and \
formatted_value: formatted_value:
# foreign key remote id reolver # foreign key remote id reolver (work on Edition, for example)
fk_model = model_field.field.related_model fk_model = model_field.field.related_model
reference = resolve_foreign_key(fk_model, formatted_value) reference = resolve_foreign_key(fk_model, formatted_value)
mapped_fields[mapping.model_key] = reference mapped_fields[mapping.model_key] = reference
elif isinstance(model_field, ManyToManyDescriptor): elif isinstance(model_field, ManyToManyDescriptor):
# status mentions book/users
many_to_many_fields[mapping.model_key] = formatted_value many_to_many_fields[mapping.model_key] = formatted_value
elif isinstance(model_field, ReverseManyToOneDescriptor): elif isinstance(model_field, ReverseManyToOneDescriptor):
# attachments on statuses, for example # attachments on Status, for example
one_to_many_fields[mapping.model_key] = formatted_value one_to_many_fields[mapping.model_key] = formatted_value
elif isinstance(model_field, ImageFileDescriptor): elif isinstance(model_field, ImageFileDescriptor):
# image fields need custom handling # image fields need custom handling
@ -127,35 +123,41 @@ class ActivityObject:
else: else:
mapped_fields[mapping.model_key] = formatted_value mapped_fields[mapping.model_key] = formatted_value
if instance: with transaction.atomic():
# updating an existing model isntance if instance:
for k, v in mapped_fields.items(): # updating an existing model isntance
setattr(instance, k, v) for k, v in mapped_fields.items():
instance.save() setattr(instance, k, v)
else:
# creating a new model instance
instance = model.objects.create(**mapped_fields)
# add many-to-many fields
for (model_key, values) in many_to_many_fields.items():
getattr(instance, model_key).set(values)
instance.save()
# add images
for (model_key, value) in image_fields.items():
getattr(instance, model_key).save(*value, save=True)
# add one to many fields
for (model_key, values) in one_to_many_fields.items():
items = []
for item in values:
# the reference id wasn't available at creation time
setattr(item, instance.__class__.__name__.lower(), instance)
item.save()
items.append(item)
if items:
getattr(instance, model_key).set(items)
instance.save() instance.save()
else:
# creating a new model instance
instance = model.objects.create(**mapped_fields)
# add images
for (model_key, value) in image_fields.items():
formatted_value = image_formatter(value)
if not formatted_value:
continue
getattr(instance, model_key).save(*formatted_value, save=True)
for (model_key, values) in many_to_many_fields.items():
# mention books, mention users
getattr(instance, model_key).set(values)
# add one to many fields
for (model_key, values) in one_to_many_fields.items():
if values == MISSING:
continue
model_field = getattr(instance, model_key)
model = model_field.model
for item in values:
item = model.activity_serializer(**item)
field_name = instance.__class__.__name__.lower()
with transaction.atomic():
item = item.to_model(model)
setattr(item, field_name, instance)
item.save()
return instance return instance
@ -188,6 +190,8 @@ def resolve_foreign_key(model, remote_id):
def tag_formatter(tags, tag_type): def tag_formatter(tags, tag_type):
''' helper function to extract foreign keys from tag activity json ''' ''' helper function to extract foreign keys from tag activity json '''
if not isinstance(tags, list):
return []
items = [] items = []
types = { types = {
'Book': models.Book, 'Book': models.Book,
@ -205,12 +209,18 @@ def tag_formatter(tags, tag_type):
return items return items
def image_formatter(image_json): def image_formatter(image_slug):
''' helper function to load images and format them for a model ''' ''' helper function to load images and format them for a model '''
url = image_json.get('url') # when it's an inline image (User avatar/icon, Book cover), it's a json
# blob, but when it's an attached image, it's just a url
if isinstance(image_slug, dict):
url = image_slug.get('url')
elif isinstance(image_slug, str):
url = image_slug
else:
return None
if not url: if not url:
return None return None
try: try:
response = requests.get(url) response = requests.get(url)
except ConnectionError: except ConnectionError:
@ -221,15 +231,3 @@ def image_formatter(image_json):
image_name = str(uuid4()) + '.' + url.split('.')[-1] image_name = str(uuid4()) + '.' + url.split('.')[-1]
image_content = ContentFile(response.content) image_content = ContentFile(response.content)
return [image_name, image_content] return [image_name, image_content]
def image_attachments_formatter(images_json):
''' deserialize a list of images '''
attachments = []
for image in images_json:
caption = image.get('name')
attachment = models.Attachment(caption=caption)
image_field = image_formatter(image)
attachment.image.save(*image_field, save=False)
attachments.append(attachment)
return attachments

View file

@ -2,42 +2,43 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import List from typing import List
from .base_activity import ActivityObject, Image from .base_activity import ActivityObject
from .image import Image
@dataclass(init=False) @dataclass(init=False)
class Book(ActivityObject): class Book(ActivityObject):
''' serializes an edition or work, abstract ''' ''' serializes an edition or work, abstract '''
authors: List[str]
first_published_date: str
published_date: str
title: str title: str
sort_title: str sortTitle: str = ''
subtitle: str subtitle: str = ''
description: str description: str = ''
languages: List[str] languages: List[str]
series: str series: str = ''
series_number: str seriesNumber: str = ''
subjects: List[str] subjects: List[str]
subject_places: List[str] subjectPlaces: List[str]
openlibrary_key: str authors: List[str]
librarything_key: str firstPublishedDate: str = ''
goodreads_key: str publishedDate: str = ''
attachment: List[Image] = field(default=lambda: []) openlibraryKey: str = ''
librarythingKey: str = ''
goodreadsKey: str = ''
cover: Image = field(default_factory=lambda: {})
type: str = 'Book' type: str = 'Book'
@dataclass(init=False) @dataclass(init=False)
class Edition(Book): class Edition(Book):
''' Edition instance of a book object ''' ''' Edition instance of a book object '''
isbn_10: str isbn10: str
isbn_13: str isbn13: str
oclc_number: str oclcNumber: str
asin: str asin: str
pages: str pages: str
physical_format: str physicalFormat: str
publishers: List[str] publishers: List[str]
work: str work: str
@ -56,10 +57,10 @@ class Work(Book):
class Author(ActivityObject): class Author(ActivityObject):
''' author of a book ''' ''' author of a book '''
name: str name: str
born: str born: str = ''
died: str died: str = ''
aliases: str aliases: str = ''
bio: str bio: str = ''
openlibrary_key: str openlibraryKey: str = ''
wikipedia_link: str wikipediaLink: str = ''
type: str = 'Person' type: str = 'Person'

View file

@ -0,0 +1,11 @@
''' an image, nothing fancy '''
from dataclasses import dataclass
from .base_activity import ActivityObject
@dataclass(init=False)
class Image(ActivityObject):
''' image block '''
url: str
name: str = ''
type: str = 'Image'
id: str = ''

View file

@ -2,7 +2,8 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Dict, List from typing import Dict, List
from .base_activity import ActivityObject, Image, Link from .base_activity import ActivityObject, Link
from .image import Image
@dataclass(init=False) @dataclass(init=False)
class Tombstone(ActivityObject): class Tombstone(ActivityObject):
@ -24,8 +25,8 @@ class Note(ActivityObject):
cc: List[str] cc: List[str]
content: str content: str
replies: Dict replies: Dict
tag: List[Link] = field(default=lambda: []) tag: List[Link] = field(default_factory=lambda: [])
attachment: List[Image] = field(default=lambda: []) attachment: List[Image] = field(default_factory=lambda: [])
sensitive: bool = False sensitive: bool = False
type: str = 'Note' type: str = 'Note'

View file

@ -2,7 +2,8 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Dict from typing import Dict
from .base_activity import ActivityObject, Image, PublicKey from .base_activity import ActivityObject, PublicKey
from .image import Image
@dataclass(init=False) @dataclass(init=False)
class Person(ActivityObject): class Person(ActivityObject):
@ -15,7 +16,7 @@ class Person(ActivityObject):
summary: str summary: str
publicKey: PublicKey publicKey: PublicKey
endpoints: Dict endpoints: Dict
icon: Image = field(default=lambda: {}) icon: Image = field(default_factory=lambda: {})
bookwyrmUser: bool = False bookwyrmUser: bool = False
manuallyApprovesFollowers: str = False manuallyApprovesFollowers: str = False
discoverable: str = True discoverable: str = True

View file

@ -157,7 +157,7 @@ class AbstractConnector(ABC):
def update_book_from_data(self, book, data, update_cover=True): def update_book_from_data(self, book, data, update_cover=True):
''' for creating a new book or syncing with data ''' ''' for creating a new book or syncing with data '''
book = update_from_mappings(book, data, self.book_mappings) book = self.update_from_mappings(book, data, self.book_mappings)
author_text = [] author_text = []
for author in self.get_authors_from_data(data): for author in self.get_authors_from_data(data):
@ -262,23 +262,23 @@ class AbstractConnector(ABC):
''' get more info on a book ''' ''' get more info on a book '''
def update_from_mappings(obj, data, mappings): def update_from_mappings(self, obj, data, mappings):
''' assign data to model with mappings ''' ''' assign data to model with mappings '''
for mapping in mappings: for mapping in mappings:
# check if this field is present in the data # check if this field is present in the data
value = data.get(mapping.remote_field) value = data.get(mapping.remote_field)
if not value: if not value:
continue continue
# extract the value in the right format # extract the value in the right format
try: try:
value = mapping.formatter(value) value = mapping.formatter(value)
except: except:
continue continue
# assign the formatted value to the model # assign the formatted value to the model
obj.__setattr__(mapping.local_field, value) obj.__setattr__(mapping.local_field, value)
return obj return obj
def get_date(date_string): def get_date(date_string):

View file

@ -1,55 +1,21 @@
''' using another bookwyrm instance as a source of book data ''' ''' using another bookwyrm instance as a source of book data '''
from uuid import uuid4
from django.core.exceptions import ObjectDoesNotExist
from django.core.files.base import ContentFile
from django.db import transaction from django.db import transaction
import requests
from bookwyrm import models from bookwyrm import activitypub, models
from .abstract_connector import AbstractConnector, SearchResult, Mapping from .abstract_connector import AbstractConnector, SearchResult
from .abstract_connector import update_from_mappings, get_date, get_data from .abstract_connector import get_data
class Connector(AbstractConnector): class Connector(AbstractConnector):
''' interact with other instances ''' ''' interact with other instances '''
def __init__(self, identifier):
super().__init__(identifier)
self.key_mappings = [
Mapping('isbn_13', model=models.Edition),
Mapping('isbn_10', model=models.Edition),
Mapping('lccn', model=models.Work),
Mapping('oclc_number', model=models.Edition),
Mapping('openlibrary_key'),
Mapping('goodreads_key'),
Mapping('asin'),
]
self.book_mappings = self.key_mappings + [ def update_from_mappings(self, obj, data, mappings):
Mapping('sort_title'), ''' serialize book data into a model '''
Mapping('subtitle'), if self.is_work_data(data):
Mapping('description'), work_data = activitypub.Work(**data)
Mapping('languages'), return work_data.to_model(models.Work, instance=obj)
Mapping('series'), edition_data = activitypub.Edition(**data)
Mapping('series_number'), return edition_data.to_model(models.Edition, instance=obj)
Mapping('subjects'),
Mapping('subject_places'),
Mapping('first_published_date'),
Mapping('published_date'),
Mapping('pages'),
Mapping('physical_format'),
Mapping('publishers'),
]
self.author_mappings = [
Mapping('name'),
Mapping('bio'),
Mapping('openlibrary_key'),
Mapping('wikipedia_link'),
Mapping('aliases'),
Mapping('born', formatter=get_date),
Mapping('died', formatter=get_date),
]
def get_remote_id_from_data(self, data): def get_remote_id_from_data(self, data):
@ -57,7 +23,7 @@ class Connector(AbstractConnector):
def is_work_data(self, data): def is_work_data(self, data):
return data['type'] == 'Work' return data.get('type') == 'Work'
def get_edition_from_work_data(self, data): def get_edition_from_work_data(self, data):
@ -71,46 +37,20 @@ class Connector(AbstractConnector):
def get_authors_from_data(self, data): def get_authors_from_data(self, data):
for author_url in data.get('authors', []): ''' load author data '''
yield self.get_or_create_author(author_url) for author_id in data.get('authors', []):
try:
yield models.Author.objects.get(origin_id=author_id)
except models.Author.DoesNotExist:
pass
data = get_data(author_id)
author_data = activitypub.Author(**data)
author = author_data.to_model(models.Author)
yield author
def get_cover_from_data(self, data): def get_cover_from_data(self, data):
cover_data = data.get('attachment') pass
if not cover_data:
return None
try:
cover_url = cover_data[0].get('url')
except IndexError:
return None
try:
response = requests.get(cover_url)
except ConnectionError:
return None
if not response.ok:
return None
image_name = str(uuid4()) + '.' + cover_url.split('.')[-1]
image_content = ContentFile(response.content)
return [image_name, image_content]
def get_or_create_author(self, remote_id):
''' load that author '''
try:
return models.Author.objects.get(origin_id=remote_id)
except ObjectDoesNotExist:
pass
data = get_data(remote_id)
# ingest a new author
author = models.Author(origin_id=remote_id)
author = update_from_mappings(author, data, self.author_mappings)
author.save()
return author
def parse_search_data(self, data): def parse_search_data(self, data):

View file

@ -7,7 +7,6 @@ from django.core.files.base import ContentFile
from bookwyrm import models from bookwyrm import models
from .abstract_connector import AbstractConnector, SearchResult, Mapping from .abstract_connector import AbstractConnector, SearchResult, Mapping
from .abstract_connector import ConnectorException from .abstract_connector import ConnectorException
from .abstract_connector import update_from_mappings
from .abstract_connector import get_date, get_data from .abstract_connector import get_date, get_data
from .openlibrary_languages import languages from .openlibrary_languages import languages
@ -185,7 +184,7 @@ class Connector(AbstractConnector):
data = get_data(url) data = get_data(url)
author = models.Author(openlibrary_key=olkey) author = models.Author(openlibrary_key=olkey)
author = update_from_mappings(author, data, self.author_mappings) author = self.update_from_mappings(author, data, self.author_mappings)
name = data.get('name') name = data.get('name')
# TODO this is making some BOLD assumption # TODO this is making some BOLD assumption
if name: if name:

View file

@ -5,6 +5,7 @@ from collections import defaultdict
from django import forms from django import forms
from django.forms import ModelForm, PasswordInput, widgets from django.forms import ModelForm, PasswordInput, widgets
from django.forms.widgets import Textarea from django.forms.widgets import Textarea
from django.utils import timezone
from bookwyrm import models from bookwyrm import models
@ -143,7 +144,7 @@ class ExpiryWidget(widgets.Select):
else: else:
return selected_string # "This will raise return selected_string # "This will raise
return datetime.datetime.now() + interval return timezone.now() + interval
class CreateInviteForm(CustomForm): class CreateInviteForm(CustomForm):
class Meta: class Meta:

View file

@ -3,7 +3,6 @@
import bookwyrm.models.connector import bookwyrm.models.connector
import bookwyrm.models.site import bookwyrm.models.site
import bookwyrm.utils.fields import bookwyrm.utils.fields
import datetime
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
@ -37,7 +36,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='status', model_name='status',
name='published_date', name='published_date',
field=models.DateTimeField(default=datetime.datetime.now), field=models.DateTimeField(default=django.utils.timezone.now),
), ),
migrations.CreateModel( migrations.CreateModel(
name='Edition', name='Edition',
@ -129,7 +128,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='book', model_name='book',
name='last_sync_date', name='last_sync_date',
field=models.DateTimeField(default=datetime.datetime.now), field=models.DateTimeField(default=django.utils.timezone.now),
), ),
migrations.AddField( migrations.AddField(
model_name='book', model_name='book',

View file

@ -0,0 +1,17 @@
# Generated by Django 3.0.7 on 2020-11-28 01:18
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0013_book_origin_id'),
]
operations = [
migrations.RenameModel(
old_name='Attachment',
new_name='Image',
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 3.0.7 on 2020-11-28 03:49
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0014_auto_20201128_0118'),
]
operations = [
migrations.AlterField(
model_name='image',
name='status',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='bookwyrm.Status'),
),
]

View file

@ -2,17 +2,24 @@
import inspect import inspect
import sys import sys
from .book import Book, Work, Edition, Author from .book import Book, Work, Edition
from .author import Author
from .connector import Connector from .connector import Connector
from .relationship import UserFollows, UserFollowRequest, UserBlocks
from .shelf import Shelf, ShelfBook from .shelf import Shelf, ShelfBook
from .status import Status, GeneratedNote, Review, Comment, Quotation from .status import Status, GeneratedNote, Review, Comment, Quotation
from .status import Attachment, Favorite, Boost, Notification, ReadThrough from .status import Favorite, Boost, Notification, ReadThrough
from .attachment import Image
from .tag import Tag from .tag import Tag
from .user import User from .user import User
from .relationship import UserFollows, UserFollowRequest, UserBlocks
from .federated_server import FederatedServer from .federated_server import FederatedServer
from .import_job import ImportJob, ImportItem from .import_job import ImportJob, ImportItem
from .site import SiteSettings, SiteInvite, PasswordReset from .site import SiteSettings, SiteInvite, PasswordReset
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass) cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)

View file

@ -0,0 +1,32 @@
''' media that is posted in the app '''
from django.db import models
from bookwyrm import activitypub
from .base_model import ActivitypubMixin
from .base_model import ActivityMapping, BookWyrmModel
class Attachment(ActivitypubMixin, BookWyrmModel):
''' an image (or, in the future, video etc) associated with a status '''
status = models.ForeignKey(
'Status',
on_delete=models.CASCADE,
related_name='attachments',
null=True
)
class Meta:
''' one day we'll have other types of attachments besides images '''
abstract = True
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('url', 'image'),
ActivityMapping('name', 'caption'),
]
class Image(Attachment):
''' an image attachment '''
image = models.ImageField(upload_to='status/', null=True, blank=True)
caption = models.TextField(null=True, blank=True)
activity_serializer = activitypub.Image

50
bookwyrm/models/author.py Normal file
View file

@ -0,0 +1,50 @@
''' database schema for info about authors '''
from django.db import models
from django.utils import timezone
from bookwyrm import activitypub
from bookwyrm.utils.fields import ArrayField
from .base_model import ActivitypubMixin, ActivityMapping, BookWyrmModel
class Author(ActivitypubMixin, BookWyrmModel):
''' basic biographic info '''
origin_id = models.CharField(max_length=255, null=True)
''' copy of an author from OL '''
openlibrary_key = models.CharField(max_length=255, blank=True, null=True)
sync = models.BooleanField(default=True)
last_sync_date = models.DateTimeField(default=timezone.now)
wikipedia_link = models.CharField(max_length=255, blank=True, null=True)
# idk probably other keys would be useful here?
born = models.DateTimeField(blank=True, null=True)
died = models.DateTimeField(blank=True, null=True)
name = models.CharField(max_length=255)
last_name = models.CharField(max_length=255, blank=True, null=True)
first_name = models.CharField(max_length=255, blank=True, null=True)
aliases = ArrayField(
models.CharField(max_length=255), blank=True, default=list
)
bio = models.TextField(null=True, blank=True)
@property
def display_name(self):
''' Helper to return a displayable name'''
if self.name:
return self.name
# don't want to return a spurious space if all of these are None
if self.first_name and self.last_name:
return self.first_name + ' ' + self.last_name
return self.last_name or self.first_name
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('name', 'name'),
ActivityMapping('born', 'born'),
ActivityMapping('died', 'died'),
ActivityMapping('aliases', 'aliases'),
ActivityMapping('bio', 'bio'),
ActivityMapping('openlibraryKey', 'openlibrary_key'),
ActivityMapping('wikipediaLink', 'wikipedia_link'),
]
activity_serializer = activitypub.Author

View file

@ -10,6 +10,7 @@ from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15 from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256 from Crypto.Hash import SHA256
from django.db import models from django.db import models
from django.db.models.fields.files import ImageFieldFile
from django.dispatch import receiver from django.dispatch import receiver
from bookwyrm import activitypub from bookwyrm import activitypub
@ -59,27 +60,36 @@ class ActivitypubMixin:
def to_activity(self, pure=False): def to_activity(self, pure=False):
''' convert from a model to an activity ''' ''' convert from a model to an activity '''
if pure: if pure:
# works around bookwyrm-specific fields for vanilla AP services
mappings = self.pure_activity_mappings mappings = self.pure_activity_mappings
else: else:
# may include custom fields that bookwyrm instances will understand
mappings = self.activity_mappings mappings = self.activity_mappings
fields = {} fields = {}
for mapping in mappings: for mapping in mappings:
if not hasattr(self, mapping.model_key) or not mapping.activity_key: if not hasattr(self, mapping.model_key) or not mapping.activity_key:
# this field on the model isn't serialized
continue continue
value = getattr(self, mapping.model_key) value = getattr(self, mapping.model_key)
if hasattr(value, 'remote_id'): if hasattr(value, 'remote_id'):
# this is probably a foreign key field, which we want to
# serialize as just the remote_id url reference
value = value.remote_id value = value.remote_id
if isinstance(value, datetime): elif isinstance(value, datetime):
value = value.isoformat() value = value.isoformat()
result = mapping.activity_formatter(value) elif isinstance(value, ImageFieldFile):
value = image_formatter(value)
# run the custom formatter function set in the model
formatted_value = mapping.activity_formatter(value)
if mapping.activity_key in fields and \ if mapping.activity_key in fields and \
isinstance(fields[mapping.activity_key], list): isinstance(fields[mapping.activity_key], list):
# there are two database fields that map to the same AP list # there can be two database fields that map to the same AP list
# this happens in status, which combines user and book tags # this happens in status tags, which combines user and book tags
fields[mapping.activity_key] += result fields[mapping.activity_key] += formatted_value
else: else:
fields[mapping.activity_key] = result fields[mapping.activity_key] = formatted_value
if pure: if pure:
return self.pure_activity_serializer( return self.pure_activity_serializer(
@ -263,12 +273,10 @@ def tag_formatter(items, name_field, activity_type):
return tags return tags
def image_formatter(image, default_path=None): def image_formatter(image):
''' convert images into activitypub json ''' ''' convert images into activitypub json '''
if image: if image and hasattr(image, 'url'):
url = image.url url = image.url
elif default_path:
url = default_path
else: else:
return None return None
url = 'https://%s%s' % (DOMAIN, url) url = 'https://%s%s' % (DOMAIN, url)

View file

@ -12,7 +12,6 @@ from bookwyrm.utils.fields import ArrayField
from .base_model import ActivityMapping, BookWyrmModel from .base_model import ActivityMapping, BookWyrmModel
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
from .base_model import image_attachments_formatter
class Book(ActivitypubMixin, BookWyrmModel): class Book(ActivitypubMixin, BookWyrmModel):
''' a generic book, which can mean either an edition or a work ''' ''' a generic book, which can mean either an edition or a work '''
@ -61,49 +60,39 @@ class Book(ActivitypubMixin, BookWyrmModel):
''' the activitypub serialization should be a list of author ids ''' ''' the activitypub serialization should be a list of author ids '''
return [a.remote_id for a in self.authors.all()] return [a.remote_id for a in self.authors.all()]
@property
def ap_parent_work(self):
''' reference the work via local id not remote '''
return self.parent_work.remote_id
activity_mappings = [ activity_mappings = [
ActivityMapping('id', 'remote_id'), ActivityMapping('id', 'remote_id'),
ActivityMapping('authors', 'ap_authors'), ActivityMapping('authors', 'ap_authors'),
ActivityMapping('first_published_date', 'first_published_date'), ActivityMapping('firstPublishedDate', 'firstpublished_date'),
ActivityMapping('published_date', 'published_date'), ActivityMapping('publishedDate', 'published_date'),
ActivityMapping('title', 'title'), ActivityMapping('title', 'title'),
ActivityMapping('sort_title', 'sort_title'), ActivityMapping('sortTitle', 'sort_title'),
ActivityMapping('subtitle', 'subtitle'), ActivityMapping('subtitle', 'subtitle'),
ActivityMapping('description', 'description'), ActivityMapping('description', 'description'),
ActivityMapping('languages', 'languages'), ActivityMapping('languages', 'languages'),
ActivityMapping('series', 'series'), ActivityMapping('series', 'series'),
ActivityMapping('series_number', 'series_number'), ActivityMapping('seriesNumber', 'series_number'),
ActivityMapping('subjects', 'subjects'), ActivityMapping('subjects', 'subjects'),
ActivityMapping('subject_places', 'subject_places'), ActivityMapping('subjectPlaces', 'subject_places'),
ActivityMapping('openlibrary_key', 'openlibrary_key'), ActivityMapping('openlibraryKey', 'openlibrary_key'),
ActivityMapping('librarything_key', 'librarything_key'), ActivityMapping('librarythingKey', 'librarything_key'),
ActivityMapping('goodreads_key', 'goodreads_key'), ActivityMapping('goodreadsKey', 'goodreads_key'),
ActivityMapping('work', 'ap_parent_work'), ActivityMapping('work', 'parent_work'),
ActivityMapping('isbn_10', 'isbn_10'), ActivityMapping('isbn10', 'isbn_10'),
ActivityMapping('isbn_13', 'isbn_13'), ActivityMapping('isbn13', 'isbn_13'),
ActivityMapping('oclc_number', 'oclc_number'), ActivityMapping('oclcNumber', 'oclc_number'),
ActivityMapping('asin', 'asin'), ActivityMapping('asin', 'asin'),
ActivityMapping('pages', 'pages'), ActivityMapping('pages', 'pages'),
ActivityMapping('physical_format', 'physical_format'), ActivityMapping('physicalFormat', 'physical_format'),
ActivityMapping('publishers', 'publishers'), ActivityMapping('publishers', 'publishers'),
ActivityMapping('lccn', 'lccn'), ActivityMapping('lccn', 'lccn'),
ActivityMapping('editions', 'editions_path'), ActivityMapping('editions', 'editions_path'),
ActivityMapping( ActivityMapping('cover', 'cover'),
'attachment', 'cover',
# this expects an iterable and the field is just an image
lambda x: image_attachments_formatter([x]),
lambda x: activitypub.image_attachments_formatter(x)[0]
),
] ]
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
@ -190,7 +179,7 @@ class Edition(Book):
if self.isbn_10 and not self.isbn_13: if self.isbn_10 and not self.isbn_13:
self.isbn_13 = isbn_10_to_13(self.isbn_10) self.isbn_13 = isbn_10_to_13(self.isbn_10)
super().save(*args, **kwargs) return super().save(*args, **kwargs)
def isbn_10_to_13(isbn_10): def isbn_10_to_13(isbn_10):
@ -234,44 +223,3 @@ def isbn_13_to_10(isbn_13):
if checkdigit == 10: if checkdigit == 10:
checkdigit = 'X' checkdigit = 'X'
return converted + str(checkdigit) return converted + str(checkdigit)
class Author(ActivitypubMixin, BookWyrmModel):
origin_id = models.CharField(max_length=255, null=True)
''' copy of an author from OL '''
openlibrary_key = models.CharField(max_length=255, blank=True, null=True)
sync = models.BooleanField(default=True)
last_sync_date = models.DateTimeField(default=timezone.now)
wikipedia_link = models.CharField(max_length=255, blank=True, null=True)
# idk probably other keys would be useful here?
born = models.DateTimeField(blank=True, null=True)
died = models.DateTimeField(blank=True, null=True)
name = models.CharField(max_length=255)
last_name = models.CharField(max_length=255, blank=True, null=True)
first_name = models.CharField(max_length=255, blank=True, null=True)
aliases = ArrayField(
models.CharField(max_length=255), blank=True, default=list
)
bio = models.TextField(null=True, blank=True)
@property
def display_name(self):
''' Helper to return a displayable name'''
if self.name:
return self.name
# don't want to return a spurious space if all of these are None
if self.first_name and self.last_name:
return self.first_name + ' ' + self.last_name
return self.last_name or self.first_name
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('name', 'display_name'),
ActivityMapping('born', 'born'),
ActivityMapping('died', 'died'),
ActivityMapping('aliases', 'aliases'),
ActivityMapping('bio', 'bio'),
ActivityMapping('openlibrary_key', 'openlibrary_key'),
ActivityMapping('wikipedia_link', 'wikipedia_link'),
]
activity_serializer = activitypub.Author

View file

@ -132,14 +132,16 @@ class ImportItem(models.Model):
def date_added(self): def date_added(self):
''' when the book was added to this dataset ''' ''' when the book was added to this dataset '''
if self.data['Date Added']: if self.data['Date Added']:
return dateutil.parser.parse(self.data['Date Added']) return timezone.make_aware(
dateutil.parser.parse(self.data['Date Added']))
return None return None
@property @property
def date_read(self): def date_read(self):
''' the date a book was completed ''' ''' the date a book was completed '''
if self.data['Date Read']: if self.data['Date Read']:
return dateutil.parser.parse(self.data['Date Read']) return timezone.make_aware(
dateutil.parser.parse(self.data['Date Read']))
return None return None
@property @property

View file

@ -54,7 +54,7 @@ class SiteInvite(models.Model):
def get_passowrd_reset_expiry(): def get_passowrd_reset_expiry():
''' give people a limited time to use the link ''' ''' give people a limited time to use the link '''
now = datetime.datetime.now() now = timezone.now()
return now + datetime.timedelta(days=1) return now + datetime.timedelta(days=1)

View file

@ -90,7 +90,6 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
ActivityMapping( ActivityMapping(
'attachment', 'attachments', 'attachment', 'attachments',
lambda x: image_attachments_formatter(x.all()), lambda x: image_attachments_formatter(x.all()),
activitypub.image_attachments_formatter
) )
] ]
@ -151,17 +150,6 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
class Attachment(BookWyrmModel):
''' an image (or, in the future, video etc) associated with a status '''
status = models.ForeignKey(
'Status',
on_delete=models.CASCADE,
related_name='attachments'
)
image = models.ImageField(upload_to='status/', null=True, blank=True)
caption = models.TextField(null=True, blank=True)
class GeneratedNote(Status): class GeneratedNote(Status):
''' these are app-generated messages about user activity ''' ''' these are app-generated messages about user activity '''
@property @property

View file

@ -112,11 +112,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
activity_formatter=lambda x: {'sharedInbox': x}, activity_formatter=lambda x: {'sharedInbox': x},
model_formatter=lambda x: x.get('sharedInbox') model_formatter=lambda x: x.get('sharedInbox')
), ),
ActivityMapping( ActivityMapping('icon', 'avatar'),
'icon', 'avatar',
lambda x: image_formatter(x, '/static/images/default_avi.jpg'),
activitypub.image_formatter
),
ActivityMapping( ActivityMapping(
'manuallyApprovesFollowers', 'manuallyApprovesFollowers',
'manually_approves_followers' 'manually_approves_followers'

View file

@ -15,6 +15,13 @@ CELERY_ACCEPT_CONTENT = ['application/json']
CELERY_TASK_SERIALIZER = 'json' CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json' CELERY_RESULT_SERIALIZER = 'json'
# email
EMAIL_HOST = env('EMAIL_HOST')
EMAIL_PORT = env('EMAIL_PORT', 587)
EMAIL_HOST_USER = env('EMAIL_HOST_USER')
EMAIL_HOST_PASSWORD = env('EMAIL_HOST_PASSWORD')
EMAIL_USE_TLS = env('EMAIL_USE_TLS', True)
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

View file

@ -1,5 +1,5 @@
''' Handle user activity ''' ''' Handle user activity '''
from datetime import datetime from django.utils import timezone
from bookwyrm import activitypub, books_manager, models from bookwyrm import activitypub, books_manager, models
from bookwyrm.sanitize_html import InputHtmlParser from bookwyrm.sanitize_html import InputHtmlParser
@ -8,7 +8,7 @@ from bookwyrm.sanitize_html import InputHtmlParser
def delete_status(status): def delete_status(status):
''' replace the status with a tombstone ''' ''' replace the status with a tombstone '''
status.deleted = True status.deleted = True
status.deleted_date = datetime.now() status.deleted_date = timezone.now()
status.save() status.save()

View file

@ -57,6 +57,35 @@
{% include 'snippets/trimmed_text.html' with full=book|book_description %} {% include 'snippets/trimmed_text.html' with full=book|book_description %}
{% if request.user.is_authenticated and perms.bookwyrm.edit_book and not book|book_description %}
<div>
<input class="toggle-control" type="radio" name="add-description" id="hide-description" checked>
<div class="toggle-content hidden">
<label class="button" for="add-description" tabindex="0" role="button">Add description</label>
</div>
</div>
<div>
<input class="toggle-control" type="radio" name="add-description" id="add-description">
<div class="toggle-content hidden">
<div class="box">
<form name="add-description" method="POST" action="/add-description/{{ book.id }}">
{% csrf_token %}
<p class="fields is-grouped">
<label class="label"for="id_description">Description:</label>
<textarea name="description" cols="None" rows="None" class="textarea" id="id_description"></textarea>
</p>
<div class="field">
<button class="button is-primary" type="submit">Save</button>
<label class="button" for="hide-description" tabindex="0" role="button">Cancel</label>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% if book.parent_work.edition_set.count > 1 %} {% if book.parent_work.edition_set.count > 1 %}
<p><a href="/book/{{ book.parent_work.id }}/editions">{{ book.parent_work.edition_set.count }} editions</a></p> <p><a href="/book/{{ book.parent_work.id }}/editions">{{ book.parent_work.edition_set.count }} editions</a></p>
{% endif %} {% endif %}
@ -112,7 +141,7 @@
</div> </div>
<div class="field is-grouped"> <div class="field is-grouped">
<button class="button is-primary" type="submit">Save</button> <button class="button is-primary" type="submit">Save</button>
<label class="button" for="show-readthrough-{{ readthrough.id }}">Cancel</label> <label class="button" for="show-readthrough-{{ readthrough.id }}" role="button" tabindex="0">Cancel</label>
</div> </div>
</form> </form>
</div> </div>
@ -135,7 +164,7 @@
<button class="button is-danger is-light" type="submit"> <button class="button is-danger is-light" type="submit">
Delete Delete
</button> </button>
<label for="delete-readthrough-{{ readthrough.id }}" class="button">Cancel</button> <label for="delete-readthrough-{{ readthrough.id }}" class="button" role="button" tabindex="0">Cancel</button>
</form> </form>
</footer> </footer>
</div> </div>

View file

@ -30,7 +30,7 @@
<button class="button is-primary" type="submit">Log in</button> <button class="button is-primary" type="submit">Log in</button>
</div> </div>
<div class="control"> <div class="control">
<small><a href="/reset-password">Forgot your password?</a></small> <small><a href="/password-reset">Forgot your password?</a></small>
</div> </div>
</div> </div>
</form> </form>

View file

@ -2,8 +2,7 @@
from django.test import TestCase from django.test import TestCase
from bookwyrm import models from bookwyrm import models
from bookwyrm.connectors.abstract_connector import Mapping,\ from bookwyrm.connectors.abstract_connector import Mapping
update_from_mappings
from bookwyrm.connectors.bookwyrm_connector import Connector from bookwyrm.connectors.bookwyrm_connector import Connector
@ -64,29 +63,6 @@ class AbstractConnector(TestCase):
self.assertEqual(mapping.formatter('bb'), 'aabb') self.assertEqual(mapping.formatter('bb'), 'aabb')
def test_update_from_mappings(self):
data = {
'title': 'Unused title',
'isbn_10': '1234567890',
'isbn_13': 'blahhh',
'blah': 'bip',
'format': 'hardcover',
'series': ['one', 'two'],
}
mappings = [
Mapping('isbn_10'),
Mapping('blah'),# not present on self.book
Mapping('physical_format', remote_field='format'),
Mapping('series', formatter=lambda x: x[0]),
]
book = update_from_mappings(self.book, data, mappings)
self.assertEqual(book.title, 'Example Edition')
self.assertEqual(book.isbn_10, '1234567890')
self.assertEqual(book.isbn_13, None)
self.assertEqual(book.physical_format, 'hardcover')
self.assertEqual(book.series, 'one')
def test_match_from_mappings(self): def test_match_from_mappings(self):
edition = models.Edition.objects.create( edition = models.Edition.objects.create(
title='Blah', title='Blah',

View file

@ -1,6 +1,7 @@
''' testing book data connectors ''' ''' testing book data connectors '''
import datetime import datetime
from django.test import TestCase from django.test import TestCase
from django.utils import timezone
from bookwyrm import models from bookwyrm import models
from bookwyrm.connectors.self_connector import Connector from bookwyrm.connectors.self_connector import Connector
@ -27,7 +28,7 @@ class SelfConnector(TestCase):
self.edition = models.Edition.objects.create( self.edition = models.Edition.objects.create(
title='Edition of Example Work', title='Edition of Example Work',
author_text='Anonymous', author_text='Anonymous',
published_date=datetime.datetime(1980, 5, 10), published_date=datetime.datetime(1980, 5, 10, tzinfo=timezone.utc),
parent_work=self.work, parent_work=self.work,
) )
models.Edition.objects.create( models.Edition.objects.create(
@ -57,10 +58,9 @@ class SelfConnector(TestCase):
def test_search_rank(self): def test_search_rank(self):
results = self.connector.search('Anonymous') results = self.connector.search('Anonymous')
self.assertEqual(len(results), 3) self.assertEqual(len(results), 2)
self.assertEqual(results[0].title, 'Edition of Example Work') self.assertEqual(results[0].title, 'More Editions')
self.assertEqual(results[1].title, 'More Editions') self.assertEqual(results[1].title, 'Edition of Example Work')
self.assertEqual(results[2].title, 'Another Edition')
def test_search_default_filter(self): def test_search_default_filter(self):

View file

@ -13,14 +13,6 @@
"sensitive": false, "sensitive": false,
"content": "commentary", "content": "commentary",
"type": "Quotation", "type": "Quotation",
"attachment": [
{
"type": "Document",
"mediaType": "image//images/covers/2b4e4712-5a4d-4ac1-9df4-634cc9c7aff3jpg",
"url": "https://example.com/images/covers/2b4e4712-5a4d-4ac1-9df4-634cc9c7aff3jpg",
"name": "Cover of \"This Is How You Lose the Time War\""
}
],
"replies": { "replies": {
"id": "https://example.com/user/mouse/quotation/13/replies", "id": "https://example.com/user/mouse/quotation/13/replies",
"type": "Collection", "type": "Collection",

View file

@ -28,9 +28,7 @@
], ],
"lccn": null, "lccn": null,
"editions": [ "editions": [
"https://bookwyrm.social/book/5989", "https://bookwyrm.social/book/5989"
"OL28439584M",
"OL28300471M"
], ],
"@context": "https://www.w3.org/ns/activitystreams" "@context": "https://www.w3.org/ns/activitystreams"
} }

View file

@ -1,3 +1,4 @@
from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
from bookwyrm import models, incoming from bookwyrm import models, incoming
@ -27,7 +28,8 @@ class IncomingFollow(TestCase):
"object": "http://local.com/user/mouse" "object": "http://local.com/user/mouse"
} }
incoming.handle_follow(activity) with patch('bookwyrm.broadcast.broadcast_task.delay') as _:
incoming.handle_follow(activity)
# notification created # notification created
notification = models.Notification.objects.get() notification = models.Notification.objects.get()
@ -55,7 +57,8 @@ class IncomingFollow(TestCase):
self.local_user.manually_approves_followers = True self.local_user.manually_approves_followers = True
self.local_user.save() self.local_user.save()
incoming.handle_follow(activity) with patch('bookwyrm.broadcast.broadcast_task.delay') as _:
incoming.handle_follow(activity)
# notification created # notification created
notification = models.Notification.objects.get() notification = models.Notification.objects.get()
@ -81,7 +84,8 @@ class IncomingFollow(TestCase):
"object": "http://local.com/user/nonexistent-user" "object": "http://local.com/user/nonexistent-user"
} }
incoming.handle_follow(activity) with patch('bookwyrm.broadcast.broadcast_task.delay') as _:
incoming.handle_follow(activity)
# do nothing # do nothing
notifications = models.Notification.objects.all() notifications = models.Notification.objects.all()

View file

@ -1,5 +1,6 @@
''' testing models ''' ''' testing models '''
import datetime import datetime
from django.utils import timezone
from django.test import TestCase from django.test import TestCase
from bookwyrm import models from bookwyrm import models
@ -77,29 +78,29 @@ class ImportJob(TestCase):
def test_date_added(self): def test_date_added(self):
''' converts to the local shelf typology ''' ''' converts to the local shelf typology '''
expected = datetime.datetime(2019, 4, 9, 0, 0) expected = datetime.datetime(2019, 4, 9, 0, 0, tzinfo=timezone.utc)
item = models.ImportItem.objects.get(index=1) item = models.ImportItem.objects.get(index=1)
self.assertEqual(item.date_added, expected) self.assertEqual(item.date_added, expected)
def test_date_read(self): def test_date_read(self):
''' converts to the local shelf typology ''' ''' converts to the local shelf typology '''
expected = datetime.datetime(2019, 4, 12, 0, 0) expected = datetime.datetime(2019, 4, 12, 0, 0, tzinfo=timezone.utc)
item = models.ImportItem.objects.get(index=2) item = models.ImportItem.objects.get(index=2)
self.assertEqual(item.date_read, expected) self.assertEqual(item.date_read, expected)
def test_currently_reading_reads(self): def test_currently_reading_reads(self):
expected = [models.ReadThrough( expected = [models.ReadThrough(
start_date=datetime.datetime(2019, 4, 9, 0, 0))] start_date=datetime.datetime(2019, 4, 9, 0, 0, tzinfo=timezone.utc))]
actual = models.ImportItem.objects.get(index=1) actual = models.ImportItem.objects.get(index=1)
self.assertEqual(actual.reads[0].start_date, expected[0].start_date) self.assertEqual(actual.reads[0].start_date, expected[0].start_date)
self.assertEqual(actual.reads[0].finish_date, expected[0].finish_date) self.assertEqual(actual.reads[0].finish_date, expected[0].finish_date)
def test_read_reads(self): def test_read_reads(self):
actual = models.ImportItem.objects.get(index=2) actual = models.ImportItem.objects.get(index=2)
self.assertEqual(actual.reads[0].start_date, datetime.datetime(2019, 4, 9, 0, 0)) self.assertEqual(actual.reads[0].start_date, datetime.datetime(2019, 4, 9, 0, 0, tzinfo=timezone.utc))
self.assertEqual(actual.reads[0].finish_date, datetime.datetime(2019, 4, 12, 0, 0)) self.assertEqual(actual.reads[0].finish_date, datetime.datetime(2019, 4, 12, 0, 0, tzinfo=timezone.utc))
def test_unread_reads(self): def test_unread_reads(self):
expected = [] expected = []

View file

@ -27,9 +27,13 @@ class User(TestCase):
shelves = models.Shelf.objects.filter(user=self.user).all() shelves = models.Shelf.objects.filter(user=self.user).all()
self.assertEqual(len(shelves), 3) self.assertEqual(len(shelves), 3)
names = [s.name for s in shelves] names = [s.name for s in shelves]
self.assertEqual(names, ['To Read', 'Currently Reading', 'Read']) self.assertTrue('To Read' in names)
self.assertTrue('Currently Reading' in names)
self.assertTrue('Read' in names)
ids = [s.identifier for s in shelves] ids = [s.identifier for s in shelves]
self.assertEqual(ids, ['to-read', 'reading', 'read']) self.assertTrue('to-read' in ids)
self.assertTrue('reading' in ids)
self.assertTrue('read' in ids)
def test_activitypub_serialize(self): def test_activitypub_serialize(self):

View file

@ -1,3 +1,4 @@
from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
from bookwyrm import models, outgoing from bookwyrm import models, outgoing
@ -22,7 +23,9 @@ class Following(TestCase):
def test_handle_follow(self): def test_handle_follow(self):
self.assertEqual(models.UserFollowRequest.objects.count(), 0) self.assertEqual(models.UserFollowRequest.objects.count(), 0)
outgoing.handle_follow(self.local_user, self.remote_user) with patch('bookwyrm.broadcast.broadcast_task.delay') as _:
outgoing.handle_follow(self.local_user, self.remote_user)
rel = models.UserFollowRequest.objects.get() rel = models.UserFollowRequest.objects.get()
self.assertEqual(rel.user_subject, self.local_user) self.assertEqual(rel.user_subject, self.local_user)
@ -33,7 +36,8 @@ class Following(TestCase):
def test_handle_unfollow(self): def test_handle_unfollow(self):
self.remote_user.followers.add(self.local_user) self.remote_user.followers.add(self.local_user)
self.assertEqual(self.remote_user.followers.count(), 1) self.assertEqual(self.remote_user.followers.count(), 1)
outgoing.handle_unfollow(self.local_user, self.remote_user) with patch('bookwyrm.broadcast.broadcast_task.delay') as _:
outgoing.handle_unfollow(self.local_user, self.remote_user)
self.assertEqual(self.remote_user.followers.count(), 0) self.assertEqual(self.remote_user.followers.count(), 0)
@ -45,7 +49,8 @@ class Following(TestCase):
) )
rel_id = rel.id rel_id = rel.id
outgoing.handle_accept(rel) with patch('bookwyrm.broadcast.broadcast_task.delay') as _:
outgoing.handle_accept(rel)
# request should be deleted # request should be deleted
self.assertEqual( self.assertEqual(
models.UserFollowRequest.objects.filter(id=rel_id).count(), 0 models.UserFollowRequest.objects.filter(id=rel_id).count(), 0
@ -61,7 +66,8 @@ class Following(TestCase):
) )
rel_id = rel.id rel_id = rel.id
outgoing.handle_reject(rel) with patch('bookwyrm.broadcast.broadcast_task.delay') as _:
outgoing.handle_reject(rel)
# request should be deleted # request should be deleted
self.assertEqual( self.assertEqual(
models.UserFollowRequest.objects.filter(id=rel_id).count(), 0 models.UserFollowRequest.objects.filter(id=rel_id).count(), 0

View file

@ -1,3 +1,4 @@
from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
from bookwyrm import models, outgoing from bookwyrm import models, outgoing
@ -26,7 +27,8 @@ class Shelving(TestCase):
def test_handle_shelve(self): def test_handle_shelve(self):
outgoing.handle_shelve(self.user, self.book, self.shelf) with patch('bookwyrm.broadcast.broadcast_task.delay') as _:
outgoing.handle_shelve(self.user, self.book, self.shelf)
# make sure the book is on the shelf # make sure the book is on the shelf
self.assertEqual(self.shelf.books.get(), self.book) self.assertEqual(self.shelf.books.get(), self.book)
@ -34,7 +36,8 @@ class Shelving(TestCase):
def test_handle_shelve_to_read(self): def test_handle_shelve_to_read(self):
shelf = models.Shelf.objects.get(identifier='to-read') shelf = models.Shelf.objects.get(identifier='to-read')
outgoing.handle_shelve(self.user, self.book, shelf) with patch('bookwyrm.broadcast.broadcast_task.delay') as _:
outgoing.handle_shelve(self.user, self.book, shelf)
# make sure the book is on the shelf # make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book) self.assertEqual(shelf.books.get(), self.book)
@ -42,7 +45,8 @@ class Shelving(TestCase):
def test_handle_shelve_reading(self): def test_handle_shelve_reading(self):
shelf = models.Shelf.objects.get(identifier='reading') shelf = models.Shelf.objects.get(identifier='reading')
outgoing.handle_shelve(self.user, self.book, shelf) with patch('bookwyrm.broadcast.broadcast_task.delay') as _:
outgoing.handle_shelve(self.user, self.book, shelf)
# make sure the book is on the shelf # make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book) self.assertEqual(shelf.books.get(), self.book)
@ -50,7 +54,8 @@ class Shelving(TestCase):
def test_handle_shelve_read(self): def test_handle_shelve_read(self):
shelf = models.Shelf.objects.get(identifier='read') shelf = models.Shelf.objects.get(identifier='read')
outgoing.handle_shelve(self.user, self.book, shelf) with patch('bookwyrm.broadcast.broadcast_task.delay') as _:
outgoing.handle_shelve(self.user, self.book, shelf)
# make sure the book is on the shelf # make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book) self.assertEqual(shelf.books.get(), self.book)
@ -59,5 +64,6 @@ class Shelving(TestCase):
self.shelf.books.add(self.book) self.shelf.books.add(self.book)
self.shelf.save() self.shelf.save()
self.assertEqual(self.shelf.books.count(), 1) self.assertEqual(self.shelf.books.count(), 1)
outgoing.handle_unshelve(self.user, self.book, self.shelf) with patch('bookwyrm.broadcast.broadcast_task.delay') as _:
outgoing.handle_unshelve(self.user, self.book, self.shelf)
self.assertEqual(self.shelf.books.count(), 0) self.assertEqual(self.shelf.books.count(), 0)

View file

@ -21,50 +21,7 @@ class RemoteUser(TestCase):
self.user_data = json.loads(datafile.read_bytes()) self.user_data = json.loads(datafile.read_bytes())
def test_get_remote_user(self): def test_get_remote_user(self):
actor = 'https://example.com/users/rat' actor = 'https://example.com/users/rat'
user = remote_user.get_or_create_remote_user(actor) user = remote_user.get_or_create_remote_user(actor)
self.assertEqual(user, self.remote_user) self.assertEqual(user, self.remote_user)
def test_create_remote_user(self):
user = remote_user.create_remote_user(self.user_data)
self.assertFalse(user.local)
self.assertEqual(user.remote_id, 'https://example.com/user/mouse')
self.assertEqual(user.username, 'mouse@example.com')
self.assertEqual(user.name, 'MOUSE?? MOUSE!!')
self.assertEqual(user.inbox, 'https://example.com/user/mouse/inbox')
self.assertEqual(user.outbox, 'https://example.com/user/mouse/outbox')
self.assertEqual(user.shared_inbox, 'https://example.com/inbox')
self.assertEqual(
user.public_key,
self.user_data['publicKey']['publicKeyPem']
)
self.assertEqual(user.local, False)
self.assertEqual(user.bookwyrm_user, True)
self.assertEqual(user.manually_approves_followers, False)
def test_create_remote_user_missing_inbox(self):
del self.user_data['inbox']
self.assertRaises(
TypeError,
remote_user.create_remote_user,
self.user_data
)
def test_create_remote_user_missing_outbox(self):
del self.user_data['outbox']
self.assertRaises(
TypeError,
remote_user.create_remote_user,
self.user_data
)
def test_create_remote_user_default_fields(self):
del self.user_data['manuallyApprovesFollowers']
user = remote_user.create_remote_user(self.user_data)
self.assertEqual(user.manually_approves_followers, False)

View file

@ -2,6 +2,7 @@ import time
from collections import namedtuple from collections import namedtuple
from urllib.parse import urlsplit from urllib.parse import urlsplit
import pathlib import pathlib
from unittest.mock import patch
import json import json
import responses import responses
@ -63,12 +64,14 @@ class Signature(TestCase):
send_data=None, send_data=None,
digest=None, digest=None,
date=None): date=None):
''' sends a follow request to the "rat" user '''
now = date or http_date() now = date or http_date()
data = json.dumps(get_follow_data(sender, self.rat)) data = json.dumps(get_follow_data(sender, self.rat))
digest = digest or make_digest(data) digest = digest or make_digest(data)
signature = make_signature( signature = make_signature(
signer or sender, self.rat.inbox, now, digest) signer or sender, self.rat.inbox, now, digest)
return self.send(signature, now, send_data or data, digest) with patch('bookwyrm.incoming.handle_follow.delay') as _:
return self.send(signature, now, send_data or data, digest)
def test_correct_signature(self): def test_correct_signature(self):
response = self.send_test_request(sender=self.mouse) response = self.send_test_request(sender=self.mouse)
@ -104,8 +107,9 @@ class Signature(TestCase):
status=200 status=200
) )
response = self.send_test_request(sender=self.fake_remote) with patch('bookwyrm.remote_user.get_remote_reviews.delay') as _:
self.assertEqual(response.status_code, 200) response = self.send_test_request(sender=self.fake_remote)
self.assertEqual(response.status_code, 200)
@responses.activate @responses.activate
def test_key_needs_refresh(self): def test_key_needs_refresh(self):
@ -141,21 +145,22 @@ class Signature(TestCase):
json=data, json=data,
status=200) status=200)
# Key correct: with patch('bookwyrm.remote_user.get_remote_reviews.delay') as _:
response = self.send_test_request(sender=self.fake_remote) # Key correct:
self.assertEqual(response.status_code, 200) response = self.send_test_request(sender=self.fake_remote)
self.assertEqual(response.status_code, 200)
# Old key is cached, so still works: # Old key is cached, so still works:
response = self.send_test_request(sender=self.fake_remote) response = self.send_test_request(sender=self.fake_remote)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# Try with new key: # Try with new key:
response = self.send_test_request(sender=new_sender) response = self.send_test_request(sender=new_sender)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# Now the old key will fail: # Now the old key will fail:
response = self.send_test_request(sender=self.fake_remote) response = self.send_test_request(sender=self.fake_remote)
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
@responses.activate @responses.activate
@ -172,23 +177,26 @@ class Signature(TestCase):
@pytest.mark.integration @pytest.mark.integration
def test_changed_data(self): def test_changed_data(self):
'''Message data must match the digest header.''' '''Message data must match the digest header.'''
response = self.send_test_request( with patch('bookwyrm.remote_user.fetch_user_data') as _:
self.mouse, response = self.send_test_request(
send_data=get_follow_data(self.mouse, self.cat)) self.mouse,
self.assertEqual(response.status_code, 401) send_data=get_follow_data(self.mouse, self.cat))
self.assertEqual(response.status_code, 401)
@pytest.mark.integration @pytest.mark.integration
def test_invalid_digest(self): def test_invalid_digest(self):
response = self.send_test_request( with patch('bookwyrm.remote_user.fetch_user_data') as _:
self.mouse, response = self.send_test_request(
digest='SHA-256=AAAAAAAAAAAAAAAAAA') self.mouse,
self.assertEqual(response.status_code, 401) digest='SHA-256=AAAAAAAAAAAAAAAAAA')
self.assertEqual(response.status_code, 401)
@pytest.mark.integration @pytest.mark.integration
def test_old_message(self): def test_old_message(self):
'''Old messages should be rejected to prevent replay attacks.''' '''Old messages should be rejected to prevent replay attacks.'''
response = self.send_test_request( with patch('bookwyrm.remote_user.fetch_user_data') as _:
self.mouse, response = self.send_test_request(
date=http_date(time.time() - 301) self.mouse,
) date=http_date(time.time() - 301)
self.assertEqual(response.status_code, 401) )
self.assertEqual(response.status_code, 401)

View file

@ -6,8 +6,8 @@ from django.urls import path, re_path
from bookwyrm import incoming, outgoing, views, settings, wellknown from bookwyrm import incoming, outgoing, views, settings, wellknown
from bookwyrm import view_actions as actions from bookwyrm import view_actions as actions
username_regex = r'(?P<username>[\w\-_]+@[\w\-\_\.]+)' username_regex = r'(?P<username>[\w\-_\.]+@[\w\-\_\.]+)'
localname_regex = r'(?P<username>[\w\-_]+)' localname_regex = r'(?P<username>[\w\-_\.]+)'
user_path = r'^user/%s' % username_regex user_path = r'^user/%s' % username_regex
local_user_path = r'^user/%s' % localname_regex local_user_path = r'^user/%s' % localname_regex
@ -61,8 +61,8 @@ urlpatterns = [
# should return a ui view or activitypub json blob as requested # should return a ui view or activitypub json blob as requested
# users # users
re_path(r'%s/?$' % user_path, views.user_page), re_path(r'%s/?$' % user_path, views.user_page),
re_path(r'%s/?$' % local_user_path, views.user_page),
re_path(r'%s\.json$' % local_user_path, views.user_page), re_path(r'%s\.json$' % local_user_path, views.user_page),
re_path(r'%s/?$' % local_user_path, views.user_page),
re_path(r'%s/shelves/?$' % local_user_path, views.user_shelves_page), re_path(r'%s/shelves/?$' % local_user_path, views.user_shelves_page),
re_path(r'%s/followers(.json)?/?$' % local_user_path, views.followers_page), re_path(r'%s/followers(.json)?/?$' % local_user_path, views.followers_page),
re_path(r'%s/following(.json)?/?$' % local_user_path, views.following_page), re_path(r'%s/following(.json)?/?$' % local_user_path, views.following_page),
@ -102,6 +102,7 @@ urlpatterns = [
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'^edit-readthrough/?', actions.edit_readthrough), re_path(r'^edit-readthrough/?', actions.edit_readthrough),
re_path(r'^delete-readthrough/?', actions.delete_readthrough), re_path(r'^delete-readthrough/?', actions.delete_readthrough),

View file

@ -14,6 +14,7 @@ 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
from django.utils import timezone from django.utils import timezone
from django.views.decorators.http import require_GET, require_POST
from bookwyrm import books_manager from bookwyrm import books_manager
from bookwyrm import forms, models, outgoing from bookwyrm import forms, models, outgoing
@ -23,11 +24,9 @@ from bookwyrm.settings import DOMAIN
from bookwyrm.views import get_user_from_username from bookwyrm.views import get_user_from_username
@require_POST
def user_login(request): def user_login(request):
''' authenticate user login ''' ''' authenticate user login '''
if request.method == 'GET':
return redirect('/login')
login_form = forms.LoginForm(request.POST) login_form = forms.LoginForm(request.POST)
username = login_form.data['username'] username = login_form.data['username']
@ -50,11 +49,9 @@ def user_login(request):
return TemplateResponse(request, 'login.html', data) return TemplateResponse(request, 'login.html', data)
@require_POST
def register(request): def register(request):
''' join the server ''' ''' join the server '''
if request.method == 'GET':
return redirect('/login')
if not models.SiteSettings.get().allow_registration: if not models.SiteSettings.get().allow_registration:
invite_code = request.POST.get('invite_code') invite_code = request.POST.get('invite_code')
@ -97,12 +94,14 @@ def register(request):
@login_required @login_required
@require_GET
def user_logout(request): def user_logout(request):
''' done with this place! outa here! ''' ''' done with this place! outa here! '''
logout(request) logout(request)
return redirect('/') return redirect('/')
@require_POST
def password_reset_request(request): def password_reset_request(request):
''' create a password reset token ''' ''' create a password reset token '''
email = request.POST.get('email') email = request.POST.get('email')
@ -121,6 +120,7 @@ def password_reset_request(request):
return TemplateResponse(request, 'password_reset_request.html', data) return TemplateResponse(request, 'password_reset_request.html', data)
@require_POST
def password_reset(request): def password_reset(request):
''' allow a user to change their password through an emailed token ''' ''' allow a user to change their password through an emailed token '''
try: try:
@ -148,6 +148,7 @@ def password_reset(request):
@login_required @login_required
@require_POST
def password_change(request): def password_change(request):
''' allow a user to change their password ''' ''' allow a user to change their password '''
new_password = request.POST.get('password') new_password = request.POST.get('password')
@ -163,11 +164,9 @@ def password_change(request):
@login_required @login_required
@require_POST
def edit_profile(request): def edit_profile(request):
''' les get fancy with images ''' ''' les get fancy with images '''
if not request.method == 'POST':
return redirect('/user/%s' % request.user.localname)
form = forms.EditUserForm(request.POST, request.FILES) form = forms.EditUserForm(request.POST, request.FILES)
if not form.is_valid(): if not form.is_valid():
data = { data = {
@ -226,11 +225,9 @@ def resolve_book(request):
@login_required @login_required
@permission_required('bookwyrm.edit_book', raise_exception=True) @permission_required('bookwyrm.edit_book', raise_exception=True)
@require_POST
def edit_book(request, book_id): def edit_book(request, book_id):
''' edit a book cool ''' ''' edit a book cool '''
if not request.method == 'POST':
return redirect('/book/%s' % book_id)
book = get_object_or_404(models.Edition, id=book_id) book = get_object_or_404(models.Edition, id=book_id)
form = forms.EditionForm(request.POST, request.FILES, instance=book) form = forms.EditionForm(request.POST, request.FILES, instance=book)
@ -248,16 +245,14 @@ def edit_book(request, book_id):
@login_required @login_required
@require_POST
def upload_cover(request, book_id): def upload_cover(request, book_id):
''' upload a new cover ''' ''' upload a new cover '''
if not request.method == 'POST':
return redirect('/book/%s' % request.user.localname)
book = get_object_or_404(models.Edition, id=book_id) book = get_object_or_404(models.Edition, id=book_id)
form = forms.CoverForm(request.POST, request.FILES, instance=book) form = forms.CoverForm(request.POST, request.FILES, instance=book)
if not form.is_valid(): if not form.is_valid():
return redirect(request.headers.get('Referer', '/')) return redirect('/book/%d' % book.id)
book.cover = form.files['cover'] book.cover = form.files['cover']
book.sync_cover = False book.sync_cover = False
@ -268,6 +263,26 @@ def upload_cover(request, book_id):
@login_required @login_required
@require_POST
@permission_required('bookwyrm.edit_book', raise_exception=True)
def add_description(request, book_id):
''' upload a new cover '''
if not request.method == 'POST':
return redirect('/')
book = get_object_or_404(models.Edition, id=book_id)
description = request.POST.get('description')
book.description = description
book.save()
outgoing.handle_update_book(request.user, book)
return redirect('/book/%s' % book.id)
@login_required
@require_POST
def create_shelf(request): def create_shelf(request):
''' user generated shelves ''' ''' user generated shelves '''
form = forms.ShelfForm(request.POST) form = forms.ShelfForm(request.POST)
@ -280,6 +295,7 @@ def create_shelf(request):
@login_required @login_required
@require_POST
def edit_shelf(request, shelf_id): def edit_shelf(request, shelf_id):
''' user generated shelves ''' ''' user generated shelves '''
shelf = get_object_or_404(models.Shelf, id=shelf_id) shelf = get_object_or_404(models.Shelf, id=shelf_id)
@ -295,6 +311,7 @@ def edit_shelf(request, shelf_id):
@login_required @login_required
@require_POST
def delete_shelf(request, shelf_id): def delete_shelf(request, shelf_id):
''' user generated shelves ''' ''' user generated shelves '''
shelf = get_object_or_404(models.Shelf, id=shelf_id) shelf = get_object_or_404(models.Shelf, id=shelf_id)
@ -306,6 +323,7 @@ def delete_shelf(request, shelf_id):
@login_required @login_required
@require_POST
def shelve(request): def shelve(request):
''' put a on a user's shelf ''' ''' put a on a user's shelf '''
book = books_manager.get_edition(request.POST['book']) book = books_manager.get_edition(request.POST['book'])
@ -340,6 +358,7 @@ def shelve(request):
@login_required @login_required
@require_POST
def unshelve(request): def unshelve(request):
''' put a on a user's shelf ''' ''' put a on a user's shelf '''
book = models.Edition.objects.get(id=request.POST['book']) book = models.Edition.objects.get(id=request.POST['book'])
@ -350,6 +369,7 @@ def unshelve(request):
@login_required @login_required
@require_POST
def start_reading(request, book_id): def start_reading(request, book_id):
''' begin reading a book ''' ''' begin reading a book '''
book = books_manager.get_edition(book_id) book = books_manager.get_edition(book_id)
@ -385,6 +405,7 @@ def start_reading(request, book_id):
@login_required @login_required
@require_POST
def finish_reading(request, book_id): def finish_reading(request, book_id):
''' a user completed a book, yay ''' ''' a user completed a book, yay '''
book = books_manager.get_edition(book_id) book = books_manager.get_edition(book_id)
@ -420,6 +441,7 @@ def finish_reading(request, book_id):
@login_required @login_required
@require_POST
def edit_readthrough(request): def edit_readthrough(request):
''' can't use the form because the dates are too finnicky ''' ''' can't use the form because the dates are too finnicky '''
readthrough = update_readthrough(request, create=False) readthrough = update_readthrough(request, create=False)
@ -435,6 +457,7 @@ def edit_readthrough(request):
@login_required @login_required
@require_POST
def delete_readthrough(request): def delete_readthrough(request):
''' remove a readthrough ''' ''' remove a readthrough '''
readthrough = get_object_or_404( readthrough = get_object_or_404(
@ -449,6 +472,7 @@ def delete_readthrough(request):
@login_required @login_required
@require_POST
def rate(request): def rate(request):
''' just a star rating for a book ''' ''' just a star rating for a book '''
form = forms.RatingForm(request.POST) form = forms.RatingForm(request.POST)
@ -456,6 +480,7 @@ def rate(request):
@login_required @login_required
@require_POST
def review(request): def review(request):
''' create a book review ''' ''' create a book review '''
form = forms.ReviewForm(request.POST) form = forms.ReviewForm(request.POST)
@ -463,6 +488,7 @@ def review(request):
@login_required @login_required
@require_POST
def quotate(request): def quotate(request):
''' create a book quotation ''' ''' create a book quotation '''
form = forms.QuotationForm(request.POST) form = forms.QuotationForm(request.POST)
@ -470,6 +496,7 @@ def quotate(request):
@login_required @login_required
@require_POST
def comment(request): def comment(request):
''' create a book comment ''' ''' create a book comment '''
form = forms.CommentForm(request.POST) form = forms.CommentForm(request.POST)
@ -477,6 +504,7 @@ def comment(request):
@login_required @login_required
@require_POST
def reply(request): def reply(request):
''' respond to a book review ''' ''' respond to a book review '''
form = forms.ReplyForm(request.POST) form = forms.ReplyForm(request.POST)
@ -493,6 +521,7 @@ def handle_status(request, form):
@login_required @login_required
@require_POST
def tag(request): def tag(request):
''' tag a book ''' ''' tag a book '''
# I'm not using a form here because sometimes "name" is sent as a hidden # I'm not using a form here because sometimes "name" is sent as a hidden
@ -512,6 +541,7 @@ def tag(request):
@login_required @login_required
@require_POST
def untag(request): def untag(request):
''' untag a book ''' ''' untag a book '''
name = request.POST.get('name') name = request.POST.get('name')
@ -522,6 +552,7 @@ def untag(request):
@login_required @login_required
@require_POST
def favorite(request, status_id): def favorite(request, status_id):
''' like a status ''' ''' like a status '''
status = models.Status.objects.get(id=status_id) status = models.Status.objects.get(id=status_id)
@ -530,6 +561,7 @@ def favorite(request, status_id):
@login_required @login_required
@require_POST
def unfavorite(request, status_id): def unfavorite(request, status_id):
''' like a status ''' ''' like a status '''
status = models.Status.objects.get(id=status_id) status = models.Status.objects.get(id=status_id)
@ -538,6 +570,7 @@ def unfavorite(request, status_id):
@login_required @login_required
@require_POST
def boost(request, status_id): def boost(request, status_id):
''' boost a status ''' ''' boost a status '''
status = models.Status.objects.get(id=status_id) status = models.Status.objects.get(id=status_id)
@ -546,6 +579,7 @@ def boost(request, status_id):
@login_required @login_required
@require_POST
def unboost(request, status_id): def unboost(request, status_id):
''' boost a status ''' ''' boost a status '''
status = models.Status.objects.get(id=status_id) status = models.Status.objects.get(id=status_id)
@ -554,6 +588,7 @@ def unboost(request, status_id):
@login_required @login_required
@require_POST
def delete_status(request, status_id): def delete_status(request, status_id):
''' delete and tombstone a status ''' ''' delete and tombstone a status '''
status = get_object_or_404(models.Status, id=status_id) status = get_object_or_404(models.Status, id=status_id)
@ -568,6 +603,7 @@ def delete_status(request, status_id):
@login_required @login_required
@require_POST
def follow(request): def follow(request):
''' follow another user, here or abroad ''' ''' follow another user, here or abroad '''
username = request.POST['user'] username = request.POST['user']
@ -583,6 +619,7 @@ def follow(request):
@login_required @login_required
@require_POST
def unfollow(request): def unfollow(request):
''' unfollow a user ''' ''' unfollow a user '''
username = request.POST['user'] username = request.POST['user']
@ -605,6 +642,7 @@ def clear_notifications(request):
@login_required @login_required
@require_POST
def accept_follow_request(request): def accept_follow_request(request):
''' a user accepts a follow request ''' ''' a user accepts a follow request '''
username = request.POST['user'] username = request.POST['user']
@ -628,6 +666,7 @@ def accept_follow_request(request):
@login_required @login_required
@require_POST
def delete_follow_request(request): def delete_follow_request(request):
''' a user rejects a follow request ''' ''' a user rejects a follow request '''
username = request.POST['user'] username = request.POST['user']
@ -649,6 +688,7 @@ def delete_follow_request(request):
@login_required @login_required
@require_POST
def import_data(request): def import_data(request):
''' ingest a goodreads csv ''' ''' ingest a goodreads csv '''
form = forms.ImportForm(request.POST, request.FILES) form = forms.ImportForm(request.POST, request.FILES)
@ -672,6 +712,7 @@ def import_data(request):
@login_required @login_required
@require_POST
def retry_import(request): def retry_import(request):
''' ingest a goodreads csv ''' ''' ingest a goodreads csv '''
job = get_object_or_404(models.ImportJob, id=request.POST.get('import_job')) job = get_object_or_404(models.ImportJob, id=request.POST.get('import_job'))
@ -689,6 +730,7 @@ def retry_import(request):
@login_required @login_required
@require_POST
@permission_required('bookwyrm.create_invites', raise_exception=True) @permission_required('bookwyrm.create_invites', raise_exception=True)
def create_invite(request): def create_invite(request):
''' creates a user invite database entry ''' ''' creates a user invite database entry '''

View file

@ -11,6 +11,7 @@ from django.core.exceptions import PermissionDenied
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
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET
from bookwyrm import outgoing from bookwyrm import outgoing
from bookwyrm.activitypub import ActivityEncoder from bookwyrm.activitypub import ActivityEncoder
@ -47,12 +48,14 @@ def not_found_page(request, _):
@login_required @login_required
@require_GET
def home(request): def home(request):
''' this is the same as the feed on the home tab ''' ''' this is the same as the feed on the home tab '''
return home_tab(request, 'home') return home_tab(request, 'home')
@login_required @login_required
@require_GET
def home_tab(request, tab): def home_tab(request, tab):
''' user's homepage with activity feed ''' ''' user's homepage with activity feed '''
try: try:
@ -160,6 +163,7 @@ def get_activity_feed(user, filter_level, model=models.Status):
return activities return activities
@require_GET
def search(request): def search(request):
''' that search bar up top ''' ''' that search bar up top '''
query = request.GET.get('q') query = request.GET.get('q')
@ -191,6 +195,7 @@ def search(request):
@login_required @login_required
@require_GET
def import_page(request): def import_page(request):
''' import history from goodreads ''' ''' import history from goodreads '''
return TemplateResponse(request, 'import.html', { return TemplateResponse(request, 'import.html', {
@ -203,6 +208,7 @@ def import_page(request):
@login_required @login_required
@require_GET
def import_status(request, job_id): def import_status(request, job_id):
''' status of an import job ''' ''' status of an import job '''
job = models.ImportJob.objects.get(id=job_id) job = models.ImportJob.objects.get(id=job_id)
@ -221,6 +227,7 @@ def import_status(request, job_id):
}) })
@require_GET
def login_page(request): def login_page(request):
''' authentication ''' ''' authentication '''
if request.user.is_authenticated: if request.user.is_authenticated:
@ -235,6 +242,7 @@ def login_page(request):
return TemplateResponse(request, 'login.html', data) return TemplateResponse(request, 'login.html', data)
@require_GET
def about_page(request): def about_page(request):
''' more information about the instance ''' ''' more information about the instance '''
data = { data = {
@ -244,6 +252,7 @@ def about_page(request):
return TemplateResponse(request, 'about.html', data) return TemplateResponse(request, 'about.html', data)
@require_GET
def password_reset_request(request): def password_reset_request(request):
''' invite management page ''' ''' invite management page '''
return TemplateResponse( return TemplateResponse(
@ -253,6 +262,7 @@ def password_reset_request(request):
) )
@require_GET
def password_reset(request, code): def password_reset(request, code):
''' endpoint for sending invites ''' ''' endpoint for sending invites '''
if request.user.is_authenticated: if request.user.is_authenticated:
@ -271,6 +281,7 @@ def password_reset(request, code):
) )
@require_GET
def invite_page(request, code): def invite_page(request, code):
''' endpoint for sending invites ''' ''' endpoint for sending invites '''
if request.user.is_authenticated: if request.user.is_authenticated:
@ -293,6 +304,7 @@ def invite_page(request, code):
@login_required @login_required
@permission_required('bookwyrm.create_invites', raise_exception=True) @permission_required('bookwyrm.create_invites', raise_exception=True)
@require_GET
def manage_invites(request): def manage_invites(request):
''' invite management page ''' ''' invite management page '''
data = { data = {
@ -304,6 +316,7 @@ def manage_invites(request):
@login_required @login_required
@require_GET
def notifications_page(request): def notifications_page(request):
''' list notitications ''' ''' list notitications '''
notifications = request.user.notification_set.all() \ notifications = request.user.notification_set.all() \
@ -319,6 +332,7 @@ def notifications_page(request):
@csrf_exempt @csrf_exempt
@require_GET
def user_page(request, username): def user_page(request, username):
''' profile page for a user ''' ''' profile page for a user '''
try: try:
@ -387,11 +401,9 @@ def user_page(request, username):
@csrf_exempt @csrf_exempt
@require_GET
def followers_page(request, username): def followers_page(request, username):
''' list of followers ''' ''' list of followers '''
if request.method != 'GET':
return HttpResponseBadRequest()
try: try:
user = get_user_from_username(username) user = get_user_from_username(username)
except models.User.DoesNotExist: except models.User.DoesNotExist:
@ -410,11 +422,9 @@ def followers_page(request, username):
@csrf_exempt @csrf_exempt
@require_GET
def following_page(request, username): def following_page(request, username):
''' list of followers ''' ''' list of followers '''
if request.method != 'GET':
return HttpResponseBadRequest()
try: try:
user = get_user_from_username(username) user = get_user_from_username(username)
except models.User.DoesNotExist: except models.User.DoesNotExist:
@ -433,11 +443,9 @@ def following_page(request, username):
@csrf_exempt @csrf_exempt
@require_GET
def status_page(request, username, status_id): def status_page(request, username, status_id):
''' display a particular status (and replies, etc) ''' ''' display a particular status (and replies, etc) '''
if request.method != 'GET':
return HttpResponseBadRequest()
try: try:
user = get_user_from_username(username) user = get_user_from_username(username)
status = models.Status.objects.select_subclasses().get(id=status_id) status = models.Status.objects.select_subclasses().get(id=status_id)
@ -476,11 +484,9 @@ def status_visible_to_user(viewer, status):
@csrf_exempt @csrf_exempt
@require_GET
def replies_page(request, username, status_id): def replies_page(request, username, status_id):
''' ordered collection of replies to a status ''' ''' ordered collection of replies to a status '''
if request.method != 'GET':
return HttpResponseBadRequest()
if not is_api_request(request): if not is_api_request(request):
return status_page(request, username, status_id) return status_page(request, username, status_id)
@ -495,6 +501,7 @@ def replies_page(request, username, status_id):
@login_required @login_required
@require_GET
def edit_profile_page(request): def edit_profile_page(request):
''' profile page for a user ''' ''' profile page for a user '''
user = request.user user = request.user
@ -508,6 +515,7 @@ def edit_profile_page(request):
return TemplateResponse(request, 'edit_user.html', data) return TemplateResponse(request, 'edit_user.html', data)
@require_GET
def book_page(request, book_id): def book_page(request, book_id):
''' info about a book ''' ''' info about a book '''
try: try:
@ -595,6 +603,7 @@ def book_page(request, book_id):
@login_required @login_required
@permission_required('bookwyrm.edit_book', raise_exception=True) @permission_required('bookwyrm.edit_book', raise_exception=True)
@require_GET
def edit_book_page(request, book_id): def edit_book_page(request, book_id):
''' info about a book ''' ''' info about a book '''
book = books_manager.get_edition(book_id) book = books_manager.get_edition(book_id)
@ -608,6 +617,7 @@ def edit_book_page(request, book_id):
return TemplateResponse(request, 'edit_book.html', data) return TemplateResponse(request, 'edit_book.html', data)
@require_GET
def editions_page(request, book_id): def editions_page(request, book_id):
''' list of editions of a book ''' ''' list of editions of a book '''
work = get_object_or_404(models.Work, id=book_id) work = get_object_or_404(models.Work, id=book_id)
@ -627,6 +637,7 @@ def editions_page(request, book_id):
return TemplateResponse(request, 'editions.html', data) return TemplateResponse(request, 'editions.html', data)
@require_GET
def author_page(request, author_id): def author_page(request, author_id):
''' landing page for an author ''' ''' landing page for an author '''
author = get_object_or_404(models.Author, id=author_id) author = get_object_or_404(models.Author, id=author_id)
@ -643,6 +654,7 @@ def author_page(request, author_id):
return TemplateResponse(request, 'author.html', data) return TemplateResponse(request, 'author.html', data)
@require_GET
def tag_page(request, tag_id): def tag_page(request, tag_id):
''' books related to a tag ''' ''' books related to a tag '''
tag_obj = models.Tag.objects.filter(identifier=tag_id).first() tag_obj = models.Tag.objects.filter(identifier=tag_id).first()
@ -663,11 +675,13 @@ def tag_page(request, tag_id):
@csrf_exempt @csrf_exempt
@require_GET
def user_shelves_page(request, username): def user_shelves_page(request, username):
''' list of followers ''' ''' list of followers '''
return shelf_page(request, username, None) return shelf_page(request, username, None)
@require_GET
def shelf_page(request, username, shelf_identifier): def shelf_page(request, username, shelf_identifier):
''' display a shelf ''' ''' display a shelf '''
try: try:

View file

@ -1,10 +1,9 @@
''' responds to various requests to /.well-know ''' ''' responds to various requests to /.well-know '''
from datetime import datetime
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.http import HttpResponseNotFound from django.http import HttpResponseNotFound
from django.http import JsonResponse from django.http import JsonResponse
from django.utils import timezone
from bookwyrm import models from bookwyrm import models
from bookwyrm.settings import DOMAIN from bookwyrm.settings import DOMAIN
@ -60,13 +59,13 @@ def nodeinfo(request):
status_count = models.Status.objects.filter(user__local=True).count() status_count = models.Status.objects.filter(user__local=True).count()
user_count = models.User.objects.filter(local=True).count() user_count = models.User.objects.filter(local=True).count()
month_ago = datetime.now() - relativedelta(months=1) month_ago = timezone.now() - relativedelta(months=1)
last_month_count = models.User.objects.filter( last_month_count = models.User.objects.filter(
local=True, local=True,
last_active_date__gt=month_ago last_active_date__gt=month_ago
).count() ).count()
six_months_ago = datetime.now() - relativedelta(months=6) six_months_ago = timezone.now() - relativedelta(months=6)
six_month_count = models.User.objects.filter( six_month_count = models.User.objects.filter(
local=True, local=True,
last_active_date__gt=six_months_ago last_active_date__gt=six_months_ago

3
bw-dev
View file

@ -43,7 +43,8 @@ case "$1" in
;; ;;
migrate) migrate)
execweb python manage.py rename_app fedireads bookwyrm execweb python manage.py rename_app fedireads bookwyrm
execweb python manage.py "$@" shift 1
execweb python manage.py migrate "$@"
;; ;;
bash) bash)
execweb bash execweb bash