forked from mirrors/bookwyrm
Merge branch 'main' into list-status
This commit is contained in:
commit
f5fe746176
237 changed files with 14980 additions and 3711 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
|
||||
|
|
10
Dockerfile
10
Dockerfile
|
@ -2,14 +2,12 @@ FROM python:3.9
|
|||
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
RUN mkdir /app
|
||||
RUN mkdir /app/static
|
||||
RUN mkdir /app/images
|
||||
RUN mkdir /app /app/static /app/images
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt /app/
|
||||
RUN pip install -r requirements.txt
|
||||
RUN pip install -r requirements.txt --no-cache-dir
|
||||
RUN apt-get update && apt-get install -y gettext libgettextpo-dev && apt-get clean
|
||||
|
||||
COPY ./bookwyrm /app
|
||||
COPY ./celerywyrm /app
|
||||
COPY ./bookwyrm ./celerywyrm /app/
|
||||
|
|
157
README.md
157
README.md
|
@ -3,52 +3,93 @@
|
|||
Social reading and reviewing, decentralized with ActivityPub
|
||||
|
||||
## Contents
|
||||
- [The overall idea](#the-overall-idea)
|
||||
- [Joining BookWyrm](#joining-bookwyrm)
|
||||
- [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)
|
||||
|
||||
## The overall idea
|
||||
## 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.
|
||||
|
||||
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).
|
||||
|
||||
|
||||
## 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 contributions 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 set up a video call during BookWyrm ["office hours"](https://calendly.com/mouse-reeve/30min).
|
||||
|
||||
### Translation
|
||||
Do you speak a language besides English? BookWyrm needs localization! If you're comfortable using git and want to get into the code, there are [instructions](#working-with-translations-and-locale-files) on how to create and edit localization files. If you feel more comfortable working in a regular text editor and would prefer not to run the application, get in touch directly and we can figure out a system, like emailing a text file, that works best.
|
||||
|
||||
### 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.
|
||||
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 data-source for books, but it does do both of those things to some degree.
|
||||
|
||||
### The role of federation
|
||||
BookWyrm is built on [ActivityPub](http://activitypub.rocks/). With ActivityPub, it inter-operates with different instances of BookWyrm, and other ActivityPub compliant services, like Mastodon and Pixelfed. This means you can run an instance for your book club, and still follow your friend who posts on a server devoted to 20th century Russian speculative fiction. It also means that your friend on mastodon can read and comment on a book review that you post on your BookWyrm instance.
|
||||
BookWyrm is built on [ActivityPub](http://activitypub.rocks/). With ActivityPub, it inter-operates with different instances of BookWyrm, and other ActivityPub compliant services, like Mastodon. This means you can run an instance for your book club, and still follow your friend who posts on a server devoted to 20th century Russian speculative fiction. It also means that your friend on mastodon can read and comment on a book review that you post on your BookWyrm instance.
|
||||
|
||||
Federation makes it possible to have small, self-determining communities, in contrast to the monolithic service you find on GoodReads or Twitter. An instance can be focused on a particular type of literature, be just for use by people who are in a book club together, or anything else that brings people together. Each community can choose which other instances they want to federate with, and moderate and run their community autonomously. Check out https://runyourown.social/ to get a sense of the philosophy and logistics behind small, high-trust social networks.
|
||||
Federation makes it possible to have small, self-determining communities, in contrast to the monolithic service you find on GoodReads or Twitter. An instance can be focused on a particular interest, be just for a group of friends, or anything else that brings people together. Each community can choose which other instances they want to federate with, and moderate and run their community autonomously. Check out https://runyourown.social/ to get a sense of the philosophy and logistics behind small, high-trust social networks.
|
||||
|
||||
### Features
|
||||
Since the project is still in its early stages, not everything here is fully implemented. There is plenty of room for suggestions and ideas. Open an [issue](https://github.com/mouse-reeve/bookwyrm/issues) to get the conversation going!
|
||||
Since the project is still in its early stages, the features are growing every day, and there is plenty of room for suggestions and ideas. Open an [issue](https://github.com/mouse-reeve/bookwyrm/issues) to get the conversation going!
|
||||
- Posting about books
|
||||
- Compose reviews, with or without ratings, which are aggregated in the book page
|
||||
- Compose other kinds of statuses about books, such as:
|
||||
- Comments on a book
|
||||
- Quotes or excerpts
|
||||
- Recommenations of other books
|
||||
- Reply to statuses
|
||||
- Aggregate reviews of a book across connected BookWyrm instances
|
||||
- Differentiate local and federated reviews and rating
|
||||
- View aggregate reviews of a book across connected BookWyrm instances
|
||||
- Differentiate local and federated reviews and rating in your activity feed
|
||||
- Track reading activity
|
||||
- Shelve books on default "to-read," "currently reading," and "read" shelves
|
||||
- Create custom shelves
|
||||
- Store started reading/finished reading dates
|
||||
- Store started reading/finished reading dates, as well as progress updates along the way
|
||||
- Update followers about reading activity (optionally, and with granular privacy controls)
|
||||
- Create lists of books which can be open to submissions from anyone, curated, or only edited by the creator
|
||||
- Federation with ActivityPub
|
||||
- Broadcast and receive user statuses and activity
|
||||
- Broadcast copies of books that can be used as canonical data sources
|
||||
- Share book data between instances to create a networked database of metadata
|
||||
- Identify shared books across instances and aggregate related content
|
||||
- Follow and interact with users across BookWyrm instances
|
||||
- Inter-operate with non-BookWyrm ActivityPub services
|
||||
- Inter-operate with non-BookWyrm ActivityPub services (currently, Mastodon is supported)
|
||||
- Granular privacy controls
|
||||
- Local-only, followers-only, and public posting
|
||||
- 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
|
||||
- Control which instances you want to federate with
|
||||
|
||||
### 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
|
||||
|
||||
|
@ -71,37 +112,75 @@ docker-compose up
|
|||
|
||||
Once the build is complete, you can access the instance at `localhost:1333`
|
||||
|
||||
### Editing static files
|
||||
If you edit the CSS or JavaScript, you will need to run Django's `collectstatic` command in order for your changes to have effect. You can do this by running:
|
||||
``` bash
|
||||
./bw-dev collectstatic
|
||||
```
|
||||
|
||||
### Working with translations and locale files
|
||||
Text in the html files are wrapped in translation tags (`{% trans %}` and `{% blocktrans %}`), and Django generates locale files for all the strings in which you can add translations for the text. You can find existing translations in the `locale/` directory.
|
||||
|
||||
The application's language is set by a request header sent by your browser to the application, so to change the language of the application, you can change the default language requested by your browser.
|
||||
|
||||
#### Adding a locale
|
||||
To start translation into a language which is currently supported, run the django-admin `makemessages` command with the language code for the language you want to add (like `de` for German, or `en-gb` for British English):
|
||||
``` bash
|
||||
./bw-dev makemessages -l <language code>
|
||||
```
|
||||
|
||||
#### Editing a locale
|
||||
When you have a locale file, open the `django.po` in the directory for the language (for example, if you were adding German, `locale/de/LC_MESSAGES/django.po`. All the the text in the application will be shown in paired strings, with `msgid` as the original text, and `msgstr` as the translation (by default, this is set to an empty string, and will display the original text).
|
||||
|
||||
Add your translations to the `msgstr` strings. As the messages in the application are updated, `gettext` will sometimes add best-guess fuzzy matched options for those translations. When a message is marked as fuzzy, it will not be used in the application, so be sure to remove it when you translate that line.
|
||||
|
||||
When you're done, compile the locale by running:
|
||||
|
||||
``` bash
|
||||
./bw-dev compilemessages
|
||||
```
|
||||
|
||||
You can add the `-l <language code>` to only compile one language. When you refresh the application, you should see your translations at work.
|
||||
|
||||
## Installing in Production
|
||||
|
||||
This project is still young and isn't, at the momoment, very stable, so please procede with caution when running in production.
|
||||
This project is still young and isn't, at the moment, very stable, so please proceed with caution when running in production.
|
||||
|
||||
### Server setup
|
||||
- Get a domain name and set up DNS for your server
|
||||
- Set your server up with appropriate firewalls for running a web application (this instruction set is tested again Ubuntu 20.04)
|
||||
- Set up a mailgun account and the appropriate DNS settings
|
||||
- Set your server up with appropriate firewalls for running a web application (this instruction set is tested against Ubuntu 20.04)
|
||||
- Set up an email service (such as mailgun) and the appropriate SMTP/DNS settings
|
||||
- Install Docker and docker-compose
|
||||
|
||||
### Install and configure BookWyrm
|
||||
|
||||
The `production` branch of BookWyrm contains a number of tools not on the `main` branch that are suited for running in production, such as `docker-compose` changes to update the default commands or configuration of containers, and individual changes to container config to enable things like SSL or regular backups.
|
||||
|
||||
Instructions for running BookWyrm in production:
|
||||
|
||||
- Get the application code:
|
||||
`git clone git@github.com:mouse-reeve/bookwyrm.git`
|
||||
- Switch to the `production` branch
|
||||
`git checkout production`
|
||||
- Create your environment variables file
|
||||
`cp .env.example .env`
|
||||
- Add your domain, email address, mailgun credentials
|
||||
- Add your domain, email address, SMTP credentials
|
||||
- Set a secure redis password and secret key
|
||||
- Set a secure database password for postgres
|
||||
- Update your nginx configuration in `nginx/default.conf`
|
||||
- Replace `your-domain.com` with your domain name
|
||||
- Run the application (this should also set up a Certbot ssl cert for your domain)
|
||||
`docker-compose up --build`
|
||||
Make sure all the images build successfully
|
||||
- Run the application (this should also set up a Certbot ssl cert for your domain) with
|
||||
`docker-compose up --build`, and make sure all the images build successfully
|
||||
- When docker has built successfully, stop the process with `CTRL-C`
|
||||
- Comment out the `command: certonly...` line in `docker-compose.yml`
|
||||
- Run docker-compose in the background
|
||||
`docker-compose up -d`
|
||||
- Initialize the database
|
||||
`./bw-dev initdb`
|
||||
- Congrats! You did it, go to your domain and enjoy the fruits of your labors
|
||||
- Run docker-compose in the background with: `docker-compose up -d`
|
||||
- Initialize the database with: `./bw-dev initdb`
|
||||
- Set up schedule backups with cron that runs that `docker-compose exec db pg_dump -U <databasename>` and saves the backup to a safe location
|
||||
|
||||
Congrats! You did it, go to your domain and enjoy the fruits of your labors.
|
||||
|
||||
### Configure your instance
|
||||
- Register a user account in the applcation UI
|
||||
- Register a user account in the application UI
|
||||
- Make your account a superuser (warning: do *not* use django's `createsuperuser` command)
|
||||
- On your server, open the django shell
|
||||
`./bw-dev shell`
|
||||
|
@ -109,26 +188,15 @@ 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()
|
||||
```
|
||||
- Go to the admin panel (`/admin/bookwyrm/sitesettings/1/change` on your domain) and set your instance name, description, code of conduct, and toggle whether registration is open on your instance
|
||||
|
||||
|
||||
## Project structure
|
||||
All the url routing is in `bookwyrm/urls.py`. This includes the application views (your home page, user page, book page, etc), application endpoints (things that happen when you click buttons), and federation api endpoints (inboxes, outboxes, webfinger, etc).
|
||||
|
||||
The application views and actions are in `bookwyrm/views.py`. The internal actions call api handlers which deal with federating content. Outgoing messages (any action done by a user that is federated out), as well as outboxes, live in `bookwyrm/outgoing.py`, and all handlers for incoming messages, as well as inboxes and webfinger, live in `bookwyrm/incoming.py`. Connection to openlibrary.org to get book data is handled in `bookwyrm/connectors/openlibrary.py`. ActivityPub serialization is handled in the `bookwyrm/activitypub/` directory.
|
||||
|
||||
Celery is used for background tasks, which includes receiving incoming ActivityPub activities, ActivityPub broadcasting, and external data import.
|
||||
|
||||
The UI is all django templates because that is the default. You can replace it with a complex javascript framework over my ~dead body~ mild objections.
|
||||
- Go to the site settings (`/settings/site-settings` on your domain) and configure your instance name, description, code of conduct, and toggle whether registration is open on your instance
|
||||
|
||||
|
||||
## Book data
|
||||
The application is set up to get book data from arbitrary outside sources -- right now, it's only able to connect to OpenLibrary, but other connectors could be written. By default, a book is non-canonical copy of an OpenLibrary book, and will be updated with OpenLibrary if the data there changes. However, a book can edited and decoupled from its original data source, or added locally with no external data source.
|
||||
The application is set up to share book and author data between instances, and get book data from arbitrary outside sources. Right now, the only connector is to OpenLibrary, but other connectors could be written.
|
||||
|
||||
There are three concepts in the book data model:
|
||||
- `Book`, an abstract, high-level concept that could mean either a `Work` or an `Edition`. No data is saved as a `Book`, it serves as shared model for `Work` and `Edition`
|
||||
|
@ -137,8 +205,3 @@ There are three concepts in the book data model:
|
|||
|
||||
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.
|
||||
|
|
|
@ -2,13 +2,12 @@
|
|||
import inspect
|
||||
import sys
|
||||
|
||||
from .base_activity import ActivityEncoder, Signature
|
||||
from .base_activity import ActivityEncoder, Signature, naive_parse
|
||||
from .base_activity import Link, Mention
|
||||
from .base_activity import ActivitySerializerError, resolve_remote_id
|
||||
from .image import Image
|
||||
from .note import Note, GeneratedNote, Article, Comment, Review, Quotation
|
||||
from .note import Tombstone
|
||||
from .interaction import Boost, Like
|
||||
from .ordered_collection import OrderedCollection, OrderedCollectionPage
|
||||
from .ordered_collection import BookList, Shelf
|
||||
from .person import Person, PublicKey
|
||||
|
@ -16,10 +15,15 @@ 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, Remove
|
||||
from .verbs import Announce, Like
|
||||
|
||||
# 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
|
||||
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
|
||||
activity_objects = {c[0]: c[1] for c in cls_members \
|
||||
if hasattr(c[1], 'to_model')}
|
||||
|
||||
def parse(activity_json):
|
||||
''' figure out what activity this is and parse it '''
|
||||
return naive_parse(activity_objects, activity_json)
|
||||
|
|
|
@ -40,6 +40,20 @@ class Signature:
|
|||
signatureValue: str
|
||||
type: str = 'RsaSignature2017'
|
||||
|
||||
def naive_parse(activity_objects, activity_json, serializer=None):
|
||||
''' this navigates circular import issues '''
|
||||
if not serializer:
|
||||
if activity_json.get('publicKeyPem'):
|
||||
# ugh
|
||||
activity_json['type'] = 'PublicKey'
|
||||
try:
|
||||
activity_type = activity_json['type']
|
||||
serializer = activity_objects[activity_type]
|
||||
except KeyError as e:
|
||||
raise ActivitySerializerError(e)
|
||||
|
||||
return serializer(activity_objects=activity_objects, **activity_json)
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class ActivityObject:
|
||||
|
@ -47,13 +61,30 @@ class ActivityObject:
|
|||
id: str
|
||||
type: str
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
def __init__(self, activity_objects=None, **kwargs):
|
||||
''' this lets you pass in an object with fields that aren't in the
|
||||
dataclass, which it ignores. Any field in the dataclass is required or
|
||||
has a default value '''
|
||||
for field in fields(self):
|
||||
try:
|
||||
value = kwargs[field.name]
|
||||
if value in (None, MISSING):
|
||||
raise KeyError()
|
||||
try:
|
||||
is_subclass = issubclass(field.type, ActivityObject)
|
||||
except TypeError:
|
||||
is_subclass = False
|
||||
# serialize a model obj
|
||||
if hasattr(value, 'to_activity'):
|
||||
value = value.to_activity()
|
||||
# parse a dict into the appropriate activity
|
||||
elif is_subclass and isinstance(value, dict):
|
||||
if activity_objects:
|
||||
value = naive_parse(activity_objects, value)
|
||||
else:
|
||||
value = naive_parse(
|
||||
activity_objects, value, serializer=field.type)
|
||||
|
||||
except KeyError:
|
||||
if field.default == MISSING and \
|
||||
field.default_factory == MISSING:
|
||||
|
@ -63,24 +94,29 @@ class ActivityObject:
|
|||
setattr(self, field.name, value)
|
||||
|
||||
|
||||
def to_model(self, model, instance=None, save=True):
|
||||
def to_model(self, model=None, instance=None, allow_create=True, save=True):
|
||||
''' convert from an activity to a model instance '''
|
||||
if not isinstance(self, model.activity_serializer):
|
||||
raise ActivitySerializerError(
|
||||
'Wrong activity type "%s" for model "%s" (expects "%s")' % \
|
||||
(self.__class__,
|
||||
model.__name__,
|
||||
model.activity_serializer)
|
||||
)
|
||||
model = model or get_model_from_type(self.type)
|
||||
|
||||
if hasattr(model, 'ignore_activity') and model.ignore_activity(self):
|
||||
return instance
|
||||
# only reject statuses if we're potentially creating them
|
||||
if allow_create and \
|
||||
hasattr(model, 'ignore_activity') and \
|
||||
model.ignore_activity(self):
|
||||
return None
|
||||
|
||||
# check for an existing instance, if we're not updating a known obj
|
||||
instance = instance or model.find_existing(self.serialize()) or model()
|
||||
# check for an existing instance
|
||||
instance = instance or model.find_existing(self.serialize())
|
||||
|
||||
if not instance and not allow_create:
|
||||
# so that we don't create when we want to delete or update
|
||||
return None
|
||||
instance = instance or model()
|
||||
|
||||
for field in instance.simple_fields:
|
||||
field.set_field_from_activity(instance, self)
|
||||
try:
|
||||
field.set_field_from_activity(instance, self)
|
||||
except AttributeError as e:
|
||||
raise ActivitySerializerError(e)
|
||||
|
||||
# image fields have to be set after other fields because they can save
|
||||
# too early and jank up users
|
||||
|
@ -93,7 +129,10 @@ class ActivityObject:
|
|||
with transaction.atomic():
|
||||
# we can't set many to many and reverse fields on an unsaved object
|
||||
try:
|
||||
instance.save()
|
||||
try:
|
||||
instance.save(broadcast=False)
|
||||
except TypeError:
|
||||
instance.save()
|
||||
except IntegrityError as e:
|
||||
raise ActivitySerializerError(e)
|
||||
|
||||
|
@ -129,7 +168,14 @@ class ActivityObject:
|
|||
|
||||
def serialize(self):
|
||||
''' convert to dictionary with context attr '''
|
||||
data = self.__dict__
|
||||
data = self.__dict__.copy()
|
||||
# recursively serialize
|
||||
for (k, v) in data.items():
|
||||
try:
|
||||
if issubclass(type(v), ActivityObject):
|
||||
data[k] = v.serialize()
|
||||
except TypeError:
|
||||
pass
|
||||
data = {k:v for (k, v) in data.items() if v is not None}
|
||||
data['@context'] = 'https://www.w3.org/ns/activitystreams'
|
||||
return data
|
||||
|
@ -172,7 +218,7 @@ def set_related_field(
|
|||
getattr(model_field, 'activitypub_field'),
|
||||
instance.remote_id
|
||||
)
|
||||
item = activity.to_model(model)
|
||||
item = activity.to_model()
|
||||
|
||||
# if the related field isn't serialized (attachments on Status), then
|
||||
# we have to set it post-creation
|
||||
|
@ -181,11 +227,24 @@ def set_related_field(
|
|||
item.save()
|
||||
|
||||
|
||||
def resolve_remote_id(model, remote_id, refresh=False, save=True):
|
||||
def get_model_from_type(activity_type):
|
||||
''' given the activity, what type of model '''
|
||||
models = apps.get_models()
|
||||
model = [m for m in models if hasattr(m, 'activity_serializer') and \
|
||||
hasattr(m.activity_serializer, 'type') and \
|
||||
m.activity_serializer.type == activity_type]
|
||||
if not model:
|
||||
raise ActivitySerializerError(
|
||||
'No model found for activity type "%s"' % activity_type)
|
||||
return model[0]
|
||||
|
||||
|
||||
def resolve_remote_id(remote_id, model=None, refresh=False, save=True):
|
||||
''' take a remote_id and return an instance, creating if necessary '''
|
||||
result = model.find_existing_by_remote_id(remote_id)
|
||||
if result and not refresh:
|
||||
return result
|
||||
if model:# a bonus check we can do if we already know the model
|
||||
result = model.find_existing_by_remote_id(remote_id)
|
||||
if result and not refresh:
|
||||
return result
|
||||
|
||||
# load the data and create the object
|
||||
try:
|
||||
|
@ -194,13 +253,15 @@ def resolve_remote_id(model, remote_id, refresh=False, save=True):
|
|||
raise ActivitySerializerError(
|
||||
'Could not connect to host for remote_id in %s model: %s' % \
|
||||
(model.__name__, remote_id))
|
||||
# determine the model implicitly, if not provided
|
||||
if not model:
|
||||
model = get_model_from_type(data.get('type'))
|
||||
|
||||
# check for existing items with shared unique identifiers
|
||||
if not result:
|
||||
result = model.find_existing(data)
|
||||
if result and not refresh:
|
||||
return result
|
||||
result = model.find_existing(data)
|
||||
if result and not refresh:
|
||||
return result
|
||||
|
||||
item = model.activity_serializer(**data)
|
||||
# if we're refreshing, "result" will be set and we'll update it
|
||||
return item.to_model(model, instance=result, save=save)
|
||||
return item.to_model(model=model, instance=result, save=save)
|
||||
|
|
|
@ -67,4 +67,4 @@ class Author(ActivityObject):
|
|||
librarythingKey: str = ''
|
||||
goodreadsKey: str = ''
|
||||
wikipediaLink: str = ''
|
||||
type: str = 'Person'
|
||||
type: str = 'Author'
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
''' boosting and liking posts '''
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .base_activity import ActivityObject
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Like(ActivityObject):
|
||||
''' a user faving an object '''
|
||||
actor: str
|
||||
object: str
|
||||
type: str = 'Like'
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Boost(ActivityObject):
|
||||
''' boosting a status '''
|
||||
actor: str
|
||||
object: str
|
||||
type: str = 'Announce'
|
|
@ -1,6 +1,7 @@
|
|||
''' note serializer and children thereof '''
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List
|
||||
from django.apps import apps
|
||||
|
||||
from .base_activity import ActivityObject, Link
|
||||
from .image import Image
|
||||
|
@ -8,17 +9,20 @@ from .image import Image
|
|||
@dataclass(init=False)
|
||||
class Tombstone(ActivityObject):
|
||||
''' the placeholder for a deleted status '''
|
||||
published: str
|
||||
deleted: str
|
||||
type: str = 'Tombstone'
|
||||
|
||||
def to_model(self, *args, **kwargs):
|
||||
''' this should never really get serialized, just searched for '''
|
||||
model = apps.get_model('bookwyrm.Status')
|
||||
return model.find_existing_by_remote_id(self.id)
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Note(ActivityObject):
|
||||
''' Note activity '''
|
||||
published: str
|
||||
attributedTo: str
|
||||
content: str
|
||||
content: str = ''
|
||||
to: List[str] = field(default_factory=lambda: [])
|
||||
cc: List[str] = field(default_factory=lambda: [])
|
||||
replies: Dict = field(default_factory=lambda: {})
|
||||
|
|
|
@ -17,6 +17,7 @@ class OrderedCollection(ActivityObject):
|
|||
|
||||
@dataclass(init=False)
|
||||
class OrderedCollectionPrivate(OrderedCollection):
|
||||
''' an ordered collection with privacy settings '''
|
||||
to: List[str] = field(default_factory=lambda: [])
|
||||
cc: List[str] = field(default_factory=lambda: [])
|
||||
|
||||
|
@ -38,6 +39,6 @@ class OrderedCollectionPage(ActivityObject):
|
|||
''' structure of an ordered collection activity '''
|
||||
partOf: str
|
||||
orderedItems: List
|
||||
next: str
|
||||
prev: str
|
||||
next: str = None
|
||||
prev: str = None
|
||||
type: str = 'OrderedCollectionPage'
|
||||
|
|
|
@ -9,7 +9,7 @@ class ActivitypubResponse(JsonResponse):
|
|||
configures some stuff beforehand. Made to be a drop-in replacement of
|
||||
JsonResponse.
|
||||
"""
|
||||
def __init__(self, data, encoder=ActivityEncoder, safe=True,
|
||||
def __init__(self, data, encoder=ActivityEncoder, safe=False,
|
||||
json_dumps_params=None, **kwargs):
|
||||
|
||||
if 'content_type' not in kwargs:
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
''' undo wrapper activity '''
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
from django.apps import apps
|
||||
|
||||
from .base_activity import ActivityObject, Signature
|
||||
from .base_activity import ActivityObject, Signature, resolve_remote_id
|
||||
from .book import Edition
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Verb(ActivityObject):
|
||||
''' generic fields for activities - maybe an unecessary level of
|
||||
|
@ -12,6 +14,10 @@ class Verb(ActivityObject):
|
|||
actor: str
|
||||
object: ActivityObject
|
||||
|
||||
def action(self):
|
||||
''' usually we just want to save, this can be overridden as needed '''
|
||||
self.object.to_model()
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Create(Verb):
|
||||
|
@ -29,6 +35,12 @@ class Delete(Verb):
|
|||
cc: List
|
||||
type: str = 'Delete'
|
||||
|
||||
def action(self):
|
||||
''' find and delete the activity object '''
|
||||
obj = self.object.to_model(save=False, allow_create=False)
|
||||
obj.delete()
|
||||
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Update(Verb):
|
||||
|
@ -36,29 +48,60 @@ class Update(Verb):
|
|||
to: List
|
||||
type: str = 'Update'
|
||||
|
||||
def action(self):
|
||||
''' update a model instance from the dataclass '''
|
||||
self.object.to_model(allow_create=False)
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Undo(Verb):
|
||||
''' Undo an activity '''
|
||||
type: str = 'Undo'
|
||||
|
||||
def action(self):
|
||||
''' find and remove the activity object '''
|
||||
# this is so hacky but it does make it work....
|
||||
# (because you Reject a request and Undo a follow
|
||||
model = None
|
||||
if self.object.type == 'Follow':
|
||||
model = apps.get_model('bookwyrm.UserFollows')
|
||||
obj = self.object.to_model(model=model, save=False, allow_create=False)
|
||||
obj.delete()
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Follow(Verb):
|
||||
''' Follow activity '''
|
||||
object: str
|
||||
type: str = 'Follow'
|
||||
|
||||
def action(self):
|
||||
''' relationship save '''
|
||||
self.to_model()
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Block(Verb):
|
||||
''' Block activity '''
|
||||
object: str
|
||||
type: str = 'Block'
|
||||
|
||||
def action(self):
|
||||
''' relationship save '''
|
||||
self.to_model()
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Accept(Verb):
|
||||
''' Accept activity '''
|
||||
object: Follow
|
||||
type: str = 'Accept'
|
||||
|
||||
def action(self):
|
||||
''' find and remove the activity object '''
|
||||
obj = self.object.to_model(save=False, allow_create=False)
|
||||
obj.accept()
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Reject(Verb):
|
||||
|
@ -66,19 +109,29 @@ class Reject(Verb):
|
|||
object: Follow
|
||||
type: str = 'Reject'
|
||||
|
||||
def action(self):
|
||||
''' find and remove the activity object '''
|
||||
obj = self.object.to_model(save=False, allow_create=False)
|
||||
obj.reject()
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Add(Verb):
|
||||
'''Add activity '''
|
||||
target: ActivityObject
|
||||
target: str
|
||||
object: Edition
|
||||
type: str = 'Add'
|
||||
notes: str = None
|
||||
order: int = 0
|
||||
approved: bool = True
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class AddBook(Verb):
|
||||
'''Add activity that's aware of the book obj '''
|
||||
target: Edition
|
||||
type: str = 'Add'
|
||||
def action(self):
|
||||
''' add obj to collection '''
|
||||
target = resolve_remote_id(self.target, refresh=False)
|
||||
# we want to related field that isn't the book, this is janky af sorry
|
||||
model = [t for t in type(target)._meta.related_objects \
|
||||
if t.name != 'edition'][0].related_model
|
||||
self.to_model(model=model)
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
|
@ -86,3 +139,30 @@ class Remove(Verb):
|
|||
'''Remove activity '''
|
||||
target: ActivityObject
|
||||
type: str = 'Remove'
|
||||
|
||||
def action(self):
|
||||
''' find and remove the activity object '''
|
||||
obj = self.object.to_model(save=False, allow_create=False)
|
||||
obj.delete()
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Like(Verb):
|
||||
''' a user faving an object '''
|
||||
object: str
|
||||
type: str = 'Like'
|
||||
|
||||
def action(self):
|
||||
''' like '''
|
||||
self.to_model()
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Announce(Verb):
|
||||
''' boosting a status '''
|
||||
object: str
|
||||
type: str = 'Announce'
|
||||
|
||||
def action(self):
|
||||
''' boost '''
|
||||
self.to_model()
|
||||
|
|
|
@ -1,87 +0,0 @@
|
|||
''' send out activitypub messages '''
|
||||
import json
|
||||
from django.utils.http import http_date
|
||||
import requests
|
||||
|
||||
from bookwyrm import models, settings
|
||||
from bookwyrm.activitypub import ActivityEncoder
|
||||
from bookwyrm.tasks import app
|
||||
from bookwyrm.signatures import make_signature, make_digest
|
||||
|
||||
|
||||
def get_public_recipients(user, software=None):
|
||||
''' everybody and their public inboxes '''
|
||||
followers = user.followers.filter(local=False)
|
||||
if software:
|
||||
followers = followers.filter(bookwyrm_user=(software == 'bookwyrm'))
|
||||
|
||||
# we want shared inboxes when available
|
||||
shared = followers.filter(
|
||||
shared_inbox__isnull=False
|
||||
).values_list('shared_inbox', flat=True).distinct()
|
||||
|
||||
# if a user doesn't have a shared inbox, we need their personal inbox
|
||||
# iirc pixelfed doesn't have shared inboxes
|
||||
inboxes = followers.filter(
|
||||
shared_inbox__isnull=True
|
||||
).values_list('inbox', flat=True)
|
||||
|
||||
return list(shared) + list(inboxes)
|
||||
|
||||
|
||||
def broadcast(sender, activity, software=None, \
|
||||
privacy='public', direct_recipients=None):
|
||||
''' send out an event '''
|
||||
# start with parsing the direct recipients
|
||||
recipients = [u.inbox for u in direct_recipients or []]
|
||||
# and then add any other recipients
|
||||
if privacy == 'public':
|
||||
recipients += get_public_recipients(sender, software=software)
|
||||
broadcast_task.delay(
|
||||
sender.id,
|
||||
json.dumps(activity, cls=ActivityEncoder),
|
||||
recipients
|
||||
)
|
||||
|
||||
|
||||
@app.task
|
||||
def broadcast_task(sender_id, activity, recipients):
|
||||
''' the celery task for broadcast '''
|
||||
sender = models.User.objects.get(id=sender_id)
|
||||
errors = []
|
||||
for recipient in recipients:
|
||||
try:
|
||||
sign_and_send(sender, activity, recipient)
|
||||
except requests.exceptions.HTTPError as e:
|
||||
errors.append({
|
||||
'error': str(e),
|
||||
'recipient': recipient,
|
||||
'activity': activity,
|
||||
})
|
||||
return errors
|
||||
|
||||
|
||||
def sign_and_send(sender, data, destination):
|
||||
''' crpyto whatever and http junk '''
|
||||
now = http_date()
|
||||
|
||||
if not sender.key_pair.private_key:
|
||||
# this shouldn't happen. it would be bad if it happened.
|
||||
raise ValueError('No private key found for sender')
|
||||
|
||||
digest = make_digest(data)
|
||||
|
||||
response = requests.post(
|
||||
destination,
|
||||
data=data,
|
||||
headers={
|
||||
'Date': now,
|
||||
'Digest': digest,
|
||||
'Signature': make_signature(sender, destination, now, digest),
|
||||
'Content-Type': 'application/activity+json; charset=utf-8',
|
||||
'User-Agent': settings.USER_AGENT,
|
||||
},
|
||||
)
|
||||
if not response.ok:
|
||||
response.raise_for_status()
|
||||
return response
|
|
@ -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
|
||||
|
||||
|
@ -127,7 +127,7 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
# create activitypub object
|
||||
work_activity = activitypub.Work(**work_data)
|
||||
# this will dedupe automatically
|
||||
work = work_activity.to_model(models.Work)
|
||||
work = work_activity.to_model(model=models.Work)
|
||||
for author in self.get_authors_from_data(data):
|
||||
work.authors.add(author)
|
||||
|
||||
|
@ -141,12 +141,13 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
mapped_data = dict_from_mappings(edition_data, self.book_mappings)
|
||||
mapped_data['work'] = work.remote_id
|
||||
edition_activity = activitypub.Edition(**mapped_data)
|
||||
edition = edition_activity.to_model(models.Edition)
|
||||
edition = edition_activity.to_model(model=models.Edition)
|
||||
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)
|
||||
|
@ -167,7 +168,7 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
mapped_data = dict_from_mappings(data, self.author_mappings)
|
||||
activity = activitypub.Author(**mapped_data)
|
||||
# this will dedupe
|
||||
return activity.to_model(models.Author)
|
||||
return activity.to_model(model=models.Author)
|
||||
|
||||
|
||||
@abstractmethod
|
||||
|
@ -210,13 +211,16 @@ 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()
|
||||
raise ConnectorException()
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
except ValueError as e:
|
||||
logger.exception(e)
|
||||
raise ConnectorException()
|
||||
|
||||
return data
|
||||
|
@ -231,7 +235,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
|
||||
|
|
|
@ -7,7 +7,7 @@ class Connector(AbstractMinimalConnector):
|
|||
''' this is basically just for search '''
|
||||
|
||||
def get_or_create_book(self, remote_id):
|
||||
edition = activitypub.resolve_remote_id(models.Edition, remote_id)
|
||||
edition = activitypub.resolve_remote_id(remote_id, model=models.Edition)
|
||||
work = edition.parent_work
|
||||
work.default_edition = work.get_default_edition()
|
||||
work.save()
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -6,6 +6,7 @@ from django import forms
|
|||
from django.forms import ModelForm, PasswordInput, widgets
|
||||
from django.forms.widgets import Textarea
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from bookwyrm import models
|
||||
|
||||
|
@ -181,13 +182,14 @@ class CreateInviteForm(CustomForm):
|
|||
exclude = ['code', 'user', 'times_used']
|
||||
widgets = {
|
||||
'expiry': ExpiryWidget(choices=[
|
||||
('day', 'One Day'),
|
||||
('week', 'One Week'),
|
||||
('month', 'One Month'),
|
||||
('forever', 'Does Not Expire')]),
|
||||
('day', _('One Day')),
|
||||
('week', _('One Week')),
|
||||
('month', _('One Month')),
|
||||
('forever', _('Does Not Expire'))]),
|
||||
'use_limit': widgets.Select(
|
||||
choices=[(i, "%d uses" % (i,)) for i in [1, 5, 10, 25, 50, 100]]
|
||||
+ [(None, 'Unlimited')])
|
||||
choices=[(i, _("%(count)d uses" % {'count': i})) \
|
||||
for i in [1, 5, 10, 25, 50, 100]]
|
||||
+ [(None, _('Unlimited'))])
|
||||
}
|
||||
|
||||
class ShelfForm(CustomForm):
|
||||
|
|
|
@ -1,128 +1,13 @@
|
|||
''' handle reading a csv from goodreads '''
|
||||
import csv
|
||||
import logging
|
||||
from bookwyrm.importer import Importer
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.broadcast import broadcast
|
||||
from bookwyrm.models import ImportJob, ImportItem
|
||||
from bookwyrm.status import create_notification
|
||||
from bookwyrm.tasks import app
|
||||
# GoodReads is the default importer, thus Importer follows its structure. For a more complete example of overriding see librarything_import.py
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
class GoodreadsImporter(Importer):
|
||||
service = 'GoodReads'
|
||||
|
||||
|
||||
def create_job(user, csv_file, include_reviews, privacy):
|
||||
''' check over a csv and creates a database entry for the job'''
|
||||
job = ImportJob.objects.create(
|
||||
user=user,
|
||||
include_reviews=include_reviews,
|
||||
privacy=privacy
|
||||
)
|
||||
for index, entry in enumerate(list(csv.DictReader(csv_file))):
|
||||
if not all(x in entry for x in ('ISBN13', 'Title', 'Author')):
|
||||
raise ValueError('Author, title, and isbn must be in data.')
|
||||
ImportItem(job=job, index=index, data=entry).save()
|
||||
return job
|
||||
|
||||
|
||||
def create_retry_job(user, original_job, items):
|
||||
''' retry items that didn't import '''
|
||||
job = ImportJob.objects.create(
|
||||
user=user,
|
||||
include_reviews=original_job.include_reviews,
|
||||
privacy=original_job.privacy,
|
||||
retry=True
|
||||
)
|
||||
for item in items:
|
||||
ImportItem(job=job, index=item.index, data=item.data).save()
|
||||
return job
|
||||
|
||||
|
||||
def start_import(job):
|
||||
''' initalizes a csv import job '''
|
||||
result = import_data.delay(job.id)
|
||||
job.task_id = result.id
|
||||
job.save()
|
||||
|
||||
|
||||
@app.task
|
||||
def import_data(job_id):
|
||||
''' does the actual lookup work in a celery task '''
|
||||
job = ImportJob.objects.get(id=job_id)
|
||||
try:
|
||||
for item in job.items.all():
|
||||
try:
|
||||
item.resolve()
|
||||
except Exception as e:# pylint: disable=broad-except
|
||||
logger.exception(e)
|
||||
item.fail_reason = 'Error loading book'
|
||||
item.save()
|
||||
continue
|
||||
|
||||
if item.book:
|
||||
item.save()
|
||||
|
||||
# shelves book and handles reviews
|
||||
handle_imported_book(
|
||||
job.user, item, job.include_reviews, job.privacy)
|
||||
else:
|
||||
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()
|
||||
|
||||
|
||||
def handle_imported_book(user, item, include_reviews, privacy):
|
||||
''' process a goodreads csv and then post about it '''
|
||||
if isinstance(item.book, models.Work):
|
||||
item.book = item.book.default_edition
|
||||
if not item.book:
|
||||
return
|
||||
|
||||
existing_shelf = models.ShelfBook.objects.filter(
|
||||
book=item.book, added_by=user).exists()
|
||||
|
||||
# shelve the book if it hasn't been shelved already
|
||||
if item.shelf and not existing_shelf:
|
||||
desired_shelf = models.Shelf.objects.get(
|
||||
identifier=item.shelf,
|
||||
user=user
|
||||
)
|
||||
shelf_book = models.ShelfBook.objects.create(
|
||||
book=item.book, shelf=desired_shelf, added_by=user)
|
||||
broadcast(user, shelf_book.to_add_activity(user), privacy=privacy)
|
||||
|
||||
for read in item.reads:
|
||||
# check for an existing readthrough with the same dates
|
||||
if models.ReadThrough.objects.filter(
|
||||
user=user, book=item.book,
|
||||
start_date=read.start_date,
|
||||
finish_date=read.finish_date
|
||||
).exists():
|
||||
continue
|
||||
read.book = item.book
|
||||
read.user = user
|
||||
read.save()
|
||||
|
||||
if include_reviews and (item.rating or item.review):
|
||||
review_title = 'Review of {!r} on Goodreads'.format(
|
||||
item.book.title,
|
||||
) if item.review else ''
|
||||
|
||||
# we don't know the publication date of the review,
|
||||
# but "now" is a bad guess
|
||||
published_date_guess = item.date_read or item.date_added
|
||||
review = models.Review.objects.create(
|
||||
user=user,
|
||||
book=item.book,
|
||||
name=review_title,
|
||||
content=item.review,
|
||||
rating=item.rating,
|
||||
published_date=published_date_guess,
|
||||
privacy=privacy,
|
||||
)
|
||||
# we don't need to send out pure activities because non-bookwyrm
|
||||
# instances don't need this data
|
||||
broadcast(user, review.to_create_activity(user), privacy=privacy)
|
||||
def parse_fields(self, data):
|
||||
data.update({'import_source': self.service })
|
||||
# add missing 'Date Started' field
|
||||
data.update({'Date Started': None })
|
||||
return data
|
||||
|
|
135
bookwyrm/importer.py
Normal file
135
bookwyrm/importer.py
Normal file
|
@ -0,0 +1,135 @@
|
|||
''' handle reading a csv from an external service, defaults are from GoodReads '''
|
||||
import csv
|
||||
import logging
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.models import ImportJob, ImportItem
|
||||
from bookwyrm.tasks import app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Importer:
|
||||
service = 'Unknown'
|
||||
delimiter = ','
|
||||
encoding = 'UTF-8'
|
||||
mandatory_fields = ['Title', 'Author']
|
||||
|
||||
def create_job(self, user, csv_file, include_reviews, privacy):
|
||||
''' check over a csv and creates a database entry for the job'''
|
||||
job = ImportJob.objects.create(
|
||||
user=user,
|
||||
include_reviews=include_reviews,
|
||||
privacy=privacy
|
||||
)
|
||||
for index, entry in enumerate(list(csv.DictReader(csv_file, delimiter=self.delimiter ))):
|
||||
if not all(x in entry for x in self.mandatory_fields):
|
||||
raise ValueError('Author and title must be in data.')
|
||||
entry = self.parse_fields(entry)
|
||||
self.save_item(job, index, entry)
|
||||
return job
|
||||
|
||||
|
||||
def save_item(self, job, index, data):
|
||||
ImportItem(job=job, index=index, data=data).save()
|
||||
|
||||
def parse_fields(self, entry):
|
||||
entry.update({'import_source': self.service })
|
||||
return entry
|
||||
|
||||
def create_retry_job(self, user, original_job, items):
|
||||
''' retry items that didn't import '''
|
||||
job = ImportJob.objects.create(
|
||||
user=user,
|
||||
include_reviews=original_job.include_reviews,
|
||||
privacy=original_job.privacy,
|
||||
retry=True
|
||||
)
|
||||
for item in items:
|
||||
self.save_item(job, item.index, item.data)
|
||||
return job
|
||||
|
||||
|
||||
def start_import(self, job):
|
||||
''' initalizes a csv import job '''
|
||||
result = import_data.delay(self.service, job.id)
|
||||
job.task_id = result.id
|
||||
job.save()
|
||||
|
||||
|
||||
@app.task
|
||||
def import_data(source, job_id):
|
||||
''' does the actual lookup work in a celery task '''
|
||||
job = ImportJob.objects.get(id=job_id)
|
||||
try:
|
||||
for item in job.items.all():
|
||||
try:
|
||||
item.resolve()
|
||||
except Exception as e:# pylint: disable=broad-except
|
||||
logger.exception(e)
|
||||
item.fail_reason = 'Error loading book'
|
||||
item.save()
|
||||
continue
|
||||
|
||||
if item.book:
|
||||
item.save()
|
||||
|
||||
# shelves book and handles reviews
|
||||
handle_imported_book(source,
|
||||
job.user, item, job.include_reviews, job.privacy)
|
||||
else:
|
||||
item.fail_reason = 'Could not find a match for book'
|
||||
item.save()
|
||||
finally:
|
||||
job.complete = True
|
||||
job.save()
|
||||
|
||||
|
||||
def handle_imported_book(source, user, item, include_reviews, privacy):
|
||||
''' process a csv and then post about it '''
|
||||
if isinstance(item.book, models.Work):
|
||||
item.book = item.book.default_edition
|
||||
if not item.book:
|
||||
return
|
||||
|
||||
existing_shelf = models.ShelfBook.objects.filter(
|
||||
book=item.book, user=user).exists()
|
||||
|
||||
# shelve the book if it hasn't been shelved already
|
||||
if item.shelf and not existing_shelf:
|
||||
desired_shelf = models.Shelf.objects.get(
|
||||
identifier=item.shelf,
|
||||
user=user
|
||||
)
|
||||
models.ShelfBook.objects.create(
|
||||
book=item.book, shelf=desired_shelf, user=user)
|
||||
|
||||
for read in item.reads:
|
||||
# check for an existing readthrough with the same dates
|
||||
if models.ReadThrough.objects.filter(
|
||||
user=user, book=item.book,
|
||||
start_date=read.start_date,
|
||||
finish_date=read.finish_date
|
||||
).exists():
|
||||
continue
|
||||
read.book = item.book
|
||||
read.user = user
|
||||
read.save()
|
||||
|
||||
if include_reviews and (item.rating or item.review):
|
||||
review_title = 'Review of {!r} on {!r}'.format(
|
||||
item.book.title,
|
||||
source,
|
||||
) if item.review else ''
|
||||
|
||||
# we don't know the publication date of the review,
|
||||
# but "now" is a bad guess
|
||||
published_date_guess = item.date_read or item.date_added
|
||||
models.Review.objects.create(
|
||||
user=user,
|
||||
book=item.book,
|
||||
name=review_title,
|
||||
content=item.review,
|
||||
rating=item.rating,
|
||||
published_date=published_date_guess,
|
||||
privacy=privacy,
|
||||
)
|
|
@ -1,392 +0,0 @@
|
|||
''' handles all of the activity coming in to the server '''
|
||||
import json
|
||||
from urllib.parse import urldefrag
|
||||
|
||||
import django.db.utils
|
||||
from django.http import HttpResponse
|
||||
from django.http import HttpResponseBadRequest, HttpResponseNotFound
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_POST
|
||||
import requests
|
||||
|
||||
from bookwyrm import activitypub, models, views
|
||||
from bookwyrm import status as status_builder
|
||||
from bookwyrm.tasks import app
|
||||
from bookwyrm.signatures import Signature
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def inbox(request, username):
|
||||
''' incoming activitypub events '''
|
||||
try:
|
||||
models.User.objects.get(localname=username)
|
||||
except models.User.DoesNotExist:
|
||||
return HttpResponseNotFound()
|
||||
|
||||
return shared_inbox(request)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def shared_inbox(request):
|
||||
''' incoming activitypub events '''
|
||||
try:
|
||||
resp = request.body
|
||||
activity = json.loads(resp)
|
||||
if isinstance(activity, str):
|
||||
activity = json.loads(activity)
|
||||
activity_object = activity['object']
|
||||
except (json.decoder.JSONDecodeError, KeyError):
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
if not has_valid_signature(request, activity):
|
||||
if activity['type'] == 'Delete':
|
||||
# Pretend that unauth'd deletes succeed. Auth may be failing because
|
||||
# the resource or owner of the resource might have been deleted.
|
||||
return HttpResponse()
|
||||
return HttpResponse(status=401)
|
||||
|
||||
# if this isn't a file ripe for refactor, I don't know what is.
|
||||
handlers = {
|
||||
'Follow': handle_follow,
|
||||
'Accept': handle_follow_accept,
|
||||
'Reject': handle_follow_reject,
|
||||
'Block': handle_block,
|
||||
'Create': {
|
||||
'BookList': handle_create_list,
|
||||
'Note': handle_create_status,
|
||||
'Article': handle_create_status,
|
||||
'Review': handle_create_status,
|
||||
'Comment': handle_create_status,
|
||||
'Quotation': handle_create_status,
|
||||
},
|
||||
'Delete': handle_delete_status,
|
||||
'Like': handle_favorite,
|
||||
'Announce': handle_boost,
|
||||
'Add': {
|
||||
'Edition': handle_add,
|
||||
},
|
||||
'Undo': {
|
||||
'Follow': handle_unfollow,
|
||||
'Like': handle_unfavorite,
|
||||
'Announce': handle_unboost,
|
||||
'Block': handle_unblock,
|
||||
},
|
||||
'Update': {
|
||||
'Person': handle_update_user,
|
||||
'Edition': handle_update_edition,
|
||||
'Work': handle_update_work,
|
||||
'BookList': handle_update_list,
|
||||
},
|
||||
}
|
||||
activity_type = activity['type']
|
||||
|
||||
handler = handlers.get(activity_type, None)
|
||||
if isinstance(handler, dict):
|
||||
handler = handler.get(activity_object['type'], None)
|
||||
|
||||
if not handler:
|
||||
return HttpResponseNotFound()
|
||||
|
||||
handler.delay(activity)
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
def has_valid_signature(request, activity):
|
||||
''' verify incoming signature '''
|
||||
try:
|
||||
signature = Signature.parse(request)
|
||||
|
||||
key_actor = urldefrag(signature.key_id).url
|
||||
if key_actor != activity.get('actor'):
|
||||
raise ValueError("Wrong actor created signature.")
|
||||
|
||||
remote_user = activitypub.resolve_remote_id(models.User, key_actor)
|
||||
if not remote_user:
|
||||
return False
|
||||
|
||||
try:
|
||||
signature.verify(remote_user.key_pair.public_key, request)
|
||||
except ValueError:
|
||||
old_key = remote_user.key_pair.public_key
|
||||
remote_user = activitypub.resolve_remote_id(
|
||||
models.User, remote_user.remote_id, refresh=True
|
||||
)
|
||||
if remote_user.key_pair.public_key == old_key:
|
||||
raise # Key unchanged.
|
||||
signature.verify(remote_user.key_pair.public_key, request)
|
||||
except (ValueError, requests.exceptions.HTTPError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@app.task
|
||||
def handle_follow(activity):
|
||||
''' someone wants to follow a local user '''
|
||||
try:
|
||||
relationship = activitypub.Follow(
|
||||
**activity
|
||||
).to_model(models.UserFollowRequest)
|
||||
except django.db.utils.IntegrityError as err:
|
||||
if err.__cause__.diag.constraint_name != 'userfollowrequest_unique':
|
||||
raise
|
||||
relationship = models.UserFollowRequest.objects.get(
|
||||
remote_id=activity['id']
|
||||
)
|
||||
# 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:
|
||||
views.handle_accept(relationship)
|
||||
|
||||
|
||||
@app.task
|
||||
def handle_unfollow(activity):
|
||||
''' unfollow a local user '''
|
||||
obj = activity['object']
|
||||
requester = activitypub.resolve_remote_id(models.User, obj['actor'])
|
||||
to_unfollow = models.User.objects.get(remote_id=obj['object'])
|
||||
# raises models.User.DoesNotExist
|
||||
|
||||
to_unfollow.followers.remove(requester)
|
||||
|
||||
|
||||
@app.task
|
||||
def handle_follow_accept(activity):
|
||||
''' hurray, someone remote accepted a follow request '''
|
||||
# figure out who they want to follow
|
||||
requester = models.User.objects.get(remote_id=activity['object']['actor'])
|
||||
# figure out who they are
|
||||
accepter = activitypub.resolve_remote_id(models.User, activity['actor'])
|
||||
|
||||
try:
|
||||
request = models.UserFollowRequest.objects.get(
|
||||
user_subject=requester,
|
||||
user_object=accepter
|
||||
)
|
||||
request.delete()
|
||||
except models.UserFollowRequest.DoesNotExist:
|
||||
pass
|
||||
accepter.followers.add(requester)
|
||||
|
||||
|
||||
@app.task
|
||||
def handle_follow_reject(activity):
|
||||
''' someone is rejecting a follow request '''
|
||||
requester = models.User.objects.get(remote_id=activity['object']['actor'])
|
||||
rejecter = activitypub.resolve_remote_id(models.User, activity['actor'])
|
||||
|
||||
request = models.UserFollowRequest.objects.get(
|
||||
user_subject=requester,
|
||||
user_object=rejecter
|
||||
)
|
||||
request.delete()
|
||||
#raises models.UserFollowRequest.DoesNotExist
|
||||
|
||||
@app.task
|
||||
def handle_block(activity):
|
||||
''' blocking a user '''
|
||||
# create "block" databse entry
|
||||
activitypub.Block(**activity).to_model(models.UserBlocks)
|
||||
# the removing relationships is handled in post-save hook in model
|
||||
|
||||
|
||||
@app.task
|
||||
def handle_unblock(activity):
|
||||
''' undoing a block '''
|
||||
try:
|
||||
block_id = activity['object']['id']
|
||||
except KeyError:
|
||||
return
|
||||
try:
|
||||
block = models.UserBlocks.objects.get(remote_id=block_id)
|
||||
except models.UserBlocks.DoesNotExist:
|
||||
return
|
||||
block.delete()
|
||||
|
||||
|
||||
@app.task
|
||||
def handle_create_list(activity):
|
||||
''' a new list '''
|
||||
activity = activity['object']
|
||||
activitypub.BookList(**activity).to_model(models.List)
|
||||
|
||||
|
||||
@app.task
|
||||
def handle_update_list(activity):
|
||||
''' update a list '''
|
||||
try:
|
||||
book_list = models.List.objects.get(id=activity['object']['id'])
|
||||
except models.List.DoesNotExist:
|
||||
return
|
||||
activitypub.BookList(
|
||||
**activity['object']).to_model(models.List, instance=book_list)
|
||||
|
||||
|
||||
@app.task
|
||||
def handle_create_status(activity):
|
||||
''' someone did something, good on them '''
|
||||
# deduplicate incoming activities
|
||||
activity = activity['object']
|
||||
status_id = activity.get('id')
|
||||
if models.Status.objects.filter(remote_id=status_id).count():
|
||||
return
|
||||
|
||||
try:
|
||||
serializer = activitypub.activity_objects[activity['type']]
|
||||
except KeyError:
|
||||
return
|
||||
|
||||
activity = serializer(**activity)
|
||||
try:
|
||||
model = models.activity_models[activity.type]
|
||||
except KeyError:
|
||||
# not a type of status we are prepared to deserialize
|
||||
return
|
||||
|
||||
status = activity.to_model(model)
|
||||
if not status:
|
||||
# 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):
|
||||
''' remove a status '''
|
||||
try:
|
||||
status_id = activity['object']['id']
|
||||
except TypeError:
|
||||
# this isn't a great fix, because you hit this when mastadon
|
||||
# is trying to delete a user.
|
||||
return
|
||||
try:
|
||||
status = models.Status.objects.get(
|
||||
remote_id=status_id
|
||||
)
|
||||
except models.Status.DoesNotExist:
|
||||
return
|
||||
models.Notification.objects.filter(related_status=status).all().delete()
|
||||
status_builder.delete_status(status)
|
||||
|
||||
|
||||
@app.task
|
||||
def handle_favorite(activity):
|
||||
''' approval of your good good post '''
|
||||
fav = activitypub.Like(**activity)
|
||||
# we dont know this status, we don't care about this status
|
||||
if not models.Status.objects.filter(remote_id=fav.object).exists():
|
||||
return
|
||||
|
||||
fav = fav.to_model(models.Favorite)
|
||||
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):
|
||||
''' approval of your good good post '''
|
||||
like = models.Favorite.objects.filter(
|
||||
remote_id=activity['object']['id']
|
||||
).first()
|
||||
if not like:
|
||||
return
|
||||
like.delete()
|
||||
|
||||
|
||||
@app.task
|
||||
def handle_boost(activity):
|
||||
''' someone gave us a boost! '''
|
||||
try:
|
||||
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):
|
||||
''' someone gave us a boost! '''
|
||||
boost = models.Boost.objects.filter(
|
||||
remote_id=activity['object']['id']
|
||||
).first()
|
||||
if boost:
|
||||
boost.delete()
|
||||
|
||||
|
||||
@app.task
|
||||
def handle_add(activity):
|
||||
''' putting a book on a shelf '''
|
||||
#this is janky as heck but I haven't thought of a better solution
|
||||
try:
|
||||
activitypub.AddBook(**activity).to_model(models.ShelfBook)
|
||||
except activitypub.ActivitySerializerError:
|
||||
activitypub.AddBook(**activity).to_model(models.Tag)
|
||||
|
||||
|
||||
@app.task
|
||||
def handle_update_user(activity):
|
||||
''' receive an updated user Person activity object '''
|
||||
try:
|
||||
user = models.User.objects.get(remote_id=activity['object']['id'])
|
||||
except models.User.DoesNotExist:
|
||||
# who is this person? who cares
|
||||
return
|
||||
activitypub.Person(
|
||||
**activity['object']
|
||||
).to_model(models.User, instance=user)
|
||||
# model save() happens in the to_model function
|
||||
|
||||
|
||||
@app.task
|
||||
def handle_update_edition(activity):
|
||||
''' a remote instance changed a book (Document) '''
|
||||
activitypub.Edition(**activity['object']).to_model(models.Edition)
|
||||
|
||||
|
||||
@app.task
|
||||
def handle_update_work(activity):
|
||||
''' a remote instance changed a book (Document) '''
|
||||
activitypub.Work(**activity['object']).to_model(models.Work)
|
42
bookwyrm/librarything_import.py
Normal file
42
bookwyrm/librarything_import.py
Normal file
|
@ -0,0 +1,42 @@
|
|||
''' handle reading a csv from librarything '''
|
||||
import csv
|
||||
import re
|
||||
import math
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.models import ImportItem
|
||||
from bookwyrm.importer import Importer
|
||||
|
||||
|
||||
class LibrarythingImporter(Importer):
|
||||
service = 'LibraryThing'
|
||||
delimiter = '\t'
|
||||
encoding = 'ISO-8859-1'
|
||||
# mandatory_fields : fields matching the book title and author
|
||||
mandatory_fields = ['Title', 'Primary Author']
|
||||
|
||||
def parse_fields(self, initial):
|
||||
data = {}
|
||||
data['import_source'] = self.service
|
||||
data['Book Id'] = initial['Book Id']
|
||||
data['Title'] = initial['Title']
|
||||
data['Author'] = initial['Primary Author']
|
||||
data['ISBN13'] = initial['ISBN']
|
||||
data['My Review'] = initial['Review']
|
||||
if initial['Rating']:
|
||||
data['My Rating'] = math.ceil(float(initial['Rating']))
|
||||
else:
|
||||
data['My Rating'] = ''
|
||||
data['Date Added'] = re.sub('\[|\]', '', initial['Entry Date'])
|
||||
data['Date Started'] = re.sub('\[|\]', '', initial['Date Started'])
|
||||
data['Date Read'] = re.sub('\[|\]', '', initial['Date Read'])
|
||||
|
||||
data['Exclusive Shelf'] = None
|
||||
if data['Date Read']:
|
||||
data['Exclusive Shelf'] = "read"
|
||||
elif data['Date Started']:
|
||||
data['Exclusive Shelf'] = "reading"
|
||||
else:
|
||||
data['Exclusive Shelf'] = "to-read"
|
||||
|
||||
return data
|
|
@ -1,6 +1,6 @@
|
|||
# Generated by Django 3.0.7 on 2020-11-30 18:19
|
||||
|
||||
import bookwyrm.models.base_model
|
||||
import bookwyrm.models.activitypub_mixin
|
||||
import bookwyrm.models.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
@ -38,7 +38,7 @@ class Migration(migrations.Migration):
|
|||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=(bookwyrm.models.base_model.ActivitypubMixin, models.Model),
|
||||
bases=(bookwyrm.models.activitypub_mixin.ActivitypubMixin, models.Model),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# Generated by Django 3.0.7 on 2021-01-31 16:14
|
||||
|
||||
import bookwyrm.models.base_model
|
||||
import bookwyrm.models.activitypub_mixin
|
||||
import bookwyrm.models.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
@ -29,7 +29,7 @@ class Migration(migrations.Migration):
|
|||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=(bookwyrm.models.base_model.OrderedCollectionMixin, models.Model),
|
||||
bases=(bookwyrm.models.activitypub_mixin.OrderedCollectionMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ListItem',
|
||||
|
@ -50,7 +50,7 @@ class Migration(migrations.Migration):
|
|||
'ordering': ('-created_date',),
|
||||
'unique_together': {('book', 'book_list')},
|
||||
},
|
||||
bases=(bookwyrm.models.base_model.ActivitypubMixin, models.Model),
|
||||
bases=(bookwyrm.models.activitypub_mixin.ActivitypubMixin, models.Model),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='list',
|
||||
|
|
23
bookwyrm/migrations/0043_auto_20210204_2223.py
Normal file
23
bookwyrm/migrations/0043_auto_20210204_2223.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 3.0.7 on 2021-02-04 22:23
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookwyrm', '0042_auto_20210201_2108'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='listitem',
|
||||
old_name='added_by',
|
||||
new_name='user',
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='shelfbook',
|
||||
old_name='added_by',
|
||||
new_name='user',
|
||||
),
|
||||
]
|
33
bookwyrm/migrations/0044_auto_20210207_1924.py
Normal file
33
bookwyrm/migrations/0044_auto_20210207_1924.py
Normal file
|
@ -0,0 +1,33 @@
|
|||
# Generated by Django 3.0.7 on 2021-02-07 19:24
|
||||
|
||||
import bookwyrm.models.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
import django.db.models.deletion
|
||||
|
||||
def set_user(app_registry, schema_editor):
|
||||
db_alias = schema_editor.connection.alias
|
||||
shelfbook = app_registry.get_model('bookwyrm', 'ShelfBook')
|
||||
for item in shelfbook.objects.using(db_alias).filter(user__isnull=True):
|
||||
item.user = item.shelf.user
|
||||
try:
|
||||
item.save(broadcast=False)
|
||||
except TypeError:
|
||||
item.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookwyrm', '0043_auto_20210204_2223'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(set_user, lambda x, y: None),
|
||||
migrations.AlterField(
|
||||
model_name='shelfbook',
|
||||
name='user',
|
||||
field=bookwyrm.models.fields.ForeignKey(default=2, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
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'),
|
||||
),
|
||||
]
|
18
bookwyrm/migrations/0046_sitesettings_privacy_policy.py
Normal file
18
bookwyrm/migrations/0046_sitesettings_privacy_policy.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.0.7 on 2021-02-27 19:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookwyrm', '0045_auto_20210210_2114'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='sitesettings',
|
||||
name='privacy_policy',
|
||||
field=models.TextField(default='Add a privacy policy here.'),
|
||||
),
|
||||
]
|
506
bookwyrm/models/activitypub_mixin.py
Normal file
506
bookwyrm/models/activitypub_mixin.py
Normal file
|
@ -0,0 +1,506 @@
|
|||
''' activitypub model functionality '''
|
||||
from base64 import b64encode
|
||||
from functools import reduce
|
||||
import json
|
||||
import operator
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
import requests
|
||||
from requests.exceptions import HTTPError, SSLError
|
||||
|
||||
from Crypto.PublicKey import RSA
|
||||
from Crypto.Signature import pkcs1_15
|
||||
from Crypto.Hash import SHA256
|
||||
from django.apps import apps
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Q
|
||||
from django.utils.http import http_date
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm.settings import USER_AGENT, PAGE_LENGTH
|
||||
from bookwyrm.signatures import make_signature, make_digest
|
||||
from bookwyrm.tasks import app
|
||||
from bookwyrm.models.fields import ImageField, ManyToManyField
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
# I tried to separate these classes into mutliple files but I kept getting
|
||||
# circular import errors so I gave up. I'm sure it could be done though!
|
||||
class ActivitypubMixin:
|
||||
''' add this mixin for models that are AP serializable '''
|
||||
activity_serializer = lambda: {}
|
||||
reverse_unfurl = False
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
''' collect some info on model fields '''
|
||||
self.image_fields = []
|
||||
self.many_to_many_fields = []
|
||||
self.simple_fields = [] # "simple"
|
||||
# sort model fields by type
|
||||
for field in self._meta.get_fields():
|
||||
if not hasattr(field, 'field_to_activity'):
|
||||
continue
|
||||
|
||||
if isinstance(field, ImageField):
|
||||
self.image_fields.append(field)
|
||||
elif isinstance(field, ManyToManyField):
|
||||
self.many_to_many_fields.append(field)
|
||||
else:
|
||||
self.simple_fields.append(field)
|
||||
|
||||
# a list of allll the serializable fields
|
||||
self.activity_fields = self.image_fields + \
|
||||
self.many_to_many_fields + self.simple_fields
|
||||
|
||||
# these are separate to avoid infinite recursion issues
|
||||
self.deserialize_reverse_fields = self.deserialize_reverse_fields \
|
||||
if hasattr(self, 'deserialize_reverse_fields') else []
|
||||
self.serialize_reverse_fields = self.serialize_reverse_fields \
|
||||
if hasattr(self, 'serialize_reverse_fields') else []
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@classmethod
|
||||
def find_existing_by_remote_id(cls, remote_id):
|
||||
''' look up a remote id in the db '''
|
||||
return cls.find_existing({'id': remote_id})
|
||||
|
||||
@classmethod
|
||||
def find_existing(cls, data):
|
||||
''' compare data to fields that can be used for deduplation.
|
||||
This always includes remote_id, but can also be unique identifiers
|
||||
like an isbn for an edition '''
|
||||
filters = []
|
||||
# grabs all the data from the model to create django queryset filters
|
||||
for field in cls._meta.get_fields():
|
||||
if not hasattr(field, 'deduplication_field') or \
|
||||
not field.deduplication_field:
|
||||
continue
|
||||
|
||||
value = data.get(field.get_activitypub_field())
|
||||
if not value:
|
||||
continue
|
||||
filters.append({field.name: value})
|
||||
|
||||
if hasattr(cls, 'origin_id') and 'id' in data:
|
||||
# kinda janky, but this handles special case for books
|
||||
filters.append({'origin_id': data['id']})
|
||||
|
||||
if not filters:
|
||||
# if there are no deduplication fields, it will match the first
|
||||
# item no matter what. this shouldn't happen but just in case.
|
||||
return None
|
||||
|
||||
objects = cls.objects
|
||||
if hasattr(objects, 'select_subclasses'):
|
||||
objects = objects.select_subclasses()
|
||||
|
||||
# an OR operation on all the match fields, sorry for the dense syntax
|
||||
match = objects.filter(
|
||||
reduce(operator.or_, (Q(**f) for f in filters))
|
||||
)
|
||||
# there OUGHT to be only one match
|
||||
return match.first()
|
||||
|
||||
|
||||
def broadcast(self, activity, sender, software=None):
|
||||
''' send out an activity '''
|
||||
broadcast_task.delay(
|
||||
sender.id,
|
||||
json.dumps(activity, cls=activitypub.ActivityEncoder),
|
||||
self.get_recipients(software=software)
|
||||
)
|
||||
|
||||
|
||||
def get_recipients(self, software=None):
|
||||
''' figure out which inbox urls to post to '''
|
||||
# first we have to figure out who should receive this activity
|
||||
privacy = self.privacy if hasattr(self, 'privacy') else 'public'
|
||||
# is this activity owned by a user (statuses, lists, shelves), or is it
|
||||
# general to the instance (like books)
|
||||
user = self.user if hasattr(self, 'user') else None
|
||||
user_model = apps.get_model('bookwyrm.User', require_ready=True)
|
||||
if not user and isinstance(self, user_model):
|
||||
# or maybe the thing itself is a user
|
||||
user = self
|
||||
# find anyone who's tagged in a status, for example
|
||||
mentions = self.recipients if hasattr(self, 'recipients') else []
|
||||
|
||||
# we always send activities to explicitly mentioned users' inboxes
|
||||
recipients = [u.inbox for u in mentions or []]
|
||||
|
||||
# unless it's a dm, all the followers should receive the activity
|
||||
if privacy != 'direct':
|
||||
# we will send this out to a subset of all remote users
|
||||
queryset = user_model.objects.filter(
|
||||
local=False,
|
||||
)
|
||||
# filter users first by whether they're using the desired software
|
||||
# this lets us send book updates only to other bw servers
|
||||
if software:
|
||||
queryset = queryset.filter(
|
||||
bookwyrm_user=(software == 'bookwyrm')
|
||||
)
|
||||
# if there's a user, we only want to send to the user's followers
|
||||
if user:
|
||||
queryset = queryset.filter(following=user)
|
||||
|
||||
# ideally, we will send to shared inboxes for efficiency
|
||||
shared_inboxes = queryset.filter(
|
||||
shared_inbox__isnull=False
|
||||
).values_list('shared_inbox', flat=True).distinct()
|
||||
# but not everyone has a shared inbox
|
||||
inboxes = queryset.filter(
|
||||
shared_inbox__isnull=True
|
||||
).values_list('inbox', flat=True)
|
||||
recipients += list(shared_inboxes) + list(inboxes)
|
||||
return recipients
|
||||
|
||||
|
||||
def to_activity_dataclass(self):
|
||||
''' convert from a model to an activity '''
|
||||
activity = generate_activity(self)
|
||||
return self.activity_serializer(**activity)
|
||||
|
||||
def to_activity(self, **kwargs): # pylint: disable=unused-argument
|
||||
''' convert from a model to a json activity '''
|
||||
return self.to_activity_dataclass().serialize()
|
||||
|
||||
|
||||
class ObjectMixin(ActivitypubMixin):
|
||||
''' add this mixin for object models that are AP serializable '''
|
||||
def save(self, *args, created=None, **kwargs):
|
||||
''' broadcast created/updated/deleted objects as appropriate '''
|
||||
broadcast = kwargs.get('broadcast', True)
|
||||
# this bonus kwarg woul cause an error in the base save method
|
||||
if 'broadcast' in kwargs:
|
||||
del kwargs['broadcast']
|
||||
|
||||
created = created or not bool(self.id)
|
||||
# first off, we want to save normally no matter what
|
||||
super().save(*args, **kwargs)
|
||||
if not broadcast:
|
||||
return
|
||||
|
||||
# this will work for objects owned by a user (lists, shelves)
|
||||
user = self.user if hasattr(self, 'user') else None
|
||||
|
||||
if created:
|
||||
# broadcast Create activities for objects owned by a local user
|
||||
if not user or not user.local:
|
||||
return
|
||||
|
||||
try:
|
||||
software = None
|
||||
# do we have a "pure" activitypub version of this for mastodon?
|
||||
if hasattr(self, 'pure_content'):
|
||||
pure_activity = self.to_create_activity(user, pure=True)
|
||||
self.broadcast(pure_activity, user, software='other')
|
||||
software = 'bookwyrm'
|
||||
# sends to BW only if we just did a pure version for masto
|
||||
activity = self.to_create_activity(user)
|
||||
self.broadcast(activity, user, software=software)
|
||||
except AttributeError:
|
||||
# janky as heck, this catches the mutliple inheritence chain
|
||||
# for boosts and ignores this auxilliary broadcast
|
||||
return
|
||||
return
|
||||
|
||||
# --- updating an existing object
|
||||
if not user:
|
||||
# users don't have associated users, they ARE users
|
||||
user_model = apps.get_model('bookwyrm.User', require_ready=True)
|
||||
if isinstance(self, user_model):
|
||||
user = self
|
||||
# book data tracks last editor
|
||||
elif hasattr(self, 'last_edited_by'):
|
||||
user = self.last_edited_by
|
||||
# again, if we don't know the user or they're remote, don't bother
|
||||
if not user or not user.local:
|
||||
return
|
||||
|
||||
# is this a deletion?
|
||||
if hasattr(self, 'deleted') and self.deleted:
|
||||
activity = self.to_delete_activity(user)
|
||||
else:
|
||||
activity = self.to_update_activity(user)
|
||||
self.broadcast(activity, user)
|
||||
|
||||
|
||||
def to_create_activity(self, user, **kwargs):
|
||||
''' returns the object wrapped in a Create activity '''
|
||||
activity_object = self.to_activity_dataclass(**kwargs)
|
||||
|
||||
signature = None
|
||||
create_id = self.remote_id + '/activity'
|
||||
if hasattr(activity_object, 'content') and activity_object.content:
|
||||
signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key))
|
||||
content = activity_object.content
|
||||
signed_message = signer.sign(SHA256.new(content.encode('utf8')))
|
||||
|
||||
signature = activitypub.Signature(
|
||||
creator='%s#main-key' % user.remote_id,
|
||||
created=activity_object.published,
|
||||
signatureValue=b64encode(signed_message).decode('utf8')
|
||||
)
|
||||
|
||||
return activitypub.Create(
|
||||
id=create_id,
|
||||
actor=user.remote_id,
|
||||
to=activity_object.to,
|
||||
cc=activity_object.cc,
|
||||
object=activity_object,
|
||||
signature=signature,
|
||||
).serialize()
|
||||
|
||||
|
||||
def to_delete_activity(self, user):
|
||||
''' notice of deletion '''
|
||||
return activitypub.Delete(
|
||||
id=self.remote_id + '/activity',
|
||||
actor=user.remote_id,
|
||||
to=['%s/followers' % user.remote_id],
|
||||
cc=['https://www.w3.org/ns/activitystreams#Public'],
|
||||
object=self,
|
||||
).serialize()
|
||||
|
||||
|
||||
def to_update_activity(self, user):
|
||||
''' wrapper for Updates to an activity '''
|
||||
activity_id = '%s#update/%s' % (self.remote_id, uuid4())
|
||||
return activitypub.Update(
|
||||
id=activity_id,
|
||||
actor=user.remote_id,
|
||||
to=['https://www.w3.org/ns/activitystreams#Public'],
|
||||
object=self
|
||||
).serialize()
|
||||
|
||||
|
||||
class OrderedCollectionPageMixin(ObjectMixin):
|
||||
''' just the paginator utilities, so you don't HAVE to
|
||||
override ActivitypubMixin's to_activity (ie, for outbox) '''
|
||||
@property
|
||||
def collection_remote_id(self):
|
||||
''' this can be overriden if there's a special remote id, ie outbox '''
|
||||
return self.remote_id
|
||||
|
||||
|
||||
def to_ordered_collection(self, queryset, \
|
||||
remote_id=None, page=False, collection_only=False, **kwargs):
|
||||
''' an ordered collection of whatevers '''
|
||||
if not queryset.ordered:
|
||||
raise RuntimeError('queryset must be ordered')
|
||||
|
||||
remote_id = remote_id or self.remote_id
|
||||
if page:
|
||||
return to_ordered_collection_page(
|
||||
queryset, remote_id, **kwargs)
|
||||
|
||||
if collection_only or not hasattr(self, 'activity_serializer'):
|
||||
serializer = activitypub.OrderedCollection
|
||||
activity = {}
|
||||
else:
|
||||
serializer = self.activity_serializer
|
||||
# a dict from the model fields
|
||||
activity = generate_activity(self)
|
||||
|
||||
if remote_id:
|
||||
activity['id'] = remote_id
|
||||
|
||||
paginated = Paginator(queryset, PAGE_LENGTH)
|
||||
# add computed fields specific to orderd collections
|
||||
activity['totalItems'] = paginated.count
|
||||
activity['first'] = '%s?page=1' % remote_id
|
||||
activity['last'] = '%s?page=%d' % (remote_id, paginated.num_pages)
|
||||
|
||||
return serializer(**activity)
|
||||
|
||||
|
||||
class OrderedCollectionMixin(OrderedCollectionPageMixin):
|
||||
''' extends activitypub models to work as ordered collections '''
|
||||
@property
|
||||
def collection_queryset(self):
|
||||
''' usually an ordered collection model aggregates a different model '''
|
||||
raise NotImplementedError('Model must define collection_queryset')
|
||||
|
||||
activity_serializer = activitypub.OrderedCollection
|
||||
|
||||
def to_activity_dataclass(self, **kwargs):
|
||||
return self.to_ordered_collection(self.collection_queryset, **kwargs)
|
||||
|
||||
def to_activity(self, **kwargs):
|
||||
''' an ordered collection of the specified model queryset '''
|
||||
return self.to_ordered_collection(
|
||||
self.collection_queryset, **kwargs).serialize()
|
||||
|
||||
|
||||
class CollectionItemMixin(ActivitypubMixin):
|
||||
''' for items that are part of an (Ordered)Collection '''
|
||||
activity_serializer = activitypub.Add
|
||||
object_field = collection_field = None
|
||||
|
||||
def save(self, *args, broadcast=True, **kwargs):
|
||||
''' broadcast updated '''
|
||||
created = not bool(self.id)
|
||||
# first off, we want to save normally no matter what
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# these shouldn't be edited, only created and deleted
|
||||
if not broadcast or not created or not self.user.local:
|
||||
return
|
||||
|
||||
# adding an obj to the collection
|
||||
activity = self.to_add_activity()
|
||||
self.broadcast(activity, self.user)
|
||||
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
''' broadcast a remove activity '''
|
||||
activity = self.to_remove_activity()
|
||||
super().delete(*args, **kwargs)
|
||||
self.broadcast(activity, self.user)
|
||||
|
||||
|
||||
def to_add_activity(self):
|
||||
''' AP for shelving a book'''
|
||||
object_field = getattr(self, self.object_field)
|
||||
collection_field = getattr(self, self.collection_field)
|
||||
return activitypub.Add(
|
||||
id='%s#add' % self.remote_id,
|
||||
actor=self.user.remote_id,
|
||||
object=object_field,
|
||||
target=collection_field.remote_id
|
||||
).serialize()
|
||||
|
||||
def to_remove_activity(self):
|
||||
''' AP for un-shelving a book'''
|
||||
object_field = getattr(self, self.object_field)
|
||||
collection_field = getattr(self, self.collection_field)
|
||||
return activitypub.Remove(
|
||||
id='%s#remove' % self.remote_id,
|
||||
actor=self.user.remote_id,
|
||||
object=object_field,
|
||||
target=collection_field.remote_id
|
||||
).serialize()
|
||||
|
||||
|
||||
class ActivityMixin(ActivitypubMixin):
|
||||
''' add this mixin for models that are AP serializable '''
|
||||
def save(self, *args, broadcast=True, **kwargs):
|
||||
''' broadcast activity '''
|
||||
super().save(*args, **kwargs)
|
||||
user = self.user if hasattr(self, 'user') else self.user_subject
|
||||
if broadcast and user.local:
|
||||
self.broadcast(self.to_activity(), user)
|
||||
|
||||
|
||||
def delete(self, *args, broadcast=True, **kwargs):
|
||||
''' nevermind, undo that activity '''
|
||||
user = self.user if hasattr(self, 'user') else self.user_subject
|
||||
if broadcast and user.local:
|
||||
self.broadcast(self.to_undo_activity(), user)
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
|
||||
def to_undo_activity(self):
|
||||
''' undo an action '''
|
||||
user = self.user if hasattr(self, 'user') else self.user_subject
|
||||
return activitypub.Undo(
|
||||
id='%s#undo' % self.remote_id,
|
||||
actor=user.remote_id,
|
||||
object=self,
|
||||
).serialize()
|
||||
|
||||
|
||||
def generate_activity(obj):
|
||||
''' go through the fields on an object '''
|
||||
activity = {}
|
||||
for field in obj.activity_fields:
|
||||
field.set_activity_from_field(activity, obj)
|
||||
|
||||
if hasattr(obj, 'serialize_reverse_fields'):
|
||||
# for example, editions of a work
|
||||
for model_field_name, activity_field_name, sort_field in \
|
||||
obj.serialize_reverse_fields:
|
||||
related_field = getattr(obj, model_field_name)
|
||||
activity[activity_field_name] = \
|
||||
unfurl_related_field(related_field, sort_field)
|
||||
|
||||
if not activity.get('id'):
|
||||
activity['id'] = obj.get_remote_id()
|
||||
return activity
|
||||
|
||||
|
||||
def unfurl_related_field(related_field, sort_field=None):
|
||||
''' load reverse lookups (like public key owner or Status attachment '''
|
||||
if hasattr(related_field, 'all'):
|
||||
return [unfurl_related_field(i) for i in related_field.order_by(
|
||||
sort_field).all()]
|
||||
if related_field.reverse_unfurl:
|
||||
return related_field.field_to_activity()
|
||||
return related_field.remote_id
|
||||
|
||||
|
||||
@app.task
|
||||
def broadcast_task(sender_id, activity, recipients):
|
||||
''' the celery task for broadcast '''
|
||||
user_model = apps.get_model('bookwyrm.User', require_ready=True)
|
||||
sender = user_model.objects.get(id=sender_id)
|
||||
for recipient in recipients:
|
||||
try:
|
||||
sign_and_send(sender, activity, recipient)
|
||||
except (HTTPError, SSLError) as e:
|
||||
logger.exception(e)
|
||||
|
||||
|
||||
def sign_and_send(sender, data, destination):
|
||||
''' crpyto whatever and http junk '''
|
||||
now = http_date()
|
||||
|
||||
if not sender.key_pair.private_key:
|
||||
# this shouldn't happen. it would be bad if it happened.
|
||||
raise ValueError('No private key found for sender')
|
||||
|
||||
digest = make_digest(data)
|
||||
|
||||
response = requests.post(
|
||||
destination,
|
||||
data=data,
|
||||
headers={
|
||||
'Date': now,
|
||||
'Digest': digest,
|
||||
'Signature': make_signature(sender, destination, now, digest),
|
||||
'Content-Type': 'application/activity+json; charset=utf-8',
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
)
|
||||
if not response.ok:
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def to_ordered_collection_page(
|
||||
queryset, remote_id, id_only=False, page=1, pure=False, **kwargs):
|
||||
''' serialize and pagiante a queryset '''
|
||||
paginated = Paginator(queryset, PAGE_LENGTH)
|
||||
|
||||
activity_page = paginated.page(page)
|
||||
if id_only:
|
||||
items = [s.remote_id for s in activity_page.object_list]
|
||||
else:
|
||||
items = [s.to_activity(pure=pure) for s in activity_page.object_list]
|
||||
|
||||
prev_page = next_page = None
|
||||
if activity_page.has_next():
|
||||
next_page = '%s?page=%d' % (remote_id, activity_page.next_page_number())
|
||||
if activity_page.has_previous():
|
||||
prev_page = '%s?page=%d' % \
|
||||
(remote_id, activity_page.previous_page_number())
|
||||
return activitypub.OrderedCollectionPage(
|
||||
id='%s?page=%s' % (remote_id, page),
|
||||
partOf=remote_id,
|
||||
orderedItems=items,
|
||||
next=next_page,
|
||||
prev=prev_page
|
||||
)
|
|
@ -2,7 +2,7 @@
|
|||
from django.db import models
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from .base_model import ActivitypubMixin
|
||||
from .activitypub_mixin import ActivitypubMixin
|
||||
from .base_model import BookWyrmModel
|
||||
from . import fields
|
||||
|
||||
|
|
|
@ -1,20 +1,9 @@
|
|||
''' base model with default fields '''
|
||||
from base64 import b64encode
|
||||
from functools import reduce
|
||||
import operator
|
||||
from uuid import uuid4
|
||||
|
||||
from Crypto.PublicKey import RSA
|
||||
from Crypto.Signature import pkcs1_15
|
||||
from Crypto.Hash import SHA256
|
||||
from django.core.paginator import Paginator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.dispatch import receiver
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm.settings import DOMAIN, PAGE_LENGTH
|
||||
from .fields import ImageField, ManyToManyField, RemoteIdField
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from .fields import RemoteIdField
|
||||
|
||||
|
||||
class BookWyrmModel(models.Model):
|
||||
|
@ -27,7 +16,7 @@ class BookWyrmModel(models.Model):
|
|||
''' generate a url that resolves to the local object '''
|
||||
base_path = 'https://%s' % DOMAIN
|
||||
if hasattr(self, 'user'):
|
||||
base_path = self.user.remote_id
|
||||
base_path = '%s%s' % (base_path, self.user.local_path)
|
||||
model_name = type(self).__name__.lower()
|
||||
return '%s/%s/%d' % (base_path, model_name, self.id)
|
||||
|
||||
|
@ -49,254 +38,7 @@ def execute_after_save(sender, instance, created, *args, **kwargs):
|
|||
return
|
||||
if not instance.remote_id:
|
||||
instance.remote_id = instance.get_remote_id()
|
||||
instance.save()
|
||||
|
||||
|
||||
def unfurl_related_field(related_field, sort_field=None):
|
||||
''' load reverse lookups (like public key owner or Status attachment '''
|
||||
if hasattr(related_field, 'all'):
|
||||
return [unfurl_related_field(i) for i in related_field.order_by(
|
||||
sort_field).all()]
|
||||
if related_field.reverse_unfurl:
|
||||
return related_field.field_to_activity()
|
||||
return related_field.remote_id
|
||||
|
||||
|
||||
class ActivitypubMixin:
|
||||
''' add this mixin for models that are AP serializable '''
|
||||
activity_serializer = lambda: {}
|
||||
reverse_unfurl = False
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
''' collect some info on model fields '''
|
||||
self.image_fields = []
|
||||
self.many_to_many_fields = []
|
||||
self.simple_fields = [] # "simple"
|
||||
for field in self._meta.get_fields():
|
||||
if not hasattr(field, 'field_to_activity'):
|
||||
continue
|
||||
|
||||
if isinstance(field, ImageField):
|
||||
self.image_fields.append(field)
|
||||
elif isinstance(field, ManyToManyField):
|
||||
self.many_to_many_fields.append(field)
|
||||
else:
|
||||
self.simple_fields.append(field)
|
||||
|
||||
self.activity_fields = self.image_fields + \
|
||||
self.many_to_many_fields + self.simple_fields
|
||||
|
||||
self.deserialize_reverse_fields = self.deserialize_reverse_fields \
|
||||
if hasattr(self, 'deserialize_reverse_fields') else []
|
||||
self.serialize_reverse_fields = self.serialize_reverse_fields \
|
||||
if hasattr(self, 'serialize_reverse_fields') else []
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@classmethod
|
||||
def find_existing_by_remote_id(cls, remote_id):
|
||||
''' look up a remote id in the db '''
|
||||
return cls.find_existing({'id': remote_id})
|
||||
|
||||
@classmethod
|
||||
def find_existing(cls, data):
|
||||
''' compare data to fields that can be used for deduplation.
|
||||
This always includes remote_id, but can also be unique identifiers
|
||||
like an isbn for an edition '''
|
||||
filters = []
|
||||
for field in cls._meta.get_fields():
|
||||
if not hasattr(field, 'deduplication_field') or \
|
||||
not field.deduplication_field:
|
||||
continue
|
||||
|
||||
value = data.get(field.get_activitypub_field())
|
||||
if not value:
|
||||
continue
|
||||
filters.append({field.name: value})
|
||||
|
||||
if hasattr(cls, 'origin_id') and 'id' in data:
|
||||
# kinda janky, but this handles special case for books
|
||||
filters.append({'origin_id': data['id']})
|
||||
|
||||
if not filters:
|
||||
# if there are no deduplication fields, it will match the first
|
||||
# item no matter what. this shouldn't happen but just in case.
|
||||
return None
|
||||
|
||||
objects = cls.objects
|
||||
if hasattr(objects, 'select_subclasses'):
|
||||
objects = objects.select_subclasses()
|
||||
|
||||
# an OR operation on all the match fields
|
||||
match = objects.filter(
|
||||
reduce(
|
||||
operator.or_, (Q(**f) for f in filters)
|
||||
)
|
||||
)
|
||||
# there OUGHT to be only one match
|
||||
return match.first()
|
||||
|
||||
|
||||
def to_activity(self):
|
||||
''' convert from a model to an activity '''
|
||||
activity = generate_activity(self)
|
||||
return self.activity_serializer(**activity).serialize()
|
||||
|
||||
|
||||
def to_create_activity(self, user, **kwargs):
|
||||
''' returns the object wrapped in a Create activity '''
|
||||
activity_object = self.to_activity(**kwargs)
|
||||
|
||||
signature = None
|
||||
create_id = self.remote_id + '/activity'
|
||||
if 'content' in activity_object:
|
||||
signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key))
|
||||
content = activity_object['content']
|
||||
signed_message = signer.sign(SHA256.new(content.encode('utf8')))
|
||||
|
||||
signature = activitypub.Signature(
|
||||
creator='%s#main-key' % user.remote_id,
|
||||
created=activity_object['published'],
|
||||
signatureValue=b64encode(signed_message).decode('utf8')
|
||||
)
|
||||
|
||||
return activitypub.Create(
|
||||
id=create_id,
|
||||
actor=user.remote_id,
|
||||
to=activity_object['to'],
|
||||
cc=activity_object['cc'],
|
||||
object=activity_object,
|
||||
signature=signature,
|
||||
).serialize()
|
||||
|
||||
|
||||
def to_delete_activity(self, user):
|
||||
''' notice of deletion '''
|
||||
return activitypub.Delete(
|
||||
id=self.remote_id + '/activity',
|
||||
actor=user.remote_id,
|
||||
to=['%s/followers' % user.remote_id],
|
||||
cc=['https://www.w3.org/ns/activitystreams#Public'],
|
||||
object=self.to_activity(),
|
||||
).serialize()
|
||||
|
||||
|
||||
def to_update_activity(self, user):
|
||||
''' wrapper for Updates to an activity '''
|
||||
activity_id = '%s#update/%s' % (self.remote_id, uuid4())
|
||||
return activitypub.Update(
|
||||
id=activity_id,
|
||||
actor=user.remote_id,
|
||||
to=['https://www.w3.org/ns/activitystreams#Public'],
|
||||
object=self.to_activity()
|
||||
).serialize()
|
||||
|
||||
|
||||
def to_undo_activity(self, user):
|
||||
''' undo an action '''
|
||||
return activitypub.Undo(
|
||||
id='%s#undo' % self.remote_id,
|
||||
actor=user.remote_id,
|
||||
object=self.to_activity()
|
||||
).serialize()
|
||||
|
||||
|
||||
class OrderedCollectionPageMixin(ActivitypubMixin):
|
||||
''' just the paginator utilities, so you don't HAVE to
|
||||
override ActivitypubMixin's to_activity (ie, for outbox '''
|
||||
@property
|
||||
def collection_remote_id(self):
|
||||
''' this can be overriden if there's a special remote id, ie outbox '''
|
||||
return self.remote_id
|
||||
|
||||
|
||||
def to_ordered_collection(self, queryset, \
|
||||
remote_id=None, page=False, collection_only=False, **kwargs):
|
||||
''' an ordered collection of whatevers '''
|
||||
if not queryset.ordered:
|
||||
raise RuntimeError('queryset must be ordered')
|
||||
|
||||
remote_id = remote_id or self.remote_id
|
||||
if page:
|
||||
return to_ordered_collection_page(
|
||||
queryset, remote_id, **kwargs)
|
||||
|
||||
if collection_only or not hasattr(self, 'activity_serializer'):
|
||||
serializer = activitypub.OrderedCollection
|
||||
activity = {}
|
||||
else:
|
||||
serializer = self.activity_serializer
|
||||
# a dict from the model fields
|
||||
activity = generate_activity(self)
|
||||
|
||||
if remote_id:
|
||||
activity['id'] = remote_id
|
||||
|
||||
paginated = Paginator(queryset, PAGE_LENGTH)
|
||||
# add computed fields specific to orderd collections
|
||||
activity['totalItems'] = paginated.count
|
||||
activity['first'] = '%s?page=1' % remote_id
|
||||
activity['last'] = '%s?page=%d' % (remote_id, paginated.num_pages)
|
||||
|
||||
return serializer(**activity).serialize()
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def to_ordered_collection_page(
|
||||
queryset, remote_id, id_only=False, page=1, **kwargs):
|
||||
''' serialize and pagiante a queryset '''
|
||||
paginated = Paginator(queryset, PAGE_LENGTH)
|
||||
|
||||
activity_page = paginated.page(page)
|
||||
if id_only:
|
||||
items = [s.remote_id for s in activity_page.object_list]
|
||||
else:
|
||||
items = [s.to_activity() for s in activity_page.object_list]
|
||||
|
||||
prev_page = next_page = None
|
||||
if activity_page.has_next():
|
||||
next_page = '%s?page=%d' % (remote_id, activity_page.next_page_number())
|
||||
if activity_page.has_previous():
|
||||
prev_page = '%s?page=%d' % \
|
||||
(remote_id, activity_page.previous_page_number())
|
||||
return activitypub.OrderedCollectionPage(
|
||||
id='%s?page=%s' % (remote_id, page),
|
||||
partOf=remote_id,
|
||||
orderedItems=items,
|
||||
next=next_page,
|
||||
prev=prev_page
|
||||
).serialize()
|
||||
|
||||
|
||||
class OrderedCollectionMixin(OrderedCollectionPageMixin):
|
||||
''' extends activitypub models to work as ordered collections '''
|
||||
@property
|
||||
def collection_queryset(self):
|
||||
''' usually an ordered collection model aggregates a different model '''
|
||||
raise NotImplementedError('Model must define collection_queryset')
|
||||
|
||||
activity_serializer = activitypub.OrderedCollection
|
||||
|
||||
def to_activity(self, **kwargs):
|
||||
''' an ordered collection of the specified model queryset '''
|
||||
return self.to_ordered_collection(self.collection_queryset, **kwargs)
|
||||
|
||||
|
||||
def generate_activity(obj):
|
||||
''' go through the fields on an object '''
|
||||
activity = {}
|
||||
for field in obj.activity_fields:
|
||||
field.set_activity_from_field(activity, obj)
|
||||
|
||||
if hasattr(obj, 'serialize_reverse_fields'):
|
||||
# for example, editions of a work
|
||||
for model_field_name, activity_field_name, sort_field in \
|
||||
obj.serialize_reverse_fields:
|
||||
related_field = getattr(obj, model_field_name)
|
||||
activity[activity_field_name] = \
|
||||
unfurl_related_field(related_field, sort_field)
|
||||
|
||||
if not activity.get('id'):
|
||||
activity['id'] = obj.get_remote_id()
|
||||
return activity
|
||||
try:
|
||||
instance.save(broadcast=False)
|
||||
except TypeError:
|
||||
instance.save()
|
||||
|
|
|
@ -7,11 +7,11 @@ from model_utils.managers import InheritanceManager
|
|||
from bookwyrm import activitypub
|
||||
from bookwyrm.settings import DOMAIN
|
||||
|
||||
from .activitypub_mixin import OrderedCollectionPageMixin, ObjectMixin
|
||||
from .base_model import BookWyrmModel
|
||||
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
|
||||
from . import fields
|
||||
|
||||
class BookDataModel(ActivitypubMixin, BookWyrmModel):
|
||||
class BookDataModel(ObjectMixin, BookWyrmModel):
|
||||
''' fields shared between editable book data (books, works, authors) '''
|
||||
origin_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
openlibrary_key = fields.CharField(
|
||||
|
@ -74,6 +74,7 @@ class Book(BookDataModel):
|
|||
|
||||
@property
|
||||
def latest_readthrough(self):
|
||||
''' most recent readthrough activity '''
|
||||
return self.readthrough_set.order_by('-updated_date').first()
|
||||
|
||||
@property
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
''' like/fav/star a status '''
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from .base_model import ActivitypubMixin, BookWyrmModel
|
||||
from .activitypub_mixin import ActivityMixin
|
||||
from .base_model import BookWyrmModel
|
||||
from . import fields
|
||||
|
||||
class Favorite(ActivitypubMixin, BookWyrmModel):
|
||||
class Favorite(ActivityMixin, BookWyrmModel):
|
||||
''' fav'ing a post '''
|
||||
user = fields.ForeignKey(
|
||||
'User', on_delete=models.PROTECT, activitypub_field='actor')
|
||||
|
@ -18,9 +20,33 @@ class Favorite(ActivitypubMixin, BookWyrmModel):
|
|||
def save(self, *args, **kwargs):
|
||||
''' update user active time '''
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save()
|
||||
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')
|
||||
|
|
|
@ -122,13 +122,12 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
|
|||
return None
|
||||
|
||||
related_model = self.related_model
|
||||
if isinstance(value, dict) and value.get('id'):
|
||||
if hasattr(value, 'id') and value.id:
|
||||
if not self.load_remote:
|
||||
# only look in the local database
|
||||
return related_model.find_existing(value)
|
||||
return related_model.find_existing(value.serialize())
|
||||
# this is an activitypub object, which we can deserialize
|
||||
activity_serializer = related_model.activity_serializer
|
||||
return activity_serializer(**value).to_model(related_model)
|
||||
return value.to_model(model=related_model)
|
||||
try:
|
||||
# make sure the value looks like a remote id
|
||||
validate_remote_id(value)
|
||||
|
@ -139,7 +138,7 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
|
|||
if not self.load_remote:
|
||||
# only look in the local database
|
||||
return related_model.find_existing_by_remote_id(value)
|
||||
return activitypub.resolve_remote_id(related_model, value)
|
||||
return activitypub.resolve_remote_id(value, model=related_model)
|
||||
|
||||
|
||||
class RemoteIdField(ActivitypubFieldMixin, models.CharField):
|
||||
|
@ -263,6 +262,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:
|
||||
|
@ -279,7 +279,8 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
|
|||
except ValidationError:
|
||||
continue
|
||||
items.append(
|
||||
activitypub.resolve_remote_id(self.related_model, remote_id)
|
||||
activitypub.resolve_remote_id(
|
||||
remote_id, model=self.related_model)
|
||||
)
|
||||
return items
|
||||
|
||||
|
@ -316,7 +317,8 @@ class TagField(ManyToManyField):
|
|||
# tags can contain multiple types
|
||||
continue
|
||||
items.append(
|
||||
activitypub.resolve_remote_id(self.related_model, link.href)
|
||||
activitypub.resolve_remote_id(
|
||||
link.href, model=self.related_model)
|
||||
)
|
||||
return items
|
||||
|
||||
|
@ -365,8 +367,8 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
|
|||
image_slug = value
|
||||
# when it's an inline image (User avatar/icon, Book cover), it's a json
|
||||
# blob, but when it's an attached image, it's just a url
|
||||
if isinstance(image_slug, dict):
|
||||
url = image_slug.get('url')
|
||||
if hasattr(image_slug, 'url'):
|
||||
url = image_slug.url
|
||||
elif isinstance(image_slug, str):
|
||||
url = image_slug
|
||||
else:
|
||||
|
|
|
@ -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 '''
|
||||
|
@ -84,8 +97,8 @@ class ImportItem(models.Model):
|
|||
def get_book_from_title_author(self):
|
||||
''' search by title and author '''
|
||||
search_term = construct_search_term(
|
||||
self.data['Title'],
|
||||
self.data['Author']
|
||||
self.title,
|
||||
self.author
|
||||
)
|
||||
search_result = connector_manager.first_search_result(
|
||||
search_term, min_confidence=0.999
|
||||
|
@ -136,6 +149,14 @@ class ImportItem(models.Model):
|
|||
dateutil.parser.parse(self.data['Date Added']))
|
||||
return None
|
||||
|
||||
@property
|
||||
def date_started(self):
|
||||
''' when the book was started '''
|
||||
if "Date Started" in self.data and self.data['Date Started']:
|
||||
return timezone.make_aware(
|
||||
dateutil.parser.parse(self.data['Date Started']))
|
||||
return None
|
||||
|
||||
@property
|
||||
def date_read(self):
|
||||
''' the date a book was completed '''
|
||||
|
@ -147,18 +168,24 @@ class ImportItem(models.Model):
|
|||
@property
|
||||
def reads(self):
|
||||
''' formats a read through dataset for the book in this line '''
|
||||
if (self.shelf == 'reading'
|
||||
and self.date_added and not self.date_read):
|
||||
return [ReadThrough(start_date=self.date_added)]
|
||||
start_date = self.date_started
|
||||
|
||||
# Goodreads special case (no 'date started' field)
|
||||
if ((self.shelf == 'reading' or (self.shelf == 'read' and self.date_read))
|
||||
and self.date_added and not start_date):
|
||||
start_date = self.date_added
|
||||
|
||||
if (start_date and start_date is not None and not self.date_read):
|
||||
return [ReadThrough(start_date=start_date)]
|
||||
if self.date_read:
|
||||
return [ReadThrough(
|
||||
start_date=self.date_added,
|
||||
start_date=start_date,
|
||||
finish_date=self.date_read,
|
||||
)]
|
||||
return []
|
||||
|
||||
def __repr__(self):
|
||||
return "<GoodreadsItem {!r}>".format(self.data['Title'])
|
||||
return "<{!r}Item {!r}>".format(self.data['import_source'], self.data['Title'])
|
||||
|
||||
def __str__(self):
|
||||
return "{} by {}".format(self.data['Title'], self.data['Author'])
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
''' make a list of books!! '''
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from .base_model import ActivitypubMixin, BookWyrmModel
|
||||
from .base_model import OrderedCollectionMixin
|
||||
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
|
||||
from .base_model import BookWyrmModel
|
||||
from . import fields
|
||||
|
||||
|
||||
|
@ -42,20 +43,22 @@ class List(OrderedCollectionMixin, BookWyrmModel):
|
|||
@property
|
||||
def collection_queryset(self):
|
||||
''' list of books for this shelf, overrides OrderedCollectionMixin '''
|
||||
return self.books.all().order_by('listitem')
|
||||
return self.books.filter(
|
||||
listitem__approved=True
|
||||
).all().order_by('listitem')
|
||||
|
||||
class Meta:
|
||||
''' default sorting '''
|
||||
ordering = ('-updated_date',)
|
||||
|
||||
|
||||
class ListItem(ActivitypubMixin, BookWyrmModel):
|
||||
class ListItem(CollectionItemMixin, BookWyrmModel):
|
||||
''' ok '''
|
||||
book = fields.ForeignKey(
|
||||
'Edition', on_delete=models.PROTECT, activitypub_field='object')
|
||||
book_list = fields.ForeignKey(
|
||||
'List', on_delete=models.CASCADE, activitypub_field='target')
|
||||
added_by = fields.ForeignKey(
|
||||
user = fields.ForeignKey(
|
||||
'User',
|
||||
on_delete=models.PROTECT,
|
||||
activitypub_field='actor'
|
||||
|
@ -65,25 +68,25 @@ class ListItem(ActivitypubMixin, BookWyrmModel):
|
|||
order = fields.IntegerField(blank=True, null=True)
|
||||
endorsement = models.ManyToManyField('User', related_name='endorsers')
|
||||
|
||||
activity_serializer = activitypub.AddBook
|
||||
activity_serializer = activitypub.Add
|
||||
object_field = 'book'
|
||||
collection_field = 'book_list'
|
||||
|
||||
def to_add_activity(self, user):
|
||||
''' AP for shelving a book'''
|
||||
return activitypub.Add(
|
||||
id='%s#add' % self.remote_id,
|
||||
actor=user.remote_id,
|
||||
object=self.book.to_activity(),
|
||||
target=self.book_list.remote_id,
|
||||
).serialize()
|
||||
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',
|
||||
)
|
||||
|
||||
def to_remove_activity(self, user):
|
||||
''' AP for un-shelving a book'''
|
||||
return activitypub.Remove(
|
||||
id='%s#remove' % self.remote_id,
|
||||
actor=user.remote_id,
|
||||
object=self.book.to_activity(),
|
||||
target=self.book_list.remote_id
|
||||
).serialize()
|
||||
|
||||
class Meta:
|
||||
''' an opinionated constraint! you can't put a book on a list twice '''
|
||||
|
|
|
@ -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 = [
|
||||
|
|
|
@ -31,7 +31,7 @@ class ReadThrough(BookWyrmModel):
|
|||
def save(self, *args, **kwargs):
|
||||
''' update user active time '''
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save()
|
||||
self.user.save(broadcast=False)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def create_update(self):
|
||||
|
@ -54,5 +54,5 @@ class ProgressUpdate(BookWyrmModel):
|
|||
def save(self, *args, **kwargs):
|
||||
''' update user active time '''
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save()
|
||||
self.user.save(broadcast=False)
|
||||
super().save(*args, **kwargs)
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
''' defines relationships between users '''
|
||||
from django.db import models
|
||||
from django.apps import apps
|
||||
from django.db import models, transaction, IntegrityError
|
||||
from django.db.models import Q
|
||||
from django.dispatch import receiver
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from .base_model import ActivitypubMixin, BookWyrmModel
|
||||
from .activitypub_mixin import ActivitypubMixin, ActivityMixin
|
||||
from .activitypub_mixin import generate_activity
|
||||
from .base_model import BookWyrmModel
|
||||
from . import fields
|
||||
|
||||
|
||||
class UserRelationship(ActivitypubMixin, BookWyrmModel):
|
||||
class UserRelationship(BookWyrmModel):
|
||||
''' many-to-many through table for followers '''
|
||||
user_subject = fields.ForeignKey(
|
||||
'User',
|
||||
|
@ -23,6 +25,16 @@ class UserRelationship(ActivitypubMixin, BookWyrmModel):
|
|||
activitypub_field='object',
|
||||
)
|
||||
|
||||
@property
|
||||
def privacy(self):
|
||||
''' all relationships are handled directly with the participants '''
|
||||
return 'direct'
|
||||
|
||||
@property
|
||||
def recipients(self):
|
||||
''' the remote user needs to recieve direct broadcasts '''
|
||||
return [u for u in [self.user_subject, self.user_object] if not u.local]
|
||||
|
||||
class Meta:
|
||||
''' relationships should be unique '''
|
||||
abstract = True
|
||||
|
@ -37,8 +49,6 @@ class UserRelationship(ActivitypubMixin, BookWyrmModel):
|
|||
)
|
||||
]
|
||||
|
||||
activity_serializer = activitypub.Follow
|
||||
|
||||
def get_remote_id(self, status=None):# pylint: disable=arguments-differ
|
||||
''' use shelf identifier in remote_id '''
|
||||
status = status or 'follows'
|
||||
|
@ -46,73 +56,128 @@ class UserRelationship(ActivitypubMixin, BookWyrmModel):
|
|||
return '%s#%s/%d' % (base_path, status, self.id)
|
||||
|
||||
|
||||
def to_accept_activity(self):
|
||||
''' generate an Accept for this follow request '''
|
||||
return activitypub.Accept(
|
||||
id=self.get_remote_id(status='accepts'),
|
||||
actor=self.user_object.remote_id,
|
||||
object=self.to_activity()
|
||||
).serialize()
|
||||
|
||||
|
||||
def to_reject_activity(self):
|
||||
''' generate a Reject for this follow request '''
|
||||
return activitypub.Reject(
|
||||
id=self.get_remote_id(status='rejects'),
|
||||
actor=self.user_object.remote_id,
|
||||
object=self.to_activity()
|
||||
).serialize()
|
||||
|
||||
|
||||
class UserFollows(UserRelationship):
|
||||
class UserFollows(ActivityMixin, UserRelationship):
|
||||
''' Following a user '''
|
||||
status = 'follows'
|
||||
|
||||
def to_activity(self):
|
||||
''' overrides default to manually set serializer '''
|
||||
return activitypub.Follow(**generate_activity(self))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' really really don't let a user follow someone who blocked them '''
|
||||
# blocking in either direction is a no-go
|
||||
if UserBlocks.objects.filter(
|
||||
Q(
|
||||
user_subject=self.user_subject,
|
||||
user_object=self.user_object,
|
||||
) | Q(
|
||||
user_subject=self.user_object,
|
||||
user_object=self.user_subject,
|
||||
)
|
||||
).exists():
|
||||
raise IntegrityError()
|
||||
# don't broadcast this type of relationship -- accepts and requests
|
||||
# are handled by the UserFollowRequest model
|
||||
super().save(*args, broadcast=False, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def from_request(cls, follow_request):
|
||||
''' converts a follow request into a follow relationship '''
|
||||
return cls(
|
||||
return cls.objects.create(
|
||||
user_subject=follow_request.user_subject,
|
||||
user_object=follow_request.user_object,
|
||||
remote_id=follow_request.remote_id,
|
||||
)
|
||||
|
||||
|
||||
class UserFollowRequest(UserRelationship):
|
||||
class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
||||
''' following a user requires manual or automatic confirmation '''
|
||||
status = 'follow_request'
|
||||
activity_serializer = activitypub.Follow
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' make sure the follow relationship doesn't already exist '''
|
||||
try:
|
||||
UserFollows.objects.get(
|
||||
def save(self, *args, broadcast=True, **kwargs):
|
||||
''' make sure the follow or block relationship doesn't already exist '''
|
||||
# don't create a request if a follow already exists
|
||||
if UserFollows.objects.filter(
|
||||
user_subject=self.user_subject,
|
||||
user_object=self.user_object
|
||||
user_object=self.user_object,
|
||||
).exists():
|
||||
raise IntegrityError()
|
||||
# blocking in either direction is a no-go
|
||||
if UserBlocks.objects.filter(
|
||||
Q(
|
||||
user_subject=self.user_subject,
|
||||
user_object=self.user_object,
|
||||
) | Q(
|
||||
user_subject=self.user_object,
|
||||
user_object=self.user_subject,
|
||||
)
|
||||
).exists():
|
||||
raise IntegrityError()
|
||||
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:
|
||||
manually_approves = self.user_object.manually_approves_followers
|
||||
if not manually_approves:
|
||||
self.accept()
|
||||
|
||||
model = apps.get_model('bookwyrm.Notification', require_ready=True)
|
||||
notification_type = 'FOLLOW_REQUEST' if \
|
||||
manually_approves else 'FOLLOW'
|
||||
model.objects.create(
|
||||
user=self.user_object,
|
||||
related_user=self.user_subject,
|
||||
notification_type=notification_type,
|
||||
)
|
||||
return None
|
||||
except UserFollows.DoesNotExist:
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class UserBlocks(UserRelationship):
|
||||
def accept(self):
|
||||
''' turn this request into the real deal'''
|
||||
user = self.user_object
|
||||
if not self.user_subject.local:
|
||||
activity = activitypub.Accept(
|
||||
id=self.get_remote_id(status='accepts'),
|
||||
actor=self.user_object.remote_id,
|
||||
object=self.to_activity()
|
||||
).serialize()
|
||||
self.broadcast(activity, user)
|
||||
with transaction.atomic():
|
||||
UserFollows.from_request(self)
|
||||
self.delete()
|
||||
|
||||
|
||||
|
||||
def reject(self):
|
||||
''' generate a Reject for this follow request '''
|
||||
if self.user_object.local:
|
||||
activity = activitypub.Reject(
|
||||
id=self.get_remote_id(status='rejects'),
|
||||
actor=self.user_object.remote_id,
|
||||
object=self.to_activity()
|
||||
).serialize()
|
||||
self.broadcast(activity, self.user_object)
|
||||
|
||||
self.delete()
|
||||
|
||||
|
||||
class UserBlocks(ActivityMixin, UserRelationship):
|
||||
''' prevent another user from following you and seeing your posts '''
|
||||
status = 'blocks'
|
||||
activity_serializer = activitypub.Block
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' remove follow or follow request rels after a block is created '''
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@receiver(models.signals.post_save, sender=UserBlocks)
|
||||
#pylint: disable=unused-argument
|
||||
def execute_after_save(sender, instance, created, *args, **kwargs):
|
||||
''' remove follow or follow request rels after a block is created '''
|
||||
UserFollows.objects.filter(
|
||||
Q(user_subject=instance.user_subject,
|
||||
user_object=instance.user_object) | \
|
||||
Q(user_subject=instance.user_object,
|
||||
user_object=instance.user_subject)
|
||||
).delete()
|
||||
UserFollowRequest.objects.filter(
|
||||
Q(user_subject=instance.user_subject,
|
||||
user_object=instance.user_object) | \
|
||||
Q(user_subject=instance.user_object,
|
||||
user_object=instance.user_subject)
|
||||
).delete()
|
||||
UserFollows.objects.filter(
|
||||
Q(user_subject=self.user_subject, user_object=self.user_object) | \
|
||||
Q(user_subject=self.user_object, user_object=self.user_subject)
|
||||
).delete()
|
||||
UserFollowRequest.objects.filter(
|
||||
Q(user_subject=self.user_subject, user_object=self.user_object) | \
|
||||
Q(user_subject=self.user_object, user_object=self.user_subject)
|
||||
).delete()
|
||||
|
|
|
@ -3,8 +3,8 @@ import re
|
|||
from django.db import models
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from .base_model import ActivitypubMixin, BookWyrmModel
|
||||
from .base_model import OrderedCollectionMixin
|
||||
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
|
||||
from .base_model import BookWyrmModel
|
||||
from . import fields
|
||||
|
||||
|
||||
|
@ -27,12 +27,11 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
|||
|
||||
def save(self, *args, **kwargs):
|
||||
''' set the identifier '''
|
||||
saved = super().save(*args, **kwargs)
|
||||
super().save(*args, **kwargs)
|
||||
if not self.identifier:
|
||||
slug = re.sub(r'[^\w]', '', self.name).lower()
|
||||
self.identifier = '%s-%d' % (slug, self.id)
|
||||
return super().save(*args, **kwargs)
|
||||
return saved
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def collection_queryset(self):
|
||||
|
@ -49,39 +48,18 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
|||
unique_together = ('user', 'identifier')
|
||||
|
||||
|
||||
class ShelfBook(ActivitypubMixin, BookWyrmModel):
|
||||
class ShelfBook(CollectionItemMixin, BookWyrmModel):
|
||||
''' many to many join table for books and shelves '''
|
||||
book = fields.ForeignKey(
|
||||
'Edition', on_delete=models.PROTECT, activitypub_field='object')
|
||||
shelf = fields.ForeignKey(
|
||||
'Shelf', on_delete=models.PROTECT, activitypub_field='target')
|
||||
added_by = fields.ForeignKey(
|
||||
'User',
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.PROTECT,
|
||||
activitypub_field='actor'
|
||||
)
|
||||
user = fields.ForeignKey(
|
||||
'User', on_delete=models.PROTECT, activitypub_field='actor')
|
||||
|
||||
activity_serializer = activitypub.AddBook
|
||||
|
||||
def to_add_activity(self, user):
|
||||
''' AP for shelving a book'''
|
||||
return activitypub.Add(
|
||||
id='%s#add' % self.remote_id,
|
||||
actor=user.remote_id,
|
||||
object=self.book.to_activity(),
|
||||
target=self.shelf.remote_id,
|
||||
).serialize()
|
||||
|
||||
def to_remove_activity(self, user):
|
||||
''' AP for un-shelving a book'''
|
||||
return activitypub.Remove(
|
||||
id='%s#remove' % self.remote_id,
|
||||
actor=user.remote_id,
|
||||
object=self.book.to_activity(),
|
||||
target=self.shelf.to_activity()
|
||||
).serialize()
|
||||
activity_serializer = activitypub.Add
|
||||
object_field = 'book'
|
||||
collection_field = 'shelf'
|
||||
|
||||
|
||||
class Meta:
|
||||
|
|
|
@ -20,6 +20,8 @@ class SiteSettings(models.Model):
|
|||
default='Contact an administrator to get an invite')
|
||||
code_of_conduct = models.TextField(
|
||||
default='Add a code of conduct here.')
|
||||
privacy_policy = models.TextField(
|
||||
default='Add a privacy policy here.')
|
||||
allow_registration = models.BooleanField(default=True)
|
||||
logo = models.ImageField(
|
||||
upload_to='logos/', null=True, blank=True
|
||||
|
|
|
@ -9,10 +9,12 @@ from django.utils import timezone
|
|||
from model_utils.managers import InheritanceManager
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
|
||||
from .activitypub_mixin import ActivitypubMixin, ActivityMixin
|
||||
from .activitypub_mixin import OrderedCollectionPageMixin
|
||||
from .base_model import BookWyrmModel
|
||||
from . import fields
|
||||
from .fields import image_serializer
|
||||
from . import fields
|
||||
|
||||
|
||||
class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||
''' any post, like a reply to a review, etc '''
|
||||
|
@ -50,9 +52,66 @@ 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,
|
||||
)
|
||||
|
||||
def delete(self, *args, **kwargs):#pylint: disable=unused-argument
|
||||
''' "delete" a status '''
|
||||
if hasattr(self, 'boosted_status'):
|
||||
# okay but if it's a boost really delete it
|
||||
super().delete(*args, **kwargs)
|
||||
return
|
||||
self.deleted = True
|
||||
self.deleted_date = timezone.now()
|
||||
self.save()
|
||||
|
||||
@property
|
||||
def recipients(self):
|
||||
''' tagged users who definitely need to get this status in broadcast '''
|
||||
mentions = [u for u in self.mention_users.all() if not u.local]
|
||||
if hasattr(self, 'reply_parent') and self.reply_parent \
|
||||
and not self.reply_parent.user.local:
|
||||
mentions.append(self.reply_parent.user)
|
||||
return list(set(mentions))
|
||||
|
||||
@classmethod
|
||||
def ignore_activity(cls, activity):
|
||||
''' keep notes if they are replies to existing statuses '''
|
||||
if activity.type == 'Announce':
|
||||
# keep it if the booster or the boosted are local
|
||||
boosted = activitypub.resolve_remote_id(activity.object, save=False)
|
||||
return cls.ignore_activity(boosted.to_activity_dataclass())
|
||||
|
||||
# keep if it if it's a custom type
|
||||
if activity.type != 'Note':
|
||||
return False
|
||||
if cls.objects.filter(
|
||||
|
@ -63,8 +122,8 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
if activity.tag == MISSING or activity.tag is None:
|
||||
return True
|
||||
tags = [l['href'] for l in activity.tag if l['type'] == 'Mention']
|
||||
user_model = apps.get_model('bookwyrm.User', require_ready=True)
|
||||
for tag in tags:
|
||||
user_model = apps.get_model('bookwyrm.User', require_ready=True)
|
||||
if user_model.objects.filter(
|
||||
remote_id=tag, local=True).exists():
|
||||
# we found a mention of a known use boost
|
||||
|
@ -96,9 +155,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
remote_id='%s/replies' % self.remote_id,
|
||||
collection_only=True,
|
||||
**kwargs
|
||||
)
|
||||
).serialize()
|
||||
|
||||
def to_activity(self, pure=False):# pylint: disable=arguments-differ
|
||||
def to_activity_dataclass(self, pure=False):# pylint: disable=arguments-differ
|
||||
''' return tombstone if the status is deleted '''
|
||||
if self.deleted:
|
||||
return activitypub.Tombstone(
|
||||
|
@ -106,32 +165,28 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
url=self.remote_id,
|
||||
deleted=self.deleted_date.isoformat(),
|
||||
published=self.deleted_date.isoformat()
|
||||
).serialize()
|
||||
activity = ActivitypubMixin.to_activity(self)
|
||||
activity['replies'] = self.to_replies()
|
||||
)
|
||||
activity = ActivitypubMixin.to_activity_dataclass(self)
|
||||
activity.replies = self.to_replies()
|
||||
|
||||
# "pure" serialization for non-bookwyrm instances
|
||||
if pure and hasattr(self, 'pure_content'):
|
||||
activity['content'] = self.pure_content
|
||||
if 'name' in activity:
|
||||
activity['name'] = self.pure_name
|
||||
activity['type'] = self.pure_type
|
||||
activity['attachment'] = [
|
||||
activity.content = self.pure_content
|
||||
if hasattr(activity, 'name'):
|
||||
activity.name = self.pure_name
|
||||
activity.type = self.pure_type
|
||||
activity.attachment = [
|
||||
image_serializer(b.cover, b.alt_text) \
|
||||
for b in self.mention_books.all()[:4] if b.cover]
|
||||
if hasattr(self, 'book') and self.book.cover:
|
||||
activity['attachment'].append(
|
||||
activity.attachment.append(
|
||||
image_serializer(self.book.cover, self.book.alt_text)
|
||||
)
|
||||
return activity
|
||||
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' update user active time '''
|
||||
if self.user.local:
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save()
|
||||
return super().save(*args, **kwargs)
|
||||
def to_activity(self, pure=False):# pylint: disable=arguments-differ
|
||||
''' json serialized activitypub class '''
|
||||
return self.to_activity_dataclass(pure=pure).serialize()
|
||||
|
||||
|
||||
class GeneratedNote(Status):
|
||||
|
@ -223,7 +278,7 @@ class Review(Status):
|
|||
pure_type = 'Article'
|
||||
|
||||
|
||||
class Boost(Status):
|
||||
class Boost(ActivityMixin, Status):
|
||||
''' boost'ing a post '''
|
||||
boosted_status = fields.ForeignKey(
|
||||
'Status',
|
||||
|
@ -231,6 +286,35 @@ class Boost(Status):
|
|||
related_name='boosters',
|
||||
activitypub_field='object',
|
||||
)
|
||||
activity_serializer = activitypub.Announce
|
||||
|
||||
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" '''
|
||||
|
@ -244,8 +328,6 @@ class Boost(Status):
|
|||
self.image_fields = []
|
||||
self.deserialize_reverse_fields = []
|
||||
|
||||
activity_serializer = activitypub.Boost
|
||||
|
||||
# This constraint can't work as it would cross tables.
|
||||
# class Meta:
|
||||
# unique_together = ('user', 'boosted_status')
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
''' models for storing different kinds of Activities '''
|
||||
import urllib.parse
|
||||
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from .base_model import OrderedCollectionMixin, BookWyrmModel
|
||||
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
|
||||
from .base_model import BookWyrmModel
|
||||
from . import fields
|
||||
|
||||
|
||||
|
@ -14,17 +16,15 @@ class Tag(OrderedCollectionMixin, BookWyrmModel):
|
|||
name = fields.CharField(max_length=100, unique=True)
|
||||
identifier = models.CharField(max_length=100)
|
||||
|
||||
@classmethod
|
||||
def book_queryset(cls, identifier):
|
||||
''' county of books associated with this tag '''
|
||||
return cls.objects.filter(
|
||||
identifier=identifier
|
||||
).order_by('-updated_date')
|
||||
|
||||
@property
|
||||
def collection_queryset(self):
|
||||
''' books associated with this tag '''
|
||||
return self.book_queryset(self.identifier)
|
||||
def books(self):
|
||||
''' count of books associated with this tag '''
|
||||
edition_model = apps.get_model('bookwyrm.Edition', require_ready=True)
|
||||
return edition_model.objects.filter(
|
||||
usertag__tag__identifier=self.identifier
|
||||
).order_by('-created_date').distinct()
|
||||
|
||||
collection_queryset = books
|
||||
|
||||
def get_remote_id(self):
|
||||
''' tag should use identifier not id in remote_id '''
|
||||
|
@ -40,7 +40,7 @@ class Tag(OrderedCollectionMixin, BookWyrmModel):
|
|||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class UserTag(BookWyrmModel):
|
||||
class UserTag(CollectionItemMixin, BookWyrmModel):
|
||||
''' an instance of a tag on a book by a user '''
|
||||
user = fields.ForeignKey(
|
||||
'User', on_delete=models.PROTECT, activitypub_field='actor')
|
||||
|
@ -49,26 +49,9 @@ class UserTag(BookWyrmModel):
|
|||
tag = fields.ForeignKey(
|
||||
'Tag', on_delete=models.PROTECT, activitypub_field='target')
|
||||
|
||||
activity_serializer = activitypub.AddBook
|
||||
|
||||
def to_add_activity(self, user):
|
||||
''' AP for shelving a book'''
|
||||
return activitypub.Add(
|
||||
id='%s#add' % self.remote_id,
|
||||
actor=user.remote_id,
|
||||
object=self.book.to_activity(),
|
||||
target=self.remote_id,
|
||||
).serialize()
|
||||
|
||||
def to_remove_activity(self, user):
|
||||
''' AP for un-shelving a book'''
|
||||
return activitypub.Remove(
|
||||
id='%s#remove' % self.remote_id,
|
||||
actor=user.remote_id,
|
||||
object=self.book.to_activity(),
|
||||
target=self.remote_id,
|
||||
).serialize()
|
||||
|
||||
activity_serializer = activitypub.Add
|
||||
object_field = 'book'
|
||||
collection_field = 'tag'
|
||||
|
||||
class Meta:
|
||||
''' unqiueness constraint '''
|
||||
|
|
|
@ -3,22 +3,21 @@ import re
|
|||
from urllib.parse import urlparse
|
||||
|
||||
from django.apps import apps
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.auth.models import AbstractUser, Group
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm.connectors import get_data
|
||||
from bookwyrm.connectors import get_data, ConnectorException
|
||||
from bookwyrm.models.shelf import Shelf
|
||||
from bookwyrm.models.status import Status, Review
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from bookwyrm.signatures import create_key_pair
|
||||
from bookwyrm.tasks import app
|
||||
from bookwyrm.utils import regex
|
||||
from .base_model import OrderedCollectionPageMixin
|
||||
from .base_model import ActivitypubMixin, BookWyrmModel
|
||||
from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin
|
||||
from .base_model import BookWyrmModel
|
||||
from .federated_server import FederatedServer
|
||||
from . import fields, Review
|
||||
|
||||
|
@ -113,6 +112,16 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
|
||||
activity_serializer = activitypub.Person
|
||||
|
||||
@classmethod
|
||||
def viewer_aware_objects(cls, viewer):
|
||||
''' the user queryset filtered for the context of the logged in user '''
|
||||
queryset = cls.objects.filter(is_active=True)
|
||||
if viewer.is_authenticated:
|
||||
queryset = queryset.exclude(
|
||||
blocks=viewer
|
||||
)
|
||||
return queryset
|
||||
|
||||
def to_outbox(self, filter_type=None, **kwargs):
|
||||
''' an ordered collection of statuses '''
|
||||
if filter_type:
|
||||
|
@ -131,7 +140,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
privacy__in=['public', 'unlisted'],
|
||||
).select_subclasses().order_by('-published_date')
|
||||
return self.to_ordered_collection(queryset, \
|
||||
collection_only=True, remote_id=self.outbox, **kwargs)
|
||||
collection_only=True, remote_id=self.outbox, **kwargs).serialize()
|
||||
|
||||
def to_following_activity(self, **kwargs):
|
||||
''' activitypub following list '''
|
||||
|
@ -172,15 +181,23 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
|
||||
def save(self, *args, **kwargs):
|
||||
''' populate fields for new local users '''
|
||||
# this user already exists, no need to populate fields
|
||||
created = not bool(self.id)
|
||||
if not self.local and not re.match(regex.full_username, self.username):
|
||||
# generate a username that uses the domain (webfinger format)
|
||||
actor_parts = urlparse(self.remote_id)
|
||||
self.username = '%s@%s' % (self.username, actor_parts.netloc)
|
||||
return super().save(*args, **kwargs)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if self.id or not self.local:
|
||||
return super().save(*args, **kwargs)
|
||||
# this user already exists, no need to populate fields
|
||||
if not created:
|
||||
super().save(*args, **kwargs)
|
||||
return
|
||||
|
||||
# this is a new remote user, we need to set their remote server field
|
||||
if not self.local:
|
||||
super().save(*args, **kwargs)
|
||||
set_remote_server.delay(self.id)
|
||||
return
|
||||
|
||||
# populate fields for local users
|
||||
self.remote_id = 'https://%s/user/%s' % (DOMAIN, self.localname)
|
||||
|
@ -188,7 +205,39 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
self.shared_inbox = 'https://%s/inbox' % DOMAIN
|
||||
self.outbox = '%s/outbox' % self.remote_id
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
# an id needs to be set before we can proceed with related models
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# make users editors by default
|
||||
try:
|
||||
self.groups.add(Group.objects.get(name='editor'))
|
||||
except Group.DoesNotExist:
|
||||
# this should only happen in tests
|
||||
pass
|
||||
|
||||
# create keys and shelves for new local users
|
||||
self.key_pair = KeyPair.objects.create(
|
||||
remote_id='%s/#main-key' % self.remote_id)
|
||||
self.save(broadcast=False)
|
||||
|
||||
shelves = [{
|
||||
'name': 'To Read',
|
||||
'identifier': 'to-read',
|
||||
}, {
|
||||
'name': 'Currently Reading',
|
||||
'identifier': 'reading',
|
||||
}, {
|
||||
'name': 'Read',
|
||||
'identifier': 'read',
|
||||
}]
|
||||
|
||||
for shelf in shelves:
|
||||
Shelf(
|
||||
name=shelf['name'],
|
||||
identifier=shelf['identifier'],
|
||||
user=self,
|
||||
editable=False
|
||||
).save(broadcast=False)
|
||||
|
||||
@property
|
||||
def local_path(self):
|
||||
|
@ -211,6 +260,9 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
|
|||
|
||||
def save(self, *args, **kwargs):
|
||||
''' create a key pair '''
|
||||
# no broadcasting happening here
|
||||
if 'broadcast' in kwargs:
|
||||
del kwargs['broadcast']
|
||||
if not self.public_key:
|
||||
self.private_key, self.public_key = create_key_pair()
|
||||
return super().save(*args, **kwargs)
|
||||
|
@ -277,42 +329,6 @@ class AnnualGoal(BookWyrmModel):
|
|||
finish_date__year__gte=self.year).count()
|
||||
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=User)
|
||||
#pylint: disable=unused-argument
|
||||
def execute_after_save(sender, instance, created, *args, **kwargs):
|
||||
''' create shelves for new users '''
|
||||
if not created:
|
||||
return
|
||||
|
||||
if not instance.local:
|
||||
set_remote_server.delay(instance.id)
|
||||
return
|
||||
|
||||
instance.key_pair = KeyPair.objects.create(
|
||||
remote_id='%s/#main-key' % instance.remote_id)
|
||||
instance.save()
|
||||
|
||||
shelves = [{
|
||||
'name': 'To Read',
|
||||
'identifier': 'to-read',
|
||||
}, {
|
||||
'name': 'Currently Reading',
|
||||
'identifier': 'reading',
|
||||
}, {
|
||||
'name': 'Read',
|
||||
'identifier': 'read',
|
||||
}]
|
||||
|
||||
for shelf in shelves:
|
||||
Shelf(
|
||||
name=shelf['name'],
|
||||
identifier=shelf['identifier'],
|
||||
user=instance,
|
||||
editable=False
|
||||
).save()
|
||||
|
||||
|
||||
@app.task
|
||||
def set_remote_server(user_id):
|
||||
''' figure out the user's remote server in the background '''
|
||||
|
@ -320,7 +336,7 @@ def set_remote_server(user_id):
|
|||
actor_parts = urlparse(user.remote_id)
|
||||
user.federated_server = \
|
||||
get_or_create_remote_server(actor_parts.netloc)
|
||||
user.save()
|
||||
user.save(broadcast=False)
|
||||
if user.bookwyrm_user:
|
||||
get_remote_reviews.delay(user.outbox)
|
||||
|
||||
|
@ -334,19 +350,24 @@ def get_or_create_remote_server(domain):
|
|||
except FederatedServer.DoesNotExist:
|
||||
pass
|
||||
|
||||
data = get_data('https://%s/.well-known/nodeinfo' % domain)
|
||||
|
||||
try:
|
||||
nodeinfo_url = data.get('links')[0].get('href')
|
||||
except (TypeError, KeyError):
|
||||
return None
|
||||
data = get_data('https://%s/.well-known/nodeinfo' % domain)
|
||||
try:
|
||||
nodeinfo_url = data.get('links')[0].get('href')
|
||||
except (TypeError, KeyError):
|
||||
raise ConnectorException()
|
||||
|
||||
data = get_data(nodeinfo_url)
|
||||
application_type = data.get('software', {}).get('name')
|
||||
application_version = data.get('software', {}).get('version')
|
||||
except ConnectorException:
|
||||
application_type = application_version = None
|
||||
|
||||
data = get_data(nodeinfo_url)
|
||||
|
||||
server = FederatedServer.objects.create(
|
||||
server_name=domain,
|
||||
application_type=data['software']['name'],
|
||||
application_version=data['software']['version'],
|
||||
application_type=application_type,
|
||||
application_version=application_version,
|
||||
)
|
||||
return server
|
||||
|
||||
|
@ -361,4 +382,4 @@ def get_remote_reviews(outbox):
|
|||
for activity in data['orderedItems']:
|
||||
if not activity['type'] == 'Review':
|
||||
continue
|
||||
activitypub.Review(**activity).to_model(Review)
|
||||
activitypub.Review(**activity).to_model()
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
''' bookwyrm settings and configuration '''
|
||||
import os
|
||||
|
||||
from environs import Env
|
||||
|
||||
import requests
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
env = Env()
|
||||
DOMAIN = env('DOMAIN')
|
||||
|
@ -27,6 +28,7 @@ EMAIL_USE_TLS = env('EMAIL_USE_TLS', True)
|
|||
|
||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
LOCALE_PATHS = [os.path.join(BASE_DIR, 'locale'),]
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/
|
||||
|
@ -58,6 +60,7 @@ INSTALLED_APPS = [
|
|||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.locale.LocaleMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
|
@ -135,6 +138,14 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||
# https://docs.djangoproject.com/en/2.0/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
LANGUAGES = [
|
||||
('en-us', _('English')),
|
||||
('de-de', _('German')),
|
||||
('es', _('Spanish')),
|
||||
('fr-fr', _('French')),
|
||||
('zh-cn', _('Simplified Chinese')),
|
||||
]
|
||||
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
|
|
|
@ -13,6 +13,13 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- SHELVING --- */
|
||||
.shelf-option:disabled > *::after {
|
||||
font-family: "icomoon";
|
||||
content: "\e918";
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
/* --- TOGGLES --- */
|
||||
.toggle-button[aria-pressed=true], .toggle-button[aria-pressed=true]:hover {
|
||||
background-color: hsl(171, 100%, 41%);
|
||||
|
@ -93,9 +100,6 @@
|
|||
.cover-container.is-medium {
|
||||
height: 100px;
|
||||
}
|
||||
.cover-container.is-small {
|
||||
height: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
.cover-container.is-medium .no-cover div {
|
||||
|
|
|
@ -12,9 +12,9 @@ window.onload = function() {
|
|||
Array.from(document.getElementsByClassName('select-all'))
|
||||
.forEach(t => t.onclick = selectAll);
|
||||
|
||||
// toggle between tabs
|
||||
Array.from(document.getElementsByClassName('tab-change'))
|
||||
.forEach(t => t.onclick = tabChange);
|
||||
// tab groups
|
||||
Array.from(document.getElementsByClassName('tab-group'))
|
||||
.forEach(t => new TabGroup(t));
|
||||
|
||||
// handle aria settings on menus
|
||||
Array.from(document.getElementsByClassName('pulldown-menu'))
|
||||
|
@ -46,13 +46,14 @@ function back(e) {
|
|||
history.back();
|
||||
}
|
||||
|
||||
function polling(el) {
|
||||
let delay = 10000 + (Math.random() * 1000);
|
||||
function polling(el, delay) {
|
||||
delay = delay || 10000;
|
||||
delay += (Math.random() * 1000);
|
||||
setTimeout(function() {
|
||||
fetch('/api/updates/' + el.getAttribute('data-poll'))
|
||||
.then(response => response.json())
|
||||
.then(data => updateCountElement(el, data));
|
||||
polling(el);
|
||||
polling(el, delay * 1.25);
|
||||
}, delay, el);
|
||||
}
|
||||
|
||||
|
@ -140,23 +141,6 @@ function selectAll(e) {
|
|||
.forEach(t => t.checked=true);
|
||||
}
|
||||
|
||||
function tabChange(e) {
|
||||
var el = e.currentTarget;
|
||||
var parentElement = el.closest('[role="tablist"]');
|
||||
|
||||
parentElement.querySelectorAll('[aria-selected="true"]')
|
||||
.forEach(t => t.setAttribute("aria-selected", false));
|
||||
el.setAttribute("aria-selected", true);
|
||||
|
||||
parentElement.querySelectorAll('li')
|
||||
.forEach(t => removeClass(t, 'is-active'));
|
||||
addClass(el, 'is-active');
|
||||
|
||||
var tabId = el.getAttribute('data-tab');
|
||||
Array.from(document.getElementsByClassName(el.getAttribute('data-category')))
|
||||
.forEach(t => addRemoveClass(t, 'hidden', t.id != tabId));
|
||||
}
|
||||
|
||||
function toggleMenu(e) {
|
||||
var el = e.currentTarget;
|
||||
var expanded = el.getAttribute('aria-expanded') == 'false';
|
||||
|
@ -202,3 +186,258 @@ function removeClass(el, className) {
|
|||
}
|
||||
el.className = classes.join(' ');
|
||||
}
|
||||
|
||||
/*
|
||||
* The content below is licensed according to the W3C Software License at
|
||||
* https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
|
||||
* Heavily modified to web component by Zach Leatherman
|
||||
* Modified back to vanilla JavaScript with support for Bulma markup and nested tabs by Ned Zimmerman
|
||||
*/
|
||||
class TabGroup {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
|
||||
this.tablist = this.container.querySelector('[role="tablist"]');
|
||||
this.buttons = this.tablist.querySelectorAll('[role="tab"]');
|
||||
this.panels = this.container.querySelectorAll(':scope > [role="tabpanel"]');
|
||||
this.delay = this.determineDelay();
|
||||
|
||||
if(!this.tablist || !this.buttons.length || !this.panels.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.keys = this.keys();
|
||||
this.direction = this.direction();
|
||||
this.initButtons();
|
||||
this.initPanels();
|
||||
}
|
||||
|
||||
keys() {
|
||||
return {
|
||||
end: 35,
|
||||
home: 36,
|
||||
left: 37,
|
||||
up: 38,
|
||||
right: 39,
|
||||
down: 40
|
||||
};
|
||||
}
|
||||
|
||||
// Add or substract depending on key pressed
|
||||
direction() {
|
||||
return {
|
||||
37: -1,
|
||||
38: -1,
|
||||
39: 1,
|
||||
40: 1
|
||||
};
|
||||
}
|
||||
|
||||
initButtons() {
|
||||
let count = 0;
|
||||
for(let button of this.buttons) {
|
||||
let isSelected = button.getAttribute("aria-selected") === "true";
|
||||
button.setAttribute("tabindex", isSelected ? "0" : "-1");
|
||||
|
||||
button.addEventListener('click', this.clickEventListener.bind(this));
|
||||
button.addEventListener('keydown', this.keydownEventListener.bind(this));
|
||||
button.addEventListener('keyup', this.keyupEventListener.bind(this));
|
||||
|
||||
button.index = count++;
|
||||
}
|
||||
}
|
||||
|
||||
initPanels() {
|
||||
let selectedPanelId = this.tablist.querySelector('[role="tab"][aria-selected="true"]').getAttribute("aria-controls");
|
||||
for(let panel of this.panels) {
|
||||
if(panel.getAttribute("id") !== selectedPanelId) {
|
||||
panel.setAttribute("hidden", "");
|
||||
}
|
||||
panel.setAttribute("tabindex", "0");
|
||||
}
|
||||
}
|
||||
|
||||
clickEventListener(event) {
|
||||
let button = event.target.closest('a');
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
this.activateTab(button, false);
|
||||
}
|
||||
|
||||
// Handle keydown on tabs
|
||||
keydownEventListener(event) {
|
||||
var key = event.keyCode;
|
||||
|
||||
switch (key) {
|
||||
case this.keys.end:
|
||||
event.preventDefault();
|
||||
// Activate last tab
|
||||
this.activateTab(this.buttons[this.buttons.length - 1]);
|
||||
break;
|
||||
case this.keys.home:
|
||||
event.preventDefault();
|
||||
// Activate first tab
|
||||
this.activateTab(this.buttons[0]);
|
||||
break;
|
||||
|
||||
// Up and down are in keydown
|
||||
// because we need to prevent page scroll >:)
|
||||
case this.keys.up:
|
||||
case this.keys.down:
|
||||
this.determineOrientation(event);
|
||||
break;
|
||||
};
|
||||
}
|
||||
|
||||
// Handle keyup on tabs
|
||||
keyupEventListener(event) {
|
||||
var key = event.keyCode;
|
||||
|
||||
switch (key) {
|
||||
case this.keys.left:
|
||||
case this.keys.right:
|
||||
this.determineOrientation(event);
|
||||
break;
|
||||
};
|
||||
}
|
||||
|
||||
// When a tablist’s aria-orientation is set to vertical,
|
||||
// only up and down arrow should function.
|
||||
// In all other cases only left and right arrow function.
|
||||
determineOrientation(event) {
|
||||
var key = event.keyCode;
|
||||
var vertical = this.tablist.getAttribute('aria-orientation') == 'vertical';
|
||||
var proceed = false;
|
||||
|
||||
if (vertical) {
|
||||
if (key === this.keys.up || key === this.keys.down) {
|
||||
event.preventDefault();
|
||||
proceed = true;
|
||||
};
|
||||
}
|
||||
else {
|
||||
if (key === this.keys.left || key === this.keys.right) {
|
||||
proceed = true;
|
||||
};
|
||||
};
|
||||
|
||||
if (proceed) {
|
||||
this.switchTabOnArrowPress(event);
|
||||
};
|
||||
}
|
||||
|
||||
// Either focus the next, previous, first, or last tab
|
||||
// depending on key pressed
|
||||
switchTabOnArrowPress(event) {
|
||||
var pressed = event.keyCode;
|
||||
|
||||
for (let button of this.buttons) {
|
||||
button.addEventListener('focus', this.focusEventHandler.bind(this));
|
||||
};
|
||||
|
||||
if (this.direction[pressed]) {
|
||||
var target = event.target;
|
||||
if (target.index !== undefined) {
|
||||
if (this.buttons[target.index + this.direction[pressed]]) {
|
||||
this.buttons[target.index + this.direction[pressed]].focus();
|
||||
}
|
||||
else if (pressed === this.keys.left || pressed === this.keys.up) {
|
||||
this.focusLastTab();
|
||||
}
|
||||
else if (pressed === this.keys.right || pressed == this.keys.down) {
|
||||
this.focusFirstTab();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Activates any given tab panel
|
||||
activateTab (tab, setFocus) {
|
||||
if(tab.getAttribute("role") !== "tab") {
|
||||
tab = tab.closest('[role="tab"]');
|
||||
}
|
||||
|
||||
setFocus = setFocus || true;
|
||||
|
||||
// Deactivate all other tabs
|
||||
this.deactivateTabs();
|
||||
|
||||
// Remove tabindex attribute
|
||||
tab.removeAttribute('tabindex');
|
||||
|
||||
// Set the tab as selected
|
||||
tab.setAttribute('aria-selected', 'true');
|
||||
|
||||
// Give the tab parent an is-active class
|
||||
tab.parentNode.classList.add('is-active');
|
||||
|
||||
// Get the value of aria-controls (which is an ID)
|
||||
var controls = tab.getAttribute('aria-controls');
|
||||
|
||||
// Remove hidden attribute from tab panel to make it visible
|
||||
document.getElementById(controls).removeAttribute('hidden');
|
||||
|
||||
// Set focus when required
|
||||
if (setFocus) {
|
||||
tab.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Deactivate all tabs and tab panels
|
||||
deactivateTabs() {
|
||||
for (let button of this.buttons) {
|
||||
button.parentNode.classList.remove('is-active');
|
||||
button.setAttribute('tabindex', '-1');
|
||||
button.setAttribute('aria-selected', 'false');
|
||||
button.removeEventListener('focus', this.focusEventHandler.bind(this));
|
||||
}
|
||||
|
||||
for (let panel of this.panels) {
|
||||
panel.setAttribute('hidden', 'hidden');
|
||||
}
|
||||
}
|
||||
|
||||
focusFirstTab() {
|
||||
this.buttons[0].focus();
|
||||
}
|
||||
|
||||
focusLastTab() {
|
||||
this.buttons[this.buttons.length - 1].focus();
|
||||
}
|
||||
|
||||
// Determine whether there should be a delay
|
||||
// when user navigates with the arrow keys
|
||||
determineDelay() {
|
||||
var hasDelay = this.tablist.hasAttribute('data-delay');
|
||||
var delay = 0;
|
||||
|
||||
if (hasDelay) {
|
||||
var delayValue = this.tablist.getAttribute('data-delay');
|
||||
if (delayValue) {
|
||||
delay = delayValue;
|
||||
}
|
||||
else {
|
||||
// If no value is specified, default to 300ms
|
||||
delay = 300;
|
||||
};
|
||||
};
|
||||
|
||||
return delay;
|
||||
}
|
||||
|
||||
focusEventHandler(event) {
|
||||
var target = event.target;
|
||||
|
||||
setTimeout(this.checkTabFocus.bind(this), this.delay, target);
|
||||
};
|
||||
|
||||
// Only activate tab on focus if it still has focus after the delay
|
||||
checkTabFocus(target) {
|
||||
let focused = document.activeElement;
|
||||
|
||||
if (target === focused) {
|
||||
this.activateTab(target, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% block content %}
|
||||
|
||||
<div class="columns">
|
||||
<div class="column block">
|
||||
{% include 'snippets/about.html' %}
|
||||
</div>
|
||||
|
||||
<div class="column block">
|
||||
<h2 class="title">Code of Conduct</h2>
|
||||
<div class="content">
|
||||
{{ site.code_of_conduct | safe }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,16 +1,20 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load bookwyrm_tags %}
|
||||
|
||||
{% block title %}{{ author.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="block">
|
||||
<div class="columns">
|
||||
<div class="columns is-mobile">
|
||||
<div class="column">
|
||||
<h1 class="title">{{ author.name }}</h1>
|
||||
</div>
|
||||
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
|
||||
<div class="column is-narrow">
|
||||
<a href="{{ author.local_path }}/edit">
|
||||
<span class="icon icon-pencil" title="Edit Author">
|
||||
<span class="is-sr-only">Edit Author</span>
|
||||
<span class="icon icon-pencil" title="{% trans 'Edit Author' %}">
|
||||
<span class="is-sr-only">{% trans "Edit Author" %}</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
@ -25,12 +29,12 @@
|
|||
</p>
|
||||
{% endif %}
|
||||
{% if author.wikipedia_link %}
|
||||
<p><a href="{{ author.wikipedia_link }}" rel=”noopener” target="_blank">Wikipedia</a></p>
|
||||
<p><a href="{{ author.wikipedia_link }}" rel=”noopener” target="_blank">{% trans "Wikipedia" %}</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
<h3 class="title is-4">Books by {{ author.name }}</h3>
|
||||
<h3 class="title is-4">{% blocktrans with name=author.name %}Books by {{ name }}{% endblocktrans %}</h3>
|
||||
{% include 'snippets/book_tiles.html' with books=books %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load bookwyrm_tags %}
|
||||
{% load humanize %}
|
||||
{% block content %}
|
||||
|
||||
{% block title %}{{ book.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="block">
|
||||
<div class="columns">
|
||||
<div class="columns is-mobile">
|
||||
<div class="column">
|
||||
<h1 class="title">
|
||||
{{ book.title }}{% if book.subtitle %}:
|
||||
|
@ -15,7 +18,7 @@
|
|||
</h1>
|
||||
{% if book.authors %}
|
||||
<h2 class="subtitle">
|
||||
by {% include 'snippets/authors.html' with book=book %}
|
||||
{% trans "by" %} {% include 'snippets/authors.html' with book=book %}
|
||||
</h2>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
@ -23,8 +26,8 @@
|
|||
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
|
||||
<div class="column is-narrow">
|
||||
<a href="{{ book.id }}/edit">
|
||||
<span class="icon icon-pencil" title="Edit Book">
|
||||
<span class="is-sr-only">Edit Book</span>
|
||||
<span class="icon icon-pencil" title="{% trans "Edit Book" %}">
|
||||
<span class="is-sr-only">{% trans "Edit Book" %}</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
@ -35,33 +38,17 @@
|
|||
<div class="column is-narrow">
|
||||
{% include 'snippets/book_cover.html' with book=book size=large %}
|
||||
{% include 'snippets/rate_action.html' with user=request.user book=book %}
|
||||
{% include 'snippets/shelve_button.html' %}
|
||||
{% include 'snippets/shelve_button/shelve_button.html' %}
|
||||
|
||||
{% if request.user.is_authenticated and not book.cover %}
|
||||
<div class="box p-2">
|
||||
<h3 class="title is-6 mb-1">Add cover</h3>
|
||||
<h3 class="title is-6 mb-1">{% trans "Add cover" %}</h3>
|
||||
<form name="add-cover" method="POST" action="/upload-cover/{{ book.id }}" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
<div class="file is-small mb-1">
|
||||
<label class="file-label">
|
||||
<input class="file-input" type="file" name="cover" accept="image/*" enctype="multipart/form-data" id="id_cover" required>
|
||||
<span class="file-cta">
|
||||
<span class="file-icon">
|
||||
<i class="fas fa-upload"></i>
|
||||
</span>
|
||||
<span class="file-label">
|
||||
Choose file...
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button is-small is-primary" type="submit">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="label">
|
||||
<input type="file" name="cover" accept="image/*" enctype="multipart/form-data" id="id_cover" required>
|
||||
</label>
|
||||
<button class="button is-small is-primary" type="submit">{% trans "Add" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
@ -70,56 +57,66 @@
|
|||
<dl>
|
||||
{% if book.isbn_13 %}
|
||||
<div class="is-flex is-justify-content-space-between">
|
||||
<dt>ISBN:</dt>
|
||||
<dt>{% trans "ISBN:" %}</dt>
|
||||
<dd>{{ book.isbn_13 }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if book.oclc_number %}
|
||||
<div class="is-flex is-justify-content-space-between">
|
||||
<dt>OCLC Number:</dt>
|
||||
<dt>{% trans "OCLC Number:" %}</dt>
|
||||
<dd>{{ book.oclc_number }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if book.asin %}
|
||||
<div class="is-flex is-justify-content-space-between">
|
||||
<dt>ASIN:</dt>
|
||||
<dt>{% trans "ASIN:" %}</dt>
|
||||
<dd>{{ book.asin }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
<p>
|
||||
{% if book.physical_format %}{{ book.physical_format | title }}{% if book.pages %},<br>{% endif %}{% endif %}
|
||||
{% if book.pages %}{{ book.pages }} pages{% endif %}
|
||||
{% if book.physical_format and not book.pages %}
|
||||
{{ book.physical_format | title }}
|
||||
{% elif book.physical_format and book.pages %}
|
||||
{% blocktrans with format=book.physical_format|title pages=book.pages %}{{ format }}, {{ pages }} pages{% endblocktrans %}
|
||||
{% elif book.pages %}
|
||||
{% blocktrans with pages=book.pages %}{{ pages }} pages{% endblocktrans %}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% if book.openlibrary_key %}
|
||||
<p><a href="https://openlibrary.org/books/{{ book.openlibrary_key }}" target="_blank" rel="noopener">View on OpenLibrary</a></p>
|
||||
<p><a href="https://openlibrary.org/books/{{ book.openlibrary_key }}" target="_blank" rel="noopener">{% trans "View on OpenLibrary" %}</a></p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<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 %}
|
||||
{% blocktrans count counter=review_count %}({{ review_count }} review){% plural %}({{ review_count }} reviews){% endblocktrans %}
|
||||
</h3>
|
||||
|
||||
{% include 'snippets/trimmed_text.html' with full=book|book_description %}
|
||||
|
||||
{% if request.user.is_authenticated and perms.bookwyrm.edit_book and not book|book_description %}
|
||||
{% include 'snippets/toggle/open_button.html' with text="Add description" controls_text="add-description" controls_uid=book.id focus="id_description" hide_active=True id="hide-description" %}
|
||||
{% trans 'Add Description' as button_text %}
|
||||
{% include 'snippets/toggle/open_button.html' with text=button_text controls_text="add-description" controls_uid=book.id focus="id_description" hide_active=True id="hide-description" %}
|
||||
|
||||
<div class="box hidden" id="add-description-{{ book.id }}">
|
||||
<form name="add-description" method="POST" action="/add-description/{{ book.id }}">
|
||||
{% csrf_token %}
|
||||
<p class="fields is-grouped">
|
||||
<label class="label"for="id_description">Description:</label>
|
||||
<label class="label"for="id_description">{% trans "Description:" %}</label>
|
||||
<textarea name="description" cols="None" rows="None" class="textarea" id="id_description"></textarea>
|
||||
</p>
|
||||
<div class="field">
|
||||
<button class="button is-primary" type="submit">Save</button>
|
||||
{% include 'snippets/toggle/close_button.html' with text="Cancel" controls_text="add-description" controls_uid=book.id hide_inactive=True %}
|
||||
<button class="button is-primary" type="submit">{% trans "Save" %}</button>
|
||||
{% trans "Cancel" as button_text %}
|
||||
{% include 'snippets/toggle/close_button.html' with text=button_text controls_text="add-description" controls_uid=book.id hide_inactive=True %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -127,7 +124,7 @@
|
|||
|
||||
|
||||
{% if book.parent_work.editions.count > 1 %}
|
||||
<p><a href="/book/{{ book.parent_work.id }}/editions">{{ book.parent_work.editions.count }} editions</a></p>
|
||||
<p>{% blocktrans with path=book.parent_work.local_path count=book.parent_work.editions.count %}<a href="{{ path }}/editions">{{ count }} editions</a>{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
@ -135,13 +132,13 @@
|
|||
<div class="block">
|
||||
{% for shelf in user_shelves %}
|
||||
<p>
|
||||
This edition is on your <a href="/user/{{ user.localname }}/shelf/{{ shelf.shelf.identifier }}">{{ shelf.shelf.name }}</a> shelf.
|
||||
{% blocktrans with path=shelf.shelf.local_path shelf_name=shelf.shelf.name %}This edition is on your <a href="{{ path }}">{{ shelf_name }}</a> shelf.{% endblocktrans %}
|
||||
{% include 'snippets/shelf_selector.html' with current=shelf.shelf %}
|
||||
</p>
|
||||
{% endfor %}
|
||||
{% for shelf in other_edition_shelves %}
|
||||
<p>
|
||||
A <a href="/book/{{ shelf.book.id }}">different edition</a> of this book is on your <a href="/user/{{ user.localname }}/shelf/{{ shelf.shelf.identifier }}">{{ shelf.shelf.name }}</a> shelf.
|
||||
{% blocktrans with book_path=shelf.book.local_path shelf_path=shelf.shelf.local_path shelf_name=shelf.shelf.name %}A <a href="{{ book_path }}">different edition</a> of this book is on your <a href="{{ shelf_path }}">{{ shelf_name }}</a> shelf.{% endblocktrans %}
|
||||
{% include 'snippets/switch_edition_button.html' with edition=book %}
|
||||
</p>
|
||||
{% endfor %}
|
||||
|
@ -150,23 +147,25 @@
|
|||
{% if request.user.is_authenticated %}
|
||||
<section class="block">
|
||||
<header class="columns">
|
||||
<h2 class="column title is-5 mb-1">Your reading activity</h2>
|
||||
<h2 class="column title is-5 mb-1">{% trans "Your reading activity" %}</h2>
|
||||
<div class="column is-narrow">
|
||||
{% include 'snippets/toggle/open_button.html' with text="Add read dates" icon="plus" class="is-small" controls_text="add-readthrough" %}
|
||||
{% trans "Add read dates" as button_text %}
|
||||
{% include 'snippets/toggle/open_button.html' with text=button_text icon="plus" class="is-small" controls_text="add-readthrough" %}
|
||||
</div>
|
||||
</header>
|
||||
{% if not readthroughs.exists %}
|
||||
<p>You don't have any reading activity for this book.</p>
|
||||
<p>{% trans "You don't have any reading activity for this book." %}</p>
|
||||
{% endif %}
|
||||
<section class="hidden box" id="add-readthrough">
|
||||
<form name="add-readthrough" action="/create-readthrough" method="post">
|
||||
{% include 'snippets/readthrough_form.html' with readthrough=None %}
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Create</button>
|
||||
<button class="button is-primary" type="submit">{% trans "Create" %}</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
{% include 'snippets/toggle/close_button.html' with text="Cancel" controls_text="add-readthrough" %}
|
||||
{% trans "Cancel" as button_text %}
|
||||
{% include 'snippets/toggle/close_button.html' with text=button_text controls_text="add-readthrough" %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -184,11 +183,11 @@
|
|||
|
||||
<section class="block">
|
||||
<form name="tag" action="/tag/" method="post">
|
||||
<label for="tags" class="is-3">Tags</label>
|
||||
<label for="tags" class="is-3">{% trans "Tags" %}</label>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="book" value="{{ book.id }}">
|
||||
<input id="tags" class="input" type="text" name="name">
|
||||
<button class="button" type="submit">Add tag</button>
|
||||
<button class="button" type="submit">{% trans "Add tag" %}</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
@ -205,7 +204,7 @@
|
|||
<div class="column is-narrow">
|
||||
{% if book.subjects %}
|
||||
<section class="content block">
|
||||
<h2 class="title is-5">Subjects</h2>
|
||||
<h2 class="title is-5">{% trans "Subjects" %}</h2>
|
||||
<ul>
|
||||
{% for subject in book.subjects %}
|
||||
<li>{{ subject }}</li>
|
||||
|
@ -216,7 +215,7 @@
|
|||
|
||||
{% if book.subject_places %}
|
||||
<section class="content block">
|
||||
<h2 class="title is-5">Places</h2>
|
||||
<h2 class="title is-5">{% trans "Places" %}</h2>
|
||||
<ul>
|
||||
{% for place in book.subject_placess %}
|
||||
<li>{{ place }}</li>
|
||||
|
@ -224,6 +223,17 @@
|
|||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if lists.exists %}
|
||||
<section class="content block">
|
||||
<h2 class="title is-5">{% trans "Lists" %}</h2>
|
||||
<ul>
|
||||
{% for list in lists %}
|
||||
<li><a href="{{ list.local_path }}">{{ list.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -231,7 +241,7 @@
|
|||
<div class="block" id="reviews">
|
||||
{% for review in reviews %}
|
||||
<div class="block">
|
||||
{% include 'snippets/status.html' with status=review hide_book=True depth=1 %}
|
||||
{% include 'snippets/status/status.html' with status=review hide_book=True depth=1 %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
|
@ -245,7 +255,7 @@
|
|||
{% include 'snippets/username.html' with user=rating.user %}
|
||||
</div>
|
||||
<div class="field is-grouped mb-0">
|
||||
<div>rated it</div>
|
||||
<div>{% trans "rated it" %}</div>
|
||||
{% include 'snippets/stars.html' with rating=rating.rating %}
|
||||
</div>
|
||||
<div>
|
||||
|
@ -262,4 +272,3 @@
|
|||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
{% block dropdown-trigger %}{% endblock %}
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<ul class="dropdown-content" role="menu" id="menu-options-{{ book.id }}">
|
||||
<ul class="dropdown-content" role="menu" id="menu-options-{{ uuid }}">
|
||||
{% block dropdown-list %}{% endblock %}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
{% load i18n %}
|
||||
<section class="card hidden {{ class }}" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}">
|
||||
<header class="card-header has-background-white-ter">
|
||||
<h2 class="card-header-title" tabindex="0" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}-header">
|
||||
{% block header %}{% endblock %}
|
||||
</h2>
|
||||
<span class="card-header-icon">
|
||||
{% include 'snippets/toggle/toggle_button.html' with label="Close" class="delete" nonbutton=True controls_text=controls_text %}
|
||||
{% trans "Close" as button_text %}
|
||||
{% include 'snippets/toggle/toggle_button.html' with label=button_text class="delete" nonbutton=True controls_text=controls_text %}
|
||||
</span>
|
||||
</header>
|
||||
<section class="card-content content">
|
||||
|
|
|
@ -1,22 +1,30 @@
|
|||
<div class="modal hidden" id="{{ controls_text }}-{{ controls_uid }}">
|
||||
<div
|
||||
role="dialog"
|
||||
class="modal hidden"
|
||||
id="{{ controls_text }}-{{ controls_uid }}"
|
||||
aria-labelledby="modal-card-title-{{ controls_text }}-{{ controls_uid }}"
|
||||
aria-modal="true"
|
||||
>
|
||||
{# @todo Implement focus traps to prevent tabbing out of the modal. #}
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head" tabindex="0" id="modal-title-{{ controls_text }}-{{ controls_uid }}">
|
||||
<h2 class="modal-card-title">
|
||||
<h2 class="modal-card-title" id="modal-card-title-{{ controls_text }}-{{ controls_uid }}">
|
||||
{% block modal-title %}{% endblock %}
|
||||
</h2>
|
||||
{% include 'snippets/toggle/toggle_button.html' with label="close" class="delete" nonbutton=True %}
|
||||
</header>
|
||||
{% block modal-form-open %}{% endblock %}
|
||||
{% if not no_body %}
|
||||
<section class="modal-card-body">
|
||||
{% block modal-body %}{% endblock %}
|
||||
</section>
|
||||
{% endif %}
|
||||
<footer class="modal-card-foot">
|
||||
{% block modal-footer %}{% endblock %}
|
||||
</footer>
|
||||
{% block modal-form-close %}{% endblock %}
|
||||
</div>
|
||||
<label class="modal-close is-large" for="{{ controls_text }}-{{ readthrough.id }}" aria-label="close"></label>
|
||||
{% include 'snippets/toggle/toggle_button.html' with label="close" class="modal-close is-large" nonbutton=True %}
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,103 +0,0 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% block content %}
|
||||
|
||||
{% if not request.user.is_authenticated %}
|
||||
<header class="block has-text-centered">
|
||||
<h1 class="title">{{ site.name }}</h1>
|
||||
<h2 class="subtitle">{{ site.instance_tagline }}</h2>
|
||||
</header>
|
||||
|
||||
<section class="level is-mobile">
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title has-text-weight-normal"><span class="icon icon-graphic-paperplane"></span></p>
|
||||
<p class="heading">Decentralized</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title has-text-weight-normal"><span class="icon icon-graphic-heart"></span></p>
|
||||
<p class="heading">Friendly</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title has-text-weight-normal"><span class="icon icon-graphic-banknote"></span></p>
|
||||
<p class="heading">Anti-Corporate</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tile is-ancestor">
|
||||
<div class="tile is-7 is-parent">
|
||||
<div class="tile is-child box">
|
||||
{% include 'snippets/about.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-5 is-parent">
|
||||
<div class="tile is-child box has-background-primary-light content">
|
||||
{% if site.allow_registration %}
|
||||
<h2 class="title">Join {{ site.name }}</h2>
|
||||
<form name="register" method="post" action="/register">
|
||||
{% include 'snippets/register_form.html' %}
|
||||
</form>
|
||||
{% else %}
|
||||
<h2 class="title">This instance is closed</h2>
|
||||
<p>{{ site.registration_closed_text | safe}}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% else %}
|
||||
<div class="block">
|
||||
<h1 class="title has-text-centered">Discover</h1>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="block is-hidden-tablet">
|
||||
<h2 class="title has-text-centered">Recent Books</h2>
|
||||
</div>
|
||||
|
||||
<section class="tile is-ancestor">
|
||||
<div class="tile is-vertical">
|
||||
<div class="tile is-parent">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
{% include 'snippets/discover/large-book.html' with book=books.0 %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<div class="tile is-parent is-6">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
{% include 'snippets/discover/small-book.html' with book=books.1 %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-parent is-6">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
{% include 'snippets/discover/small-book.html' with book=books.2 %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-vertical">
|
||||
<div class="tile">
|
||||
<div class="tile is-parent is-6">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
{% include 'snippets/discover/small-book.html' with book=books.3 %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-parent is-6">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
{% include 'snippets/discover/small-book.html' with book=books.4 %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-parent">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
{% include 'snippets/discover/large-book.html' with book=books.5 %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
37
bookwyrm/templates/discover/about.html
Normal file
37
bookwyrm/templates/discover/about.html
Normal file
|
@ -0,0 +1,37 @@
|
|||
{% extends 'discover/landing_layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block panel %}
|
||||
<div class="block columns mt-4">
|
||||
<nav class="menu column is-one-quarter">
|
||||
<h2 class="menu-label">{% blocktrans with site_name=site.name %}About {{ site_name }}{% endblocktrans %}</h2>
|
||||
<ul class="menu-list">
|
||||
<li>
|
||||
<a href="#coc">{% trans "Code of Conduct" %}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#privacy">{% trans "Privacy Policy" %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="column content">
|
||||
<div class="block" id="coc">
|
||||
<h2 class="title">{% trans "Code of Conduct" %}</h2>
|
||||
<div class="content">
|
||||
{{ site.code_of_conduct | safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr aria-hidden="true">
|
||||
|
||||
<div class="block" id="privacy">
|
||||
<h2 class="title">{% trans "Privacy Policy" %}</h2>
|
||||
<div class="content">
|
||||
{{ site.privacy_policy | safe }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
50
bookwyrm/templates/discover/discover.html
Normal file
50
bookwyrm/templates/discover/discover.html
Normal file
|
@ -0,0 +1,50 @@
|
|||
{% extends 'discover/landing_layout.html' %}
|
||||
{% load i18n %}
|
||||
{% block panel %}
|
||||
|
||||
<div class="block is-hidden-tablet">
|
||||
<h2 class="title has-text-centered">{% trans "Recent Books" %}</h2>
|
||||
</div>
|
||||
|
||||
<section class="tile is-ancestor">
|
||||
<div class="tile is-vertical">
|
||||
<div class="tile is-parent">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
{% include 'discover/large-book.html' with book=books.0 %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<div class="tile is-parent is-6">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
{% include 'discover/small-book.html' with book=books.1 %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-parent is-6">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
{% include 'discover/small-book.html' with book=books.2 %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-vertical">
|
||||
<div class="tile">
|
||||
<div class="tile is-parent is-6">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
{% include 'discover/small-book.html' with book=books.3 %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-parent is-6">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
{% include 'discover/small-book.html' with book=books.4 %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-parent">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
{% include 'discover/large-book.html' with book=books.5 %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
69
bookwyrm/templates/discover/landing_layout.html
Normal file
69
bookwyrm/templates/discover/landing_layout.html
Normal file
|
@ -0,0 +1,69 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load bookwyrm_tags %}
|
||||
|
||||
{% block title %}{% trans "Welcome" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="block has-text-centered">
|
||||
<h1 class="title">{{ site.name }}</h1>
|
||||
<h2 class="subtitle">{{ site.instance_tagline }}</h2>
|
||||
</header>
|
||||
|
||||
<section class="level is-mobile">
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title has-text-weight-normal"><span class="icon icon-graphic-paperplane"></span></p>
|
||||
<p class="heading">{% trans "Decentralized" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title has-text-weight-normal"><span class="icon icon-graphic-heart"></span></p>
|
||||
<p class="heading">{% trans "Friendly" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title has-text-weight-normal"><span class="icon icon-graphic-banknote"></span></p>
|
||||
<p class="heading">{% trans "Anti-Corporate" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tile is-ancestor">
|
||||
<div class="tile is-7 is-parent">
|
||||
<div class="tile is-child box">
|
||||
{% include 'snippets/about.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-5 is-parent">
|
||||
{% if not request.user.is_authenticated %}
|
||||
<div class="tile is-child box has-background-primary-light content">
|
||||
{% if site.allow_registration %}
|
||||
<h2 class="title">{% blocktrans with name=site.name %}Join {{ name }}{% endblocktrans %}</h2>
|
||||
<form name="register" method="post" action="/register">
|
||||
{% include 'snippets/register_form.html' %}
|
||||
</form>
|
||||
{% else %}
|
||||
<h2 class="title">{% trans "This instance is closed" %}</h2>
|
||||
<p>{{ site.registration_closed_text | safe}}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="tile is-child box has-background-white-bis">
|
||||
<h2 class="title is-4">{% trans "Your Account" %}</h2>
|
||||
{% include 'user/user_preview.html' with user=request.user %}
|
||||
{% if request.user.summary %}
|
||||
<div class="box content">
|
||||
{{ request.user.summary | to_markdown | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% block panel %}{% endblock %}
|
||||
|
||||
{% endblock %}
|
|
@ -1,14 +1,15 @@
|
|||
{% load bookwyrm_tags %}
|
||||
{% load i18n %}
|
||||
{% if book %}
|
||||
<div class="columns">
|
||||
<div class="column is-narrow">
|
||||
{% include 'snippets/book_cover.html' with book=book size="large" %}
|
||||
{% include 'snippets/stars.html' with rating=ratings|dict_key:book.id %}
|
||||
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book size="large" %}</a>
|
||||
{% include 'snippets/stars.html' with rating=book|rating:request.user %}
|
||||
</div>
|
||||
<div class="column">
|
||||
<h3 class="title is-5"><a href="/book/{{ book.id }}">{{ book.title }}</a></h3>
|
||||
{% if book.authors %}
|
||||
<p class="subtitle is-5">by {% include 'snippets/authors.html' with book=book %}</p>
|
||||
<p class="subtitle is-5">{% trans "by" %} {% include 'snippets/authors.html' with book=book %}</p>
|
||||
{% endif %}
|
||||
{% if book|book_description %}
|
||||
<blockquote class="content">{{ book|book_description|to_markdown|safe|truncatewords_html:50 }}</blockquote>
|
12
bookwyrm/templates/discover/small-book.html
Normal file
12
bookwyrm/templates/discover/small-book.html
Normal file
|
@ -0,0 +1,12 @@
|
|||
{% load bookwyrm_tags %}
|
||||
{% load i18n %}
|
||||
{% if book %}
|
||||
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book %}</a>
|
||||
{% include 'snippets/stars.html' with rating=book|rating:request.user %}
|
||||
|
||||
<h3 class="title is-6"><a href="/book/{{ book.id }}">{{ book.title }}</a></h3>
|
||||
{% if book.authors %}
|
||||
<p class="subtitle is-6">{% trans "by" %} {% include 'snippets/authors.html' with book=book %}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
|
@ -1,14 +1,18 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load humanize %}
|
||||
|
||||
{% block title %}{% trans "Edit Author" %}: {{ author.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="block">
|
||||
<h1 class="title level-left">
|
||||
Edit "{{ author.name }}"
|
||||
</h1>
|
||||
<div>
|
||||
<p>Added: {{ author.created_date | naturaltime }}</p>
|
||||
<p>Updated: {{ author.updated_date | naturaltime }}</p>
|
||||
<p>Last edited by: <a href="{{ author.last_edited_by.remote_id }}">{{ author.last_edited_by.display_name }}</a></p>
|
||||
<p>{% trans "Added:" %} {{ author.created_date | naturaltime }}</p>
|
||||
<p>{% trans "Updated:" %} {{ author.updated_date | naturaltime }}</p>
|
||||
<p>{% trans "Last edited by:" %} <a href="{{ author.last_edited_by.remote_id }}">{{ author.last_edited_by.display_name }}</a></p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
@ -24,45 +28,45 @@
|
|||
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<h2 class="title is-4">Metadata</h2>
|
||||
<p><label class="label" for="id_name">Name:</label> {{ form.name }}</p>
|
||||
<h2 class="title is-4">{% trans "Metadata" %}</h2>
|
||||
<p><label class="label" for="id_name">{% trans "Name:" %}</label> {{ form.name }}</p>
|
||||
{% for error in form.name.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
<p><label class="label" for="id_bio">Bio:</label> {{ form.bio }}</p>
|
||||
<p><label class="label" for="id_bio">{% trans "Bio:" %}</label> {{ form.bio }}</p>
|
||||
{% for error in form.bio.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
<p><label class="label" for="id_wikipedia_link">Wikipedia link:</label> {{ form.wikipedia_link }}</p>
|
||||
<p><label class="label" for="id_wikipedia_link">{% trans "Wikipedia link:" %}</label> {{ form.wikipedia_link }}</p>
|
||||
{% for error in form.wikipedia_link.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
<p><label class="label" for="id_born">Birth date:</label> {{ form.born }}</p>
|
||||
<p><label class="label" for="id_born">{% trans "Birth date:" %}</label> {{ form.born }}</p>
|
||||
{% for error in form.born.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
<p><label class="label" for="id_died">Death date:</label> {{ form.died }}</p>
|
||||
<p><label class="label" for="id_died">{% trans "Death date:" %}</label> {{ form.died }}</p>
|
||||
{% for error in form.died.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="column">
|
||||
<h2 class="title is-4">Author Identifiers</h2>
|
||||
<p><label class="label" for="id_openlibrary_key">Openlibrary key:</label> {{ form.openlibrary_key }}</p>
|
||||
<h2 class="title is-4">{% trans "Author Identifiers" %}</h2>
|
||||
<p><label class="label" for="id_openlibrary_key">{% trans "Openlibrary key:" %}</label> {{ form.openlibrary_key }}</p>
|
||||
{% for error in form.openlibrary_key.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
<p><label class="label" for="id_librarything_key">Librarything key:</label> {{ form.librarything_key }}</p>
|
||||
<p><label class="label" for="id_librarything_key">{% trans "Librarything key:" %}</label> {{ form.librarything_key }}</p>
|
||||
{% for error in form.librarything_key.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
<p><label class="label" for="id_goodreads_key">Goodreads key:</label> {{ form.goodreads_key }}</p>
|
||||
<p><label class="label" for="id_goodreads_key">{% trans "Goodreads key:" %}</label> {{ form.goodreads_key }}</p>
|
||||
{% for error in form.goodreads_key.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
@ -71,10 +75,9 @@
|
|||
</div>
|
||||
|
||||
<div class="block">
|
||||
<button class="button is-primary" type="submit">Save</button>
|
||||
<a class="button" href="/author/{{ author.id }}">Cancel</a>
|
||||
<button class="button is-primary" type="submit">{% trans "Save" %}</button>
|
||||
<a class="button" href="/author/{{ author.id }}">{% trans "Cancel" %}</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
@ -1,14 +1,18 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load humanize %}
|
||||
|
||||
{% block title %}{% trans "Edit Book" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="block">
|
||||
<h1 class="title level-left">
|
||||
Edit "{{ book.title }}"
|
||||
</h1>
|
||||
<div>
|
||||
<p>Added: {{ book.created_date | naturaltime }}</p>
|
||||
<p>Updated: {{ book.updated_date | naturaltime }}</p>
|
||||
<p>Last edited by: <a href="{{ book.last_edited_by.remote_id }}">{{ book.last_edited_by.display_name }}</a></p>
|
||||
<p>{% trans "Added:" %} {{ book.created_date | naturaltime }}</p>
|
||||
<p>{% trans "Updated:" %} {{ book.updated_date | naturaltime }}</p>
|
||||
<p>{% trans "Last edited by:" %} <a href="{{ book.last_edited_by.remote_id }}">{{ book.last_edited_by.display_name }}</a></p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
@ -23,32 +27,32 @@
|
|||
<input type="hidden" name="last_edited_by" value="{{ request.user.id }}">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<h2 class="title is-4">Metadata</h2>
|
||||
<p class="fields is-grouped"><label class="label" for="id_title">Title:</label> {{ form.title }} </p>
|
||||
<h2 class="title is-4">{% trans "Metadata" %}</h2>
|
||||
<p class="fields is-grouped"><label class="label" for="id_title">{% trans "Title:" %}</label> {{ form.title }} </p>
|
||||
{% for error in form.title.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
<p class="fields is-grouped"><label class="label" for="id_subtitle">Subtitle:</label> {{ form.subtitle }} </p>
|
||||
<p class="fields is-grouped"><label class="label" for="id_subtitle">{% trans "Subtitle:" %}</label> {{ form.subtitle }} </p>
|
||||
{% for error in form.subtitle.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
<p class="fields is-grouped"><label class="label" for="id_description">Description:</label> {{ form.description }} </p>
|
||||
<p class="fields is-grouped"><label class="label" for="id_description">{% trans "Description:" %}</label> {{ form.description }} </p>
|
||||
{% for error in form.description.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
<p class="fields is-grouped"><label class="label" for="id_series">Series:</label> {{ form.series }} </p>
|
||||
<p class="fields is-grouped"><label class="label" for="id_series">{% trans "Series:" %}</label> {{ form.series }} </p>
|
||||
{% for error in form.series.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
<p class="fields is-grouped"><label class="label" for="id_series_number">Series number:</label> {{ form.series_number }} </p>
|
||||
<p class="fields is-grouped"><label class="label" for="id_series_number">{% trans "Series number:" %}</label> {{ form.series_number }} </p>
|
||||
{% for error in form.series_number.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
<p class="fields is-grouped"><label class="label" for="id_first_published_date">First published date:</label> {{ form.first_published_date }} </p>
|
||||
<p class="fields is-grouped"><label class="label" for="id_first_published_date">{% trans "First published date:" %}</label> {{ form.first_published_date }} </p>
|
||||
{% for error in form.first_published_date.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
<p class="fields is-grouped"><label class="label" for="id_published_date">Published date:</label> {{ form.published_date }} </p>
|
||||
<p class="fields is-grouped"><label class="label" for="id_published_date">{% trans "Published date:" %}</label> {{ form.published_date }} </p>
|
||||
{% for error in form.published_date.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
@ -61,8 +65,8 @@
|
|||
</div>
|
||||
<div class="column is-narrow">
|
||||
<div class="block">
|
||||
<h2 class="title is-4">Cover</h2>
|
||||
<p>{{ form.cover }} </p>
|
||||
<h2 class="title is-4">{% trans "Cover" %}</h2>
|
||||
<p>{{ form.cover }}</p>
|
||||
{% for error in form.cover.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
@ -71,8 +75,8 @@
|
|||
</div>
|
||||
|
||||
<div class="block">
|
||||
<h2 class="title is-4">Physical Properties</h2>
|
||||
<p class="fields is-grouped"><label class="label" for="id_physical_format">Format:</label> {{ form.physical_format }} </p>
|
||||
<h2 class="title is-4">{% trans "Physical Properties" %}</h2>
|
||||
<p class="fields is-grouped"><label class="label" for="id_physical_format">{% trans "Format:" %}</label> {{ form.physical_format }} </p>
|
||||
{% for error in form.physical_format.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
@ -80,31 +84,31 @@
|
|||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
<p class="fields is-grouped"><label class="label" for="id_pages">Pages:</label> {{ form.pages }} </p>
|
||||
<p class="fields is-grouped"><label class="label" for="id_pages">{% trans "Pages:" %}</label> {{ form.pages }} </p>
|
||||
{% for error in form.pages.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
<h2 class="title is-4">Book Identifiers</h2>
|
||||
<p class="fields is-grouped"><label class="label" for="id_isbn_13">ISBN 13:</label> {{ form.isbn_13 }} </p>
|
||||
<h2 class="title is-4">{% trans "Book Identifiers" %}</h2>
|
||||
<p class="fields is-grouped"><label class="label" for="id_isbn_13">{% trans "ISBN 13:" %}</label> {{ form.isbn_13 }} </p>
|
||||
{% for error in form.isbn_13.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
<p class="fields is-grouped"><label class="label" for="id_isbn_10">ISBN 10:</label> {{ form.isbn_10 }} </p>
|
||||
<p class="fields is-grouped"><label class="label" for="id_isbn_10">{% trans "ISBN 10:" %}</label> {{ form.isbn_10 }} </p>
|
||||
{% for error in form.isbn_10.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
<p class="fields is-grouped"><label class="label" for="id_openlibrary_key">Openlibrary key:</label> {{ form.openlibrary_key }} </p>
|
||||
<p class="fields is-grouped"><label class="label" for="id_openlibrary_key">{% trans "Openlibrary key:" %}</label> {{ form.openlibrary_key }} </p>
|
||||
{% for error in form.openlibrary_key.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
<p class="fields is-grouped"><label class="label" for="id_librarything_key">OCLC Number:</label> {{ form.oclc_number }} </p>
|
||||
<p class="fields is-grouped"><label class="label" for="id_librarything_key">{% trans "OCLC Number:" %}</label> {{ form.oclc_number }} </p>
|
||||
{% for error in form.oclc_number.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
<p class="fields is-grouped"><label class="label" for="id_asin">ASIN:</label> {{ form.asin }} </p>
|
||||
<p class="fields is-grouped"><label class="label" for="id_asin">{% trans "ASIN:" %}</label> {{ form.asin }} </p>
|
||||
{% for error in form.ASIN.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
@ -113,8 +117,8 @@
|
|||
</div>
|
||||
|
||||
<div class="block">
|
||||
<button class="button is-primary" type="submit">Save</button>
|
||||
<a class="button" href="/book/{{ book.id }}">Cancel</a>
|
||||
<button class="button is-primary" type="submit">{% trans "Save" %}</button>
|
||||
<a class="button" href="/book/{{ book.id }}">{% trans "Cancel" %}</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load bookwyrm_tags %}
|
||||
|
||||
{% block title %}{% blocktrans with book_title=work.title %}Editions of {{ book_title }}{% endblocktrans %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="block">
|
||||
<h1 class="title">Editions of <a href="/book/{{ work.id }}">"{{ work.title }}"</a></h1>
|
||||
<h1 class="title">{% blocktrans with path=work.local_path work_title=work.title %}Editions of <a href="{{ work_path }}">"{{ work_title }}"</a>{% endblocktrans %}</h1>
|
||||
|
||||
{% include 'snippets/book_tiles.html' with books=editions %}
|
||||
</div>
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Oops!" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="block">
|
||||
<h1 class="title">Server Error</h1>
|
||||
<p>Something went wrong! Sorry about that.</p>
|
||||
<h1 class="title">{% trans "Server Error" %}</h1>
|
||||
<p>{% trans "Something went wrong! Sorry about that." %}</p>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
@ -1,9 +1,16 @@
|
|||
{% extends 'feed/feed_layout.html' %}
|
||||
{% load i18n %}
|
||||
{% block panel %}
|
||||
|
||||
<header class="block">
|
||||
<h1 class="title">Direct Messages{% if partner %} with {% include 'snippets/username.html' with user=partner %}{% endif %}</h1>
|
||||
{% if partner %}<p class="subtitle"><a href="/direct-messages"><span class="icon icon-arrow-left" aria-hidden="true"></span> All messages</a></p>{% endif %}
|
||||
<h1 class="title">
|
||||
{% if partner %}
|
||||
{% blocktrans with username=partner.display_name path=partner.local_path %}Direct Messages with <a href="{{ path }}">{{ username }}</a>{% endblocktrans %}
|
||||
{% else %}
|
||||
{% trans "Direct Messages" %}
|
||||
{% endif %}
|
||||
</h1>
|
||||
{% if partner %}<p class="subtitle"><a href="/direct-messages"><span class="icon icon-arrow-left" aria-hidden="true"></span> {% trans "All messages" %}</a></p>{% endif %}
|
||||
</header>
|
||||
|
||||
<div class="box">
|
||||
|
@ -12,11 +19,11 @@
|
|||
|
||||
<section class="block">
|
||||
{% if not activities %}
|
||||
<p>You have no messages right now.</p>
|
||||
<p>{% trans "You have no messages right now." %}</p>
|
||||
{% endif %}
|
||||
{% for activity in activities %}
|
||||
<div class="block">
|
||||
{% include 'snippets/status.html' with status=activity %}
|
||||
{% include 'snippets/status/status.html' with status=activity %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
|
|
|
@ -1,18 +1,19 @@
|
|||
{% extends 'feed/feed_layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load bookwyrm_tags %}
|
||||
{% block panel %}
|
||||
|
||||
<h1 class="title">{{ tab | title }} Timeline</h1>
|
||||
<h1 class="title">{% blocktrans %}{{ tab_title }} Timeline{% endblocktrans %}</h1>
|
||||
<div class="tabs">
|
||||
<ul>
|
||||
<li class="{% if tab == 'home' %}is-active{% endif %}">
|
||||
<a href="/#feed">Home</a>
|
||||
<a href="/#feed">{% trans "Home" %}</a>
|
||||
</li>
|
||||
<li class="{% if tab == 'local' %}is-active{% endif %}">
|
||||
<a href="/local#feed">Local</a>
|
||||
<a href="/local#feed">{% trans "Local" %}</a>
|
||||
</li>
|
||||
<li class="{% if tab == 'federated' %}is-active{% endif %}">
|
||||
<a href="/federated#feed">Federated</a>
|
||||
<a href="/federated#feed">{% trans "Federated" %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -20,7 +21,7 @@
|
|||
{# announcements and system messages #}
|
||||
{% if not goal and tab == 'home' %}
|
||||
{% now 'Y' as year %}
|
||||
<section class="block hidden" aria-title="Announcements" data-hide="hide-{{ year }}-reading-goal">
|
||||
<section class="block hidden" aria-title="{% trans 'Announcements' %}" data-hide="hide-{{ year }}-reading-goal">
|
||||
{% include 'snippets/goal_card.html' with year=year %}
|
||||
<hr>
|
||||
</section>
|
||||
|
@ -28,11 +29,11 @@
|
|||
|
||||
{# activity feed #}
|
||||
{% if not activities %}
|
||||
<p>There aren't any activities right now! Try following a user to get started</p>
|
||||
<p>{% trans "There aren't any activities right now! Try following a user to get started" %}</p>
|
||||
{% endif %}
|
||||
{% for activity in activities %}
|
||||
<div class="block">
|
||||
{% include 'snippets/status.html' with status=activity %}
|
||||
{% include 'snippets/status/status.html' with status=activity %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
|
|
|
@ -1,70 +1,79 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load bookwyrm_tags %}
|
||||
{% block content %}
|
||||
|
||||
{% block title %}{% trans "Updates" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="columns">
|
||||
{% if user.is_authenticated %}
|
||||
<div class="column is-one-third">
|
||||
<h2 class="title is-5">Your books</h2>
|
||||
<h2 class="title is-5">{% trans "Your books" %}</h2>
|
||||
{% if not suggested_books %}
|
||||
<p>There are no books here right now! Try searching for a book to get started</p>
|
||||
<p>{% trans "There are no books here right now! Try searching for a book to get started" %}</p>
|
||||
{% else %}
|
||||
<div class="tabs is-small">
|
||||
<ul role="tablist">
|
||||
{% for shelf in suggested_books %}
|
||||
{% if shelf.books %}
|
||||
{% with shelf_counter=forloop.counter %}
|
||||
<li>
|
||||
<p>
|
||||
{{ shelf.name }}
|
||||
</p>
|
||||
<div class="tabs is-small is-toggle">
|
||||
<ul>
|
||||
{% for book in shelf.books %}
|
||||
<li class="tab-change{% if shelf_counter == 1 and forloop.first %} is-active{% endif %}" data-tab="book-{{ book.id }}" data-tab="book-{{ book.id }}" role="tab" tabindex="0" aria-selected="{% if shelf_counter == 1 and forloop.first %}true{% else %}false{% endif %}" aria-controls="book-{{ book.id }}" data-category="suggested-tabs">
|
||||
<a>
|
||||
{% include 'snippets/book_cover.html' with book=book size="medium" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% for shelf in suggested_books %}
|
||||
{% with shelf_counter=forloop.counter %}
|
||||
{% for book in shelf.books %}
|
||||
<div class="suggested-tabs card{% if shelf_counter != 1 or not forloop.first %} hidden{% endif %}" role="tabpanel" id="book-{{ book.id }}">
|
||||
<div class="card-header">
|
||||
<p class="card-header-title">
|
||||
<span>{% include 'snippets/book_titleby.html' with book=book %}</span>
|
||||
</p>
|
||||
<div class="card-header-icon is-hidden-tablet">
|
||||
{% include 'snippets/toggle/toggle_button.html' with label="close" controls_text="book" controls_uid=book.id class="delete" nonbutton=True pressed=True %}
|
||||
</div>
|
||||
<div class="tab-group">
|
||||
<div class="tabs is-small">
|
||||
<ul role="tablist">
|
||||
{% for shelf in suggested_books %}
|
||||
{% if shelf.books %}
|
||||
{% with shelf_counter=forloop.counter %}
|
||||
<li>
|
||||
<p>
|
||||
{% if shelf.identifier == 'to-read' %}{% trans "To Read" %}
|
||||
{% elif shelf.identifier == 'reading' %}{% trans "Currently Reading" %}
|
||||
{% elif shelf.identifier == 'read' %}{% trans "Read" %}
|
||||
{% else %}{{ shelf.name }}{% endif %}
|
||||
</p>
|
||||
<div class="tabs is-small is-toggle">
|
||||
<ul>
|
||||
{% for book in shelf.books %}
|
||||
<li{% if shelf_counter == 1 and forloop.first %} class="is-active"{% endif %}>
|
||||
<a href="#book-{{ book.id }}" id="tab-book-{{ book.id }}" role="tab" aria-label="{{ book.title }}" aria-selected="{% if shelf_counter == 1 and forloop.first %}true{% else %}false{% endif %}" aria-controls="book-{{ book.id }}">
|
||||
{% include 'snippets/book_cover.html' with book=book size="medium" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
{% include 'snippets/shelve_button.html' with book=book %}
|
||||
{% for shelf in suggested_books %}
|
||||
{% with shelf_counter=forloop.counter %}
|
||||
{% for book in shelf.books %}
|
||||
<div class="suggested-tabs card" role="tabpanel" id="book-{{ book.id }}"{% if shelf_counter != 1 or not forloop.first %} hidden{% endif %} aria-labelledby="tab-book-{{ book.id }}">
|
||||
<div class="card-header">
|
||||
<p class="card-header-title">
|
||||
<span>{% include 'snippets/book_titleby.html' with book=book %}</span>
|
||||
</p>
|
||||
<div class="card-header-icon is-hidden-tablet">
|
||||
{% trans "Close" as button_text %}
|
||||
{% include 'snippets/toggle/toggle_button.html' with label=button_text controls_text="book" controls_uid=book.id class="delete" nonbutton=True pressed=True %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
{% include 'snippets/shelve_button/shelve_button.html' with book=book %}
|
||||
{% active_shelf book as active_shelf %}
|
||||
{% if active_shelf.shelf.identifier == 'reading' and book.latest_readthrough %}
|
||||
{% include 'snippets/progress_update.html' with readthrough=book.latest_readthrough %}
|
||||
{% endif %}
|
||||
{% include 'snippets/create_status.html' with book=book %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if goal %}
|
||||
<section class="section">
|
||||
<div class="block">
|
||||
<h3 class="title is-4">{{ goal.year }} Reading Goal</h3>
|
||||
<h3 class="title is-4">{% blocktrans with yar=goal.year %}{{ year }} Reading Goal{% endblocktrans %}</h3>
|
||||
{% include 'snippets/goal_progress.html' with goal=goal %}
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
{% extends 'feed/feed_layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block panel %}
|
||||
<header class="block">
|
||||
<a href="/#feed" class="button" data-back>
|
||||
<span class="icon icon-arrow-left" aira-hidden="true"></span>
|
||||
<span>Back</span>
|
||||
<span>{% trans "Back" %}</span>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
{% endwith %}
|
||||
{% endif %}
|
||||
|
||||
{% include 'snippets/status.html' with status=status main=is_root %}
|
||||
{% include 'snippets/status/status.html' with status=status main=is_root %}
|
||||
|
||||
{% if depth <= max_depth and direction >= 0 %}
|
||||
{% for reply in status|replies %}
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
{% extends 'user/user_layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block header %}
|
||||
<div class="columns is-mobile">
|
||||
<div class="column">
|
||||
<h1 class="title">{{ year }} Reading Progress</h1>
|
||||
<h1 class="title">{% blocktrans %}{{ year }} Reading Progress{% endblocktrans %}</h1>
|
||||
</div>
|
||||
{% if is_self and goal %}
|
||||
<div class="column is-narrow">
|
||||
{% include 'snippets/toggle/open_button.html' with text="Edit goal" icon="pencil" controls_text="show-edit-goal" focus="edit-form-header" %}
|
||||
{% trans "Edit Goal" as button_text %}
|
||||
{% include 'snippets/toggle/open_button.html' with text=button_text icon="pencil" controls_text="show-edit-goal" focus="edit-form-header" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
@ -21,11 +23,11 @@
|
|||
<section class="card {% if goal %}hidden{% endif %}" id="show-edit-goal">
|
||||
<header class="card-header">
|
||||
<h2 class="card-header-title has-background-primary has-text-white" tabindex="0" id="edit-form-header">
|
||||
<span class="icon icon-book is-size-3 mr-2" aria-hidden="true"></span> {{ year }} reading goal
|
||||
<span class="icon icon-book is-size-3 mr-2" aria-hidden="true"></span> {% blocktrans %}{{ year }} Reading Goal{% endblocktrans %}
|
||||
</h2>
|
||||
</header>
|
||||
<section class="card-content content">
|
||||
<p>Set a goal for how many books you'll finish reading in {{ year }}, and track your progress throughout the year.</p>
|
||||
<p>{% blocktrans %}Set a goal for how many books you'll finish reading in {{ year }}, and track your progress throughout the year.{% endblocktrans %}</p>
|
||||
|
||||
{% include 'snippets/goal_form.html' with goal=goal year=year %}
|
||||
</section>
|
||||
|
@ -34,7 +36,7 @@
|
|||
{% endif %}
|
||||
|
||||
{% if not goal and user != request.user %}
|
||||
<p>{{ user.display_name }} hasn't set a reading goal for {{ year }}.</p>
|
||||
<p>{% blocktrans with name=user.display_name %}{{ name }} hasn't set a reading goal for {{ year }}.{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if goal %}
|
||||
|
@ -44,13 +46,19 @@
|
|||
|
||||
{% if goal.books %}
|
||||
<section class="content">
|
||||
<h2>{% if goal.user == request.user %}Your{% else %}{{ goal.user.display_name }}'s{% endif %} {{ year }} Books</h2>
|
||||
<h2>
|
||||
{% if goal.user == request.user %}
|
||||
{% blocktrans %}Your {{ year }} Books{% endblocktrans %}
|
||||
{% else %}
|
||||
{% blocktrans with username=goal.user.display_name %}{{ username }}'s {{ year }} Books{% endblocktrans %}
|
||||
{% endif %}
|
||||
</h2>
|
||||
<div class="columns is-multiline">
|
||||
{% for book in goal.books %}
|
||||
<div class="column is-narrow">
|
||||
<div class="box">
|
||||
<a href="{{ book.book.local_path }}">
|
||||
{% include 'snippets/discover/small-book.html' with book=book.book rating=goal.ratings %}
|
||||
{% include 'discover/small-book.html' with book=book.book rating=goal.ratings %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,32 +1,51 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load humanize %}
|
||||
|
||||
{% block title %}{% trans "Import Books" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="block">
|
||||
<h1 class="title">Import Books from GoodReads</h1>
|
||||
<h1 class="title">{% trans "Import Books" %}</h1>
|
||||
<form name="import" action="/import" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
<label class="label" for="source">
|
||||
<p>{% trans "Data source" %}</p>
|
||||
<div class="select {{ class }}">
|
||||
<select name="source" id="source">
|
||||
<option value="GoodReads" {% if current == 'GoodReads' %}selected{% endif %}>
|
||||
GoodReads
|
||||
</option>
|
||||
<option value="LibraryThing" {% if current == 'LibraryThing' %}selected{% endif %}>
|
||||
LibraryThing
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="field">
|
||||
{{ import_form.as_p }}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">
|
||||
<input type="checkbox" name="include_reviews" checked> Include reviews
|
||||
<input type="checkbox" name="include_reviews" checked> {% trans "Include reviews" %}
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">
|
||||
<p>Privacy setting for imported reviews:</p>
|
||||
<p>{% trans "Privacy setting for imported reviews:" %}</p>
|
||||
{% include 'snippets/privacy_select.html' with no_label=True %}
|
||||
</label>
|
||||
</div>
|
||||
<button class="button is-primary" type="submit">Import</button>
|
||||
<button class="button is-primary" type="submit">{% trans "Import" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="content block">
|
||||
<h2 class="title">Recent Imports</h2>
|
||||
<h2 class="title">{% trans "Recent Imports" %}</h2>
|
||||
{% if not jobs %}
|
||||
<p>No recent imports</p>
|
||||
<p>{% trans "No recent imports" %}</p>
|
||||
{% endif %}
|
||||
<ul>
|
||||
{% for job in jobs %}
|
||||
|
|
|
@ -1,34 +1,38 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load bookwyrm_tags %}
|
||||
{% load humanize %}
|
||||
|
||||
{% block title %}{% trans "Import Status" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="block">
|
||||
<h1 class="title">Import Status</h1>
|
||||
<h1 class="title">{% trans "Import Status" %}</h1>
|
||||
|
||||
<p>
|
||||
Import started: {{ job.created_date | naturaltime }}
|
||||
{% trans "Import started:" %} {{ job.created_date | naturaltime }}
|
||||
</p>
|
||||
{% if job.complete %}
|
||||
<p>
|
||||
Import completed: {{ task.date_done | naturaltime }}
|
||||
{% trans "Import completed:" %} {{ task.date_done | naturaltime }}
|
||||
</p>
|
||||
{% elif task.failed %}
|
||||
<div class="notification is-danger">TASK FAILED</div>
|
||||
<div class="notification is-danger">{% trans "TASK FAILED" %}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
{% if not job.complete %}
|
||||
Import still in progress.
|
||||
{% trans "Import still in progress." %}
|
||||
<p>
|
||||
(Hit reload to update!)
|
||||
{% trans "(Hit reload to update!)" %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if failed_items %}
|
||||
<div class="block">
|
||||
<h2 class="title is-4">Failed to load</h2>
|
||||
<h2 class="title is-4">{% trans "Failed to load" %}</h2>
|
||||
{% if not job.retry %}
|
||||
<form name="retry" action="/import/{{ job.id }}" method="post">
|
||||
{% csrf_token %}
|
||||
|
@ -52,10 +56,10 @@
|
|||
<div class="block pt-1 select-all">
|
||||
<label class="label">
|
||||
<input type="checkbox" class="checkbox">
|
||||
Select all
|
||||
{% trans "Select all" %}
|
||||
</label>
|
||||
</div>
|
||||
<button class="button" type="submit">Retry items</button>
|
||||
<button class="button" type="submit">{% trans "Retry items" %}</button>
|
||||
{% else %}
|
||||
<ul>
|
||||
{% for item in failed_items %}
|
||||
|
@ -77,17 +81,17 @@
|
|||
{% endif %}
|
||||
|
||||
<div class="block">
|
||||
<h2 class="title is-4">Successfully imported</h2>
|
||||
<h2 class="title is-4">{% trans "Successfully imported" %}</h2>
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th>
|
||||
Book
|
||||
{% trans "Book" %}
|
||||
</th>
|
||||
<th>
|
||||
Title
|
||||
{% trans "Title" %}
|
||||
</th>
|
||||
<th>
|
||||
Author
|
||||
{% trans "Author" %}
|
||||
</th>
|
||||
<th>
|
||||
</th>
|
||||
|
@ -110,7 +114,7 @@
|
|||
<td>
|
||||
{% if item.book %}
|
||||
<span class="icon icon-check">
|
||||
<span class="is-sr-only">Imported</span>
|
||||
<span class="is-sr-only">{% trans "Imported" %}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Create an Account" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<div class="block">
|
||||
{% if valid %}
|
||||
<h1 class="title">Create an Account</h1>
|
||||
<h1 class="title">{% trans "Create an Account" %}</h1>
|
||||
<div>
|
||||
<form name="register" method="post" action="/register">
|
||||
<input type=hidden name="invite_code" value="{{ invite.code }}">
|
||||
|
@ -14,8 +18,8 @@
|
|||
</div>
|
||||
{% else %}
|
||||
<div class="content">
|
||||
<h1 class="title">Permission Denied</h1>
|
||||
<p>Sorry! This invite code is no longer valid.</p>
|
||||
<h1 class="title">{% trans "Permission Denied" %}</h1>
|
||||
<p>{% trans "Sorry! This invite code is no longer valid." %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
{% load bookwyrm_tags %}
|
||||
{% load i18n %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{% if title %}{{ title }} | {% endif %}{{ site.name }}</title>
|
||||
<title>{% block title %}BookWyrm{% endblock %} | {{ site.name }}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link type="text/css" rel="stylesheet" href="/static/css/bulma.min.css">
|
||||
<link type="text/css" rel="stylesheet" href="/static/css/format.css">
|
||||
|
@ -21,7 +22,7 @@
|
|||
<meta name="twitter:image:alt" content="BookWyrm Logo">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar container" role="navigation" aria-label="main navigation">
|
||||
<nav class="navbar container" aria-label="main navigation">
|
||||
<div class="navbar-brand">
|
||||
<a class="navbar-item" href="/">
|
||||
<img class="image logo" src="{% if site.logo_small %}/images/{{ site.logo_small }}{% else %}/static/images/logo-small.png{% endif %}" alt="Home page">
|
||||
|
@ -29,12 +30,12 @@
|
|||
<form class="navbar-item column" action="/search/">
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
<input aria-label="Search for a book or user" id="search-input" class="input" type="text" name="q" placeholder="Search for a book or user" value="{{ query }}">
|
||||
<input aria-label="{% trans 'Search for a book or user' %}" id="search-input" class="input" type="text" name="q" placeholder="{% trans 'Search for a book or user' %}" value="{{ query }}">
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button" type="submit">
|
||||
<span class="icon icon-search" title="Search">
|
||||
<span class="is-sr-only">search</span>
|
||||
<span class="icon icon-search" title="{% trans 'Search' %}">
|
||||
<span class="is-sr-only">{% trans "Search" %}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -43,8 +44,8 @@
|
|||
|
||||
<div role="button" tabindex="0" class="navbar-burger pulldown-menu" data-controls="main-nav" aria-expanded="false">
|
||||
<div class="navbar-item mt-3">
|
||||
<div class="icon icon-dots-three-vertical" title="Main navigation menu">
|
||||
<span class="is-sr-only">Main navigation menu</span>
|
||||
<div class="icon icon-dots-three-vertical" title="{% trans 'Main navigation menu' %}">
|
||||
<span class="is-sr-only">{% trans "Main navigation menu" %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -54,66 +55,74 @@
|
|||
<div class="navbar-start">
|
||||
{% if request.user.is_authenticated %}
|
||||
<a href="{% url 'user-shelves' request.user.localname %}" class="navbar-item">
|
||||
Your shelves
|
||||
{% trans "Your shelves" %}
|
||||
</a>
|
||||
<a href="/#feed" class="navbar-item">
|
||||
Feed
|
||||
{% trans "Feed" %}
|
||||
</a>
|
||||
<a href="{% url 'lists' %}" class="navbar-item">
|
||||
Lists
|
||||
{% trans "Lists" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
{% if request.user.is_authenticated %}
|
||||
{% if request.user.is_authenticated %}
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<div class="navbar-link pulldown-menu" role="button" aria-expanded="false" tabindex="0" aria-haspopup="true" aria-controls="navbar-dropdown"><p>
|
||||
<a
|
||||
href="{{ user.local_path }}"
|
||||
class="navbar-link pulldown-menu"
|
||||
role="button"
|
||||
aria-expanded="false"
|
||||
tabindex="0"
|
||||
aria-haspopup="true"
|
||||
aria-controls="navbar-dropdown"
|
||||
>
|
||||
{% include 'snippets/avatar.html' with user=request.user %}
|
||||
{% include 'snippets/username.html' with user=request.user %}
|
||||
</p></div>
|
||||
{% include 'snippets/username.html' with user=request.user anchor=false %}
|
||||
</a>
|
||||
<ul class="navbar-dropdown" id="navbar-dropdown">
|
||||
<li>
|
||||
<a href="/direct-messages" class="navbar-item">
|
||||
Direct messages
|
||||
{% trans "Direct Messages" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/user/{{request.user.localname}}" class="navbar-item">
|
||||
Profile
|
||||
{% trans 'Profile' %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/preferences/profile" class="navbar-item">
|
||||
Settings
|
||||
{% trans 'Settings' %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/import" class="navbar-item">
|
||||
Import books
|
||||
{% trans 'Import Books' %}
|
||||
</a>
|
||||
</li>
|
||||
{% if perms.bookwyrm.create_invites or perms.bookwyrm.edit_instance_settings%}
|
||||
<hr class="navbar-divider">
|
||||
<li class="navbar-divider" role="presentation"></li>
|
||||
{% endif %}
|
||||
{% if perms.bookwyrm.create_invites %}
|
||||
<li>
|
||||
<a href="{% url 'settings-invites' %}" class="navbar-item">
|
||||
Invites
|
||||
{% trans 'Invites' %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.bookwyrm.edit_instance_settings %}
|
||||
<li>
|
||||
<a href="{% url 'settings-site' %}" class="navbar-item">
|
||||
Site Configuration
|
||||
{% trans 'Site Configuration' %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<hr class="navbar-divider">
|
||||
<li class="navbar-divider" role="presentation"></li>
|
||||
<li>
|
||||
<a href="/logout" class="navbar-item">
|
||||
Log out
|
||||
{% trans 'Log out' %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
@ -121,8 +130,8 @@
|
|||
<div class="navbar-item">
|
||||
<a href="/notifications" class="tags has-addons">
|
||||
<span class="tag is-medium">
|
||||
<span class="icon icon-bell" title="Notifications">
|
||||
<span class="is-sr-only">Notifications</span>
|
||||
<span class="icon icon-bell" title="{% trans 'Notifications' %}">
|
||||
<span class="is-sr-only">{% trans "Notifications" %}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="{% if not request.user|notification_count %}hidden {% endif %}tag is-danger is-medium" data-poll="notifications">
|
||||
|
@ -139,16 +148,16 @@
|
|||
{% csrf_token %}
|
||||
<div class="columns is-variable is-1">
|
||||
<div class="column">
|
||||
<label class="is-sr-only" for="id_localname">Username:</label>
|
||||
<input type="text" name="localname" maxlength="150" class="input" required="" id="id_localname" placeholder="username">
|
||||
<label class="is-sr-only" for="id_localname">{% trans "Username:" %}</label>
|
||||
<input type="text" name="localname" maxlength="150" class="input" required="" id="id_localname" placeholder="{% trans 'username' %}">
|
||||
</div>
|
||||
<div class="column">
|
||||
<label class="is-sr-only" for="id_password">Username:</label>
|
||||
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password" placeholder="password">
|
||||
<p class="help"><a href="/password-reset">Forgot your password?</a></p>
|
||||
<label class="is-sr-only" for="id_password">{% trans "Username:" %}</label>
|
||||
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password" placeholder="{% trans 'password' %}">
|
||||
<p class="help"><a href="/password-reset">{% trans "Forgot your password?" %}</a></p>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<button class="button is-primary" type="submit">Log in</button>
|
||||
<button class="button is-primary" type="submit">{% trans "Log in" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -156,7 +165,7 @@
|
|||
{% if site.allow_registration and request.path != '' and request.path != '/' %}
|
||||
<div class="column is-narrow">
|
||||
<a href="/" class="button is-link">
|
||||
Join
|
||||
{% trans "Join" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
@ -179,22 +188,22 @@
|
|||
<div class="columns">
|
||||
<div class="column">
|
||||
<p>
|
||||
<a href="/about">About this server</a>
|
||||
<a href="/about">{% trans "About this server" %}</a>
|
||||
</p>
|
||||
{% if site.admin_email %}
|
||||
<p>
|
||||
<a href="mailto:{{ site.admin_email }}">Contact site admin</a>
|
||||
<a href="mailto:{{ site.admin_email }}">{% trans "Contact site admin" %}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if site.support_link %}
|
||||
<div class="column">
|
||||
<span class="icon icon-heart"></span>
|
||||
Support {{ site.name }} on <a href="{{ site.support_link }}" target="_blank">{{ site.support_title }}</a>
|
||||
{% blocktrans %}Support {{ site.name }} on <a href="{{ site.support_link }}" target="_blank">{{ site.support_title }}</a>{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="column">
|
||||
BookWyrm is open source software. You can contribute or report issues on <a href="https://github.com/mouse-reeve/bookwyrm">GitHub</a>.
|
||||
{% trans 'BookWyrm is open source software. You can contribute or report issues on <a href="https://github.com/mouse-reeve/bookwyrm">GitHub</a>.' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
{% extends 'components/inline_form.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block header %}
|
||||
Create List
|
||||
{% trans "Create List" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
|
|
|
@ -1,17 +1,18 @@
|
|||
{% extends 'lists/list_layout.html' %}
|
||||
{% load i18n %}
|
||||
{% block panel %}
|
||||
|
||||
<section class="content block">
|
||||
<h2>Pending Books</h2>
|
||||
<p><a href="{% url 'list' list.id %}">Go to list</a></p>
|
||||
<h2>{% trans "Pending Books" %}</h2>
|
||||
<p><a href="{% url 'list' list.id %}">{% trans "Go to list" %}</a></p>
|
||||
{% if not pending.exists %}
|
||||
<p>You're all set!</p>
|
||||
<p>{% trans "You're all set!" %}</p>
|
||||
{% else %}
|
||||
<table class="table is-striped">
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Book</th>
|
||||
<th>Suggested by</th>
|
||||
<th>{% trans "Book" %}</th>
|
||||
<th>{% trans "Suggested by" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% for item in pending %}
|
||||
|
@ -23,7 +24,7 @@
|
|||
{% include 'snippets/book_titleby.html' with book=item.book %}
|
||||
</td>
|
||||
<td>
|
||||
{% include 'snippets/username.html' with user=item.added_by %}
|
||||
{% include 'snippets/username.html' with user=item.user %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="field has-addons">
|
||||
|
@ -31,13 +32,13 @@
|
|||
{% csrf_token %}
|
||||
<input type="hidden" name="item" value="{{ item.id }}">
|
||||
<input type="hidden" name="approved" value="true">
|
||||
<button class="button">Approve</button>
|
||||
<button class="button">{% trans "Approve" %}</button>
|
||||
</form>
|
||||
<form class="control" method="POST" action="{% url 'list-curate' list.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="item" value="{{ item.id }}">
|
||||
<input type="hidden" name="approved" value="false">
|
||||
<button class="button is-danger is-light">Discard</button>
|
||||
<button class="button is-danger is-light">{% trans "Discard" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
{% extends 'components/inline_form.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block header %}
|
||||
Edit List
|
||||
{% trans "Edit List" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
|
|
|
@ -1,34 +1,35 @@
|
|||
{% load i18n %}
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="user" value="{{ request.user.id }}">
|
||||
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label" for="id_name">Name:</label>
|
||||
<label class="label" for="id_name">{% trans "Name:" %}</label>
|
||||
{{ list_form.name }}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="id_description">Description:</label>
|
||||
<label class="label" for="id_description">{% trans "Description:" %}</label>
|
||||
{{ list_form.description }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<fieldset class="field">
|
||||
<legend class="label">List curation:</legend>
|
||||
<legend class="label">{% trans "List curation:" %}</legend>
|
||||
|
||||
<label class="field">
|
||||
<input type="radio" name="curation" value="closed"{% if not list or list.curation == 'closed' %} checked{% endif %}> Closed
|
||||
<p class="help mb-2">Only you can add and remove books to this list</p>
|
||||
<input type="radio" name="curation" value="closed"{% if not list or list.curation == 'closed' %} checked{% endif %}> {% trans "Closed" %}
|
||||
<p class="help mb-2">{% trans "Only you can add and remove books to this list" %}</p>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<input type="radio" name="curation" value="curated"{% if list.curation == 'curated' %} checked{% endif %}> Curated
|
||||
<p class="help mb-2">Anyone can suggest books, subject to your approval</p>
|
||||
<input type="radio" name="curation" value="curated"{% if list.curation == 'curated' %} checked{% endif %}> {% trans "Curated" %}
|
||||
<p class="help mb-2">{% trans "Anyone can suggest books, subject to your approval" %}</p>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<input type="radio" name="curation" value="open"{% if list.curation == 'open' %} checked{% endif %}> Open
|
||||
<p class="help mb-2">Anyone can add books to this list</p>
|
||||
<input type="radio" name="curation" value="open"{% if list.curation == 'open' %} checked{% endif %}> {% trans "Open" %}
|
||||
<p class="help mb-2">{% trans "Anyone can add books to this list" %}</p>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
@ -38,7 +39,7 @@
|
|||
{% include 'snippets/privacy_select.html' with current=list.privacy %}
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="submit" class="button is-primary">Save</button>
|
||||
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
{% extends 'lists/list_layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load bookwyrm_tags %}
|
||||
{% block panel %}
|
||||
|
||||
{% block panel %}
|
||||
{% if items.exists and list.user == request.user %}
|
||||
<section class="block">
|
||||
<h2 class="title is-4">Post list to feed</h2>
|
||||
<h2 class="title is-4">{% trans "Post list to feed" %}</h2>
|
||||
<div class="block column is-three-quarters">
|
||||
{% include 'lists/list_status.html' %}
|
||||
</div>
|
||||
|
@ -22,31 +23,31 @@
|
|||
<div class="columns mt-3">
|
||||
<section class="column is-three-quarters">
|
||||
{% if not items.exists %}
|
||||
<p>This list is currently empty</p>
|
||||
<p>{% trans "This list is currently empty" %}</p>
|
||||
{% else %}
|
||||
<ol>
|
||||
{% for item in items %}
|
||||
<li class="block pb-3">
|
||||
<div class="card">
|
||||
<div class="card-content columns p-0 mb-0">
|
||||
<div class="card-content columns p-0 mb-0 is-mobile">
|
||||
<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">
|
||||
<div class="card-footer-item">
|
||||
<p>Added by {% include 'snippets/username.html' with user=item.added_by %}</p>
|
||||
<p>{% blocktrans with username=item.user.display_name user_path=user.local_path %}Added by <a href="{{ user_path }}">{{ username }}</a>{% endblocktrans %}</p>
|
||||
</div>
|
||||
{% if list.user == request.user or list.curation == 'open' and item.added_by == request.user %}
|
||||
{% if list.user == request.user or list.curation == 'open' and item.user == request.user %}
|
||||
<form name="add-book" method="post" action="{% url 'list-remove-book' list.id %}" class="card-footer-item">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="item" value="{{ item.id }}">
|
||||
<button type="submit" class="button is-small is-danger">Remove</button>
|
||||
<button type="submit" class="button is-small is-danger">{% trans "Remove" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
@ -59,29 +60,33 @@
|
|||
|
||||
{% if request.user.is_authenticated and not list.curation == 'closed' or request.user == list.user %}
|
||||
<section class="column is-one-quarter content">
|
||||
<h2>{% if list.curation == 'open' or request.user == list.user %}Add{% else %}Suggest{% endif %} Books</h2>
|
||||
<h2>{% if list.curation == 'open' or request.user == list.user %}{% trans "Add Books" %}{% else %}{% trans "Suggest Books" %}{% endif %}</h2>
|
||||
<form name="search" action="{% url 'list' list.id %}" method="GET" class="block">
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
<input aria-label="Search for a book" class="input" type="text" name="q" placeholder="Search for a book" value="{{ query }}">
|
||||
<input aria-label="{% trans 'Search for a book' %}" class="input" type="text" name="q" placeholder="{% trans 'Search for a book' %}" value="{{ query }}">
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button" type="submit">
|
||||
<span class="icon icon-search" title="Search">
|
||||
<span class="is-sr-only">search</span>
|
||||
<span class="icon icon-search" title="{% trans 'Search' %}">
|
||||
<span class="is-sr-only">{% trans "search" %}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% if query %}
|
||||
<p class="help"><a href="{% url 'list' list.id %}">Clear search</a></p>
|
||||
<p class="help"><a href="{% url 'list' list.id %}">{% trans "Clear search" %}</a></p>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% if not suggested_books %}
|
||||
<p>No books found{% if query %} matching the query "{{ query }}"{% endif %}</p>
|
||||
{% if query %}
|
||||
<p>{% blocktrans %}No books found matching the query "{{ query }}"{% endblocktrans %}</p>{% else %}
|
||||
<p>{% trans "No books found" %}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% for book in suggested_books %}
|
||||
<div class="block columns">
|
||||
{% if book %}
|
||||
<div class="block columns is-mobile">
|
||||
<div class="column is-narrow">
|
||||
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book size="small" %}</a>
|
||||
</div>
|
||||
|
@ -90,10 +95,11 @@
|
|||
<form name="add-book" method="post" action="{% url 'list-add-book' list.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="book" value="{{ book.id }}">
|
||||
<button type="submit" class="button is-small is-link">{% if list.curation == 'open' or request.user == list.user %}Add{% else %}Suggest{% endif %}</button>
|
||||
<button type="submit" class="button is-small is-link">{% if list.curation == 'open' or request.user == list.user %}{% trans "Add" %}{% else %}{% trans "Suggest" %}{% endif %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
{% load bookwyrm_tags %}
|
||||
{% load i18n %}
|
||||
<div class="columns is-multiline">
|
||||
{% for list in lists %}
|
||||
<div class="column is-one-quarter">
|
||||
|
@ -8,14 +9,14 @@
|
|||
<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">
|
||||
{% for book in list.books.all|slice:5 %}
|
||||
{% include 'snippets/book_cover.html' with book=book size="small" %}
|
||||
<div class="card-image is-flex is-clipped">
|
||||
{% for book in list.listitem_set.all|slice:5 %}
|
||||
<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">
|
||||
{% if list.description %}{{ list.description | to_markdown | safe | truncatewords_html:20 }}{% endif %}
|
||||
<p class="subtitle help">Created {% if list.curation != 'open' %} and curated{% endif %} by {% include 'snippets/username.html' with user=list.user %}</p>
|
||||
<p class="subtitle help">{% if list.curation != 'open' %}{% trans "Created and curated by" %}{% else %}{% trans "Created by" %}{% endif %} {% include 'snippets/username.html' with user=list.user %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,16 +1,21 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load bookwyrm_tags %}
|
||||
{% block content %}
|
||||
|
||||
<header class="columns content">
|
||||
{% block title %}{{ list.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="columns content is-mobile">
|
||||
<div class="column">
|
||||
<h1 class="title">{{ list.name }} <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=list %}</span></h1>
|
||||
<p class="subtitle help">Created {% if list.curation != 'open' %} and curated{% endif %} by {% include 'snippets/username.html' with user=list.user %}</p>
|
||||
<p class="subtitle help">{% if list.curation != 'open' %}{% trans "Created and curated by" %}{% else %}{% trans "Created by" %} {% include 'snippets/username.html' with user=list.user %}</p>
|
||||
{% endif %}
|
||||
{% include 'snippets/trimmed_text.html' with full=list.description %}
|
||||
</div>
|
||||
{% if request.user == list.user %}
|
||||
<div class="column is-narrow">
|
||||
{% include 'snippets/toggle/open_button.html' with text="Edit list" icon="pencil" controls_text="edit-list" focus="edit-list-header" %}
|
||||
{% trans "Edit List" as button_text %}
|
||||
{% include 'snippets/toggle/open_button.html' with text=button_text icon="pencil" controls_text="edit-list" focus="edit-list-header" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
|
|
@ -1,16 +1,21 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Lists" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<header class="block">
|
||||
<h1 class="title">Lists</h1>
|
||||
<h1 class="title">{% trans "Lists" %}</h1>
|
||||
</header>
|
||||
{% if request.user.is_authenticated and not lists.has_previous %}
|
||||
<header class="block columns">
|
||||
<header class="block columns is-mobile">
|
||||
<div class="column">
|
||||
<h2 class="title">Your lists</h2>
|
||||
<h2 class="title">{% trans "Your lists" %}</h2>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
{% include 'snippets/toggle/open_button.html' with controls_text="create-list" icon="plus" text="Create new list" focus="create-list-header" %}
|
||||
{% trans "Create List" as button_text %}
|
||||
{% include 'snippets/toggle/open_button.html' with controls_text="create-list" icon="plus" text=button_text focus="create-list-header" %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
@ -18,13 +23,13 @@
|
|||
{% include 'lists/create_form.html' with controls_text="create-list" %}
|
||||
</div>
|
||||
|
||||
<div class="block content">
|
||||
<section class="block content">
|
||||
{% if request.user.list_set.exists %}
|
||||
{% include 'lists/list_items.html' with lists=request.user.list_set.all|slice:4 %}
|
||||
{% endif %}
|
||||
|
||||
{% if request.user.list_set.count > 4 %}
|
||||
<a href="{% url 'user-lists' request.user.localname %}">See all {{ request.user.list_set.count}} lists</a>
|
||||
<a href="{% url 'user-lists' request.user.localname %}">{% blocktrans with size=request.user.list_set.count %}See all {{ size }} lists{% endblocktrans %}</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
@ -32,10 +37,8 @@
|
|||
|
||||
{% if lists %}
|
||||
<section class="block content">
|
||||
<h2 class="title">Recent Lists</h2>
|
||||
{% if request.user.list_set.exists %}
|
||||
<h2 class="title">{% trans "Recent Lists" %}</h2>
|
||||
{% include 'lists/list_items.html' with lists=lists %}
|
||||
{% endif %}
|
||||
</section>
|
||||
<div>
|
||||
{% include 'snippets/pagination.html' with page=lists path=path %}
|
||||
|
|
|
@ -1,23 +1,26 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% block content %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Login" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<div class="box">
|
||||
<h1 class="title">Log in</h1>
|
||||
<h1 class="title">{% trans "Log in" %}</h1>
|
||||
{% if login_form.non_field_errors %}
|
||||
<p class="notification is-danger">{{ login_form.non_field_errors }}</p>
|
||||
{% endif %}
|
||||
<form name="login" method="post" action="/login">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label class="label" for="id_localname">Username:</label>
|
||||
<label class="label" for="id_localname">{% trans "Username:" %}</label>
|
||||
<div class="control">
|
||||
{{ login_form.localname }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="id_password">Password:</label>
|
||||
<label class="label" for="id_password">{% trans "Password:" %}</label>
|
||||
<div class="control">
|
||||
{{ login_form.password }}
|
||||
</div>
|
||||
|
@ -27,23 +30,23 @@
|
|||
</div>
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Log in</button>
|
||||
<button class="button is-primary" type="submit">{% trans "Log in" %}</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<small><a href="/password-reset">Forgot your password?</a></small>
|
||||
<small><a href="/password-reset">{% trans "Forgot your password?" %}</a></small>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="box has-background-primary-light">
|
||||
{% if site.allow_registration %}
|
||||
<h2 class="title">Create an Account</h2>
|
||||
<h2 class="title">{% trans "Create an Account" %}</h2>
|
||||
<form name="register" method="post" action="/register">
|
||||
{% include 'snippets/register_form.html' %}
|
||||
</form>
|
||||
{% else %}
|
||||
<h2 class="title">This instance is closed</h2>
|
||||
<p>Contact an administrator to get an invite</p>
|
||||
<h2 class="title">{% trans "This instance is closed" %}</h2>
|
||||
<p>{% trans "Contact an administrator to get an invite" %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -53,7 +56,7 @@
|
|||
{% include 'snippets/about.html' %}
|
||||
|
||||
<p class="block">
|
||||
<a href="/about/">More about this site</a>
|
||||
<a href="/about/">{% trans "More about this site" %}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Not Found" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="block">
|
||||
<h1 class="title">Not Found</h1>
|
||||
<p>The page your requested doesn't seem to exist!</p>
|
||||
<h1 class="title">{% trans "Not Found" %}</h1>
|
||||
<p>{% trans "The page you requested doesn't seem to exist!" %}</p>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load humanize %}
|
||||
{% load bookwyrm_tags %}
|
||||
|
||||
{% block title %}{% trans "Notifications" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="block">
|
||||
<h1 class="title">Notifications</h1>
|
||||
<h1 class="title">{% trans "Notifications" %}</h1>
|
||||
|
||||
<form name="clear" action="/notifications" method="POST">
|
||||
{% csrf_token %}
|
||||
<button class="button is-danger is-light" type="submit" class="secondary">Delete notifications</button>
|
||||
<button class="button is-danger is-light" type="submit" class="secondary">{% trans "Delete notifications" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
@ -29,6 +33,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,34 +42,68 @@
|
|||
<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' %}
|
||||
{% if related_status.status_type == 'Review' %}
|
||||
{% blocktrans with book_title=related_status.book.title related_path=related_status.local_path %}favorited your <a href="{{ related_path }}">review of <em>{{ book_title }}</em></a>{% endblocktrans %}
|
||||
{% elif related_status.status_type == 'Comment' %}
|
||||
{% blocktrans with book_title=related_status.book.title related_path=related_status.local_path %}favorited your <a href="{{ related_path }}">comment on <em>{{ book_title }}</em></a>{% endblocktrans %}
|
||||
{% elif related_status.status_type == 'Quotation' %}
|
||||
{% blocktrans with book_title=related_status.book.title related_path=related_status.local_path %}favorited your <a href="{{ related_path }}">quote from <em>{{ book_title }}</em></a>{% endblocktrans %}
|
||||
{% else %}
|
||||
{% blocktrans with related_path=related_status.local_path %}favorited your <a href="{{ related_path }}">status</a>{% endblocktrans %}
|
||||
{% endif %}
|
||||
|
||||
{% 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' %}
|
||||
{% if related_status.status_type == 'Review' %}
|
||||
{% blocktrans with related_path=related_status.local_path book_title=related_status.book.title %}mentioned you in a <a href="{{ related_path }}">review of <em>{{ book_title }}</em></a>{% endblocktrans %}
|
||||
{% elif related_status.status_type == 'Comment' %}
|
||||
{% blocktrans with related_path=related_status.local_path book_title=related_status.book.title %}mentioned you in a <a href="{{ related_path }}">comment on <em>{{ book_title }}</em></a>{% endblocktrans %}
|
||||
{% elif related_status.status_type == 'Quotation' %}
|
||||
{% blocktrans with related_path=related_status.local_path book_title=related_status.book.title %}mentioned you in a <a href="{{ related_path }}">quote from <em>{{ book_title }}</em></a>{% endblocktrans %}
|
||||
{% else %}
|
||||
{% blocktrans with related_path=related_status.local_path %}mentioned you in a <a href="{{ related_path }}">status</a>{% endblocktrans %}
|
||||
{% endif %}
|
||||
|
||||
{% 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 == 'REPLY' %}
|
||||
{% if related_status.status_type == 'Review' %}
|
||||
{% blocktrans with related_path=related_status.local_path parent_path=related_status.reply_parent.local_path book_title=related_status.reply_parent.book.title %}<a href="{{ related_path }}">replied</a> to your <a href="{{ parent_path }}">review of <em>{{ book_title }}</em></a>{% endblocktrans %}
|
||||
{% elif related_status.status_type == 'Comment' %}
|
||||
{% blocktrans with related_path=related_status.local_path parent_path=related_status.reply_parent.local_path book_title=related_status.reply_parent.book.title %}<a href="{{ related_path }}">replied</a> to your <a href="{{ parent_path }}">comment on <em>{{ book_title }}</em></a>{% endblocktrans %}
|
||||
{% elif related_status.status_type == 'Quotation' %}
|
||||
{% blocktrans with related_path=related_status.local_path parent_path=related_status.reply_parent.local_path book_title=related_status.reply_parent.book.title %}<a href="{{ related_path }}">replied</a> to your <a href="{{ parent_path }}">quote from <em>{{ book_title }}</em></a>{% endblocktrans %}
|
||||
{% else %}
|
||||
{% blocktrans with related_path=related_status.local_path parent_path=related_status.reply_parent.local_path %}<a href="{{ related_path }}">replied</a> to your <a href="{{ parent_path }}">status</a>{% endblocktrans %}
|
||||
{% endif %}
|
||||
|
||||
{% elif notification.notification_type == 'BOOST' %}
|
||||
boosted your <a href="{{ related_status.local_path }}">{{ related_status | status_preview_name|safe }}</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
your <a href="/import/{{ notification.related_import.id }}">import</a> completed.
|
||||
{% elif notification.notification_type == 'FOLLOW' %}
|
||||
{% trans "followed you" %}
|
||||
{% include 'snippets/follow_button.html' with user=notification.related_user %}
|
||||
{% elif notification.notification_type == 'FOLLOW_REQUEST' %}
|
||||
{% trans "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' %}
|
||||
{% if related_status.status_type == 'Review' %}
|
||||
{% blocktrans with related_path=related_status.local_path book_title=related_status.book.title %}boosted your <a href="{{ related_path }}">review of <em>{{ book_title }}</em></a>{% endblocktrans %}
|
||||
{% elif related_status.status_type == 'Comment' %}
|
||||
{% blocktrans with related_path=related_status.local_path book_title=related_status.book.title %}boosted your <a href="{{ related_path }}">comment on<em>{{ book_title }}</em></a>{% endblocktrans %}
|
||||
{% elif related_status.status_type == 'Quotation' %}
|
||||
{% blocktrans with related_path=related_status.local_path book_title=related_status.book.title %}boosted your <a href="{{ related_path }}">quote from <em>{{ book_title }}</em></a>{% endblocktrans %}
|
||||
{% else %}
|
||||
{% blocktrans with related_path=related_status.local_path %}boosted your <a href="{{ related_path }}">status</a>{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% elif notification.notification_type == 'ADD' %}
|
||||
{% if notification.related_list_item.approved %}
|
||||
{% blocktrans with book_path=notification.related_list_item.book.local_path book_title=notification.related_list_item.book.title list_path=notification.related_list_item.book_list.local_path list_name=notification.related_list_item.book_list.name %} added <em><a href="{{ book_path }}">{{ book_title }}</a></em> to your list "<a href="{{ list_path }}">{{ list_name }}</a>"{% endblocktrans %}
|
||||
{% else %}
|
||||
{% blocktrans with book_path=notification.related_list_item.book.local_path book_title=notification.related_list_item.book.title list_path=notification.related_list_item.book_list.local_path list_name=notification.related_list_item.book_list.name %} suggested adding <em><a href="{{ book_path }}">{{ book_title }}</a></em> to your list "<a href="{{ list_path }}/curate">{{ list_name }}</a>"{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% elif notification.related_import %}
|
||||
{% blocktrans with related_id=notification.related_import.id %} your <a href="/import/{{ related_id }}">import</a> completed.{% endblocktrans %}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
@ -95,7 +135,7 @@
|
|||
{% endfor %}
|
||||
|
||||
{% if not notifications %}
|
||||
<p>You're all caught up!</p>
|
||||
<p>{% trans "You're all caught up!" %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,30 +1,33 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% block content %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Reset Password" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<div class="block">
|
||||
<h1 class="title">Reset Password</h1>
|
||||
<h1 class="title">{% trans "Reset Password" %}</h1>
|
||||
{% for error in errors %}
|
||||
<p class="is-danger">{{ error }}</p>
|
||||
{% endfor %}
|
||||
<form name="password-reset" method="post" action="/password-reset/{{ code }}">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label class="label" for="id_password">Password:</label>
|
||||
<label class="label" for="id_password">{% trans "Password:" %}</label>
|
||||
<div class="control">
|
||||
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="id_confirm_password">Confirm password:</label>
|
||||
<label class="label" for="id_confirm_password">{% trans "Confirm password:" %}</label>
|
||||
<div class="control">
|
||||
<input type="password" name="confirm-password" maxlength="128" class="input" required="" id="id_confirm_password">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Confirm</button>
|
||||
<button class="button is-primary" type="submit">{% trans "Confirm" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -36,7 +39,6 @@
|
|||
{% include 'snippets/about.html' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
@ -1,23 +1,26 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% block content %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Reset Password" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-half">
|
||||
<div class="block">
|
||||
<h1 class="title">Reset Password</h1>
|
||||
<h1 class="title">{% trans "Reset Password" %}</h1>
|
||||
{% if message %}<p>{{ message }}</p>{% endif %}
|
||||
<p>A link to reset your password will be sent to your email address</p>
|
||||
<p>{% trans "A link to reset your password will be sent to your email address" %}</p>
|
||||
<form name="password-reset" method="post" action="/password-reset">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label class="label" for="id_email_register">Email address:</label>
|
||||
<label class="label" for="id_email_register">{% trans "Email address:" %}</label>
|
||||
<div class="control">
|
||||
<input type="email" name="email" maxlength="254" class="input" id="id_email_register">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button class="button is-link" type="submit">Reset password</button>
|
||||
<button class="button is-link" type="submit">{% trans "Reset password" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
{% extends 'preferences/preferences_layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Blocked Users" %}{{ author.name }}{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
Blocked Users
|
||||
{% trans "Blocked Users" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
{% if not request.user.blocks.exists %}
|
||||
<p>No users currently blocked.</p>
|
||||
<p>{% trans "No users currently blocked." %}</p>
|
||||
{% else %}
|
||||
<ul>
|
||||
{% for user in request.user.blocks.all %}
|
||||
|
|
|
@ -1,19 +1,23 @@
|
|||
{% extends 'preferences/preferences_layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Change Password" %}{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
Change Password
|
||||
{% trans "Change Password" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
<form name="edit-profile" action="/change-password/" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div class="block">
|
||||
<label class="label" for="id_password">New password:</label>
|
||||
<label class="label" for="id_password">{% trans "New password:" %}</label>
|
||||
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password">
|
||||
</div>
|
||||
<div class="block">
|
||||
<label class="label" for="id_confirm_password">Confirm password:</label>
|
||||
<label class="label" for="id_confirm_password">{% trans "Confirm password:" %}</label>
|
||||
<input type="password" name="confirm-password" maxlength="128" class="input" required="" id="id_confirm_password">
|
||||
</div>
|
||||
<button class="button is-primary" type="submit">Change password</button>
|
||||
<button class="button is-primary" type="submit">{% trans "Change Password" %}</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
{% extends 'preferences/preferences_layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Edit Profile" %}{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
Edit Profile
|
||||
{% trans "Edit Profile" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
|
@ -10,28 +14,28 @@ Edit Profile
|
|||
<form name="edit-profile" action="{% url 'prefs-profile' %}" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div class="block">
|
||||
<label class="label" for="id_avatar">Avatar:</label>
|
||||
<label class="label" for="id_avatar">{% trans "Avatar:" %}</label>
|
||||
{{ form.avatar }}
|
||||
{% for error in form.avatar.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="block">
|
||||
<label class="label" for="id_name">Display name:</label>
|
||||
<label class="label" for="id_name">{% trans "Display name:" %}</label>
|
||||
{{ form.name }}
|
||||
{% for error in form.name.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="block">
|
||||
<label class="label" for="id_summary">Summary:</label>
|
||||
<label class="label" for="id_summary">{% trans "Summary:" %}</label>
|
||||
{{ form.summary }}
|
||||
{% for error in form.summary.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="block">
|
||||
<label class="label" for="id_email">Email address:</label>
|
||||
<label class="label" for="id_email">{% trans "Email address:" %}</label>
|
||||
{{ form.email }}
|
||||
{% for error in form.email.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
|
@ -39,10 +43,10 @@ Edit Profile
|
|||
</div>
|
||||
<div class="block">
|
||||
<label class="checkbox label" for="id_manually_approves_followers">
|
||||
Manually approve followers:
|
||||
{% trans "Manually approve followers:" %}
|
||||
{{ form.manually_approves_followers }}
|
||||
</label>
|
||||
</div>
|
||||
<button class="button is-primary" type="submit">Save</button>
|
||||
<button class="button is-primary" type="submit">{% trans "Save" %}</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load i18n %}
|
||||
{% block content %}
|
||||
|
||||
<header class="block column is-offset-one-quarter pl-1">
|
||||
|
@ -7,19 +8,19 @@
|
|||
|
||||
<div class="block columns">
|
||||
<nav class="menu column is-one-quarter">
|
||||
<h2 class="menu-label">Account</h2>
|
||||
<h2 class="menu-label">{% trans "Account" %}</h2>
|
||||
<ul class="menu-list">
|
||||
<li>
|
||||
<a href="/preferences/profile"{% if '/preferences/profile' in request.path %} class="is-active" aria-selected="true"{% endif %}>Profile</a>
|
||||
<a href="/preferences/profile"{% if '/preferences/profile' in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Profile" %}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/preferences/password"{% if '/preferences/password' in request.path %} class="is-active" aria-selected="true"{% endif %}>Change password</a>
|
||||
<a href="/preferences/password"{% if '/preferences/password' in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Change Password" %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<h2 class="menu-label">Relationships</h2>
|
||||
<h2 class="menu-label">{% trans "Relationships" %}</h2>
|
||||
<ul class="menu-list">
|
||||
<li>
|
||||
<a href="/preferences/block"{% if '/preferences/block' in request.path %} class="is-active" aria-selected="true"{% endif %}>Blocked users</a>
|
||||
<a href="/preferences/block"{% if '/preferences/block' in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Blocked Users" %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
|
|
@ -1,16 +1,20 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Search Results" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% with book_results|first as local_results %}
|
||||
<div class="block">
|
||||
<h1 class="title">Search Results for "{{ query }}"</h1>
|
||||
<h1 class="title">{% blocktrans %}Search Results for "{{ query }}"{% endblocktrans %}</h1>
|
||||
</div>
|
||||
|
||||
<div class="block columns">
|
||||
<div class="column">
|
||||
<h2 class="title">Matching Books</h2>
|
||||
<h2 class="title">{% trans "Matching Books" %}</h2>
|
||||
<section class="block">
|
||||
{% if not local_results.results %}
|
||||
<p>No books found for "{{ query }}"</p>
|
||||
<p>{% blocktrans %}No books found for "{{ query }}"{% endblocktrans %}</p>
|
||||
{% else %}
|
||||
<ul>
|
||||
{% for result in local_results.results %}
|
||||
|
@ -22,12 +26,14 @@
|
|||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if book_results|slice:":1" and local_results.results and request.user.is_authenticated %}
|
||||
{% if request.user.is_authenticated %}
|
||||
{% if book_results|slice:":1" and local_results.results %}
|
||||
<div class="block">
|
||||
<p>
|
||||
Didn't find what you were looking for?
|
||||
{% trans "Didn't find what you were looking for?" %}
|
||||
</p>
|
||||
{% include 'snippets/toggle/open_button.html' with text="Show results from other catalogues" small=True controls_text="more-results" %}
|
||||
{% trans "Show results from other catalogues" as button_text %}
|
||||
{% include 'snippets/toggle/open_button.html' with text=button_text small=True controls_text="more-results" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
@ -48,7 +54,7 @@
|
|||
{% csrf_token %}
|
||||
<input type="hidden" name="remote_id" value="{{ result.key }}">
|
||||
<div>{% include 'snippets/search_result_text.html' with result=result link=False %}</div>
|
||||
<button type="submit" class="button is-small is-link">Import book</button>
|
||||
<button type="submit" class="button is-small is-link">{% trans "Import book" %}</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
@ -58,15 +64,17 @@
|
|||
{% endfor %}
|
||||
|
||||
{% if local_results.results %}
|
||||
{% include 'snippets/toggle/close_button.html' with text="Hide results from other catalogues" small=True controls_text="more-results" %}
|
||||
{% trans "Hide results from other catalogues" as button_text %}
|
||||
{% include 'snippets/toggle/close_button.html' with text=button_text small=True controls_text="more-results" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="column">
|
||||
<section class="block">
|
||||
<h2 class="title">Matching Users</h2>
|
||||
<h2 class="title">{% trans "Matching Users" %}</h2>
|
||||
{% if not user_results %}
|
||||
<p>No users found for "{{ query }}"</p>
|
||||
<p>{% blocktrans %}No users found for "{{ query }}"{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
<ul>
|
||||
{% for result in user_results %}
|
||||
|
@ -79,9 +87,9 @@
|
|||
</ul>
|
||||
</section>
|
||||
<section class="block">
|
||||
<h2 class="title">Lists</h2>
|
||||
<h2 class="title">{% trans "Lists" %}</h2>
|
||||
{% if not list_results %}
|
||||
<p>No lists found for "{{ query }}"</p>
|
||||
<p>{% blocktrans %}No lists found for "{{ query }}"{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
{% for result in list_results %}
|
||||
<div class="block">
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Administration" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<header class="block column is-offset-one-quarter pl-1">
|
||||
|
@ -8,30 +12,30 @@
|
|||
<div class="block columns">
|
||||
<nav class="menu column is-one-quarter">
|
||||
{% if perms.bookwyrm.create_invites %}
|
||||
<h2 class="menu-label">Manage Users</h2>
|
||||
<h2 class="menu-label">{% trans "Manage Users" %}</h2>
|
||||
<ul class="menu-list">
|
||||
<li>
|
||||
{% url 'settings-invites' as url %}
|
||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>Invites</a>
|
||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Invites" %}</a>
|
||||
</li>
|
||||
<li>
|
||||
{% url 'settings-federation' as url %}
|
||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>Federated Servers</a>
|
||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Federated Servers" %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if perms.bookwyrm.edit_instance_settings %}
|
||||
<h2 class="menu-label">Instance Settings</h2>
|
||||
<h2 class="menu-label">{% trans "Instance Settings" %}</h2>
|
||||
<ul class="menu-list">
|
||||
<li>
|
||||
{% url 'settings-site' as url %}
|
||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>Site Configuration</a>
|
||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Site Settings" %}</a>
|
||||
{% if url in request.path %}
|
||||
<ul class="emnu-list">
|
||||
<li><a href="{{ url }}#instance-info">Instance Info</a></li>
|
||||
<li><a href="{{ url }}#images">Images</a></li>
|
||||
<li><a href="{{ url }}#footer">Footer Content</a></li>
|
||||
<li><a href="{{ url }}#registration">Registration</a></li>
|
||||
<li><a href="{{ url }}#instance-info">{% trans "Instance Info" %}</a></li>
|
||||
<li><a href="{{ url }}#images">{% trans "Images" %}</a></li>
|
||||
<li><a href="{{ url }}#footer">{% trans "Footer Content" %}</a></li>
|
||||
<li><a href="{{ url }}#registration">{% trans "Registration" %}</a></li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
</li>
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
{% extends 'settings/admin_layout.html' %}
|
||||
{% block header %}Federated Servers{% endblock %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block header %}{% trans "Federated Servers" %}{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
|
||||
<table class="table is-striped">
|
||||
<tr>
|
||||
<th>Server name</th>
|
||||
<th>Software</th>
|
||||
<th>Status</th>
|
||||
<th>{% trans "Server name" %}</th>
|
||||
<th>{% trans "Software" %}</th>
|
||||
<th>{% trans "Status" %}</th>
|
||||
</tr>
|
||||
{% for server in servers %}
|
||||
<tr>
|
||||
|
|
|
@ -1,17 +1,42 @@
|
|||
{% extends 'settings/admin_layout.html' %}
|
||||
{% block header %}Invites{% endblock %}
|
||||
{% load i18n %}
|
||||
{% block header %}{% trans "Invites" %}{% endblock %}
|
||||
{% load humanize %}
|
||||
{% block panel %}
|
||||
<section class="block">
|
||||
<h2 class="title is-4">{% trans "Generate New Invite" %}</h2>
|
||||
|
||||
<form name="invite" action="{% url 'settings-invites' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<label class="label" for="id_expiry">{% trans "Expiry:" %}</label>
|
||||
<div class="select">
|
||||
{{ form.expiry }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="label" for="id_use_limit">{% trans "Use limit:" %}</label>
|
||||
<div class="select">
|
||||
{{ form.use_limit }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="button is-primary" type="submit">{% trans "Create Invite" %}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<table class="table is-striped">
|
||||
<tr>
|
||||
<th>Link</th>
|
||||
<th>Expires</th>
|
||||
<th>Max uses</th>
|
||||
<th>Times used</th>
|
||||
<th>{% trans "Link" %}</th>
|
||||
<th>{% trans "Expires" %}</th>
|
||||
<th>{% trans "Max uses" %}</th>
|
||||
<th>{% trans "Times used" %}</th>
|
||||
</tr>
|
||||
{% if not invites %}
|
||||
<tr><td colspan="4">No active invites</td></tr>
|
||||
<tr><td colspan="4">{% trans "No active invites" %}</td></tr>
|
||||
{% endif %}
|
||||
{% for invite in invites %}
|
||||
<tr>
|
||||
|
@ -22,29 +47,6 @@
|
|||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2 class="title is-4">Generate New Invite</h2>
|
||||
|
||||
<form name="invite" action="{% url 'settings-invites' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<label class="label" for="id_expiry">Expiry:</label>
|
||||
<div class="select">
|
||||
{{ form.expiry }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="label" for="id_use_limit">Use limit:</label>
|
||||
<div class="select">
|
||||
{{ form.use_limit }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="button is-primary" type="submit">Create Invite</button>
|
||||
</form>
|
||||
{% include 'snippets/pagination.html' with page=invites path=request.path %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,45 +1,53 @@
|
|||
{% extends 'settings/admin_layout.html' %}
|
||||
{% block header %}Site Configuration{% endblock %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Site Settings" %}{% endblock %}
|
||||
|
||||
{% block header %}{% trans "Site Settings" %}{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
|
||||
<form action="{% url 'settings-site' %}" method="POST" class="content">
|
||||
{% csrf_token %}
|
||||
<section class="block" id="instance-info">
|
||||
<h2 class="title is-4">Instance Info</h2>
|
||||
<h2 class="title is-4">{% trans "Instance Info" %}</h2>
|
||||
<div class="control">
|
||||
<label class="label" for="id_name">Instance Name:</label>
|
||||
<label class="label" for="id_name">{% trans "Instance Name:" %}</label>
|
||||
{{ site_form.name }}
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="label" for="id_instance_tagline">Tagline:</label>
|
||||
<label class="label" for="id_instance_tagline">{% trans "Tagline:" %}</label>
|
||||
{{ site_form.instance_tagline }}
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="label" for="id_instance_description">Instance description:</label>
|
||||
<label class="label" for="id_instance_description">{% trans "Instance description:" %}</label>
|
||||
{{ site_form.instance_description }}
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="label" for="id_code_of_conduct">Code of conduct:</label>
|
||||
<label class="label" for="id_code_of_conduct">{% trans "Code of conduct:" %}</label>
|
||||
{{ site_form.code_of_conduct }}
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="label" for="id_privacy_policy">{% trans "Privacy Policy:" %}</label>
|
||||
{{ site_form.privacy_policy }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr aria-hidden="true">
|
||||
|
||||
<section class="block" id="images">
|
||||
<h2 class="title is-4">Images</h2>
|
||||
<h2 class="title is-4">{% trans "Images" %}</h2>
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<label class="label" for="id_logo">Logo:</label>
|
||||
<label class="label" for="id_logo">{% trans "Logo:" %}</label>
|
||||
{{ site_form.logo }}
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="label" for="id_logo_small">Logo small:</label>
|
||||
<label class="label" for="id_logo_small">{% trans "Logo small:" %}</label>
|
||||
{{ site_form.logo_small }}
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="label" for="id_favicon">Favicon:</label>
|
||||
<label class="label" for="id_favicon">{% trans "Favicon:" %}</label>
|
||||
{{ site_form.favicon }}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -48,17 +56,17 @@
|
|||
<hr aria-hidden="true">
|
||||
|
||||
<section class="block" id="footer">
|
||||
<h2 class="title is-4">Footer Content</h2>
|
||||
<h2 class="title is-4">{% trans "Footer Content" %}</h2>
|
||||
<div class="control">
|
||||
<label class="label" for="id_support_link">Support link:</label>
|
||||
<label class="label" for="id_support_link">{% trans "Support link:" %}</label>
|
||||
<input type="text" name="support_link" maxlength="255" class="input" id="id_support_link" placeholder="https://www.patreon.com/bookwyrm"{% if site.support_link %} value="{{ site.support_link }}"{% endif %}>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="label" for="id_support_title">Support title:</label>
|
||||
<label class="label" for="id_support_title">{% trans "Support title:" %}</label>
|
||||
<input type="text" name="support_title" maxlength="100" class="input" id="id_support_title" placeholder="Patreon"{% if site.support_title %} value="{{ site.support_title }}"{% endif %}>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="label" for="id_admin_email">Admin email:</label>
|
||||
<label class="label" for="id_admin_email">{% trans "Admin email:" %}</label>
|
||||
{{ site_form.admin_email }}
|
||||
</div>
|
||||
</section>
|
||||
|
@ -66,19 +74,19 @@
|
|||
<hr aria-hidden="true">
|
||||
|
||||
<section class="block" id="registration">
|
||||
<h2 class="title is-4">Registration</h2>
|
||||
<h2 class="title is-4">{% trans "Registration" %}</h2>
|
||||
<div class="control">
|
||||
<label class="label" for="id_allow_registration">Allow registration:
|
||||
<label class="label" for="id_allow_registration">{% trans "Allow registration:" %}
|
||||
{{ site_form.allow_registration }}
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="label" for="id_registration_closed_text">Registration closed text:</label>
|
||||
<label class="label" for="id_registration_closed_text">{% trans "Registration closed text:" %}</label>
|
||||
{{ site_form.registration_closed_text }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="block">
|
||||
<button class="button is-primary" type="submit">Save Changes</button>
|
||||
<button class="button is-primary" type="submit">{% trans "Save" %}</button>
|
||||
</footer>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
{% load i18n %}
|
||||
{% if not user in request.user.blocks.all %}
|
||||
<form name="blocks" method="post" action="/block/{{ user.id }}">
|
||||
{% csrf_token %}
|
||||
<button class="button is-danger is-light is-small {{ class }}" type="submit">Block</button>
|
||||
<button class="button is-danger is-light is-small {{ class }}" type="submit">{% trans "Block" %}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form name="unblocks" method="post" action="/unblock/{{ user.id }}">
|
||||
{% csrf_token %}
|
||||
<button class="button is-small {{ class }}" type="submit">Un-block</button>
|
||||
<button class="button is-small {{ class }}" type="submit">{% trans "Un-block" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{% load bookwyrm_tags %}
|
||||
<div class="cover-container is-{{ size }}">
|
||||
{% if book.cover %}
|
||||
<img class="book-cover" src="/images/{{ book.cover }}" alt="{{ book.alt_text }}" title="{{ book.alt_text }}">
|
||||
<img class="book-cover" src="/images/{{ book.cover }}" alt="{{ book.alt_text }}">
|
||||
{% else %}
|
||||
<div class="no-cover book-cover">
|
||||
<img class="book-cover" src="/static/images/no_cover.jpg" alt="No cover">
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<a href="/book/{{ book.id }}">
|
||||
{% include 'snippets/book_cover.html' with book=book %}
|
||||
</a>
|
||||
{% include 'snippets/shelve_button.html' with book=book switch_mode=True %}
|
||||
{% include 'snippets/shelve_button/shelve_button.html' with book=book switch_mode=True %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
<a href="/book/{{ book.id }}">{{ book.title }}</a>
|
||||
{% load i18n %}
|
||||
{% if book.authors %}
|
||||
by {% include 'snippets/authors.html' with book=book %}
|
||||
{% blocktrans with path=book.local_path title=book.title %}<a href="{{ path }}">{{ title }}</a> by {% endblocktrans %}{% include 'snippets/authors.html' with book=book %}
|
||||
{% else %}
|
||||
<a href="{{ book.local_path }}">{{ book.title }}</a>
|
||||
{% endif %}
|
||||
|
||||
|
|
|
@ -1,19 +1,20 @@
|
|||
{% load bookwyrm_tags %}
|
||||
{% load i18n %}
|
||||
|
||||
{% with status.id|uuid as uuid %}
|
||||
<form name="boost" action="/boost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} {% if request.user|boosted:status %}hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
|
||||
{% csrf_token %}
|
||||
<button class="button is-small" type="submit" {% if not status.boostable %}disabled{% endif %}>
|
||||
<span class="icon icon-boost" title="Boost status">
|
||||
<span class="is-sr-only">Boost status</span>
|
||||
<span class="icon icon-boost" title="{% trans 'Boost status' %}">
|
||||
<span class="is-sr-only">{% trans "Boost status" %}</span>
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
<form name="unboost" action="/unboost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} active {% if not request.user|boosted:status %}hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
|
||||
{% csrf_token %}
|
||||
<button class="button is-small is-success" type="submit">
|
||||
<span class="icon icon-boost" title="Un-boost status">
|
||||
<span class="is-sr-only">Un-boost status</span>
|
||||
<button class="button is-small is-primary" type="submit">
|
||||
<span class="icon icon-boost" title="{% trans 'Un-boost status' %}">
|
||||
<span class="is-sr-only">{% trans "Un-boost status" %}</span>
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
{% load i18n %}
|
||||
<div class="control{% if not parent_status.content_warning %} hidden{% endif %}" id="spoilers-{{ uuid }}">
|
||||
<label class="is-sr-only" for="id_content_warning-{{ uuid }}">Spoiler alert:</label>
|
||||
<input type="text" name="content_warning" maxlength="255" class="input" id="id_content_warning-{{ uuid }}" placeholder="Spoilers ahead!"{% if parent_status.content_warning %} value="{{ parent_status.content_warning }}"{% endif %}>
|
||||
<label class="is-sr-only" for="id_content_warning-{{ uuid }}">{% trans "Spoiler alert:" %}</label>
|
||||
<input type="text" name="content_warning" maxlength="255" class="input" id="id_content_warning-{{ uuid }}" placeholder="{% trans 'Spoilers ahead!' %}"{% if parent_status.content_warning %} value="{{ parent_status.content_warning }}"{% endif %}>
|
||||
</div>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue