mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-11-27 03:51:08 +00:00
Merge branch 'main' into user-creation
This commit is contained in:
commit
8cf7da4b19
40 changed files with 560 additions and 231 deletions
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
|
@ -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
|
||||
|
|
50
README.md
50
README.md
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")' % \
|
||||
|
|
|
@ -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 '''
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
58
bookwyrm/migrations/0045_auto_20210210_2114.py
Normal file
58
bookwyrm/migrations/0045_auto_20210210_2114.py
Normal 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'),
|
||||
),
|
||||
]
|
|
@ -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')
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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 '''
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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 = [
|
||||
|
|
|
@ -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'''
|
||||
|
|
|
@ -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" '''
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
1
bookwyrm/templates/snippets/generated_status/goal.html
Normal file
1
bookwyrm/templates/snippets/generated_status/goal.html
Normal file
|
@ -0,0 +1 @@
|
|||
{% load humanize %}set a goal to read {{ goal.goal | intcomma }} book{{ goal.goal | pluralize }} in {{ goal.year }}
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
@ -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', '/'))
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue