Merge branch 'main' into user-creation

This commit is contained in:
Mouse Reeve 2021-02-15 12:26:15 -08:00
commit 8cf7da4b19
40 changed files with 560 additions and 231 deletions

2
.github/FUNDING.yml vendored
View file

@ -2,7 +2,7 @@
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: bookwyrm
open_collective: # Replace with a single Open Collective username
open_collective: bookwyrm
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry

View file

@ -4,22 +4,36 @@ Social reading and reviewing, decentralized with ActivityPub
## Contents
- [Joining BookWyrm](#joining-bookwyrm)
- [The overall idea](#the-overall-idea)
- [Contributing](#contributing)
- [About BookWyrm](#about-bookwyrm)
- [What it is and isn't](#what-it-is-and-isnt)
- [The role of federation](#the-role-of-federation)
- [Features](#features)
- [Setting up the developer environment](#setting-up-the-developer-environment)
- [Installing in Production](#installing-in-production)
- [Project structure](#project-structure)
- [Book data](#book-data)
- [Contributing](#contributing)
## Joining BookWyrm
BookWyrm is still a young piece of software, and isn't at the level of stability and feature-richness that you'd find in a production-ready application. But it does what it says on the box! If you'd like to join an instance, you can check out the [instances](https://github.com/mouse-reeve/bookwyrm/blob/main/instances.md) list.
I, the maintianer of this project, run https://bookwyrm.social, and I generally give out invite codes to those who ask by [email](mailto:mousereeve@riseup.net), [Mastodon direct message](https://friend.camp/@tripofmice), or [Twitter direct message](https://twitter.com/tripofmice).
You can request an invite to https://bookwyrm.social by [email](mailto:mousereeve@riseup.net), [Mastodon direct message](https://friend.camp/@tripofmice), or [Twitter direct message](https://twitter.com/tripofmice).
## The overall idea
## Contributing
There are many ways you can contribute to this project, regardless of your level of technical expertise.
### Feedback and feature requests
Please feel encouraged and welcome to point out bugs, suggestions, feature requests, and ideas for how things ought to work using [GitHub issues](https://github.com/mouse-reeve/bookwyrm/issues).
### Code contributions
Code contributons are gladly welcomed! If you're not sure where to start, take a look at the ["Good first issue"](https://github.com/mouse-reeve/bookwyrm/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) tag. Because BookWyrm is a small project, there isn't a lot of formal structure, but there is a huge capacity for one-on-one support, which can look like asking questions as you go, pair programming, video chats, et cetera, so please feel free to reach out.
If you have questions about the project or contributing, you can seet up a video call during BookWyrm ["office hours"](https://calendly.com/mouse-reeve/30min).
### Financial Support
BookWyrm is an ad-free passion project with no intentions of seeking out venture funding or corporate financial relationships. If you want to help keep the project going, you can donate to the [Patreon](https://www.patreon.com/bookwyrm), or make a one time gift via [PayPal](https://paypal.me/oulipo).
## About BookWyrm
### What it is and isn't
BookWyrm is a platform for social reading! You can use it to track what you're reading, review books, and follow your friends. It isn't primarily meant for cataloguing or as a datasource for books, but it does do both of those things to some degree.
@ -54,6 +68,25 @@ Since the project is still in its early stages, the features are growing every d
- Private, followers-only, and public privacy levels for posting, shelves, and lists
- Option for users to manually approve followers
- Allow blocking and flagging for moderation
### The Tech Stack
Web backend
- [Django](https://www.djangoproject.com/) web server
- [PostgreSQL](https://www.postgresql.org/) database
- [ActivityPub](http://activitypub.rocks/) federation
- [Celery](http://celeryproject.org/) task queuing
- [Redis](https://redis.io/) task backend
Front end
- Django templates
- [Bulma.io](https://bulma.io/) css framework
- Vanilla JavaScript, in moderation
Deployment
- [Docker](https://www.docker.com/) and docker-compose
- [Gunicorn](https://gunicorn.org/) web runner
- [Flower](https://github.com/mher/flower) celery monitoring
- [Nginx](https://nginx.org/en/) HTTP server
## Setting up the developer environment
@ -116,7 +149,6 @@ This project is still young and isn't, at the momoment, very stable, so please p
```python
from bookwyrm import models
user = models.User.objects.get(id=1)
user.is_admin = True
user.is_staff = True
user.is_superuser = True
user.save()
@ -133,9 +165,3 @@ There are three concepts in the book data model:
- `Edition`, a concrete, actually published version of a book
Whenever a user interacts with a book, they are interacting with a specific edition. Every work has a default edition, but the user can select other editions. Reviews aggregated for all editions of a work when you view an edition's page.
## Contributing
There are many ways you can contribute to this project! You are welcome and encouraged to create or contribute an issue to report a bug, request a feature, make a usability suggestion, or express a nebulous desire.
If you'd like to add to the codebase, that's super rad and you should do it! At this point, there isn't a formalized process, but you can take a look at the open issues, or contact me directly and chat about it.

View file

@ -16,7 +16,7 @@ from .response import ActivitypubResponse
from .book import Edition, Work, Author
from .verbs import Create, Delete, Undo, Update
from .verbs import Follow, Accept, Reject, Block
from .verbs import Add, AddBook, Remove
from .verbs import Add, AddBook, AddListItem, Remove
# this creates a list of all the Activity types that we can serialize,
# so when an Activity comes in from outside, we can check if it's known

View file

@ -65,6 +65,13 @@ class ActivityObject:
def to_model(self, model, instance=None, save=True):
''' convert from an activity to a model instance '''
if self.type != model.activity_serializer.type:
raise ActivitySerializerError(
'Wrong activity type "%s" for activity of type "%s"' % \
(model.activity_serializer.type,
self.type)
)
if not isinstance(self, model.activity_serializer):
raise ActivitySerializerError(
'Wrong activity type "%s" for model "%s" (expects "%s")' % \

View file

@ -70,17 +70,26 @@ class Reject(Verb):
@dataclass(init=False)
class Add(Verb):
'''Add activity '''
target: ActivityObject
target: str
object: ActivityObject
type: str = 'Add'
@dataclass(init=False)
class AddBook(Verb):
class AddBook(Add):
'''Add activity that's aware of the book obj '''
target: Edition
object: Edition
type: str = 'Add'
@dataclass(init=False)
class AddListItem(AddBook):
'''Add activity that's aware of the book obj '''
notes: str = None
order: int = 0
approved: bool = True
@dataclass(init=False)
class Remove(Verb):
'''Remove activity '''

View file

@ -107,7 +107,7 @@ class AbstractConnector(AbstractMinimalConnector):
if self.is_work_data(data):
try:
edition_data = self.get_edition_from_work_data(data)
except KeyError:
except (KeyError, ConnectorException):
# hack: re-use the work data as the edition data
# this is why remote ids aren't necessarily unique
edition_data = data
@ -116,7 +116,7 @@ class AbstractConnector(AbstractMinimalConnector):
try:
work_data = self.get_work_from_edition_data(data)
work_data = dict_from_mappings(work_data, self.book_mappings)
except KeyError:
except (KeyError, ConnectorException):
work_data = mapped_data
edition_data = data
@ -145,8 +145,9 @@ class AbstractConnector(AbstractMinimalConnector):
edition.connector = self.connector
edition.save()
work.default_edition = edition
work.save()
if not work.default_edition:
work.default_edition = edition
work.save()
for author in self.get_authors_from_data(edition_data):
edition.authors.add(author)
@ -210,13 +211,20 @@ def get_data(url):
'User-Agent': settings.USER_AGENT,
},
)
except (RequestError, SSLError):
except (RequestError, SSLError) as e:
logger.exception(e)
raise ConnectorException()
if not resp.ok:
resp.raise_for_status()
try:
resp.raise_for_status()
except requests.exceptions.HTTPError as e:
logger.exception(e)
raise ConnectorException()
try:
data = resp.json()
except ValueError:
except ValueError as e:
logger.exception(e)
raise ConnectorException()
return data
@ -231,7 +239,8 @@ def get_image(url):
'User-Agent': settings.USER_AGENT,
},
)
except (RequestError, SSLError):
except (RequestError, SSLError) as e:
logger.exception(e)
return None
if not resp.ok:
return None

View file

@ -142,7 +142,12 @@ class Connector(AbstractConnector):
work = book.parent_work
# we can mass download edition data from OL to avoid repeatedly querying
edition_options = self.load_edition_data(work.openlibrary_key)
try:
edition_options = self.load_edition_data(work.openlibrary_key)
except ConnectorException:
# who knows, man
return
for edition_data in edition_options.get('entries'):
# does this edition have ANY interesting data?
if ignore_edition(edition_data):

View file

@ -4,7 +4,6 @@ import logging
from bookwyrm import models
from bookwyrm.models import ImportJob, ImportItem
from bookwyrm.status import create_notification
from bookwyrm.tasks import app
logger = logging.getLogger(__name__)
@ -68,7 +67,6 @@ def import_data(job_id):
item.fail_reason = 'Could not find a match for book'
item.save()
finally:
create_notification(job.user, 'IMPORT', related_import=job)
job.complete = True
job.save()

View file

@ -136,14 +136,7 @@ def handle_follow(activity):
)
# send the accept normally for a duplicate request
manually_approves = relationship.user_object.manually_approves_followers
status_builder.create_notification(
relationship.user_object,
'FOLLOW_REQUEST' if manually_approves else 'FOLLOW',
related_user=relationship.user_subject
)
if not manually_approves:
if not relationship.user_object.manually_approves_followers:
relationship.accept()
@ -223,9 +216,9 @@ def handle_create_list(activity):
def handle_update_list(activity):
''' update a list '''
try:
book_list = models.List.objects.get(id=activity['object']['id'])
book_list = models.List.objects.get(remote_id=activity['object']['id'])
except models.List.DoesNotExist:
return
book_list = None
activitypub.BookList(
**activity['object']).to_model(models.List, instance=book_list)
@ -256,27 +249,6 @@ def handle_create_status(activity):
# it was discarded because it's not a bookwyrm type
return
# create a notification if this is a reply
notified = []
if status.reply_parent and status.reply_parent.user.local:
notified.append(status.reply_parent.user)
status_builder.create_notification(
status.reply_parent.user,
'REPLY',
related_user=status.user,
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
def handle_delete_status(activity):
@ -309,13 +281,6 @@ def handle_favorite(activity):
if fav.user.local:
return
status_builder.create_notification(
fav.status.user,
'FAVORITE',
related_user=fav.user,
related_status=fav.status,
)
@app.task
def handle_unfavorite(activity):
@ -332,19 +297,11 @@ def handle_unfavorite(activity):
def handle_boost(activity):
''' someone gave us a boost! '''
try:
boost = activitypub.Boost(**activity).to_model(models.Boost)
activitypub.Boost(**activity).to_model(models.Boost)
except activitypub.ActivitySerializerError:
# this probably just means we tried to boost an unknown status
return
if not boost.user.local:
status_builder.create_notification(
boost.boosted_status.user,
'BOOST',
related_user=boost.user,
related_status=boost.boosted_status,
)
@app.task
def handle_unboost(activity):
@ -362,8 +319,19 @@ def handle_add(activity):
#this is janky as heck but I haven't thought of a better solution
try:
activitypub.AddBook(**activity).to_model(models.ShelfBook)
return
except activitypub.ActivitySerializerError:
activitypub.AddBook(**activity).to_model(models.Tag)
pass
try:
activitypub.AddListItem(**activity).to_model(models.ListItem)
return
except activitypub.ActivitySerializerError:
pass
try:
activitypub.AddBook(**activity).to_model(models.UserTag)
return
except activitypub.ActivitySerializerError:
pass
@app.task

View file

@ -10,7 +10,10 @@ def set_user(app_registry, schema_editor):
shelfbook = app_registry.get_model('bookwyrm', 'ShelfBook')
for item in shelfbook.objects.using(db_alias).filter(user__isnull=True):
item.user = item.shelf.user
item.save(broadcast=False)
try:
item.save(broadcast=False)
except TypeError:
item.save()
class Migration(migrations.Migration):

View file

@ -0,0 +1,58 @@
# Generated by Django 3.0.7 on 2021-02-10 21:14
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0044_auto_20210207_1924'),
]
operations = [
migrations.RemoveConstraint(
model_name='notification',
name='notification_type_valid',
),
migrations.AddField(
model_name='notification',
name='related_list_item',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.ListItem'),
),
migrations.AlterField(
model_name='notification',
name='notification_type',
field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('MENTION', 'Mention'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request'), ('BOOST', 'Boost'), ('IMPORT', 'Import'), ('ADD', 'Add')], max_length=255),
),
migrations.AlterField(
model_name='notification',
name='related_book',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.Edition'),
),
migrations.AlterField(
model_name='notification',
name='related_import',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.ImportJob'),
),
migrations.AlterField(
model_name='notification',
name='related_status',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.Status'),
),
migrations.AlterField(
model_name='notification',
name='related_user',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='related_user', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='notification',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
migrations.AddConstraint(
model_name='notification',
constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'MENTION', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST', 'BOOST', 'IMPORT', 'ADD']), name='notification_type_valid'),
),
]

View file

@ -1,4 +1,5 @@
''' like/fav/star a status '''
from django.apps import apps
from django.db import models
from django.utils import timezone
@ -22,6 +23,30 @@ class Favorite(ActivityMixin, BookWyrmModel):
self.user.save(broadcast=False)
super().save(*args, **kwargs)
if self.status.user.local and self.status.user != self.user:
notification_model = apps.get_model(
'bookwyrm.Notification', require_ready=True)
notification_model.objects.create(
user=self.status.user,
notification_type='FAVORITE',
related_user=self.user,
related_status=self.status
)
def delete(self, *args, **kwargs):
''' delete and delete notifications '''
# check for notification
if self.status.user.local:
notification_model = apps.get_model(
'bookwyrm.Notification', require_ready=True)
notification = notification_model.objects.filter(
user=self.status.user, related_user=self.user,
related_status=self.status, notification_type='FAVORITE'
).first()
if notification:
notification.delete()
super().delete(*args, **kwargs)
class Meta:
''' can't fav things twice '''
unique_together = ('user', 'status')

View file

@ -263,6 +263,7 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
if formatted is None or formatted is MISSING:
return
getattr(instance, self.name).set(formatted)
instance.save(broadcast=False)
def field_to_activity(self, value):
if self.link_only:

View file

@ -2,6 +2,7 @@
import re
import dateutil.parser
from django.apps import apps
from django.contrib.postgres.fields import JSONField
from django.db import models
from django.utils import timezone
@ -50,6 +51,18 @@ class ImportJob(models.Model):
)
retry = models.BooleanField(default=False)
def save(self, *args, **kwargs):
''' save and notify '''
super().save(*args, **kwargs)
if self.complete:
notification_model = apps.get_model(
'bookwyrm.Notification', require_ready=True)
notification_model.objects.create(
user=self.user,
notification_type='IMPORT',
related_import=self,
)
class ImportItem(models.Model):
''' a single line of a csv being imported '''

View file

@ -1,4 +1,5 @@
''' make a list of books!! '''
from django.apps import apps
from django.db import models
from bookwyrm import activitypub
@ -67,10 +68,26 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
order = fields.IntegerField(blank=True, null=True)
endorsement = models.ManyToManyField('User', related_name='endorsers')
activity_serializer = activitypub.AddBook
activity_serializer = activitypub.AddListItem
object_field = 'book'
collection_field = 'book_list'
def save(self, *args, **kwargs):
''' create a notification too '''
created = not bool(self.id)
super().save(*args, **kwargs)
list_owner = self.book_list.user
# create a notification if somoene ELSE added to a local user's list
if created and list_owner.local and list_owner != self.user:
model = apps.get_model('bookwyrm.Notification', require_ready=True)
model.objects.create(
user=list_owner,
related_user=self.user,
related_list_item=self,
notification_type='ADD',
)
class Meta:
''' an opinionated constraint! you can't put a book on a list twice '''
unique_together = ('book', 'book_list')

View file

@ -5,24 +5,41 @@ from .base_model import BookWyrmModel
NotificationType = models.TextChoices(
'NotificationType',
'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT')
'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD')
class Notification(BookWyrmModel):
''' you've been tagged, liked, followed, etc '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
user = models.ForeignKey('User', on_delete=models.CASCADE)
related_book = models.ForeignKey(
'Edition', on_delete=models.PROTECT, null=True)
'Edition', on_delete=models.CASCADE, null=True)
related_user = models.ForeignKey(
'User',
on_delete=models.PROTECT, null=True, related_name='related_user')
on_delete=models.CASCADE, null=True, related_name='related_user')
related_status = models.ForeignKey(
'Status', on_delete=models.PROTECT, null=True)
'Status', on_delete=models.CASCADE, null=True)
related_import = models.ForeignKey(
'ImportJob', on_delete=models.PROTECT, null=True)
'ImportJob', on_delete=models.CASCADE, null=True)
related_list_item = models.ForeignKey(
'ListItem', on_delete=models.CASCADE, null=True)
read = models.BooleanField(default=False)
notification_type = models.CharField(
max_length=255, choices=NotificationType.choices)
def save(self, *args, **kwargs):
''' save, but don't make dupes '''
# there's probably a better way to do this
if self.__class__.objects.filter(
user=self.user,
related_book=self.related_book,
related_user=self.related_user,
related_status=self.related_status,
related_import=self.related_import,
related_list_item=self.related_list_item,
notification_type=self.notification_type,
).exists():
return
super().save(*args, **kwargs)
class Meta:
''' checks if notifcation is in enum list for valid types '''
constraints = [

View file

@ -1,4 +1,5 @@
''' defines relationships between users '''
from django.apps import apps
from django.db import models, transaction
from django.db.models import Q
@ -80,18 +81,34 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
try:
UserFollows.objects.get(
user_subject=self.user_subject,
user_object=self.user_object
user_object=self.user_object,
)
# blocking in either direction is a no-go
UserBlocks.objects.get(
user_subject=self.user_subject,
user_object=self.user_object
user_object=self.user_object,
)
UserBlocks.objects.get(
user_subject=self.user_object,
user_object=self.user_subject,
)
return None
except (UserFollows.DoesNotExist, UserBlocks.DoesNotExist):
super().save(*args, **kwargs)
if broadcast and self.user_subject.local and not self.user_object.local:
self.broadcast(self.to_activity(), self.user_subject)
if self.user_object.local:
model = apps.get_model('bookwyrm.Notification', require_ready=True)
notification_type = 'FOLLOW_REQUEST' \
if self.user_object.manually_approves_followers else 'FOLLOW'
model.objects.create(
user=self.user_object,
related_user=self.user_subject,
notification_type=notification_type,
)
def accept(self):
''' turn this request into the real deal'''

View file

@ -52,6 +52,38 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
serialize_reverse_fields = [('attachments', 'attachment', 'id')]
deserialize_reverse_fields = [('attachments', 'attachment')]
def save(self, *args, **kwargs):
''' save and notify '''
super().save(*args, **kwargs)
notification_model = apps.get_model(
'bookwyrm.Notification', require_ready=True)
if self.deleted:
notification_model.objects.filter(related_status=self).delete()
if self.reply_parent and self.reply_parent.user != self.user and \
self.reply_parent.user.local:
notification_model.objects.create(
user=self.reply_parent.user,
notification_type='REPLY',
related_user=self.user,
related_status=self,
)
for mention_user in self.mention_users.all():
# avoid double-notifying about this status
if not mention_user.local or \
(self.reply_parent and \
mention_user == self.reply_parent.user):
continue
notification_model.objects.create(
user=mention_user,
notification_type='MENTION',
related_user=self.user,
related_status=self,
)
@property
def recipients(self):
''' tagged users who definitely need to get this status in broadcast '''
@ -236,6 +268,33 @@ class Boost(ActivityMixin, Status):
)
activity_serializer = activitypub.Boost
def save(self, *args, **kwargs):
''' save and notify '''
super().save(*args, **kwargs)
if not self.boosted_status.user.local:
return
notification_model = apps.get_model(
'bookwyrm.Notification', require_ready=True)
notification_model.objects.create(
user=self.boosted_status.user,
related_status=self.boosted_status,
related_user=self.user,
notification_type='BOOST',
)
def delete(self, *args, **kwargs):
''' delete and un-notify '''
notification_model = apps.get_model(
'bookwyrm.Notification', require_ready=True)
notification_model.objects.filter(
user=self.boosted_status.user,
related_status=self.boosted_status,
related_user=self.user,
notification_type='BOOST',
).delete()
super().delete(*args, **kwargs)
def __init__(self, *args, **kwargs):
''' the user field is "actor" here instead of "attributedTo" '''

View file

@ -1,4 +1,5 @@
''' Handle user activity '''
from django.db import transaction
from django.utils import timezone
from bookwyrm import models
@ -19,30 +20,18 @@ def create_generated_note(user, content, mention_books=None, privacy='public'):
parser.feed(content)
content = parser.get_output()
status = models.GeneratedNote.objects.create(
user=user,
content=content,
privacy=privacy
)
if mention_books:
for book in mention_books:
status.mention_books.add(book)
with transaction.atomic():
# create but don't save
status = models.GeneratedNote(
user=user,
content=content,
privacy=privacy
)
# we have to save it to set the related fields, but hold off on telling
# folks about it because it is not ready
status.save(broadcast=False)
if mention_books:
status.mention_books.set(mention_books)
status.save(created=True)
return status
def create_notification(user, notification_type, related_user=None, \
related_book=None, related_status=None, related_import=None):
''' let a user know when someone interacts with their content '''
if user == related_user:
# don't create notification when you interact with your own stuff
return
models.Notification.objects.create(
user=user,
related_book=related_book,
related_user=related_user,
related_status=related_status,
related_import=related_import,
notification_type=notification_type,
)

View file

@ -103,7 +103,7 @@
<div class="column">
<div class="block">
<h3 class="field is-grouped">{% include 'snippets/stars.html' with rating=rating %} ({{ review_count }} review{{ reviews|length|pluralize }})</h3>
<h3 class="field is-grouped">{% include 'snippets/stars.html' with rating=rating %} ({{ review_count }} review{{ review_count|pluralize }})</h3>
{% include 'snippets/trimmed_text.html' with full=book|book_description %}
@ -224,6 +224,17 @@
</ul>
</section>
{% endif %}
{% if lists.exists %}
<section class="content block">
<h2 class="title is-5">Lists</h2>
<ul>
{% for list in lists %}
<li><a href="{{ list.local_path }}">{{ list.name }}</a></li>
{% endfor %}
</ul>
</section>
{% endif %}
</div>
</div>
</div>

View file

@ -21,12 +21,12 @@
<div class="card">
<div class="card-content columns p-0 mb-0">
<div class="column is-narrow pt-0 pb-0">
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=item.book size="medium" %}</a>
<a href="{{ item.book.local_path }}">{% include 'snippets/book_cover.html' with book=item.book size="medium" %}</a>
</div>
<div class="column is-flex-direction-column is-align-items-self-start">
<span>{% include 'snippets/book_titleby.html' with book=item.book %}</span>
{% include 'snippets/stars.html' with rating=item.book|rating:request.user %}
{% include 'snippets/shelve_button.html' with book=item.book %}
{% include 'snippets/shelve_button/shelve_button.html' with book=item.book %}
</div>
</div>
<div class="card-footer has-background-white-bis">
@ -72,6 +72,7 @@
<p>No books found{% if query %} matching the query "{{ query }}"{% endif %}</p>
{% endif %}
{% for book in suggested_books %}
{% if book %}
<div class="block columns">
<div class="column is-narrow">
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book size="small" %}</a>
@ -85,6 +86,7 @@
</form>
</div>
</div>
{% endif %}
{% endfor %}
</section>
{% endif %}

View file

@ -8,9 +8,9 @@
<a href="{{ list.local_path }}">{{ list.name }}</a> <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=list %}</span>
</h4>
</header>
<div class="card-image is-flex">
<div class="card-image is-flex is-clipped">
{% for book in list.listitem_set.all|slice:5 %}
{% include 'snippets/book_cover.html' with book=book.book size="small" %}
<a href="{{ book.book.local_path }}">{% include 'snippets/book_cover.html' with book=book.book size="small" %}</a>
{% endfor %}
</div>
<div class="card-content is-flex-grow-0">

View file

@ -29,6 +29,8 @@
<span class="icon icon-heart"></span>
{% elif notification.notification_type == 'IMPORT' %}
<span class="icon icon-list"></span>
{% elif notification.notification_type == 'ADD' %}
<span class="icon icon-plus"></span>
{% endif %}
</div>
<div class="column">
@ -36,33 +38,34 @@
<p>
{# DESCRIPTION #}
{% if notification.related_user %}
{% include 'snippets/avatar.html' with user=notification.related_user %}
{% include 'snippets/username.html' with user=notification.related_user %}
{% if notification.notification_type == 'FAVORITE' %}
favorited your
<a href="{{ related_status.local_path }}">{{ related_status | status_preview_name|safe }}</a>
{% include 'snippets/avatar.html' with user=notification.related_user %}
{% include 'snippets/username.html' with user=notification.related_user %}
{% if notification.notification_type == 'FAVORITE' %}
favorited your
<a href="{{ related_status.local_path }}">{{ related_status | status_preview_name|safe }}</a>
{% elif notification.notification_type == 'MENTION' %}
mentioned you in a
<a href="{{ related_status.local_path }}">{{ related_status | status_preview_name|safe }}</a>
{% elif notification.notification_type == 'MENTION' %}
mentioned you in a
<a href="{{ related_status.local_path }}">{{ related_status | status_preview_name|safe }}</a>
{% elif notification.notification_type == 'REPLY' %}
<a href="{{ related_status.local_path }}">replied</a>
to your
<a href="{{ related_status.reply_parent.local_path }}">{{ related_status | status_preview_name|safe }}</a>
{% elif notification.notification_type == 'FOLLOW' %}
followed you
{% include 'snippets/follow_button.html' with user=notification.related_user %}
{% elif notification.notification_type == 'FOLLOW_REQUEST' %}
sent you a follow request
<div class="row shrink">
{% include 'snippets/follow_request_buttons.html' with user=notification.related_user %}
</div>
{% elif notification.notification_type == 'BOOST' %}
boosted your <a href="{{ related_status.local_path }}">{{ related_status | status_preview_name|safe }}</a>
{% endif %}
{% else %}
{% elif notification.notification_type == 'REPLY' %}
<a href="{{ related_status.local_path }}">replied</a>
to your
<a href="{{ related_status.reply_parent.local_path }}">{{ related_status | status_preview_name|safe }}</a>
{% elif notification.notification_type == 'FOLLOW' %}
followed you
{% include 'snippets/follow_button.html' with user=notification.related_user %}
{% elif notification.notification_type == 'FOLLOW_REQUEST' %}
sent you a follow request
<div class="row shrink">
{% include 'snippets/follow_request_buttons.html' with user=notification.related_user %}
</div>
{% elif notification.notification_type == 'BOOST' %}
boosted your <a href="{{ related_status.local_path }}">{{ related_status | status_preview_name|safe }}</a>
{% elif notification.notification_type == 'ADD' %}
{% if notification.related_list_item.approved %}added{% else %}suggested adding{% endif %} {% include 'snippets/book_titleby.html' with book=notification.related_list_item.book %} to your list "<a href="{{ notification.related_list_item.book_list.local_path }}{% if not notification.related_list_item.approved %}/curate{% endif %}">{{ notification.related_list_item.book_list.name }}</a>"
{% endif %}
{% elif notification.related_import %}
your <a href="/import/{{ notification.related_import.id }}">import</a> completed.
{% endif %}
</p>

View file

@ -0,0 +1 @@
{% load humanize %}set a goal to read {{ goal.goal | intcomma }} book{{ goal.goal | pluralize }} in {{ goal.year }}

View file

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

View file

@ -37,10 +37,10 @@
{% for book in books %}
<tr class="book-preview">
<td>
{% include 'snippets/book_cover.html' with book=book size="small" %}
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book size="small" %}</a>
</td>
<td>
<a href="/book/{{ book.id }}">{{ book.title }}</a>
<a href="{{ book.local_path }}">{{ book.title }}</a>
</td>
<td>
{{ book.authors.first.name }}

View file

@ -54,8 +54,13 @@
{% endif %}
<div>
<div class="block">
<h2 class="title">User Activity</h2>
<div class="columns">
<h2 class="title column">User Activity</h2>
<div class="column is-narrow">
<a class="icon icon-rss" target="_blank" href="{{ user.local_path }}/rss">
<span class="is-sr-only">RSS feed</span>
</a>
</div>
</div>
{% for activity in activities %}
<div class="block" id="feed">

View file

@ -75,19 +75,25 @@
<li{% if url == request.path or url == request.path|add:'/' %} class="is-active"{% endif %}>
<a href="{{ url }}">Activity</a>
</li>
{% if is_self or user.goal.exists %}
{% now 'Y' as year %}
{% url 'user-goal' user|username year as url %}
<li{% if url in request.path %} class="is-active"{% endif %}>
<a href="{{ url }}">Reading Goal</a>
</li>
{% endif %}
{% if is_self or user.lists.exists %}
{% url 'user-lists' user|username as url %}
<li{% if url in request.path %} class="is-active"{% endif %}>
<a href="{{ url }}">Lists</a>
</li>
{% endif %}
{% if user.shelf_set.exists %}
{% url 'user-shelves' user|username as url %}
<li{% if url in request.path %} class="is-active"{% endif %}>
<a href="{{ url }}">Shelves</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}

View file

@ -308,7 +308,7 @@ class Incoming(TestCase):
"@context": "https://www.w3.org/ns/activitystreams"
}
}
incoming.handle_create_list(activity)
incoming.handle_update_list(activity)
book_list.refresh_from_db()
self.assertEqual(book_list.name, 'Test List')
self.assertEqual(book_list.curation, 'curated')
@ -626,7 +626,7 @@ class Incoming(TestCase):
activity = {
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/9e1f41ac-9ddd-4159-aede-9f43c6b9314f",
"id": "https://example.com/9e1f41ac-9ddd-4159",
"type": "Block",
"actor": "https://example.com/users/rat",
"object": "https://example.com/user/mouse"
@ -636,6 +636,29 @@ class Incoming(TestCase):
block = models.UserBlocks.objects.get()
self.assertEqual(block.user_subject, self.remote_user)
self.assertEqual(block.user_object, self.local_user)
self.assertEqual(
block.remote_id, 'https://example.com/9e1f41ac-9ddd-4159')
self.assertFalse(models.UserFollows.objects.exists())
self.assertFalse(models.UserFollowRequest.objects.exists())
def test_handle_unblock(self):
''' unblock a user '''
self.remote_user.blocks.add(self.local_user)
block = models.UserBlocks.objects.get()
block.remote_id = 'https://example.com/9e1f41ac-9ddd-4159'
block.save()
self.assertEqual(block.user_subject, self.remote_user)
self.assertEqual(block.user_object, self.local_user)
activity = {'type': 'Undo', 'object': {
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/9e1f41ac-9ddd-4159",
"type": "Block",
"actor": "https://example.com/users/rat",
"object": "https://example.com/user/mouse"
}}
incoming.handle_unblock(activity)
self.assertFalse(models.UserBlocks.objects.exists())

View file

@ -65,9 +65,9 @@ class TemplateTags(TestCase):
self.assertEqual(bookwyrm_tags.get_notification_count(self.user), 0)
models.Notification.objects.create(
user=self.user, notification_type='FOLLOW')
user=self.user, notification_type='FAVORITE')
models.Notification.objects.create(
user=self.user, notification_type='FOLLOW')
user=self.user, notification_type='MENTION')
models.Notification.objects.create(
user=self.remote_user, notification_type='FOLLOW')

View file

@ -40,7 +40,7 @@ class BookViews(TestCase):
parent_work=self.work
)
def test_handle_follow(self):
def test_handle_follow_remote(self):
''' send a follow request '''
request = self.factory.post('', {'user': self.remote_user.username})
request.user = self.local_user
@ -56,6 +56,49 @@ class BookViews(TestCase):
self.assertEqual(rel.status, 'follow_request')
def test_handle_follow_local_manually_approves(self):
''' send a follow request '''
rat = models.User.objects.create_user(
'rat@local.com', 'rat@rat.com', 'ratword',
local=True, localname='rat',
remote_id='https://example.com/users/rat',
manually_approves_followers=True,
)
request = self.factory.post('', {'user': rat})
request.user = self.local_user
self.assertEqual(models.UserFollowRequest.objects.count(), 0)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
views.follow(request)
rel = models.UserFollowRequest.objects.get()
self.assertEqual(rel.user_subject, self.local_user)
self.assertEqual(rel.user_object, rat)
self.assertEqual(rel.status, 'follow_request')
def test_handle_follow_local(self):
''' send a follow request '''
rat = models.User.objects.create_user(
'rat@local.com', 'rat@rat.com', 'ratword',
local=True, localname='rat',
remote_id='https://example.com/users/rat',
)
request = self.factory.post('', {'user': rat})
request.user = self.local_user
self.assertEqual(models.UserFollowRequest.objects.count(), 0)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
views.follow(request)
rel = models.UserFollows.objects.get()
self.assertEqual(rel.user_subject, self.local_user)
self.assertEqual(rel.user_object, rat)
self.assertEqual(rel.status, 'follows')
def test_handle_unfollow(self):
''' send an unfollow '''
request = self.factory.post('', {'user': self.remote_user.username})

View file

@ -30,7 +30,7 @@ class NotificationViews(TestCase):
def test_clear_notifications(self):
''' erase notifications '''
models.Notification.objects.create(
user=self.local_user, notification_type='MENTION')
user=self.local_user, notification_type='FAVORITE')
models.Notification.objects.create(
user=self.local_user, notification_type='MENTION', read=True)
self.assertEqual(models.Notification.objects.count(), 2)

View file

@ -217,6 +217,16 @@ class StatusViews(TestCase):
'is rad</p>')
def test_to_markdown_link(self):
''' this is mostly handled in other places, but nonetheless '''
text = '[hi](http://fish.com) is <marquee>rad</marquee>'
result = views.status.to_markdown(text)
self.assertEqual(
result,
'<p><a href="http://fish.com">hi</a> ' \
'is rad</p>')
def test_handle_delete_status(self):
''' marks a status as deleted '''
view = views.DeleteStatus.as_view()

View file

@ -3,7 +3,9 @@ import pathlib
from unittest.mock import patch
from PIL import Image
from django.contrib.auth.models import AnonymousUser
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import SimpleUploadedFile
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
@ -24,6 +26,8 @@ class UserViews(TestCase):
'rat@local.com', 'rat@rat.rat', 'password',
local=True, localname='rat')
models.SiteSettings.objects.create()
self.anonymous_user = AnonymousUser
self.anonymous_user.is_authenticated = False
def test_user_page(self):
@ -38,6 +42,14 @@ class UserViews(TestCase):
result.render()
self.assertEqual(result.status_code, 200)
request.user = self.anonymous_user
with patch('bookwyrm.views.user.is_api_request') as is_api:
is_api.return_value = False
result = view(request, 'mouse')
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.user.is_api_request') as is_api:
is_api.return_value = True
result = view(request, 'mouse')
@ -119,7 +131,7 @@ class UserViews(TestCase):
self.assertEqual(result.status_code, 404)
def test_edit_profile_page(self):
def test_edit_user_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.EditUser.as_view()
request = self.factory.get('')
@ -135,12 +147,42 @@ class UserViews(TestCase):
view = views.EditUser.as_view()
form = forms.EditUserForm(instance=self.local_user)
form.data['name'] = 'New Name'
form.data['email'] = 'wow@email.com'
request = self.factory.post('', form.data)
request.user = self.local_user
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
self.assertIsNone(self.local_user.name)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay') \
as delay_mock:
view(request)
self.assertEqual(delay_mock.call_count, 1)
self.assertEqual(self.local_user.name, 'New Name')
self.assertEqual(self.local_user.email, 'wow@email.com')
# idk how to mock the upload form, got tired of triyng to make it work
# def test_edit_user_avatar(self):
# ''' use a form to update a user '''
# view = views.EditUser.as_view()
# form = forms.EditUserForm(instance=self.local_user)
# form.data['name'] = 'New Name'
# form.data['email'] = 'wow@email.com'
# image_file = pathlib.Path(__file__).parent.joinpath(
# '../../static/images/no_cover.jpg')
# image = Image.open(image_file)
# form.files['avatar'] = SimpleUploadedFile(
# image_file, open(image_file), content_type='image/jpeg')
# request = self.factory.post('', form.data, form.files)
# request.user = self.local_user
# with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay') \
# as delay_mock:
# view(request)
# self.assertEqual(delay_mock.call_count, 1)
# self.assertEqual(self.local_user.name, 'New Name')
# self.assertEqual(self.local_user.email, 'wow@email.com')
# self.assertIsNotNone(self.local_user.avatar)
# self.assertEqual(self.local_user.avatar.size, (120, 120))
def test_crop_avatar(self):

View file

@ -15,6 +15,7 @@ from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.connectors import connector_manager
from bookwyrm.settings import PAGE_LENGTH
from .helpers import is_api_request, get_activity_feed, get_edition
from .helpers import privacy_filter
# pylint: disable= no-self-use
@ -94,6 +95,10 @@ class Book(View):
'ratings': reviews.filter(Q(content__isnull=True) | Q(content='')),
'rating': reviews.aggregate(Avg('rating'))['rating__avg'],
'tags': models.UserTag.objects.filter(book=book),
'lists': privacy_filter(
request.user,
book.list_set.all(),
['public', 'unlisted', 'followers']),
'user_tags': user_tags,
'user_shelves': user_shelves,
'other_edition_shelves': other_edition_shelves,

View file

@ -17,10 +17,13 @@ def follow(request):
except models.User.DoesNotExist:
return HttpResponseBadRequest()
models.UserFollowRequest.objects.get_or_create(
rel, _ = models.UserFollowRequest.objects.get_or_create(
user_subject=request.user,
user_object=to_follow,
)
if to_follow.local and not to_follow.manually_approves_followers:
rel.accept()
return redirect(to_follow.local_path)

View file

@ -2,6 +2,7 @@
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseNotFound
from django.shortcuts import redirect
from django.template.loader import get_template
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views import View
@ -62,9 +63,10 @@ class Goal(View):
if request.POST.get('post-status'):
# create status, if appropraite
template = get_template('snippets/generated_status/goal.html')
create_generated_note(
request.user,
'set a goal to read %d books in %d' % (goal.goal, goal.year),
template.render({'goal': goal, 'user': request.user}).strip(),
privacy=goal.privacy
)

View file

@ -7,7 +7,6 @@ from django.utils.decorators import method_decorator
from django.views import View
from bookwyrm import models
from bookwyrm.status import create_notification
# pylint: disable= no-self-use
@ -26,13 +25,6 @@ class Favorite(View):
# you already fav'ed that
return HttpResponseBadRequest()
if status.user.local:
create_notification(
status.user,
'FAVORITE',
related_user=request.user,
related_status=status
)
return redirect(request.headers.get('Referer', '/'))
@ -52,15 +44,6 @@ class Unfavorite(View):
return HttpResponseNotFound()
favorite.delete()
# check for notification
if status.user.local:
notification = models.Notification.objects.filter(
user=status.user, related_user=request.user,
related_status=status, notification_type='FAVORITE'
).first()
if notification:
notification.delete()
return redirect(request.headers.get('Referer', '/'))
@ -84,14 +67,6 @@ class Boost(View):
privacy=status.privacy,
user=request.user,
)
if status.user.local:
create_notification(
status.user,
'BOOST',
related_user=request.user,
related_status=status
)
return redirect(request.headers.get('Referer', '/'))
@ -106,13 +81,4 @@ class Unboost(View):
).first()
boost.delete()
# delete related notification
if status.user.local:
notification = models.Notification.objects.filter(
user=status.user, related_user=request.user,
related_status=status, notification_type='BOOST'
).first()
if notification:
notification.delete()
return redirect(request.headers.get('Referer', '/'))

View file

@ -1,6 +1,7 @@
''' book list views'''
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db import IntegrityError
from django.db.models import Count, Q
from django.http import HttpResponseNotFound, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect
@ -181,24 +182,28 @@ def add_book(request, list_id):
book = get_object_or_404(models.Edition, id=request.POST.get('book'))
# do you have permission to add to the list?
if request.user == book_list.user or book_list.curation == 'open':
# go ahead and add it
models.ListItem.objects.create(
book=book,
book_list=book_list,
user=request.user,
)
elif book_list.curation == 'curated':
# make a pending entry
models.ListItem.objects.create(
approved=False,
book=book,
book_list=book_list,
user=request.user,
)
else:
# you can't add to this list, what were you THINKING
return HttpResponseBadRequest()
try:
if request.user == book_list.user or book_list.curation == 'open':
# go ahead and add it
models.ListItem.objects.create(
book=book,
book_list=book_list,
user=request.user,
)
elif book_list.curation == 'curated':
# make a pending entry
models.ListItem.objects.create(
approved=False,
book=book,
book_list=book_list,
user=request.user,
)
else:
# you can't add to this list, what were you THINKING
return HttpResponseBadRequest()
except IntegrityError:
# if the book is already on the list, don't flip out
pass
return redirect('list', list_id)

View file

@ -10,7 +10,7 @@ from markdown import markdown
from bookwyrm import forms, models
from bookwyrm.sanitize_html import InputHtmlParser
from bookwyrm.settings import DOMAIN
from bookwyrm.status import create_notification, delete_status
from bookwyrm.status import delete_status
from bookwyrm.utils import regex
from .helpers import handle_remote_webfinger
@ -48,32 +48,12 @@ class CreateStatus(View):
r'<a href="%s">%s</a>\g<1>' % \
(mention_user.remote_id, mention_text),
content)
# add reply parent to mentions and notify
# add reply parent to mentions
if status.reply_parent:
status.mention_users.add(status.reply_parent.user)
if status.reply_parent.user.local:
create_notification(
status.reply_parent.user,
'REPLY',
related_user=request.user,
related_status=status
)
# deduplicate mentions
status.mention_users.set(set(status.mention_users.all()))
# create mention notifications
for mention_user in status.mention_users.all():
if status.reply_parent and mention_user == status.reply_parent.user:
continue
if mention_user.local:
create_notification(
mention_user,
'MENTION',
related_user=request.user,
related_status=status
)
# don't apply formatting to generated notes
if not isinstance(status, models.GeneratedNote):
@ -126,8 +106,8 @@ def format_links(content):
def to_markdown(content):
''' catch links and convert to markdown '''
content = format_links(content)
content = markdown(content)
content = format_links(content)
# sanitize resulting html
sanitizer = InputHtmlParser()
sanitizer.feed(content)