mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-12-02 14:26:41 +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]
|
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
|
||||||
|
|
50
README.md
50
README.md
|
@ -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.
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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")' % \
|
||||||
|
|
|
@ -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 '''
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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):
|
||||||
|
|
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 '''
|
''' 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')
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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 '''
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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 = [
|
||||||
|
|
|
@ -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'''
|
||||||
|
|
|
@ -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" '''
|
||||||
|
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
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>
|
<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>
|
||||||
|
|
||||||
|
|
|
@ -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 }}
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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})
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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', '/'))
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in a new issue