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] github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: bookwyrm patreon: bookwyrm
open_collective: # Replace with a single Open Collective username open_collective: bookwyrm
ko_fi: # Replace with a single Ko-fi username ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 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 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 ## Contents
- [Joining BookWyrm](#joining-bookwyrm) - [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) - [What it is and isn't](#what-it-is-and-isnt)
- [The role of federation](#the-role-of-federation) - [The role of federation](#the-role-of-federation)
- [Features](#features) - [Features](#features)
- [Setting up the developer environment](#setting-up-the-developer-environment) - [Setting up the developer environment](#setting-up-the-developer-environment)
- [Installing in Production](#installing-in-production) - [Installing in Production](#installing-in-production)
- [Project structure](#project-structure)
- [Book data](#book-data) - [Book data](#book-data)
- [Contributing](#contributing)
## Joining BookWyrm ## 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. 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 ### 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. 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.
@ -55,6 +69,25 @@ Since the project is still in its early stages, the features are growing every d
- Option for users to manually approve followers - Option for users to manually approve followers
- Allow blocking and flagging for moderation - 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 ## Setting up the developer environment
Set up the environment file: Set up the environment file:
@ -116,7 +149,6 @@ This project is still young and isn't, at the momoment, very stable, so please p
```python ```python
from bookwyrm import models from bookwyrm import models
user = models.User.objects.get(id=1) user = models.User.objects.get(id=1)
user.is_admin = True
user.is_staff = True user.is_staff = True
user.is_superuser = True user.is_superuser = True
user.save() user.save()
@ -133,9 +165,3 @@ There are three concepts in the book data model:
- `Edition`, a concrete, actually published version of a book - `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. 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 .book import Edition, Work, Author
from .verbs import Create, Delete, Undo, Update from .verbs import Create, Delete, Undo, Update
from .verbs import Follow, Accept, Reject, Block 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, # 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 # 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): def to_model(self, model, instance=None, save=True):
''' convert from an activity to a model instance ''' ''' 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): if not isinstance(self, model.activity_serializer):
raise ActivitySerializerError( raise ActivitySerializerError(
'Wrong activity type "%s" for model "%s" (expects "%s")' % \ 'Wrong activity type "%s" for model "%s" (expects "%s")' % \

View file

@ -70,17 +70,26 @@ class Reject(Verb):
@dataclass(init=False) @dataclass(init=False)
class Add(Verb): class Add(Verb):
'''Add activity ''' '''Add activity '''
target: ActivityObject target: str
object: ActivityObject
type: str = 'Add' type: str = 'Add'
@dataclass(init=False) @dataclass(init=False)
class AddBook(Verb): class AddBook(Add):
'''Add activity that's aware of the book obj ''' '''Add activity that's aware of the book obj '''
target: Edition object: Edition
type: str = 'Add' 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) @dataclass(init=False)
class Remove(Verb): class Remove(Verb):
'''Remove activity ''' '''Remove activity '''

View file

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

View file

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

View file

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

View file

@ -136,14 +136,7 @@ def handle_follow(activity):
) )
# send the accept normally for a duplicate request # send the accept normally for a duplicate request
manually_approves = relationship.user_object.manually_approves_followers if not 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:
relationship.accept() relationship.accept()
@ -223,9 +216,9 @@ def handle_create_list(activity):
def handle_update_list(activity): def handle_update_list(activity):
''' update a list ''' ''' update a list '''
try: 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: except models.List.DoesNotExist:
return book_list = None
activitypub.BookList( activitypub.BookList(
**activity['object']).to_model(models.List, instance=book_list) **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 # it was discarded because it's not a bookwyrm type
return 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 @app.task
def handle_delete_status(activity): def handle_delete_status(activity):
@ -309,13 +281,6 @@ def handle_favorite(activity):
if fav.user.local: if fav.user.local:
return return
status_builder.create_notification(
fav.status.user,
'FAVORITE',
related_user=fav.user,
related_status=fav.status,
)
@app.task @app.task
def handle_unfavorite(activity): def handle_unfavorite(activity):
@ -332,19 +297,11 @@ def handle_unfavorite(activity):
def handle_boost(activity): def handle_boost(activity):
''' someone gave us a boost! ''' ''' someone gave us a boost! '''
try: try:
boost = activitypub.Boost(**activity).to_model(models.Boost) activitypub.Boost(**activity).to_model(models.Boost)
except activitypub.ActivitySerializerError: except activitypub.ActivitySerializerError:
# this probably just means we tried to boost an unknown status # this probably just means we tried to boost an unknown status
return 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 @app.task
def handle_unboost(activity): 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 #this is janky as heck but I haven't thought of a better solution
try: try:
activitypub.AddBook(**activity).to_model(models.ShelfBook) activitypub.AddBook(**activity).to_model(models.ShelfBook)
return
except activitypub.ActivitySerializerError: 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 @app.task

View file

@ -10,7 +10,10 @@ def set_user(app_registry, schema_editor):
shelfbook = app_registry.get_model('bookwyrm', 'ShelfBook') shelfbook = app_registry.get_model('bookwyrm', 'ShelfBook')
for item in shelfbook.objects.using(db_alias).filter(user__isnull=True): for item in shelfbook.objects.using(db_alias).filter(user__isnull=True):
item.user = item.shelf.user item.user = item.shelf.user
try:
item.save(broadcast=False) item.save(broadcast=False)
except TypeError:
item.save()
class Migration(migrations.Migration): 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 ''' ''' like/fav/star a status '''
from django.apps import apps
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
@ -22,6 +23,30 @@ class Favorite(ActivityMixin, BookWyrmModel):
self.user.save(broadcast=False) self.user.save(broadcast=False)
super().save(*args, **kwargs) 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: class Meta:
''' can't fav things twice ''' ''' can't fav things twice '''
unique_together = ('user', 'status') unique_together = ('user', 'status')

View file

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

View file

@ -2,6 +2,7 @@
import re import re
import dateutil.parser import dateutil.parser
from django.apps import apps
from django.contrib.postgres.fields import JSONField from django.contrib.postgres.fields import JSONField
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
@ -50,6 +51,18 @@ class ImportJob(models.Model):
) )
retry = models.BooleanField(default=False) 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): class ImportItem(models.Model):
''' a single line of a csv being imported ''' ''' a single line of a csv being imported '''

View file

@ -1,4 +1,5 @@
''' make a list of books!! ''' ''' make a list of books!! '''
from django.apps import apps
from django.db import models from django.db import models
from bookwyrm import activitypub from bookwyrm import activitypub
@ -67,10 +68,26 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
order = fields.IntegerField(blank=True, null=True) order = fields.IntegerField(blank=True, null=True)
endorsement = models.ManyToManyField('User', related_name='endorsers') endorsement = models.ManyToManyField('User', related_name='endorsers')
activity_serializer = activitypub.AddBook activity_serializer = activitypub.AddListItem
object_field = 'book' object_field = 'book'
collection_field = 'book_list' 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: class Meta:
''' an opinionated constraint! you can't put a book on a list twice ''' ''' an opinionated constraint! you can't put a book on a list twice '''
unique_together = ('book', 'book_list') unique_together = ('book', 'book_list')

View file

@ -5,24 +5,41 @@ from .base_model import BookWyrmModel
NotificationType = models.TextChoices( NotificationType = models.TextChoices(
'NotificationType', 'NotificationType',
'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT') 'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD')
class Notification(BookWyrmModel): class Notification(BookWyrmModel):
''' you've been tagged, liked, followed, etc ''' ''' 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( related_book = models.ForeignKey(
'Edition', on_delete=models.PROTECT, null=True) 'Edition', on_delete=models.CASCADE, null=True)
related_user = models.ForeignKey( related_user = models.ForeignKey(
'User', '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( related_status = models.ForeignKey(
'Status', on_delete=models.PROTECT, null=True) 'Status', on_delete=models.CASCADE, null=True)
related_import = models.ForeignKey( 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) read = models.BooleanField(default=False)
notification_type = models.CharField( notification_type = models.CharField(
max_length=255, choices=NotificationType.choices) 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: class Meta:
''' checks if notifcation is in enum list for valid types ''' ''' checks if notifcation is in enum list for valid types '''
constraints = [ constraints = [

View file

@ -1,4 +1,5 @@
''' defines relationships between users ''' ''' defines relationships between users '''
from django.apps import apps
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Q from django.db.models import Q
@ -80,18 +81,34 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
try: try:
UserFollows.objects.get( UserFollows.objects.get(
user_subject=self.user_subject, 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( UserBlocks.objects.get(
user_subject=self.user_subject, 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 return None
except (UserFollows.DoesNotExist, UserBlocks.DoesNotExist): except (UserFollows.DoesNotExist, UserBlocks.DoesNotExist):
super().save(*args, **kwargs) super().save(*args, **kwargs)
if broadcast and self.user_subject.local and not self.user_object.local: if broadcast and self.user_subject.local and not self.user_object.local:
self.broadcast(self.to_activity(), self.user_subject) 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): def accept(self):
''' turn this request into the real deal''' ''' turn this request into the real deal'''

View file

@ -52,6 +52,38 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
serialize_reverse_fields = [('attachments', 'attachment', 'id')] serialize_reverse_fields = [('attachments', 'attachment', 'id')]
deserialize_reverse_fields = [('attachments', 'attachment')] 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 @property
def recipients(self): def recipients(self):
''' tagged users who definitely need to get this status in broadcast ''' ''' tagged users who definitely need to get this status in broadcast '''
@ -236,6 +268,33 @@ class Boost(ActivityMixin, Status):
) )
activity_serializer = activitypub.Boost 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): def __init__(self, *args, **kwargs):
''' the user field is "actor" here instead of "attributedTo" ''' ''' the user field is "actor" here instead of "attributedTo" '''

View file

@ -1,4 +1,5 @@
''' Handle user activity ''' ''' Handle user activity '''
from django.db import transaction
from django.utils import timezone from django.utils import timezone
from bookwyrm import models from bookwyrm import models
@ -19,30 +20,18 @@ def create_generated_note(user, content, mention_books=None, privacy='public'):
parser.feed(content) parser.feed(content)
content = parser.get_output() content = parser.get_output()
status = models.GeneratedNote.objects.create( with transaction.atomic():
# create but don't save
status = models.GeneratedNote(
user=user, user=user,
content=content, content=content,
privacy=privacy 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: if mention_books:
for book in mention_books: status.mention_books.set(mention_books)
status.mention_books.add(book) status.save(created=True)
return status 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="column">
<div class="block"> <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 %} {% include 'snippets/trimmed_text.html' with full=book|book_description %}
@ -224,6 +224,17 @@
</ul> </ul>
</section> </section>
{% endif %} {% 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> </div>
</div> </div>

View file

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

View file

@ -29,6 +29,8 @@
<span class="icon icon-heart"></span> <span class="icon icon-heart"></span>
{% elif notification.notification_type == 'IMPORT' %} {% elif notification.notification_type == 'IMPORT' %}
<span class="icon icon-list"></span> <span class="icon icon-list"></span>
{% elif notification.notification_type == 'ADD' %}
<span class="icon icon-plus"></span>
{% endif %} {% endif %}
</div> </div>
<div class="column"> <div class="column">
@ -58,11 +60,12 @@
<div class="row shrink"> <div class="row shrink">
{% include 'snippets/follow_request_buttons.html' with user=notification.related_user %} {% include 'snippets/follow_request_buttons.html' with user=notification.related_user %}
</div> </div>
{% elif notification.notification_type == 'BOOST' %} {% elif notification.notification_type == 'BOOST' %}
boosted your <a href="{{ related_status.local_path }}">{{ related_status | status_preview_name|safe }}</a> 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 %} {% endif %}
{% else %} {% elif notification.related_import %}
your <a href="/import/{{ notification.related_import.id }}">import</a> completed. your <a href="/import/{{ notification.related_import.id }}">import</a> completed.
{% endif %} {% endif %}
</p> </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> <p>
{% if goal.progress_percent >= 100 %} {% if goal.progress_percent >= 100 %}
Success! Success!
{% elif goal.progress_percent %} {% elif goal.progress_percent %}
{{ goal.progress_percent }}% complete! {{ goal.progress_percent }}% complete!
{% endif %} {% 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> </p>
<progress class="progress is-large" value="{{ goal.book_count }}" max="{{ goal.goal }}" aria-hidden="true">{{ goal.progress_percent }}%</progress> <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 %} {% for book in books %}
<tr class="book-preview"> <tr class="book-preview">
<td> <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>
<td> <td>
<a href="/book/{{ book.id }}">{{ book.title }}</a> <a href="{{ book.local_path }}">{{ book.title }}</a>
</td> </td>
<td> <td>
{{ book.authors.first.name }} {{ book.authors.first.name }}

View file

@ -54,8 +54,13 @@
{% endif %} {% endif %}
<div> <div>
<div class="block"> <div class="columns">
<h2 class="title">User Activity</h2> <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> </div>
{% for activity in activities %} {% for activity in activities %}
<div class="block" id="feed"> <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 %}> <li{% if url == request.path or url == request.path|add:'/' %} class="is-active"{% endif %}>
<a href="{{ url }}">Activity</a> <a href="{{ url }}">Activity</a>
</li> </li>
{% if is_self or user.goal.exists %}
{% now 'Y' as year %} {% now 'Y' as year %}
{% url 'user-goal' user|username year as url %} {% url 'user-goal' user|username year as url %}
<li{% if url in request.path %} class="is-active"{% endif %}> <li{% if url in request.path %} class="is-active"{% endif %}>
<a href="{{ url }}">Reading Goal</a> <a href="{{ url }}">Reading Goal</a>
</li> </li>
{% endif %}
{% if is_self or user.lists.exists %}
{% url 'user-lists' user|username as url %} {% url 'user-lists' user|username as url %}
<li{% if url in request.path %} class="is-active"{% endif %}> <li{% if url in request.path %} class="is-active"{% endif %}>
<a href="{{ url }}">Lists</a> <a href="{{ url }}">Lists</a>
</li> </li>
{% endif %}
{% if user.shelf_set.exists %}
{% url 'user-shelves' user|username as url %} {% url 'user-shelves' user|username as url %}
<li{% if url in request.path %} class="is-active"{% endif %}> <li{% if url in request.path %} class="is-active"{% endif %}>
<a href="{{ url }}">Shelves</a> <a href="{{ url }}">Shelves</a>
</li> </li>
{% endif %}
</ul> </ul>
</nav> </nav>
{% endif %} {% endif %}

View file

@ -308,7 +308,7 @@ class Incoming(TestCase):
"@context": "https://www.w3.org/ns/activitystreams" "@context": "https://www.w3.org/ns/activitystreams"
} }
} }
incoming.handle_create_list(activity) incoming.handle_update_list(activity)
book_list.refresh_from_db() book_list.refresh_from_db()
self.assertEqual(book_list.name, 'Test List') self.assertEqual(book_list.name, 'Test List')
self.assertEqual(book_list.curation, 'curated') self.assertEqual(book_list.curation, 'curated')
@ -626,7 +626,7 @@ class Incoming(TestCase):
activity = { activity = {
"@context": "https://www.w3.org/ns/activitystreams", "@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", "type": "Block",
"actor": "https://example.com/users/rat", "actor": "https://example.com/users/rat",
"object": "https://example.com/user/mouse" "object": "https://example.com/user/mouse"
@ -636,6 +636,29 @@ class Incoming(TestCase):
block = models.UserBlocks.objects.get() block = models.UserBlocks.objects.get()
self.assertEqual(block.user_subject, self.remote_user) self.assertEqual(block.user_subject, self.remote_user)
self.assertEqual(block.user_object, self.local_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.UserFollows.objects.exists())
self.assertFalse(models.UserFollowRequest.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) self.assertEqual(bookwyrm_tags.get_notification_count(self.user), 0)
models.Notification.objects.create( models.Notification.objects.create(
user=self.user, notification_type='FOLLOW') user=self.user, notification_type='FAVORITE')
models.Notification.objects.create( models.Notification.objects.create(
user=self.user, notification_type='FOLLOW') user=self.user, notification_type='MENTION')
models.Notification.objects.create( models.Notification.objects.create(
user=self.remote_user, notification_type='FOLLOW') user=self.remote_user, notification_type='FOLLOW')

View file

@ -40,7 +40,7 @@ class BookViews(TestCase):
parent_work=self.work parent_work=self.work
) )
def test_handle_follow(self): def test_handle_follow_remote(self):
''' send a follow request ''' ''' send a follow request '''
request = self.factory.post('', {'user': self.remote_user.username}) request = self.factory.post('', {'user': self.remote_user.username})
request.user = self.local_user request.user = self.local_user
@ -56,6 +56,49 @@ class BookViews(TestCase):
self.assertEqual(rel.status, 'follow_request') 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): def test_handle_unfollow(self):
''' send an unfollow ''' ''' send an unfollow '''
request = self.factory.post('', {'user': self.remote_user.username}) request = self.factory.post('', {'user': self.remote_user.username})

View file

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

View file

@ -217,6 +217,16 @@ class StatusViews(TestCase):
'is rad</p>') '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): def test_handle_delete_status(self):
''' marks a status as deleted ''' ''' marks a status as deleted '''
view = views.DeleteStatus.as_view() view = views.DeleteStatus.as_view()

View file

@ -3,7 +3,9 @@ import pathlib
from unittest.mock import patch from unittest.mock import patch
from PIL import Image from PIL import Image
from django.contrib.auth.models import AnonymousUser
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.uploadedfile import SimpleUploadedFile
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.test import TestCase from django.test import TestCase
from django.test.client import RequestFactory from django.test.client import RequestFactory
@ -24,6 +26,8 @@ class UserViews(TestCase):
'rat@local.com', 'rat@rat.rat', 'password', 'rat@local.com', 'rat@rat.rat', 'password',
local=True, localname='rat') local=True, localname='rat')
models.SiteSettings.objects.create() models.SiteSettings.objects.create()
self.anonymous_user = AnonymousUser
self.anonymous_user.is_authenticated = False
def test_user_page(self): def test_user_page(self):
@ -38,6 +42,14 @@ class UserViews(TestCase):
result.render() result.render()
self.assertEqual(result.status_code, 200) 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: with patch('bookwyrm.views.user.is_api_request') as is_api:
is_api.return_value = True is_api.return_value = True
result = view(request, 'mouse') result = view(request, 'mouse')
@ -119,7 +131,7 @@ class UserViews(TestCase):
self.assertEqual(result.status_code, 404) 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 ''' ''' there are so many views, this just makes sure it LOADS '''
view = views.EditUser.as_view() view = views.EditUser.as_view()
request = self.factory.get('') request = self.factory.get('')
@ -135,12 +147,42 @@ class UserViews(TestCase):
view = views.EditUser.as_view() view = views.EditUser.as_view()
form = forms.EditUserForm(instance=self.local_user) form = forms.EditUserForm(instance=self.local_user)
form.data['name'] = 'New Name' form.data['name'] = 'New Name'
form.data['email'] = 'wow@email.com'
request = self.factory.post('', form.data) request = self.factory.post('', form.data)
request.user = self.local_user 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) view(request)
self.assertEqual(delay_mock.call_count, 1)
self.assertEqual(self.local_user.name, 'New Name') 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): def test_crop_avatar(self):

View file

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

View file

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

View file

@ -2,6 +2,7 @@
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import HttpResponseNotFound from django.http import HttpResponseNotFound
from django.shortcuts import redirect from django.shortcuts import redirect
from django.template.loader import get_template
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views import View from django.views import View
@ -62,9 +63,10 @@ class Goal(View):
if request.POST.get('post-status'): if request.POST.get('post-status'):
# create status, if appropraite # create status, if appropraite
template = get_template('snippets/generated_status/goal.html')
create_generated_note( create_generated_note(
request.user, 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 privacy=goal.privacy
) )

View file

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

View file

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

View file

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